diff --git a/SKILL.md b/SKILL.md index 03ab235..de7035a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,75 +3,283 @@ ## 职责 协助玩家操作和管理游戏"缺氧"(Oxygen Not Included),提供游戏知识、策略建议,并通过 Mod API 直接操控游戏。 +## 工程结构 + +``` +oni-agent/ +├── config.json # Mod 连接配置 +├── mod/ +│ ├── mod_info.yaml # Mod 元信息 +│ └── ONIAgentBridge.cs # Mod HTTP API 服务 (端口 23876) +├── tools/ +│ ├── oni_api.py # Mod API 客户端 +│ ├── oni_analyzer.py # 游戏状态分析 +│ └── oni_builder.py # 蓝图建造规划 +├── scripts/ +│ ├── auto_repair.sh # 连接诊断 +│ ├── auto_analyze.sh # 一键分析 +│ ├── watch.sh # 持续监控 +│ └── setup.sh # 环境初始化 +├── docs/ +│ ├── MOD_DEV_GUIDE.md # Mod 开发指南 +│ └── AI_KNOWLEDGE_BASE.md # AI 知识库 (ID注册表/语义标签) +├── skills/ +│ └── oni_agent.md # Agent skill 定义 +└── SKILL.md # 本文件 +``` + +--- + +## 重要概念:理解 ONI 的数据模型 + +### 1. 坐标系 + +ONI 使用二维方格(tile)系统。AI 必须理解坐标系才能正确操作: + +``` + y ▲ + │ ┌────┬────┬────┐ + │ │(5,5)│(6,5)│(7,5)│ + │ ├────┼────┼────┤ + │ │(5,4)│(6,4)│(7,4)│ ← 这个格子 (6,4) 包含一个电解器 + │ ├────┼────┼────┤ + │ │(5,3)│(6,3)│(7,3)│ + │ └────┴────┴────┘ + └──────────────────────────► x + (0,0) +``` + +- **原点 (0,0)** 在地图**左下角** +- **x 轴**向右增加,**y 轴**向上增加 +- 每个格子 (cell) 有唯一的 (x, y) 坐标 +- 建筑占用 w×h 个格子,其坐标是**左下角锚点** +- 世界大小通过 `/api/state/game` 查询(`gridWidth` x `gridHeight`) +- 典型地图: ~256 x 384 格 + +### 2. 理解格子状态 + +每格的数据结构如下(通过 `/api/state/cell?x=&y=` 查询): + +```json +{ + "x": 10, "y": 5, + "element": "Oxygen", // 该格包含的元素名称 + "elementState": "gas", // solid/liquid/gas/vacuum + "massKg": 1.8, // 该格中元素的质量 + "temperatureC": 23.5, // 温度(摄氏度) + "hasBuilding": true, // 是否有建筑 + "buildingName": "Electrolyzer",// 建筑名称(如有) + "hasDuplicant": false, // 是否有复制人 + "isVacuum": false, // 是否为真空 + "isSolid": false, // 是否为固体 + "isLiquid": false, + "isGas": true, + "isVisible": true // 是否已探索 +} +``` + +### 3. 理解地图区域 + +通过 `/api/state/cells?x=&y=&width=&height=` 获取矩形区域的格子数组。 + +通过 `explore ` 命令获取 AI 友好的结构化摘要: +- 该区域的建筑列表(带是否可运行) +- 该区域的复制人列表(带压力/当前任务) +- 元素分布统计 +- 感兴趣的关键格子 + +--- + ## 通信方式 + - Mod 在游戏内启动 HTTP 服务,暴露 RESTful API - 通过 `http://127.0.0.1:PORT` 与游戏通信 - 端口在 `oni-agent/config.json` 中配置(默认 23876) +--- + ## 可用 API 端点 ### 状态查询 (GET) -| 端点 | 返回内容 | -|------|---------| -| `/api/state/game` | 周期、复制人数量、全局资源 | -| `/api/state/resources` | 所有主要资源存量 | -| `/api/state/duplicants` | 所有复制人状态 | -| `/api/state/buildings` | 所有建筑列表 | -| `/api/state/research` | 科技树进度 | -| `/api/state/geysers` | 喷泉状态 | -| `/api/state/alert` | 当前警报 | -| `/api/state/critters` | 小动物状态 | + +| 端点 | 说明 | 用途 | +|------|------|------| +| `/health` | Mod 存活检测 | 连接检查 | +| `/api/state/game` | 全局状态(周期/人数/世界尺寸) | 总览 | +| `/api/state/resources` | 资源列表(含分类/物态) | 物资盘点 | +| `/api/state/duplicants` | 复制人详情(位置/压力/食物/当前任务) | 人员管理 | +| `/api/state/buildings` | 建筑列表(位置/是否运行/功耗/分类) | 基建评估 | +| `/api/state/research` | 科技树(进度/解锁的建筑) | 科研规划 | +| `/api/state/geysers` | 喷泉(位置/状态/排放率) | 资源规划 | +| `/api/state/alert` | 警报列表 | 紧急处理 | +| `/api/state/critters` | 小动物(位置/种类/幸福度/年龄) | 养殖管理 | +| `/api/state/plants` | 植物(位置/生长进度/是否枯萎) | 农业管理 | +| `/api/state/rooms` | 房间(类型/格数/建筑数) | 房间判定 | + +### 地图/格子数据 (GET) + +| 端点 | 说明 | 示例 | +|------|------|------| +| `/api/state/cell?x=10&y=5` | 单格详情 | 查看某个格子是气体/液体/建筑 | +| `/api/state/cells?x=0&y=0&width=10&height=10` | 矩形区域 | 查看 10x10 区域 | +| `/api/state/cells/slice?axis=y&index=20&start=0&end=50` | 行/列扫描 | 查看第 20 行 | +| `/api/state/gas?x=10&y=10&radius=20` | 区域气体分析 | 查看周围气体成分 | + +### 实体注册表 (GET,AI 参考用) + +| 端点 | 说明 | +|------|------| +| `/api/registry/buildings` | 所有建筑 ID 及尺寸/功耗/发热 | +| `/api/registry/elements` | 所有元素 ID 及比热容/导热/熔沸点 | +| `/api/registry/techs` | 所有科技 ID 及前置/解锁内容 | ### 操作 (POST) -| 端点 | 请求体 | -|------|--------| -| `/api/action/dig` | `{x, y, width, height}` | -| `/api/action/build` | `{buildingId, x, y, rotation?}` | -| `/api/action/deconstruct` | `{buildingId, x, y}` | -| `/api/action/prioritize` | `{x, y, priority}` | -| `/api/action/research` | `{techId}` | -| `/api/action/schedule` | `{duplicantId, schedule}` | -| `/api/action/wardrobe` | `{duplicantId, equipment}` | + +| 端点 | 请求体 | 用途 | +|------|--------|------| +| `/api/action/dig` | `{x, y, width, height}` | 挖掘区域 | +| `/api/action/build` | `{buildingId, x, y}` | 建造建筑 | +| `/api/action/deconstruct` | `{buildingId, x, y}` | 拆除建筑 | +| `/api/action/prioritize` | `{x, y, priority}` | 设优先级 | +| `/api/action/research` | `{techId}` | 选研究项目 | +| `/api/action/mop` | `{x, y}` | 清理液体 | +| `/api/action/harvest` | `{x, y}` | 收获植物 | + +--- + +## AI 如何进行推理和操作 + +### 第一步:获取全局上下文 + +```bash +python3 tools/oni_api.py status +python3 tools/oni_api.py buildings +python3 tools/oni_analyzer.py +``` + +### 第二步:理解地图 + +```bash +# 探索基地中心区域(假设基地在 50,50) +python3 tools/oni_api.py explore 40 40 40 30 + +# 检查某个格子的详细信息 +python3 tools/oni_api.py cell 45 48 + +# 查看气体分布 +python3 tools/oni_api.py gas 50 50 30 +``` + +### 第三步:参考知识库 + +```bash +# 查找某个建筑的 ID +python3 tools/oni_api.py registry buildings Electrolyzer + +# 查看元素属性 +python3 tools/oni_api.py registry elements Water + +# 查看科技树 +python3 tools/oni_api.py registry techs +``` + +### 第四步:执行操作 + +```bash +# 建造电解器 +python3 tools/oni_api.py build Electrolyzer 45 48 + +# 挖掘空间 +python3 tools/oni_api.py dig 40 45 8 6 + +# 选择科研方向 +python3 tools/oni_api.py research_select ImprovedOxygen + +# 使用蓝图 +python3 tools/oni_builder.py build spom 42 42 +``` + +--- + +## AI 如何理解常见游戏场景 + +### 场景 1:氧气不足 + +**AI 推理过程:** +1. 检查 `/api/state/resources` 中的 O2 和 Algae 存量 +2. 检查 `/api/state/buildings` 是否有电解器或氧气扩散器 +3. 检查 `/api/state/cell?x=&y=` 查询基地气体分布 +4. 如果 Algae < 1t 且无电解器 → 建议建造 SPOM +5. SPOM 需要:水源 + 电解器 + 气体泵 + 氢气发电机 + 气体过滤器 +6. 通过 `explore` 找到一个 8x6 的空地 +7. 执行 `build Electrolyzer x y` + `build GasPump ...` + `build HydrogenGenerator ...` + +### 场景 2:食物短缺 + +**AI 推理过程:** +1. 检查 Calories < 500,000 kcal → 食物预警 +2. 检查是否有 PlanterBox/FarmTile 和 ElectricGrill +3. 如果没有农场 → 建议建造 5 个 PlanterBox 种 Mealwood +4. Mealwood 不需要灌溉或施肥,只需 Dirt +5. 检查 Dirt 存量,如果足够 → 执行建造 +6. 如果有污水 → 建议建造 Water Sieve + 厕所水循环 + +### 场景 3:温度过高 + +**AI 推理过程:** +1. 检查温度数据(通过资源中的 Temperature 或格子数据) +2. 查看热源(煤发电机、精炼厂等靠近基地的位置) +3. 建议:用隔热门包围热源 + 建造液冷模块 +4. 液冷模块需要:Aquatuner + SteamTurbine + 导热液体管道 + +--- + +## AI 如何表达"在哪个格子做什么" + +### 定位语法 + +AI 在描述操作时应使用以下格式: + +``` +在坐标 (x, y) 建造 +在区域 (x, y, width, height) 进行挖掘 +从 (x1,y1) 到 (x2,y2) 铺设管道/电线 +在格子 (x, y) 设置优先级为 +``` + +### 坐标查找策略 + +当 AI 不确定在哪里建造时: +1. 先用 `explore` 找一个空闲区域(没有建筑和固体阻挡) +2. 检查空闲区域的元素和温度是否适合 +3. 用 `cell` 命令确认目标格子状态 +4. 然后用 `dig` 清理空间 +5. 最后用 `build` 建造 + +### 建筑放置规则 + +- 建筑坐标是其**左下角**的位置 +- 建筑占用的 w×h 区域必须全部是空地 +- 需要确认目标区域无建筑、无固体自然方块 +- 气体/液体不会阻挡建筑 +- 如果建筑需要特定环境(如电解器需要水),AI 需要先检查环境 + +--- ## 工具列表 | 工具 | 用途 | |------|------| -| `tools/oni_api.py` | 与 Mod HTTP API 通信 | -| `tools/oni_analyzer.py` | 分析游戏状态、生成建议 | -| `tools/oni_builder.py` | 蓝图/建造规划 | -| `scripts/auto_repair.sh` | 诊断连接问题 | -| `scripts/auto_analyze.sh` | 一键拉取状态+分析 | -| `scripts/watch.sh` | 持续监控模式 | -| `scripts/setup.sh` | 环境初始化 | +| `tools/oni_api.py` | 与 Mod HTTP API 通信(所有查询/操作) | +| `tools/oni_analyzer.py` | 自动分析游戏状态、生成预警和建议 | +| `tools/oni_builder.py` | 预置蓝图建造(SPOM/农场/养殖等) | +| `scripts/auto_repair.sh` | 诊断 Mod 连接问题 | +| `scripts/auto_analyze.sh` | 一键健康检查+状态+分析 | +| `scripts/watch.sh [秒]` | 循环监控模式 | +| `scripts/setup.sh` | 环境初始化与检查 | +| `docs/AI_KNOWLEDGE_BASE.md` | 建筑/元素/科技 ID 注册表和游戏机制参考 | -## 使用方式 - -```bash -# 检查游戏连接 -python3 tools/oni_api.py health - -# 获取游戏状态概览 -python3 tools/oni_api.py status - -# 列出所有资源 -python3 tools/oni_api.py resources - -# 分析并生成建议 -python3 tools/oni_analyzer.py - -# 查看可用蓝图 -python3 tools/oni_builder.py list - -# 执行建造计划 -python3 tools/oni_builder.py build spom 15 10 - -# 持续监控(每 60 秒) -bash scripts/watch.sh 60 - -# 诊断连接 -bash scripts/auto_repair.sh -``` +--- ## 核心游戏知识 @@ -83,7 +291,7 @@ bash scripts/auto_repair.sh 5. **水资源管理** — 净水器、污水过滤 ### 常用布局 -- SPOM (Self-Powered Oxygen Module): 电解制氧 + 氢气发电闭环 +- SPOM: 电解制氧 + 氢气发电闭环 - 卫生间水循环: 卫生间 → 净水器 → 卫生间 - 冷却系统: 液冷 + 蒸汽机 + 导热管 - Ranch 模块: 养殖哈奇/滑鳞/飞鱼 diff --git a/docs/AI_KNOWLEDGE_BASE.md b/docs/AI_KNOWLEDGE_BASE.md new file mode 100644 index 0000000..05f9a13 --- /dev/null +++ b/docs/AI_KNOWLEDGE_BASE.md @@ -0,0 +1,268 @@ +# ONI Knowledge Base — AI Reference + +This file aggregates all building IDs, element IDs, tech IDs, and their semantic metadata +in one structured document. The AI uses this to understand what each entity means and +how to reason about the game. + +## Coordinate System + +ONI uses a 2D grid. The origin (0,0) is at the **bottom-left** of the map. +- x increases to the right +- y increases upward +- Each cell is 1x1 tile +- Buildings may occupy multiple cells (width x height); the (x,y) is the **bottom-left anchor** +- World size varies by asteroid (default ~256x384 cells) + +When the AI wants to reference a location: +- Use absolute (x,y) coordinates +- For regions: (x, y, width, height) +- For buildings: reference the bottom-left cell of the building +- For movement: "at (x,y)" or "from (x1,y1) to (x2,y2)" + +## Building Categories + +| Category | Purpose | Examples | +|------------|----------------------------------------|---------------------------------------------------| +| Base | Structure, storage, doors, ladders | Tile, Ladder, StorageBin, InsulatedTile, Door | +| Oxygen | Oxygen production, gas cleaning | Electrolyzer, OxygenDiffuser, Deodorizer | +| Power | Power generation, storage, wiring | CoalGenerator, HydrogenGenerator, Battery | +| Food | Food production, cooking, ranching | PlanterBox, ElectricGrill, MicrobeMusher, Ranch | +| Plumbing | Liquid handling, hygiene | LiquidPump, Lavatory, WaterSiever, Shower | +| Ventilation| Gas handling | GasPump, GasFilter, GasVent | +| Refinement | Material processing | MetalRefinery, RockCrusher, Kiln, Compost | +| Medicine | Health, disease treatment | Apothecary, MassageTable, SickBay | +| Furniture | Decor, morale, stress relief | Cot, MessTable, ArcadeCabinet, FlowerPot | +| Stations | Research, suit docks, crafting | ResearchStation, SuperComputer, AtmoSuitDock | +| Utilities | Temperature management | ThermoAquatuner, SpaceHeater, TempshiftPlate | +| Automation | Logic circuits, sensors | AND Gate, AtmoSensor, AutomationWire | +| Shipping | Conveyor systems | ConveyorLoader, ConveyorRail, SolidFilter | + +## Key Building IDs (frequently used by AI) + +``` +# Oxygen production +Electrolyzer -> Produces O2 + H2 from water (needs 1kg/s water) +OxygenDiffuser -> Produces O2 from algae (early game) +Deodorizer -> Converts PollutedOxygen to Oxygen (needs filtration medium) + +# Power +ManualGenerator -> 400W, duplicant powered +CoalGenerator -> 600W, consumes Coal +HydrogenGenerator -> 800W, consumes H2 +NaturalGasGenerator-> 800W, consumes NaturalGas +PetroleumGenerator -> 2000W, consumes Petroleum +SteamTurbine -> Extracts heat from steam, produces power +SolarPanel -> 380W max, needs light +WoodBurner -> 300W, consumes Lumber + +# Power storage +Battery -> 10kJ storage, small +JumboBattery -> 40kJ storage +SmartBattery -> 20kJ storage, automation output + +# Food +PlanterBox -> Grows plants, needs dupe delivery +FarmTile -> Grows plants with irrigation +HydroponicFarm -> Grows plants with automatic irrigation +ElectricGrill -> Cooks food (better quality) +MicrobeMusher -> Makes basic mush bars from water + dirt +GasRange -> Advanced cooking with gas +Refrigerator -> Stores food, slows decay + +# Plumbing +LiquidPump -> Pumps liquids (240kg/s) +Lavatory -> Produces PollutedWater from dupe use +WaterSiever -> Filters PollutedWater -> Water +Desalinator -> Removes salt from SaltWater/Brine +LiquidVent -> Outputs liquid into world + +# Ventilation +GasPump -> Pumps gases (500g/s) +GasFilter -> Filters specific gas from mixed pipes +GasVent -> Outputs gas into world +HighPressureGasVent-> 20kg/tile max pressure + +# Refinement +MetalRefinery -> Refines metal ores into refined metals +RockCrusher -> Crushes rock into sand/power +Kiln -> Burns clay -> ceramic +GlassForge -> Makes glass from sand +Compost -> Converts polluted dirt into dirt + +# Base +Tile -> Standard tile, 2 tiles high +Ladder -> Allows vertical movement +InsulatedTile -> Reduces heat transfer (best insulation) +ManualAirlock -> Manual door +PneumaticDoor -> Auto door, lets gas pass +MechanizedAirlock -> Auto door, seals gas/liquid +StorageBin -> Stores solid resources +LiquidReservoir -> Stores 5t of liquid +GasReservoir -> Stores 150kg of gas + +# Temperature +ThermoAquatuner -> Cools liquid piped through it (needs steam room) +ThermoRegulator -> Cools gas piped through it +SpaceHeater -> Heats area (inefficient) +LiquidTepidizer -> Heats liquid (up to 85C) +TempshiftPlate -> Distributes heat evenly + +# Stations +ResearchStation -> Basic research (needs dirt) +SuperComputer -> Advanced research (needs plastic) +AtmoSuitDock -> Stores atmo suit +GroomingStation -> Grooms critters for happiness/eggs +RanchStation -> Ranching skill station +FarmStation -> Improves farm yield (needs fertilizer) +PowerControlStation-> Improves generator efficiency + +# Medicine +MassageTable -> Reduces stress +SickBay -> Cures diseases +TriageCot -> Heals physical damage +Apothecary -> Produces medicine + +# Automation (common items) +AtmoSensor -> Senses gas pressure +ThermoSensor -> Senses temperature +HydroSensor -> Senses liquid pressure +GasElementSensor -> Senses specific gas type +AND Gate, NOT Gate, FILTER Gate, BUFFER Gate +``` + +## Key Element IDs (resources) + +``` +# Critical survival +Oxygen -> Breathable gas (needed by dupes) +Water -> Essential for farming, electrolysis, life support +Dirt -> Used in research (early) and farming +Algae -> Consumed by OxygenDiffuser + +# Food chain +Calories -> Aggregate food energy for colony +MealLice -> Basic food from mealwood plants +BristleBerry -> Mid-tier food from bristle blossom +Mushroom -> Food from dusk cap (needs CO2 + slime) +RawEgg -> Egg for cooking +Meat -> From ranching + +# Power chain +Coal -> Burned in CoalGenerator +Hydrogen -> Burned in HydrogenGenerator +NaturalGas -> Burned in NaturalGasGenerator +Petroleum -> Burned in PetroleumGenerator +CrudeOil -> Can be refined to Petroleum + +# Construction metals +CopperOre -> Early building material +IronOre -> Mid building material +GoldAmalgam -> High corrosion resistance +Wolframite -> Very high melting point (tungsten source) +Steel -> Strong, high melting point (refined) +RefinedIron -> Mid refined metal +Plastic -> Advanced material (from polymer press or dreckos) +Ceramic -> Best insulation material + +# Water / liquids +SaltWater -> Can be desalinated to Water +Brine -> Can be desalinated (more salt per water) +PollutedWater -> Can be filtered to Water (also used by reed fiber) +CrudeOil -> Pumped from oil biome +Petroleum -> Refined from oil (cooking or refinery) +Lumber -> From arbor trees, burned in wood burner +Ethanol -> From lumber, burned in petroleum generator + +# Gas handling +PollutedOxygen -> Can be deodorized to Oxygen +CarbonDioxide -> Sinks to bottom, used by mushrooms/soda fountain +ChlorineGas -> Disinfects, used by balm lily +SourGas -> Can be cooled to methane + sulfur + +# Rare / advanced +Niobium -> End-game space material +Thermium -> Best heat conductor (space material) +Isoresin -> Used for insulation (space material) +ViscoGel -> Non-mixing liquid (space material) +Radium -> Radioactive, spaced out DLC +``` + +## Research Tech Tree (simplified path) + +``` +Tier 0 (start): FarmingTech, PowerRegulation, Plumbing, Ventilation +Tier 1: FoodPreparation (-> ElectricGrill, MicrobeMusher) + InteriorDecor (-> Cot, FlowerPot, various decor) + AdvancedPowerRegulation (-> SmartBattery, PowerTransformer) + LiquidPiping (-> LiquidPump, WaterSiever, Desalinator) + GasPiping (-> GasPump, GasFilter) + FineDining (-> GasRange, Refrigerator) +Tier 2: ImprovedOxygen (-> Electrolyzer) + RefinedObjects (-> MetalRefinery, RockCrusher) + TemperatureModulation (-> ThermoAquatuner, ThermoRegulator) + Ranching (-> GroomingStation, Incubator) + SmartStorage (-> LiquidReservoir, GasReservoir) + Automation (-> first automation buildings) +Tier 3: HighTemperatureForging (-> Steel) + SpaceProgram (-> telescope, rocket platform) + CryoFuelPropulsion (-> hydrogen rocket engine) +Tier 4: MaterialsScience (-> SuperCoolant, ViscoGel) +``` + +## Building Size Reference (width x height) + +``` +1x1: Wire, GasPipe, LiquidPipe, AutomationWire, Switch, Sensor +1x2: ManualGenerator, Battery, PlanterBox, FarmTile, RationBox +2x1: GasFilter, LiquidFilter, Incubator, Jukebot +2x2: CoalGenerator, HydrogenGenerator, OxygenDiffuser, Electrolyzer, + GasPump, LiquidPump, AlgaeTerrarium, Deodorizer, WaterSiever, + ElectricGrill, MicrobeMusher, RockCrusher, Kiln, Compost, + MassageTable, Apothecary, ResearchStation, TextileLoom +2x3: MetalRefinery, PolymerPress, OilRefinery, AtmoSuitDock +2x4: Telescope, MolecularForge +3x1: LadderBed, DisplayShelf +3x2: ManualAirlock, PneumaticDoor, MechanizedAirlock, ConveyorLoader, + SmartStorageBin, LiquidReservoir, GasReservoir +3x3: Shower, Sauna, HotTub +3x4: SteamTurbine +4x2: SuperComputer, JumboBattery, StorageBin +4x4: SolarPanel +``` + +## Common Game Mechanics (AI reference) + +### Gas/Liquid Physics +- Gases **layer by density**: CO2 (sinks) < O2 < PollutedO2 < NaturalGas < H2 (rises) +- Liquids **layer by density**: Petroleum < Water/SaltWater/Brine < CrudeOil < Mercury +- Pressure limit for gas vents: GasVent = 2kg/tile, HighPressureGasVent = 20kg/tile +- Buildings overheat above their overheat temperature (default 75C, some higher) +- Insulated Tile reduces heat transfer by ~100x + +### Duplicant Needs +- Oxygen: need ~100g/s per dupe +- Food: need ~1000kcal/cycle per dupe +- Stress: > 50% starts causing problems, > 80% mental breaks +- Temperature: comfortable at 18-35C, hypothermia below 10C, hyperthermia above 40C +- Morale: affected by decor, food quality, room bonuses + +### Room Types +- Latrine: Outhouse + WashBasin = +1 morale +- Washroom: Lavatory + Sink = +2 morale +- Barracks: Cot/LadderBed + 1 decor = +1 morale +- Bedroom: ComfyBed + 1 decor = +2 morale +- Mess Hall: MessTable + 1 decor = +3 morale +- Great Hall: MessTable + 2+ decor and recreation = +6 morale +- Park: 4+ wild/planted plants = +1 morale +- Nature Reserve: 12+ wild plants = +6 morale +- Stable: GroomingStation + CritterDropOff = ranching room +- Ranch: Incubator + feeder (spaced out) + +### SPOM (Self-Powered Oxygen Module) +The classic Rodriguez SPOM: +- 1 Electrolyzer (consumes 1kg/s water -> 888g/s O2 + 112g/s H2) +- 2 GasPumps for O2 extraction +- 1 HydrogenGenerator (burns 100g/s H2, produces 800W) +- 1 GasFilter to separate H2 from O2 +- Net power positive (runs on its own hydrogen) +- Standard size: 8x6 tiles diff --git a/docs/MOD_DEV_GUIDE.md b/docs/MOD_DEV_GUIDE.md index 31bb751..ea8ab94 100644 --- a/docs/MOD_DEV_GUIDE.md +++ b/docs/MOD_DEV_GUIDE.md @@ -52,16 +52,98 @@ GET /health 所有状态查询为 `GET` 请求,路径前缀 `/api/state/`。 -| 端点 | 返回内容 | 必需 | -|------|---------|------| -| `/api/state/game` | 周期、复制人数量、世界名称 | **是** | -| `/api/state/resources` | 所有主要资源存量列表 | **是** | -| `/api/state/duplicants` | 每个复制人的压力/食物/体力/氧气 | **是** | -| `/api/state/buildings` | 已建造的建筑列表 | 推荐 | -| `/api/state/research` | 科技树进度 | 推荐 | -| `/api/state/geysers` | 喷泉位置和状态 | 可选 | -| `/api/state/alert` | 当前游戏警报 | 推荐 | -| `/api/state/critters` | 小动物状态 | 可选 | +| 端点 | 返回内容 | 必需 | 新增字段 | +|------|---------|------|---------| +| `/api/state/game` | 周期、复制人、世界尺寸 | **是** | `gridWidth`, `gridHeight` | +| `/api/state/resources` | 资源列表(含分类/物态) | **是** | `id`, `state`, `category` | +| `/api/state/duplicants` | 复制人详情 | **是** | `x`, `y`, `cell`, `currentChore` | +| `/api/state/buildings` | 建筑列表 | 推荐 | `cell`, `category`, `powerWatt` | +| `/api/state/research` | 科技树(含解锁列表) | 推荐 | `requiredTechs`, `unlockedBuildings` | +| `/api/state/geysers` | 喷泉详情 | 可选 | `cell`, `isActive`, `isDormant` | +| `/api/state/alert` | 当前警报 | 推荐 | `clickable` | +| `/api/state/critters` | 小动物详情 | 可选 | `cell`, `calories` | +| `/api/state/plants` | 植物列表 | 可选 | `isGrown`, `progress`, `isWilting` | +| `/api/state/rooms` | 房间列表 | 可选 | 类型/格数/建筑/生物/植物 | + +### 2.3 地图/格子数据端点 + +这是 AI 理解游戏世界最重要的端点。格子级数据让 AI 知道每个具体位置的状态。 + +| 端点 | 说明 | 参数 | +|------|------|------| +| `GET /api/state/cell` | 单格详情 | `?x=&y=` | +| `GET /api/state/cells` | 矩形区域(批量) | `?x=&y=&width=&height=` | +| `GET /api/state/cells/slice` | 行或列扫描 | `?axis=x&index=&start=&end=` | +| `GET /api/state/gas` | 气体分布分析 | `?x=&y=&radius=` | + +#### `GET /api/state/cell?x=10&y=5` + +```json +{ + "x": 10, "y": 5, "cell": 4523, + "element": "Oxygen", + "elementId": "Oxygen", + "elementState": "gas", + "massKg": 1.8, + "temperatureC": 23.5, + "isSolid": false, "isLiquid": false, "isGas": true, + "hasBuilding": true, "buildingName": "GasPump", + "hasDuplicant": false, "duplicantName": null, + "isVacuum": false, "isVisible": true +} +``` + +#### `GET /api/state/cells?x=0&y=0&width=5&height=5` + +```json +{ + "region": { "x": 0, "y": 0, "width": 5, "height": 5 }, + "cells": [ /* array of cell objects */ ] +} +``` + +### 2.4 实体注册表端点 (AI 参考) + +用于 AI 在运行时查询游戏实体的元数据。所有为 `GET` 请求。 + +| 端点 | 返回内容 | +|------|---------| +| `/api/registry/buildings` | 全部建筑定义(尺寸/功耗/发热/材料) | +| `/api/registry/elements` | 全部元素定义(比热容/导热/熔沸点) | +| `/api/registry/techs` | 全部科技定义(前置/解锁建筑) | + +#### `GET /api/registry/buildings` + +```json +[ + { + "id": "Electrolyzer", + "name": "Electrolyzer", + "category": "Oxygen", + "width": 2, "height": 2, + "powerCost": 120, + "heatGeneration": 1.25, + "constructionMass": ["IronOre", "IronOre"] + } +] +``` + +### 2.5 操作端点 + +所有操作为 `POST` 请求,路径前缀 `/api/action/`。 + +| 端点 | 作用 | 必需 | +|------|------|------| +| `/api/action/dig` | 挖掘指定区域 | **是** | +| `/api/action/build` | 建造建筑 | **是** | +| `/api/action/deconstruct` | 拆除建筑 | 推荐 | +| `/api/action/prioritize` | 设置优先级 | 可选 | +| `/api/action/research` | 选择研究方向 | 推荐 | +| `/api/action/schedule` | 修改复制人日程 | 可选 | +| `/api/action/wardrobe` | 修改复制人装备 | 可选 | +| `/api/action/mop` | 清理液体 | 推荐 | +| `/api/action/harvest` | 收获植物 | 推荐 | +| `/api/action/cancel` | 取消操作 | 可选 | #### `GET /api/state/game` @@ -205,7 +287,39 @@ Agent 侧通过 `config.json` 定位 Mod: --- -## 5. 扩展建议 +## 5. AI-Friendly 数据设计原则 + +### 5.1 数据可读性 +所有暴露的数据应满足 AI 可直接理解的三个条件: +1. **命名语义化** — 使用自然语言字段名(如 `temperatureC` 而非 `tempK`) +2. **数据类型合理** — 使用数字而非枚举字符串(温度用 `float` 而非 `string`) +3. **上下文完整** — 每个实体包含足够的位置和状态信息(坐标、类别、是否可运行) + +### 5.2 坐标系统 +- 统一使用 `(x, y)` 整数坐标,对应游戏网格 +- 原点在左下角:`(0, 0)` +- 建筑使用其左下角锚点坐标 +- 返回数据中同时提供 `cell` 索引(Grid 内部使用)和 `(x, y)` 坐标 + +### 5.3 实体分类 +每个实体(建筑、元素、科技)必须包含分类标签: +- 建筑: `category`(Base/Oxygen/Power/Food/...) +- 元素: `state`(solid/liquid/gas)+ `category`(metal/water/fuel/...) +- 资源: 带 `state` 和 `category` 帮助 AI 推理用途 + +### 5.4 AI 推理辅助 +- `/api/registry/*` 端点提供完整的实体元数据查询 +- `GET /api/state/gas` 提供区域气体分布统计(AI 无法逐格遍历) +- `/api/state/rooms` 提供房间判定结果(AI 无法自行判断房间类型) + +### 5.5 操作设计 +操作设计遵循以下原则: +- **幂等性** — 同个操作重复执行不产生副作用 +- **非阻塞** — 操作立即返回 `queued`,异步执行 +- **确定性** — 使用绝对坐标 `(x, y)`,不支持"在某个建筑旁边"这类模糊表述 +- **输入校验** — 拒绝未知的 `buildingId` 或越界坐标 + +## 6. 扩展建议 ### 5.1 添加新端点 1. 在 `ProcessRequest` 中添加路由匹配 diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs index 5dd6ed3..57098b6 100644 --- a/mod/ONIAgentBridge.cs +++ b/mod/ONIAgentBridge.cs @@ -56,84 +56,110 @@ namespace ONIAgentBridge { var path = ctx.Request.Url.AbsolutePath.TrimEnd('/'); var method = ctx.Request.HttpMethod; + var query = ctx.Request.QueryString; string responseJson; - if (path == "/health" && method == "GET") + switch (path, method) { - responseJson = JsonSerializer.Serialize(new { status = "ok", service = "oni-agent-bridge" }); - } - else if (path == "/api/state/game" && method == "GET") - { - responseJson = GetGameState(); - } - else if (path == "/api/state/resources" && method == "GET") - { - responseJson = GetResources(); - } - else if (path == "/api/state/duplicants" && method == "GET") - { - responseJson = GetDuplicants(); - } - else if (path == "/api/state/buildings" && method == "GET") - { - responseJson = GetBuildings(); - } - else if (path == "/api/state/research" && method == "GET") - { - responseJson = GetResearch(); - } - else if (path == "/api/state/geysers" && method == "GET") - { - responseJson = GetGeysers(); - } - else if (path == "/api/state/alert" && method == "GET") - { - responseJson = GetAlerts(); - } - else if (path == "/api/state/critters" && method == "GET") - { - responseJson = GetCritters(); - } - else if (path == "/api/action/dig" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecuteDig(body); - } - else if (path == "/api/action/build" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecuteBuild(body); - } - else if (path == "/api/action/deconstruct" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecuteDeconstruct(body); - } - else if (path == "/api/action/prioritize" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecutePrioritize(body); - } - else if (path == "/api/action/research" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecuteResearch(body); - } - else if (path == "/api/action/schedule" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecuteSchedule(body); - } - else if (path == "/api/action/wardrobe" && method == "POST") - { - var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); - responseJson = ExecuteWardrobe(body); - } - else - { - ctx.Response.StatusCode = 404; - responseJson = JsonSerializer.Serialize(new { error = "not_found" }); + // --- Health --- + case ("/health", "GET"): + responseJson = JsonSerializer.Serialize(new { status = "ok", service = "oni-agent-bridge" }); + break; + + // --- State Queries --- + case ("/api/state/game", "GET"): + responseJson = GetGameState(); + break; + case ("/api/state/resources", "GET"): + responseJson = GetResources(); + break; + case ("/api/state/duplicants", "GET"): + responseJson = GetDuplicants(); + break; + case ("/api/state/buildings", "GET"): + responseJson = GetBuildings(); + break; + case ("/api/state/research", "GET"): + responseJson = GetResearch(); + break; + case ("/api/state/geysers", "GET"): + responseJson = GetGeysers(); + break; + case ("/api/state/alert", "GET"): + responseJson = GetAlerts(); + break; + case ("/api/state/critters", "GET"): + responseJson = GetCritters(); + break; + case ("/api/state/plants", "GET"): + responseJson = GetPlants(); + break; + case ("/api/state/rooms", "GET"): + responseJson = GetRooms(); + break; + + // --- Cell-level map data --- + case ("/api/state/cell", "GET"): + responseJson = GetCell(query); + break; + case ("/api/state/cells", "GET"): + responseJson = GetCells(query); + break; + case ("/api/state/cells/slice", "GET"): + responseJson = GetCellSlice(query); + break; + case ("/api/state/gas", "GET"): + responseJson = GetGas(query); + break; + + // --- Entity Registry (for AI reference) --- + case ("/api/registry/buildings", "GET"): + responseJson = GetBuildingRegistry(); + break; + case ("/api/registry/elements", "GET"): + responseJson = GetElementRegistry(); + break; + case ("/api/registry/techs", "GET"): + responseJson = GetTechRegistry(); + break; + + // --- Actions --- + case ("/api/action/dig", "POST"): + responseJson = ExecuteDig(ctx); + break; + case ("/api/action/build", "POST"): + responseJson = ExecuteBuild(ctx); + break; + case ("/api/action/deconstruct", "POST"): + responseJson = ExecuteDeconstruct(ctx); + break; + case ("/api/action/prioritize", "POST"): + responseJson = ExecutePrioritize(ctx); + break; + case ("/api/action/research", "POST"): + responseJson = ExecuteResearch(ctx); + break; + case ("/api/action/schedule", "POST"): + responseJson = ExecuteSchedule(ctx); + break; + case ("/api/action/wardrobe", "POST"): + responseJson = ExecuteWardrobe(ctx); + break; + case ("/api/action/mop", "POST"): + responseJson = ExecuteMop(ctx); + break; + case ("/api/action/harvest", "POST"): + responseJson = ExecuteHarvest(ctx); + break; + case ("/api/action/cancel", "POST"): + responseJson = ExecuteCancel(ctx); + break; + + default: + ctx.Response.StatusCode = 404; + responseJson = JsonSerializer.Serialize(new { error = "not_found", path = path, method = method }); + break; } var buffer = Encoding.UTF8.GetBytes(responseJson); @@ -142,7 +168,7 @@ namespace ONIAgentBridge } catch (Exception e) { - var err = JsonSerializer.Serialize(new { error = e.Message }); + var err = JsonSerializer.Serialize(new { error = e.Message, type = e.GetType().Name }); var buf = Encoding.UTF8.GetBytes(err); ctx.Response.ContentType = "application/json"; ctx.Response.StatusCode = 500; @@ -154,18 +180,29 @@ namespace ONIAgentBridge } } + // =================================================================== + // API: Game State + // =================================================================== private string GetGameState() { + int cellCount = 0; + try { cellCount = Grid.CellCount; } catch { } + var state = new Dictionary { {"cycle", GameClock.Instance?.GetCycle() ?? 0}, {"duplicantCount", Components.MinionIdentities?.Count ?? 0}, {"worldName", World.Instance?.worldName ?? ""}, - {"worldSize", World.Instance?.WorldGrid?.WorldSize ?? 0} + {"worldSize", cellCount}, + {"gridWidth", Grid.WidthInCells}, + {"gridHeight", Grid.HeightInCells} }; return JsonSerializer.Serialize(state); } + // =================================================================== + // API: Resources + // =================================================================== private string GetResources() { var list = new List(); @@ -174,38 +211,63 @@ namespace ONIAgentBridge var worldCount = WorldInventory.CountValue(elem.tag); if (worldCount > 0) { - list.Add(new { name = elem.name, tag = elem.tag.ToString(), amount = worldCount, unit = "kg" }); + list.Add(new + { + id = elem.id.ToString(), + name = elem.name, + tag = elem.tag.ToString(), + amount = worldCount, + unit = "kg", + state = GetElementStateCategory(elem), + category = GetElementCategory(elem) + }); } } return JsonSerializer.Serialize(list); } + // =================================================================== + // API: Duplicants + // =================================================================== private string GetDuplicants() { var list = new List(); foreach (var minion in Components.MinionIdentities) { var go = minion.gameObject; + var pos = go.transform.position; var stress = go.GetComponent(); var calories = go.GetComponent(); var stamina = go.GetComponent(); var breath = go.GetComponent(); var diseases = go.GetComponent(); var skills = go.GetComponent(); + var ai = go.GetComponent(); + var nav = go.GetComponent(); + list.Add(new { name = minion.GetName(), + id = minion.GetProperName(), + x = (int)pos.x, + y = (int)pos.y, + cell = Grid.PosToCell(pos), stress = stress?.GetStressValue() ?? 0, calories = calories?.GetCaloriesValue() ?? 0, stamina = stamina?.GetStaminaValue() ?? 0, oxygen = breath?.GetOxygenAvailable() ?? 0, diseases = diseases?.GetSicknesses()?.Count ?? 0, - skillLevels = skills?.GetTotalSkillPointsGained() ?? 0 + skillLevels = skills?.GetTotalSkillPointsGained() ?? 0, + currentChore = ai?.GetCurrentChore()?.GetType()?.Name ?? "idle", + isSleeping = nav?.IsMoving() == false && ai?.GetCurrentChore()?.GetType()?.Name == "SleepChore" }); } return JsonSerializer.Serialize(list); } + // =================================================================== + // API: Buildings + // =================================================================== private string GetBuildings() { var list = new List(); @@ -214,19 +276,33 @@ namespace ONIAgentBridge if (building == null) continue; var go = building.gameObject; var pos = building.transform.position; - var name = building.Def?.Name ?? building.name; + var def = building.Def; + var energy = go.GetComponent(); + var conduit = go.GetComponent(); + var storage = go.GetComponent(); + list.Add(new { - name = name, - id = building.Def?.PrefabId ?? "", + id = def?.PrefabId ?? "", + name = def?.Name ?? building.name, x = (int)pos.x, y = (int)pos.y, - isOperational = building.IsOperational + cell = Grid.PosToCell(pos), + width = def?.Width ?? 1, + height = def?.Height ?? 1, + isOperational = building.IsOperational, + category = GetBuildingCategory(def?.PrefabId ?? ""), + powerWatt = energy?.WattsNeededWhenActive ?? 0, + isPowered = energy?.IsPowered ?? true, + storageKg = storage?.MassStored() ?? 0 }); } return JsonSerializer.Serialize(list); } + // =================================================================== + // API: Research + // =================================================================== private string GetResearch() { var list = new List(); @@ -238,12 +314,17 @@ namespace ONIAgentBridge name = tech.Name, isComplete = tech.IsComplete(), progress = tech.Progress(), - category = tech.category?.Name ?? "" + category = tech.category?.Name ?? "", + requiredTechs = tech.requiredTechs?.Select(t => t.Id).ToList() ?? new List(), + unlockedBuildings = tech.unlockedBuildings?.ToList() ?? new List() }); } return JsonSerializer.Serialize(list); } + // =================================================================== + // API: Geysers + // =================================================================== private string GetGeysers() { var list = new List(); @@ -251,19 +332,27 @@ namespace ONIAgentBridge { if (geyser == null) continue; var pos = geyser.transform.position; + list.Add(new { - name = geyser.name, + id = geyser.name, + name = geyser.GetType().Name, x = (int)pos.x, y = (int)pos.y, + cell = Grid.PosToCell(pos), state = geyser.GetState().ToString(), emitRate = geyser.GetEmitRate(), - pressure = geyser.GetPressure() + pressure = geyser.GetPressure(), + isActive = geyser.IsActive(), + isDormant = geyser.IsDormant() }); } return JsonSerializer.Serialize(list); } + // =================================================================== + // API: Alerts + // =================================================================== private string GetAlerts() { var list = new List(); @@ -274,12 +363,16 @@ namespace ONIAgentBridge title = notification.TitleText, message = notification.GetMessage(), severity = notification.severity.ToString(), - type = notification.TypeString + type = notification.TypeString, + clickable = notification.clickable }); } return JsonSerializer.Serialize(list); } + // =================================================================== + // API: Critters + // =================================================================== private string GetCritters() { var list = new List(); @@ -290,34 +383,106 @@ namespace ONIAgentBridge var pos = go.transform.position; var age = go.GetComponent(); var happiness = go.GetComponent(); + var cal = go.GetComponent(); + list.Add(new { + id = critter.GetProperName(), name = critter.GetName(), species = critter.name, x = (int)pos.x, y = (int)pos.y, + cell = Grid.PosToCell(pos), age = age?.GetAgeInCycles() ?? 0, - happiness = happiness?.GetHappiness() ?? 0 + happiness = happiness?.GetHappiness() ?? 0, + calories = cal?.GetCaloriesValue() ?? 0 }); } return JsonSerializer.Serialize(list); } - private string ExecuteDig(string body) + // =================================================================== + // API: Plants + // =================================================================== + private string GetPlants() + { + var list = new List(); + foreach (var plant in Components.CropSleepingMonitor) + { + if (plant == null) continue; + var go = plant.gameObject; + var pos = go.transform.position; + var growing = go.GetComponent(); + + list.Add(new + { + id = go.name, + name = growing?.GetPlantID() ?? go.name, + x = (int)pos.x, + y = (int)pos.y, + cell = Grid.PosToCell(pos), + isGrown = growing?.IsGrown() ?? false, + progress = growing?.GetProgress() ?? 0, + isWilting = growing?.IsWilting() ?? false + }); + } + if (list.Count == 0) + { + foreach (var plant in Components.Plants) + { + if (plant == null) continue; + var go = plant.gameObject; + var pos = go.transform.position; + list.Add(new + { + id = go.name, + name = plant.Name, + x = (int)pos.x, + y = (int)pos.y, + cell = Grid.PosToCell(pos) + }); + } + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // API: Rooms + // =================================================================== + private string GetRooms() + { + var list = new List(); + foreach (var room in Game.Instance?.roomManager?.rooms ?? new List()) + { + list.Add(new + { + id = room.cavity?.GetType()?.Name ?? "unknown", + name = room.roomType?.Name ?? "unknown", + type = room.roomType?.Id ?? "unknown", + cellCount = room.cavity?.numCells ?? 0, + buildings = room.cavity?.buildings?.Count ?? 0, + creatures = room.cavity?.creatures?.Count ?? 0, + plants = room.cavity?.plants?.Count ?? 0 + }); + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // API: Cell - single cell detail + // =================================================================== + private string GetCell(System.Collections.Specialized.NameValueCollection query) { try { - var data = JsonSerializer.Deserialize(body); - if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + int x = int.Parse(query["x"] ?? "-1"); + int y = int.Parse(query["y"] ?? "-1"); - var pos = new CellPos(data.x, data.y); - var dig = DigTool(); - if (dig == null) - return JsonSerializer.Serialize(new { error = "dig_tool_unavailable" }); + int cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) + return JsonSerializer.Serialize(new { error = "cell_out_of_bounds", x = x, y = y }); - dig.Dig(data.x, data.y, data.width, data.height); - return JsonSerializer.Serialize(new { result = "dig_queued", x = data.x, y = data.y, width = data.width, height = data.height }); + return JsonSerializer.Serialize(MakeCellData(cell, x, y)); } catch (Exception e) { @@ -325,11 +490,289 @@ namespace ONIAgentBridge } } - private string ExecuteBuild(string body) + // =================================================================== + // API: Cells - rectangular region + // =================================================================== + private string GetCells(System.Collections.Specialized.NameValueCollection query) { try { - var data = JsonSerializer.Deserialize(body); + int x = int.Parse(query["x"] ?? "0"); + int y = int.Parse(query["y"] ?? "0"); + int w = int.Parse(query["width"] ?? "10"); + int h = int.Parse(query["height"] ?? "10"); + + var cells = new List(); + for (int cy = y; cy < y + h; cy++) + { + for (int cx = x; cx < x + w; cx++) + { + int cell = Grid.XYToCell(cx, cy); + if (cell >= 0 && cell < Grid.CellCount) + { + cells.Add(MakeCellData(cell, cx, cy)); + } + } + } + return JsonSerializer.Serialize(new + { + region = new { x, y, width = w, height = h }, + cells = cells + }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + // =================================================================== + // API: Cells/slice - row or column scan + // =================================================================== + private string GetCellSlice(System.Collections.Specialized.NameValueCollection query) + { + try + { + string axis = query["axis"] ?? "x"; + int index = int.Parse(query["index"] ?? "0"); + int start = int.Parse(query["start"] ?? "0"); + int end = int.Parse(query["end"] ?? "100"); + + var cells = new List(); + if (axis == "y") + { + for (int cx = start; cx < end; cx++) + { + int cell = Grid.XYToCell(cx, index); + if (cell >= 0 && cell < Grid.CellCount) + cells.Add(MakeCellData(cell, cx, index)); + } + } + else + { + for (int cy = start; cy < end; cy++) + { + int cell = Grid.XYToCell(index, cy); + if (cell >= 0 && cell < Grid.CellCount) + cells.Add(MakeCellData(cell, index, cy)); + } + } + return JsonSerializer.Serialize(new { axis, index, cells }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + // =================================================================== + // API: Gas overview - find gas pockets + // =================================================================== + private string GetGas(System.Collections.Specialized.NameValueCollection query) + { + try + { + int x = int.Parse(query["x"] ?? "0"); + int y = int.Parse(query["y"] ?? "0"); + int radius = int.Parse(query["radius"] ?? "20"); + + var gases = new Dictionary(); + int cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) + return JsonSerializer.Serialize(new { error = "invalid_center" }); + + int minX = Math.Max(0, x - radius); + int maxX = Math.Min(Grid.WidthInCells - 1, x + radius); + int minY = Math.Max(0, y - radius); + int maxY = Math.Min(Grid.HeightInCells - 1, y + radius); + + for (int cy = minY; cy <= maxY; cy++) + { + for (int cx = minX; cx <= maxX; cx++) + { + int c = Grid.XYToCell(cx, cy); + if (c < 0) continue; + var elem = Grid.Element[c]; + if (elem != null && elem.IsGas) + { + float mass = Grid.Mass[c]; + string name = elem.name; + if (gases.ContainsKey(name)) + { + var e = (GasEntry)gases[name]; + e.mass += mass; + e.count++; + } + else + { + gases[name] = new GasEntry { gas = name, mass = mass, count = 1 }; + } + } + } + } + + return JsonSerializer.Serialize(new + { + center = new { x, y }, + radius, + gases = gases.Values + }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private class GasEntry + { + public string gas { get; set; } + public float mass { get; set; } + public int count { get; set; } + } + + // =================================================================== + // Cell Data Builder + // =================================================================== + private object MakeCellData(int cell, int x, int y) + { + var elem = Grid.Element[cell]; + float mass = Grid.Mass[cell]; + float temp = Grid.Temperature[cell]; + bool isSolid = Grid.Solid[cell]; + bool isVisible = Grid.IsVisible[cell]; + + var building = Grid.Objects[cell, (int)ObjectLayer.Building]; + var pickupable = Grid.Objects[cell, (int)ObjectLayer.Pickupables]; + var dupe = Grid.Objects[cell, (int)ObjectLayer.Minion]; + + return new + { + x, + y, + cell, + element = elem?.name ?? "Vacuum", + elementId = elem?.id.ToString() ?? "Vacuum", + elementState = elem == null ? "vacuum" : (elem.IsGas ? "gas" : elem.IsLiquid ? "liquid" : "solid"), + massKg = mass, + temperatureC = temp > 0 ? temp - 273.15f : -273.15f, + temperatureK = temp, + isSolid, + isVisible, + isLiquid = elem?.IsLiquid ?? false, + isGas = elem?.IsGas ?? false, + hasBuilding = building != null, + buildingName = building?.name ?? null, + hasPickupable = pickupable != null, + hasDuplicant = dupe != null, + duplicantName = dupe?.GetComponent()?.GetName() ?? null, + isVacuum = elem == null, + pressure = elem == null ? 0 : mass + }; + } + + // =================================================================== + // API: Building Registry (AI reference) + // =================================================================== + private string GetBuildingRegistry() + { + var list = new List(); + foreach (var def in Assets.BuildingDefs) + { + if (def == null) continue; + list.Add(new + { + id = def.PrefabId, + name = def.Name, + category = def.Category.ToString(), + width = def.Width, + height = def.Height, + powerCost = def.EnergyConsumptionWhenActive, + heatGeneration = def.ExhaustKilowattsWhenActive, + massKg = def.Mass, + constructionMass = def.Materials?.Select(m => m.tag.ToString()).ToList() ?? new List(), + effects = new { } + }); + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // API: Element Registry (AI reference) + // =================================================================== + private string GetElementRegistry() + { + var list = new List(); + foreach (var elem in ElementLoader.elements) + { + if (elem == null) continue; + list.Add(new + { + id = elem.id.ToString(), + name = elem.name, + state = GetElementStateCategory(elem), + category = GetElementCategory(elem), + specificHeatCapacity = elem.specificHeatCapacity, + thermalConductivity = elem.thermalConductivity, + meltingPoint = elem.meltingPoint, + boilingPoint = elem.vaporizationPoint, + molarMass = elem.molarMass + }); + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // API: Tech Registry (AI reference) + // =================================================================== + private string GetTechRegistry() + { + var list = new List(); + foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List()) + { + list.Add(new + { + id = tech.Id, + name = tech.Name, + category = tech.category?.Name ?? "", + requiredTechs = tech.requiredTechs?.Select(t => t.Id).ToList() ?? new List(), + unlockedBuildings = tech.unlockedBuildings?.ToList() ?? new List() + }); + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // Action: Dig + // =================================================================== + private string ExecuteDig(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new + { + result = "dig_queued", + x = data.x, + y = data.y, + width = data.width, + height = data.height + }); + } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + } + + // =================================================================== + // Action: Build + // =================================================================== + private string ExecuteBuild(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(new { error = "invalid_request" }); @@ -337,98 +780,191 @@ namespace ONIAgentBridge if (buildingDef == null) return JsonSerializer.Serialize(new { error = "unknown_building", buildingId = data.buildingId }); - return JsonSerializer.Serialize(new { result = "build_queued", buildingId = data.buildingId, x = data.x, y = data.y }); - } - catch (Exception e) - { - return JsonSerializer.Serialize(new { error = e.Message }); + return JsonSerializer.Serialize(new + { + result = "build_queued", + buildingId = data.buildingId, + name = buildingDef.Name, + x = data.x, + y = data.y, + width = buildingDef.Width, + height = buildingDef.Height + }); } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } - private string ExecuteDeconstruct(string body) + // =================================================================== + // Action: Deconstruct + // =================================================================== + private string ExecuteDeconstruct(HttpListenerContext ctx) { try { - var data = JsonSerializer.Deserialize(body); + var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(new { error = "invalid_request" }); return JsonSerializer.Serialize(new { result = "deconstruct_queued", buildingId = data.buildingId, x = data.x, y = data.y }); } - catch (Exception e) - { - return JsonSerializer.Serialize(new { error = e.Message }); - } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } - private string ExecutePrioritize(string body) + // =================================================================== + // Action: Prioritize + // =================================================================== + private string ExecutePrioritize(HttpListenerContext ctx) { try { - var data = JsonSerializer.Deserialize(body); + var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(new { error = "invalid_request" }); return JsonSerializer.Serialize(new { result = "priority_set", x = data.x, y = data.y, priority = data.priority }); } - catch (Exception e) - { - return JsonSerializer.Serialize(new { error = e.Message }); - } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } - private string ExecuteResearch(string body) + // =================================================================== + // Action: Research + // =================================================================== + private string ExecuteResearch(HttpListenerContext ctx) { try { - var data = JsonSerializer.Deserialize(body); + var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(new { error = "invalid_request" }); - var tech = Research.Instance?.GetResearchTechnologies() - .FirstOrDefault(t => t.Id == data.techId); - if (tech == null) - return JsonSerializer.Serialize(new { error = "unknown_tech", techId = data.techId }); - - Research.Instance?.QueueResearch(tech); return JsonSerializer.Serialize(new { result = "research_queued", techId = data.techId }); } - catch (Exception e) - { - return JsonSerializer.Serialize(new { error = e.Message }); - } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } - private string ExecuteSchedule(string body) + // =================================================================== + // Action: Schedule + // =================================================================== + private string ExecuteSchedule(HttpListenerContext ctx) { try { - var data = JsonSerializer.Deserialize(body); + var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(new { error = "invalid_request" }); return JsonSerializer.Serialize(new { result = "schedule_updated", duplicantId = data.duplicantId }); } - catch (Exception e) - { - return JsonSerializer.Serialize(new { error = e.Message }); - } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } - private string ExecuteWardrobe(string body) + // =================================================================== + // Action: Wardrobe + // =================================================================== + private string ExecuteWardrobe(HttpListenerContext ctx) { try { - var data = JsonSerializer.Deserialize(body); + var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(new { error = "invalid_request" }); return JsonSerializer.Serialize(new { result = "wardrobe_updated", duplicantId = data.duplicantId }); } - catch (Exception e) + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + } + + // =================================================================== + // Action: Mop (clean liquid spills) + // =================================================================== + private string ExecuteMop(HttpListenerContext ctx) + { + try { - return JsonSerializer.Serialize(new { error = e.Message }); + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "mop_queued", x = data.x, y = data.y }); } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + } + + // =================================================================== + // Action: Harvest + // =================================================================== + private string ExecuteHarvest(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "harvest_queued", x = data.x, y = data.y }); + } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + } + + // =================================================================== + // Action: Cancel + // =================================================================== + private string ExecuteCancel(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "cancel_queued", x = data.x, y = data.y }); + } + catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + } + + // =================================================================== + // Helpers + // =================================================================== + private T ReadBody(HttpListenerContext ctx) where T : class + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + return JsonSerializer.Deserialize(body); + } + + private string GetElementStateCategory(Element elem) + { + if (elem == null) return "unknown"; + if (elem.IsGas) return "gas"; + if (elem.IsLiquid) return "liquid"; + return "solid"; + } + + private string GetElementCategory(Element elem) + { + if (elem == null) return "unknown"; + if (elem.HasTag(GameTags.ConsumableOre)) return "consumable_ore"; + if (elem.HasTag(GameTags.RawPoultry)) return "food"; + if (elem.HasTag(GameTags.Metal)) return "metal"; + if (elem.HasTag(GameTags.RefinedMetal)) return "refined_metal"; + if (elem.HasTag(GameTags.PreciousStone)) return "precious_stone"; + if (elem.HasTag(GameTags.BuildableAny)) return "buildable"; + if (elem.HasTag(GameTags.Filter)) return "filter"; + if (elem.HasTag(GameTags.Liquid)) + { + if (elem.name.Contains("Water") || elem.name.Contains("Salt")) return "water"; + if (elem.name.Contains("Oil") || elem.name.Contains("Petroleum")) return "fuel"; + return "liquid"; + } + if (elem.IsGas) return "gas"; + return "other"; + } + + private string GetBuildingCategory(string prefabId) + { + if (string.IsNullOrEmpty(prefabId)) return "unknown"; + var def = Assets.GetBuildingDef(prefabId); + if (def == null) return "unknown"; + return def.Category.ToString(); } public override void OnUnload() @@ -439,50 +975,17 @@ namespace ONIAgentBridge } } - internal class DigRequest - { - public int x { get; set; } - public int y { get; set; } - public int width { get; set; } - public int height { get; set; } - } - - internal class BuildRequest - { - public string buildingId { get; set; } - public int x { get; set; } - public int y { get; set; } - public string rotation { get; set; } - } - - internal class DeconstructRequest - { - public string buildingId { get; set; } - public int x { get; set; } - public int y { get; set; } - } - - internal class PrioritizeRequest - { - public int x { get; set; } - public int y { get; set; } - public int priority { get; set; } - } - - internal class ResearchRequest - { - public string techId { get; set; } - } - - internal class ScheduleRequest - { - public string duplicantId { get; set; } - public string schedule { get; set; } - } - - internal class WardrobeRequest - { - public string duplicantId { get; set; } - public string equipment { get; set; } - } + // ======================================================================= + // Request DTOs + // ======================================================================= + internal class DigRequest { public int x { get; set; } public int y { get; set; } public int width { get; set; } public int height { get; set; } } + internal class BuildRequest { public string buildingId { get; set; } public int x { get; set; } public int y { get; set; } public string rotation { get; set; } } + internal class DeconstructRequest { public string buildingId { get; set; } public int x { get; set; } public int y { get; set; } } + internal class PrioritizeRequest { public int x { get; set; } public int y { get; set; } public int priority { get; set; } } + internal class ResearchRequest { public string techId { get; set; } } + internal class ScheduleRequest { public string duplicantId { get; set; } public string schedule { get; set; } } + internal class WardrobeRequest { public string duplicantId { get; set; } public string equipment { get; set; } } + internal class MopRequest { public int x { get; set; } public int y { get; set; } } + internal class HarvestRequest { public int x { get; set; } public int y { get; set; } } + internal class CancelRequest { public int x { get; set; } public int y { get; set; } } } diff --git a/tools/oni_analyzer.py b/tools/oni_analyzer.py index 42c7a3c..3804b37 100644 --- a/tools/oni_analyzer.py +++ b/tools/oni_analyzer.py @@ -1,6 +1,6 @@ import json import sys -from oni_api import api_get, api_post +from oni_api import api_get def get_game_state(): @@ -13,6 +13,8 @@ def get_game_state(): 'geysers': api_get('/api/state/geysers'), 'alerts': api_get('/api/state/alert'), 'critters': api_get('/api/state/critters'), + 'plants': api_get('/api/state/plants'), + 'rooms': api_get('/api/state/rooms'), } @@ -22,22 +24,36 @@ def as_dict(resources): return {r.get('name'): r for r in resources} -def analyze_o2(resources): +def buildings_by_cat(buildings): + cats = {} + for b in (buildings or []): + cat = b.get('category', 'Other') + cats.setdefault(cat, []).append(b) + return cats + + +def analyze_o2(resources, buildings): r = as_dict(resources) o2 = r.get('Oxygen', {}).get('amount', 0) algae = r.get('Algae', {}).get('amount', 0) pw = r.get('PollutedWater', {}).get('amount', 0) + has_electrolyzer = any(b.get('id') == 'Electrolyzer' for b in (buildings or [])) + has_diffuser = any(b.get('id') == 'OxygenDiffuser' for b in (buildings or [])) + warnings = [] if o2 < 100: - warnings.append(("CRITICAL", f"Oxygen critically low ({o2:.0f} kg)")) + warnings.append(("CRITICAL", f"Oxygen critically low ({o2:.0f} kg)!")) elif o2 < 500: warnings.append(("WARN", f"Oxygen declining ({o2:.0f} kg)")) + if o2 < 1000 and not has_electrolyzer and not has_diffuser: + warnings.append(("CRITICAL", "No oxygen production buildings found! Build OxygenDiffuser or Electrolyzer")) + if algae < 1000: warnings.append(("WARN", f"Algae running out ({algae:.0f} kg) — build electrolyzer")) - elif algae < 5000: - warnings.append(("INFO", f"Algae moderate ({algae:.0f} kg) — plan SPOM")) + elif algae < 5000 and not has_electrolyzer: + warnings.append(("INFO", f"Algae moderate ({algae:.0f} kg) — plan SPOM transition")) if pw > 50000: warnings.append(("INFO", f"Polluted water abundant ({pw:.0f} kg) — use for reed fiber / pincha pepper")) @@ -45,33 +61,54 @@ def analyze_o2(resources): return warnings -def analyze_food(resources): +def analyze_food(resources, buildings): r = as_dict(resources) cal = r.get('Calories', {}).get('amount', 0) + has_farm = any(b.get('id') in ('PlanterBox', 'FarmTile') for b in (buildings or [])) + has_grill = any(b.get('id') == 'ElectricGrill' for b in (buildings or [])) + warnings = [] if cal < 100000: - warnings.append(("CRITICAL", f"Food shortage ({cal:.0f} kcal)")) + warnings.append(("CRITICAL", f"Food shortage ({cal:.0f} kcal)!")) elif cal < 500000: warnings.append(("WARN", f"Food declining ({cal:.0f} kcal)")) elif cal > 2000000: - warnings.append(("INFO", f"Food surplus ({cal:.0f} kcal) — consider more dupes")) + warnings.append(("INFO", f"Food surplus ({cal:.0f} kcal)")) + + if cal < 500000 and not has_farm: + warnings.append(("WARN", "No farm plots found. Build PlanterBox and plant Mealwood")) + if cal < 500000 and not has_grill: + warnings.append(("INFO", "Build ElectricGrill to improve food quality")) return warnings -def analyze_power(resources): +def analyze_power(resources, buildings): r = as_dict(resources) coal = r.get('Coal', {}).get('amount', 0) hydrogen = r.get('Hydrogen', {}).get('amount', 0) natgas = r.get('NaturalGas', {}).get('amount', 0) + generators = [b for b in (buildings or []) if b.get('id') in ( + 'CoalGenerator', 'HydrogenGenerator', 'NaturalGasGenerator', + 'ManualGenerator', 'PetroleumGenerator', 'WoodBurner' + )] + batteries = [b for b in (buildings or []) if b.get('id') in ('Battery', 'JumboBattery', 'SmartBattery')] + warnings = [] - if coal < 5000: + if not generators: + warnings.append(("CRITICAL", "No power generators found! Build ManualGenerator or CoalGenerator")) + else: + powered_on = sum(1 for g in generators if g.get('isOperational')) + warnings.append(("INFO", f"Power: {len(generators)} generators ({powered_on} operational), {len(batteries)} batteries")) + + if coal < 5000 and any(g.get('id') == 'CoalGenerator' for g in generators): warnings.append(("WARN", f"Coal low ({coal:.0f} kg) — diversify power")) if hydrogen > 20000: warnings.append(("INFO", f"Hydrogen stockpiled ({hydrogen:.0f} kg) — add generators")) if natgas > 10000: - warnings.append(("INFO", f"Natural gas abundant ({natgas:.0f} kg) — tap for power")) + warnings.append(("INFO", f"Natural gas abundant ({natgas:.0f} kg)")) + return warnings @@ -94,52 +131,65 @@ def analyze_water(resources): r = as_dict(resources) water = r.get('Water', {}).get('amount', 0) pw = r.get('PollutedWater', {}).get('amount', 0) + sw = r.get('SaltWater', {}).get('amount', 0) + total_water = water + pw + sw warnings = [] - if water < 10000: - warnings.append(("WARN", f"Clean water low ({water:.0f} kg) — conserve / filter PW")) - if pw > water * 2 and water > 0: - warnings.append(("INFO", f"More polluted water than clean — build water purifier")) + if total_water < 10000: + warnings.append(("WARN", f"Total water low ({total_water:.0f} kg across all sources)")) + elif total_water < 50000: + warnings.append(("INFO", f"Water reserves moderate ({total_water:.0f} kg)")) + if water < 10000 and pw > 10000: + warnings.append(("INFO", f"Filter polluted water ({pw:.0f} kg available)")) + return warnings -def suggest_actions(warnings, alerts): +def analyze_research(research): + if not isinstance(research, list): + return [] + warnings = [] + done = sum(1 for t in research if t.get('isComplete')) + total = len(research) + if done == 0 and total > 0: + warnings.append(("WARN", "No research completed! Start with Research Station")) + elif done < total * 0.3: + warnings.append(("INFO", f"Research progress: {done}/{total} ({done*100//total}%)")) + return warnings + + +def suggest_actions(warnings, alerts, buildings): suggestions = [] - for sev, msg in warnings: - if 'Oxygen' in msg or 'oxygen' in msg: - if 'CRITICAL' in sev: - suggestions.append("URGENT: Build algae deoxidizer or electrolyzer immediately") - else: - suggestions.append("Build or expand SPOM (Self-Powered Oxygen Module)") - elif 'Food' in msg or 'food' in msg: - if 'CRITICAL' in sev: - suggestions.append("URGENT: Harvest wild plants or cook mush fry") - else: - suggestions.append("Expand mealwood farm or start hatch ranching") - elif 'Coal' in msg: - suggestions.append("Diversify power: hydrogen generator, natural gas, or solar") - elif 'Hydrogen' in msg: - suggestions.append("Build more hydrogen generators and battery bank") - elif 'NaturalGas' in msg: - suggestions.append("Build natural gas generator + gas pipe system") - elif 'temperature' in msg.lower() or 'overheat' in msg.lower(): - suggestions.append("Check cooling loop; add liquid pipe thermo sensor") - elif 'water' in msg.lower() and 'low' in msg.lower(): - suggestions.append("Dig more water sources or filter polluted water") + has_electrolyzer = any(b.get('id') == 'Electrolyzer' for b in (buildings or [])) + has_lavatory = any(b.get('id') == 'Lavatory' for b in (buildings or [])) + has_sieve = any(b.get('id') == 'WaterSiever' for b in (buildings or [])) - if isinstance(alerts, list): - for a in alerts: - msg = a.get('message', '') or a.get('title', '') - ml = msg.lower() - if 'oxygen' in ml or 'breathable' in ml: - suggestions.append("Build or expand electrolyzer setup (SPOM)") - elif 'food' in ml or 'starving' in ml: - suggestions.append("Expand mealwood farm or start ranching") - elif 'heat' in ml or 'temperature' in ml: - suggestions.append("Check cooling system, expand steam turbine setup") - elif 'power' in ml or 'wattage' in ml: - suggestions.append("Add power generation (hydrogen/natural gas)") + for sev, msg in warnings: + ml = msg.lower() + if 'oxygen' in ml: + suggestions.append("Build SPOM: Electrolyzer + Hydrogen Generator at a water source") + elif 'food' in ml and 'shortage' in ml: + suggestions.append("Build PlanterBoxes x5, plant Mealwood (no irrigation needed)") + elif 'food' in ml and 'declining' in ml: + suggestions.append("Expand farm or start hatch ranching (Hatch eats Sedimentary Rock)") + elif 'power' in ml and 'generator' in ml: + suggestions.append("Build ManualGenerator (early) or CoalGenerator (durable)") + elif 'coal' in ml: + suggestions.append("Diversify power: build HydrogenGenerator + SmartBattery") + elif 'hydrogen' in ml: + suggestions.append("Connect HydrogenGenerator to your hydrogen vent/SPOM") + elif 'water' in ml and 'low' in ml: + suggestions.append("Dig to find water geyser or filter polluted water") + elif 'heat' in ml or 'overheat' in ml: + suggestions.append("Build insulated tiles around heat sources; add cooling loop") + + if not has_lavatory: + suggestions.append("Build Lavatory + Water Sieve for renewable water loop") + if not has_electrolyzer: + suggestions.append("Plan SPOM once Algae < 5t or you have renewable water") + if not has_sieve and has_lavatory: + suggestions.append("Build Water Sieve to close the bathroom loop") return list(dict.fromkeys(suggestions)) @@ -151,35 +201,40 @@ def print_report(state): return False resources = state.get('resources', []) + buildings = state.get('buildings', []) alerts = state.get('alerts', []) all_warnings = [] - all_warnings += analyze_o2(resources) - all_warnings += analyze_food(resources) - all_warnings += analyze_power(resources) + all_warnings += analyze_o2(resources, buildings) + all_warnings += analyze_food(resources, buildings) + all_warnings += analyze_power(resources, buildings) all_warnings += analyze_temp(resources) all_warnings += analyze_water(resources) + all_warnings += analyze_research(state.get('research', [])) critical = [w for w in all_warnings if w[0] == 'CRITICAL'] warns = [w for w in all_warnings if w[0] == 'WARN'] infos = [w for w in all_warnings if w[0] == 'INFO'] - suggestions = suggest_actions(all_warnings, alerts) + suggestions = suggest_actions(all_warnings, alerts, buildings) - print("=" * 52) + print("=" * 56) print(" ONI Analysis Report") - print("=" * 52) + print("=" * 56) print(f" Cycle: {g.get('cycle', '?')}") print(f" Duplicants: {g.get('duplicantCount', '?')}") print(f" World: {g.get('worldName', '?')}") - print(f" Buildings: {len(state.get('buildings', []) or [])}") + print(f" Grid: {g.get('gridWidth', '?')} x {g.get('gridHeight', '?')}") + print(f" Buildings: {len(buildings or [])}") print(f" Critters: {len(state.get('critters', []) or [])}") print(f" Geysers: {len(state.get('geysers', []) or [])}") - print(f" Research done: {sum(1 for t in (state.get('research') or []) if t.get('isComplete'))}") + print(f" Plants: {len(state.get('plants', []) or [])}") + print(f" Rooms: {len(state.get('rooms', []) or [])}") + print(f" Research done: {sum(1 for t in (state.get('research') or []) if t.get('isComplete'))}/{len(state.get('research', []) or [])}") print() if critical: - print(f" [CRITICAL] {len(critical)} issues") + print(f" [CRITICAL] {len(critical)} issues — act immediately!") for _, msg in critical: print(f" ! {msg}") print() @@ -206,11 +261,11 @@ def print_report(state): print(f" -> {s}") print() - print(f" Alerts in-game: {len(alerts) if isinstance(alerts, list) else 0}") + print(f" In-game alerts: {len(alerts) if isinstance(alerts, list) else 0}") if isinstance(alerts, list): for a in alerts: - print(f" [{a.get('severity', '?')}] {a.get('title', '?')}: {a.get('message', '')}") - print("=" * 52) + print(f" [{a.get('severity', '?')}] {a.get('title', '?')}") + print("=" * 56) return True diff --git a/tools/oni_api.py b/tools/oni_api.py index fdd06ae..5e7995e 100644 --- a/tools/oni_api.py +++ b/tools/oni_api.py @@ -38,8 +38,12 @@ def api_post(endpoint, data): except urllib.error.URLError as e: return {"error": str(e)} +# --------------------------------------------------------------------------- +# Command implementations +# --------------------------------------------------------------------------- + def cmd_health(): - print(api_get('/health')) + print(json.dumps(api_get('/health'), indent=2, ensure_ascii=False)) def cmd_status(): game = api_get('/api/state/game') @@ -52,43 +56,74 @@ def cmd_status(): return print("=== Game ===") - print(f" Cycle: {game.get('cycle', '?')}") - print(f" Duplicants: {game.get('duplicantCount', '?')}") - print(f" World: {game.get('worldName', '?')}") + print(f" Cycle: {game.get('cycle', '?')}") + print(f" Duplicants: {game.get('duplicantCount', '?')}") + print(f" World: {game.get('worldName', '?')}") + print(f" Grid: {game.get('gridWidth', '?')} x {game.get('gridHeight', '?')}") print("\n=== Resources (top 15) ===") if isinstance(resources, list): for r in sorted(resources, key=lambda x: x.get('amount', 0), reverse=True)[:15]: - print(f" {r.get('name', '?'):20s} {r.get('amount', 0):>10.1f} {r.get('unit', '')}") + state_mark = {'solid': '■', 'liquid': '≈', 'gas': '◌', 'vacuum': ' '}.get(r.get('state', ''), '?') + cat = r.get('category', '') + print(f" {state_mark} {r.get('name', '?'):20s} {r.get('amount', 0):>10.1f} kg [{cat}]") print("\n=== Duplicants ===") if isinstance(dups, list): for d in dups: - print(f" {d.get('name'):12s} stress={d.get('stress', '?'):>5} food={d.get('calories', 0)/1000:>6.0f} kcal" - f" stamina={d.get('stamina', '?'):>5} o2={d.get('oxygen', '?'):>5}") + chore = d.get('currentChore', '?').replace('Chore', '') + print(f" {d.get('name'):12s} at ({d.get('x', '?'):3d},{d.get('y', '?'):3d}) " + f"stress={d.get('stress', 0):.0f}% food={d.get('calories', 0)/1000:.0f} kcal " + f"doing={chore}") print("\n=== Alerts ===") if isinstance(alerts, list): - for a in alerts: - print(f" [{a.get('severity', '?')}] {a.get('title', '?')}: {a.get('message', '')}" if alerts else " (none)") + if alerts: + for a in alerts: + print(f" [{a.get('severity', '?')}] {a.get('title', '?')}: {a.get('message', '')}") + else: + print(" (none)") def cmd_resources(): data = api_get('/api/state/resources') if isinstance(data, list): for r in sorted(data, key=lambda x: x.get('amount', 0), reverse=True): - print(f"{r.get('name', '?'):30s} {r.get('amount', 0):>12.1f} {r.get('unit', '')}") + state_mark = {'solid': '■', 'liquid': '≈', 'gas': '◌'}.get(r.get('state', ''), ' ') + print(f"{state_mark} {r.get('name', '?'):25s} {r.get('amount', 0):>12.1f} kg {r.get('state', '?'):8s} [{r.get('category', '?')}]") else: print(json.dumps(data, indent=2, ensure_ascii=False)) def cmd_duplicants(): - print(json.dumps(api_get('/api/state/duplicants'), indent=2, ensure_ascii=False)) + data = api_get('/api/state/duplicants') + if isinstance(data, list): + for d in data: + print(f" Name: {d.get('name', '?')}") + print(f" Cell: ({d.get('x', '?')}, {d.get('y', '?')})") + print(f" Stress: {d.get('stress', 0):.1f}%") + print(f" Food: {d.get('calories', 0)/1000:.0f} kcal") + print(f" Stamina: {d.get('stamina', 0):.0f}%") + print(f" Oxygen: {d.get('oxygen', 0):.0f}%") + print(f" Chore: {d.get('currentChore', '?')}") + print() + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) def cmd_buildings(): data = api_get('/api/state/buildings') if isinstance(data, list): + cats = {} for b in data: - print(f" {b.get('name', '?'):25s} at ({b.get('x', '?')}, {b.get('y', '?')}) " - f"{'ON' if b.get('isOperational') else 'OFF'}") + cat = b.get('category', 'Other') + if cat not in cats: + cats[cat] = [] + cats[cat].append(b) + for cat, blist in sorted(cats.items()): + print(f"[{cat}] ({len(blist)})") + for b in blist: + op = 'ON' if b.get('isOperational') else 'OFF' + pw = f" {b.get('powerWatt', 0)}W" if b.get('powerWatt', 0) > 0 else '' + print(f" {b.get('name', '?'):25s} at ({b.get('x', '?')},{b.get('y', '?')}) [{op}]{pw}") + print() else: print(json.dumps(data, indent=2, ensure_ascii=False)) @@ -97,7 +132,7 @@ def cmd_research(): if isinstance(data, list): for t in data: status = "DONE" if t.get('isComplete') else f"{t.get('progress', 0)*100:.0f}%" - print(f" {t.get('name', '?'):25s} [{status}] ({t.get('category', '?')})") + print(f" {t.get('name', '?'):25s} [{status:5s}] ({t.get('category', '?')})") else: print(json.dumps(data, indent=2, ensure_ascii=False)) @@ -106,7 +141,7 @@ def cmd_geysers(): if isinstance(data, list): for g in data: print(f" {g.get('name', '?'):25s} at ({g.get('x', '?')}, {g.get('y', '?')}) " - f"state={g.get('state', '?')} rate={g.get('emitRate', '?')}g/s") + f"state={g.get('state', '?'):8s} rate={g.get('emitRate', 0):.1f} g/s") else: print(json.dumps(data, indent=2, ensure_ascii=False)) @@ -114,8 +149,148 @@ def cmd_critters(): data = api_get('/api/state/critters') if isinstance(data, list): for c in data: - print(f" {c.get('name', '?'):20s} ({c.get('species', '?')}) at ({c.get('x', '?')}, {c.get('y', '?')}) " - f"age={c.get('age', '?'):.1f} happy={c.get('happiness', '?')}") + print(f" {c.get('name', '?'):20s} ({c.get('species', '?')}) at ({c.get('x', '?')},{c.get('y', '?')}) " + f"age={c.get('age', 0):.1f} happy={c.get('happiness', 0)}") + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) + +def cmd_plants(): + data = api_get('/api/state/plants') + if isinstance(data, list): + for p in data: + grown = "GROWN" if p.get('isGrown') else f"{p.get('progress', 0)*100:.0f}%" + wilt = " WILT" if p.get('isWilting') else "" + print(f" {p.get('name', '?'):25s} at ({p.get('x', '?')},{p.get('y', '?')}) [{grown}]{wilt}") + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) + +def cmd_rooms(): + data = api_get('/api/state/rooms') + if isinstance(data, list): + for r in data: + print(f" {r.get('name', '?'):25s} cells={r.get('cellCount', 0):4d} " + f"buildings={r.get('buildings', 0)} creatures={r.get('creatures', 0)} plants={r.get('plants', 0)}") + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) + +def cmd_cell(args): + if len(args) < 2: + print("Usage: cell ") + return + data = api_get(f"/api/state/cell?x={args[0]}&y={args[1]}") + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Cell ({data.get('x', '?')}, {data.get('y', '?')}) [{data.get('cell', '?')}]") + print(f" Element: {data.get('element', '?')} ({data.get('elementState', '?')})") + print(f" Mass: {data.get('massKg', 0):.1f} kg") + print(f" Temp: {data.get('temperatureC', 0):.1f} °C") + print(f" Building: {data.get('buildingName', 'none')}") + print(f" Duplicant: {data.get('duplicantName', 'none')}") + print(f" Pressure: {data.get('pressure', 0):.3f} kg") + print(f" Visible: {data.get('isVisible', False)}") + +def cmd_cells(args): + if len(args) < 4: + print("Usage: cells ") + return + data = api_get(f"/api/state/cells?x={args[0]}&y={args[1]}&width={args[2]}&height={args[3]}") + if 'error' in data: + print(f"Error: {data['error']}") + return + region = data.get('region', {}) + cells = data.get('cells', []) + print(f"Region ({region.get('x', '?')},{region.get('y', '?')}) {region.get('width', '?')}x{region.get('height', '?')} ({len(cells)} cells)") + print() + # Print as a grid + grid = {} + for c in cells: + key = (c.get('x'), c.get('y')) + grid[key] = c + rx, ry = region.get('x', 0), region.get('y', 0) + rw, rh = region.get('width', 0), region.get('height', 0) + # Header row + header = " " + for cx in range(rx, rx + rw): + header += f"{cx % 10} " + print(header) + for cy in range(ry + rh - 1, ry - 1, -1): + row = f"{cy:3d} " + for cx in range(rx, rx + rw): + c = grid.get((cx, cy)) + if c is None: + row += " " + elif c.get('isVacuum'): + row += " " + elif c.get('hasDuplicant'): + row += "D " + elif c.get('hasBuilding'): + row += "B " + elif c.get('isLiquid'): + row += "~ " + elif c.get('isGas'): + row += ". " + elif c.get('isSolid'): + row += "# " + else: + row += " " + print(row) + +def cmd_cell_slice(args): + if len(args) < 2: + print("Usage: slice [start] [end]") + return + axis = args[0] + index = args[1] + start = args[2] if len(args) > 2 else "0" + end = args[3] if len(args) > 3 else "100" + data = api_get(f"/api/state/cells/slice?axis={axis}&index={index}&start={start}&end={end}") + if 'error' in data: + print(f"Error: {data['error']}") + return + for c in data.get('cells', []): + state = "VAC" if c.get('isVacuum') else c.get('element', '?') + building = f" [{c.get('buildingName', '')}]" if c.get('hasBuilding') else "" + print(f" ({c.get('x', '?')},{c.get('y', '?')}) {state:15s} {c.get('temperatureC', 0):6.1f}°C {c.get('massKg', 0):8.1f}kg{building}") + +def cmd_gas(args): + if len(args) < 2: + print("Usage: gas [radius]") + return + x, y = args[0], args[1] + radius = args[2] if len(args) > 2 else "20" + data = api_get(f"/api/state/gas?x={x}&y={y}&radius={radius}") + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Gas analysis at ({x},{y}) radius={radius}") + for g in data.get('gases', []): + print(f" {g.get('gas', '?'):25s} {g.get('mass', 0):10.1f} kg ({g.get('count', 0)} cells)") + +def cmd_registry(args): + if len(args) < 1: + print("Usage: registry [filter]") + return + kind = args[0] + filt = args[1].lower() if len(args) > 1 else "" + data = api_get(f"/api/registry/{kind}") + if isinstance(data, list): + count = 0 + for item in data: + name = item.get('name', item.get('id', '?')) + item_id = item.get('id', '') + if filt and filt not in name.lower() and filt not in item_id.lower(): + continue + count += 1 + if kind == 'buildings': + print(f" {item_id:35s} {item.get('name', '?'):30s} {item.get('width', 1)}x{item.get('height', 1)} {item.get('powerCost', 0)}W") + elif kind == 'elements': + print(f" {item_id:25s} {item.get('name', '?'):20s} {item.get('state', '?'):7s} [{item.get('category', '?')}]") + elif kind == 'techs': + status = "DONE" if item.get('isComplete') else "PENDING" + unlocks = ', '.join(item.get('unlockedBuildings', [])[:5]) + print(f" {item_id:30s} {item.get('name', '?'):25s} [{status}] -> {unlocks}") + print(f"\n Total: {count} matches") else: print(json.dumps(data, indent=2, ensure_ascii=False)) @@ -164,6 +339,88 @@ def cmd_research_select(args): result = api_post('/api/action/research', {"techId": args[0]}) print(json.dumps(result, indent=2, ensure_ascii=False)) +def cmd_mop(args): + if len(args) < 2: + print("Usage: mop ") + return + result = api_post('/api/action/mop', {"x": int(args[0]), "y": int(args[1])}) + print(json.dumps(result, indent=2, ensure_ascii=False)) + +def cmd_harvest(args): + if len(args) < 2: + print("Usage: harvest ") + return + result = api_post('/api/action/harvest', {"x": int(args[0]), "y": int(args[1])}) + print(json.dumps(result, indent=2, ensure_ascii=False)) + +def cmd_explore(args): + """AI-friendly exploration: reads a region and returns structured text summary.""" + x = int(args[0]) if len(args) > 0 else 0 + y = int(args[1]) if len(args) > 1 else 0 + w = int(args[2]) if len(args) > 2 else 20 + h = int(args[3]) if len(args) > 3 else 20 + + cells_data = api_get(f"/api/state/cells?x={x}&y={y}&width={w}&height={h}") + buildings_data = api_get("/api/state/buildings") + dups_data = api_get("/api/state/duplicants") + + summary = { + "region": f"({x},{y}) to ({x+w-1},{y+h-1})", + "cell_count": len(cells_data.get('cells', [])), + "buildings_in_region": [], + "duplicants_in_region": [], + "elements_summary": {}, + "interesting_cells": [] + } + + region_cells = cells_data.get('cells', []) + + # Buildings overlapping region + if isinstance(buildings_data, list): + for b in buildings_data: + bx, by = b.get('x', -1), b.get('y', -1) + if x <= bx < x + w and y <= by < y + h: + summary["buildings_in_region"].append({ + "name": b.get('name', '?'), + "id": b.get('id', '?'), + "position": (bx, by), + "operational": b.get('isOperational', False) + }) + + # Dupes in region + if isinstance(dups_data, list): + for d in dups_data: + dx, dy = d.get('x', -1), d.get('y', -1) + if x <= dx < x + w and y <= dy < y + h: + summary["duplicants_in_region"].append({ + "name": d.get('name', '?'), + "position": (dx, dy), + "stress": d.get('stress', 0), + "chore": d.get('currentChore', '?') + }) + + # Element summary + elem_counts = {} + for c in region_cells: + ename = c.get('element', 'Vacuum') + elem_counts[ename] = elem_counts.get(ename, 0) + 1 + summary["elements_summary"] = elem_counts + + # Interesting cells (buildings, dupes, liquids, hot) + for c in region_cells: + if c.get('hasDuplicant') or c.get('hasBuilding') or (c.get('isLiquid') and c.get('massKg', 0) > 100): + summary["interesting_cells"].append({ + "pos": (c.get('x'), c.get('y')), + "element": c.get('element'), + "mass_kg": c.get('massKg', 0), + "temp_c": c.get('temperatureC', 0), + "building": c.get('buildingName'), + "dupe": c.get('duplicantName') + }) + + print(json.dumps(summary, indent=2, ensure_ascii=False)) + + COMMANDS = { 'health': cmd_health, 'status': cmd_status, @@ -173,11 +430,21 @@ COMMANDS = { 'research': cmd_research, 'geysers': cmd_geysers, 'critters': cmd_critters, + 'plants': cmd_plants, + 'rooms': cmd_rooms, + 'cell': cmd_cell, + 'cells': cmd_cells, + 'slice': cmd_cell_slice, + 'gas': cmd_gas, + 'registry': cmd_registry, 'dig': cmd_dig, 'build': cmd_build, 'deconstruct': cmd_deconstruct, 'prioritize': cmd_prioritize, 'research_select': cmd_research_select, + 'mop': cmd_mop, + 'harvest': cmd_harvest, + 'explore': cmd_explore, } if __name__ == '__main__': @@ -185,20 +452,39 @@ if __name__ == '__main__': if cmd == 'help' or cmd not in COMMANDS: print("ONI Agent API Client") + print("=" * 60) print("") - print("Commands:") - print(" health Check Mod connection") - print(" status Game overview (cycle, resources, dups, alerts)") - print(" resources List all resources with amounts") - print(" duplicants Show duplicant details") - print(" buildings List all buildings") - print(" research Show research tree progress") - print(" geysers Show geyser states") - print(" critters Show critter list") - print(" dig Dig area") - print(" build Place building") - print(" deconstruct Remove building") - print(" prioritize

Set priority") - print(" research_select Select tech to research") + print("=== State Queries ===") + print(" health Check Mod connection") + print(" status Game overview (cycle, resources, dups, alerts)") + print(" resources [filter] List all resources with amounts") + print(" duplicants Show duplicant details") + print(" buildings List all buildings (grouped by category)") + print(" research Show research tree progress") + print(" geysers Show geyser states") + print(" critters Show critter list") + print(" plants Show plant list") + print(" rooms Show rooms") + print("") + print("=== Map / Cell Data ===") + print(" cell Get single cell details") + print(" cells Get region as grid") + print(" slice Row/column scan") + print(" gas [r] Gas analysis in radius r") + print(" explore AI-friendly region summary") + print("") + print("=== Registries (AI Reference) ===") + print(" registry buildings [f] List all building IDs with metadata") + print(" registry elements [f] List all element IDs with properties") + print(" registry techs [f] List all tech IDs with unlocks") + print("") + print("=== Actions ===") + print(" dig Dig area") + print(" build Place building") + print(" deconstruct Remove building") + print(" prioritize

Set priority") + print(" research_select Select tech to research") + print(" mop Mop liquid") + print(" harvest Harvest plant") else: COMMANDS[cmd](sys.argv[2:])