From 3d70338087377600e733c1b29ae355ff7756a015 Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Sat, 30 May 2026 11:58:45 +0800 Subject: [PATCH] v2.1.0 Complete rewrite: proper Mod API, Python toolchain, auto-camera, comprehensive SKILL --- SKILL.md | 769 +++++++--------- mod/ONIAgentBridge.cs | 1296 +++++++++++++++++++++++---- mod/mod.yaml | 3 +- mod/mod_info.yaml | 2 +- scripts/event_daemon.py | 316 ++----- skills/oni_agent.md | 558 +++--------- tools/oni_analyzer.py | 396 +++------ tools/oni_api.py | 1828 ++++++++------------------------------- tools/oni_builder.py | 278 +++--- tools/oni_commander.py | 540 +++++------- 10 files changed, 2479 insertions(+), 3507 deletions(-) diff --git a/SKILL.md b/SKILL.md index 54a6379..76e257d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,489 +1,328 @@ -# Oxygen Not Included (ONI) Agent +# Oxygen Not Included (ONI) AI Agent — 完整游玩指南 -## 职责 -协助玩家操作和管理游?缺氧"(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 # 本文?``` +你通过 REST API + Python CLI 工具链完全控制《缺氧》。你的目标是**让复制人存活并建立自持基地**。 --- -## 重要概念:理?ONI 的数据模? -### 1. 坐标? -ONI 使用二维方格(tile)系统。AI 必须理解坐标系才能正确操作: +## 一、核心概念 +### 1.1 坐标系 +- (0,0) = 地图**左下角**,x→右,y→上 +- 每个格子 (cell) 1×1,通过 (x,y) 唯一定位 +- 建筑坐标 = 其**左下角锚点** +- 地图大小:`/api/state/game` → gridWidth × gridHeight(通常 256×384) +- **自动拉视角**:dig/build/deconstruct/prioritize 会自动将镜头移到坐标 + +### 1.2 格子数据 +通过 `cell ` 查看: ``` - y ? ? ┌────┬────┬────? ? ?5,5)?6,5)?7,5)? ? ├────┼────┼────? ? ?5,4)?6,4)?7,4)? ?这个格子 (6,4) 包含一个电解器 - ? ├────┼────┼────? ? ?5,3)?6,3)?7,3)? ? └────┴────┴────? └──────────────────────────?x - (0,0) +element: "Oxygen" | "Water" | "SandStone" | "Vacuum" +massKg: 质量 | temperatureC: 温度 +isSolid / isLiquid / isGas / isVacuum: 物态 +isDiggable: 可挖掘(固体且非Unobtanium) +hasBuilding: 有建筑 | hasDuplicant: 有复制人 ``` -- **原点 (0,0)** 在地?*左下?* -- **x ?*向右增加?*y ?*向上增加 -- 每个格子 (cell) 有唯一?(x, y) 坐标 -- 建筑占用 w×h 个格子,其坐标是**左下角锚?* -- 世界大小通过 `/api/state/game` 查询(`gridWidth` x `gridHeight`?- 典型地图: ~256 x 384 ? -### 2. 理解格子状? -每格的数据结构如下(通过 `/api/state/cell?x=&y=` 查询): +### 1.3 可达性 (AI 必须理解) +复制人只能通过**开放空间**移动。障碍物和规则: +- **固体方块**(isSolid=true)阻挡移动——需要挖掘 +- **液体/气体** 不阻挡移动 +- **梯子 (Ladder)** 提供垂直移动 +- **火棒 (FirePole)** 提供快速下落 +- **门 (Door/PneumaticDoor)** 控制通行 +- **砖块 (Tile)** 提供稳固地面 +- **区域必须互相连通**:被固体完全包围的空间不可达 +- **水位**:超过复制人高度的液体(约800kg/tile)会减慢甚至阻止移动 -```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` 与游戏通信 -- 端口?`config.json` 中配置(默认 23876? ---- - -## 可用 API 端点 - -### 状态查?(GET) - -| 端点 | 说明 | 用?| +### 1.4 方块类型 +| 方块 | 功能 | 材料 | |------|------|------| -| `/api/state/buildable` | 当前科技解锁的建?| 查看 AI 现在能造什?| -| `/api/state/research` | 科技树(进度/解锁的建筑) | 科研规划 | -| `/api/state/geysers` | 喷泉(位?状?排放率) | 资源规划 | -| `/api/state/alert` | 警报列表 | 紧急处?| -| `/api/state/critters` | 小动物(位置/种类/幸福?年龄?| 养殖管理 | -| `/api/state/plants` | 植物(位?生长进度/是否枯萎?| 农业管理 | -| `/api/state/rooms` | 房间(类?格数/建筑数) | 房间判定 | +| Tile (砖块) | 地板/天花板,提供站立面 | RawMineral | +| Ladder (梯子) | 垂直攀爬 | RawMineral | +| FirePole (火棒) | 快速下落(比梯子快) | Metal | +| PneumaticDoor (气动门) | 控制进出 | Metal | +| ManualDoor (手动门) | 初期手动门 | RawMineral | +| InsulationTile (隔热砖) | 阻止热传递 | RawMineral + Ceramic | +| WindowTile (透光砖) | 透光地板 | Glass | +| MeshTile (网格砖) | 透水透气的砖块 | Metal | +| PlasticTile (塑料砖) | 高装饰地板 | Plastic | +| CarpetTile (地毯砖) | 高装饰 | Reed Fiber + Plastic | -### 地图/格子数据 (GET) +### 1.5 材料系统 +建筑需要**特定类别的材料**。游戏把材料分为以下类别: -| 端点 | 说明 | 示例 | +| 材料类别 | 包含元素 | 如何获得 | +|----------|---------|---------| +| **BuildableRaw** | SandStone, Granite, IgneousRock, SedimentaryRock, Obsidian, MaficRock | 挖掘 | +| **RawMineral** | 同上(BuildableRaw的子集) | 挖掘 | +| **Metal** | Cuprite(铜矿), IronOre(铁矿), GoldAmalgam(金汞齐), Wolframite(钨矿), Cobaltite(钴矿) | 挖掘 → 精炼 | +| **RefinedMetal** | Copper(铜), Iron(铁), Gold(金), Steel(钢), Tungsten(钨) | 在精炼炉/金属精炼器中生产 | +| **Plastic** | Polypropylene(塑料) | 聚合压机制造 | +| **Glass** | Glass(玻璃) | 窑炉烧制砂→熔融玻璃→冷却 | +| **BuildingFiber** | Reed Fiber(芦苇纤维) | 种植芦苇→收获 | +| **Transparent** | Diamond(钻石), Glass(玻璃) | 挖掘或制造 | +| **Farmable** | Dirt(泥土) | 挖掘或堆肥 | + +**AI 必须理解**:造建筑时,需要查看世界库存中**有哪些元素满足该类别**。 +例如 `ManualGenerator` 需要 Material=`['Metal']`,世界库存中有 Cuprite(铜矿) 和 IronOre(铁矿),AI 应选择存量最多的。 + +### 1.6 挖掘系统 +- 挖掘命令:`dig ` → 在矩形区域内标记所有可挖掘格子 +- 可挖掘条件:`isSolid === true && element !== Unobtanium && element !== Vacuum` +- 挖掘需要**任务排队**:复制人把挖掘任务加入队列后才会执行 +- 挖掘完成后:该格变为真空或气体(取决于背后的元素) +- 挖掘产物:掉落物 → 复制人会捡起→放入附近储存箱 +- **必须在要建造的区域先挖掘**,因为建筑不能放置在固体方块上 + +### 1.7 任务队列与优先级 +- 游戏内所有操作(挖掘/建造/运输/研究)都由复制人执行 +- 优先级 1-9(9最高)影响复制人选择顺序 +- 全局优先级默认5。紧急任务设为9,非紧急设为1 + +--- + +## 二、分阶段游戏目标 + +### 第一阶段:初期基地 (Cycle 1-20) — 生存基础 + +**目标:建立基本生存设施** + +**检查清单:** +``` +□ 手动发电机(ManualGenerator) ×1 — 提供电力 +□ 电池(Battery) ×1 — 储电 +□ 氧气扩散器(OxygenDiffuser) ×1 — 用藻类产氧(如有) +□ 或电解器(Electrolyzer) + 水泵 — 用水产氧 +□ 厕所(Outhouse/Lavatory) ×1-2 — 控制污染 +□ 洗手盆(WashBasin) ×1 — 防止食物中毒 +□ 床(Cot) ×3 — 每复制人1张 +□ 配给盒(RationBox) ×1 — 储食(放在CO₂中防腐) +□ 种植箱(PlanterBox) ×3-5 — 种Mealwood产食物 +□ 研究站(ResearchStation) ×1 — 开始科研 +□ 储存箱(StorageLocker) ×N — 分类储料 +``` + +**关键决策点:** +- **氧气路径选择**:藻类 >200kg 可以用OxygenDiffuser;否则必须尽快建造 Electrolyzer +- **电力规划**:先手工(ManualGenerator),尽快过渡到燃煤(CoalGenerator) +- **食物规划**:Mealwood(用泥土)是最简单的初期食物来源 + +**我该怎么做:** +```bash +# 1. 暂停游戏 +python tools/oni_api.py pause "Phase 1 - Initial base" +# 2. 查看状态 +python tools/oni_api.py status +# 3. 探索起始区域(通常在120-140, 180-200附近) +python tools/oni_api.py explore 120 180 40 30 +# 4. 检查资源 +python tools/oni_api.py resources +# 5. 挖掘基地空间 +python tools/oni_api.py dig 120 180 20 15 +# 6. 建造初期设施(检查材料是否足够) +python tools/oni_api.py build ManualGenerator 125 192 +python tools/oni_api.py build Battery 123 192 +python tools/oni_api.py build ResearchStation 125 189 +python tools/oni_api.py build Cot 120 192 +# 7. 开始研究(农牧技术 → 电力调节) +python tools/oni_api.py research_select FarmingTech +# 8. 恢复游戏 +python tools/oni_api.py unpause 1 +``` + +### 第二阶段:中期扩张 (Cycle 20-100) — 自持系统 + +**目标:建立闭环系统,摆脱对初始资源的依赖** + +**检查清单:** +``` +□ SPOM(电解器+氢气发电机)— 无限氧气+部分电力 +□ 卫生间水循环(马桶→净水器→马桶)— 无限水循环 +□ 燃煤发电机 ×1-2 — 主电力来源 +□ 变压器(Transformer) — 电路分离,防止过载 +□ 食物农场扩张 — ≥6个种植箱或4个农场砖 +□ 医疗床(TriageCot) — 治疗受伤复制人 +□ 装饰物 — 缓解压力(参考房间系统) +□ 隔热砖包围基地 — 阻止外部高温进入 +□ 气压服存放柜(OxygenMaskLocker) — 探索外部 +``` + +**关键系统详解:** +- **SPOM**:水→电解器→O₂+H₂→H₂发电机(发电)+O₂排入基地 + - 需要:水源(初始水池或喷泉)+ 电解器 + 气泵 + 氢气发电机 + 气体过滤器 + - 使用蓝图:`python tools/oni_builder.py build spom ` +- **卫生间水循环**:抽水马桶→水泵→净水器→抽水马桶 + - 净水器消耗过滤介质(沙/砂石),排出清水+污染物(污染土) + - 使用蓝图:`python tools/oni_builder.py build toilet_loop ` +- **电力管理**:单路电线最大1000W,超过=过载损坏 + - 使用变压器分成小电路:发电线路(重导线) → 变压器 → 用电线路(普通导线) + - 检查电力:`/api/state/power` 查看各电路负载(需要Mod实现) + +**科研优先级:** +1. FarmingTech → 食物生产 +2. PowerRegulation → 变压器+重导线 +3. Plumbing → 厕所水循环 +4. Ventilation → 气泵+管道 +5. Refinement → 精炼金属+玻璃 +6. TemperatureModulation → 温度控制 + +### 第三阶段:后期自动化 (Cycle 100-300) — 全面自持 + +**目标:全自动化,应对长期挑战** + +**检查清单:** +``` +□ 石油发电 / 天然气发电 — 稳定大容量电力 +□ 液冷模块(蒸汽机+导热管)— 主动降温 +□ 自动化控制(自动化线+传感器)— 智能管理 +□ 喷泉开发(冷水泉/蒸汽泉/天然气泉)— 无限资源 +□ 畜牧模块(哈奇/滑鳞/飞鱼)— 稳定食物+材料 +□ 太空探索(望远镜+火箭)— 终极资源 +□ 精炼系统(金属精炼器/聚合压机/窑炉) +``` + +**温度控制:** +- 生物生存温度:-10°C ~ 40°C +- 高温来源:发电机/精炼器/电池 → 需隔热+冷却 +- 冷却方案:Aquatuner(导热管)+ SteamTurbine(蒸汽机) → 主动热删除 +- 隔热方案:InsulationTile + Vacuum气密层 + +**房间系统:** +房间提供重要加成。房间判定条件: +- **卧室(Bedroom)**:4×床 + 封闭空间 + 门 → +1士气 +- **卫生间(Washroom)**:洗手盆+马桶+封闭 → +1士气 +- **餐厅(MessHall)**:餐桌+食物盒 → +2士气 +- **温室(Greenhouse)**:种植箱+光照+封闭 → 植物生长+50% + +--- + +## 三、建筑数据库(AI 常用) + +| buildingId | 尺寸 | 材料 | 耗电 | 用途 | +|-----------|------|------|------|------| +| ManualGenerator | 2×2 | Metal | -400W | 人力发电 | +| CoalGenerator | 2×2 | Metal | -600W | 燃煤发电 | +| HydrogenGenerator | 2×2 | Metal | -800W | 燃氢发电 | +| Electrolyzer | 2×2 | Metal | +120W | 水→O₂+H₂ | +| OxygenDiffuser | 2×2 | Metal | +120W | 藻类→O₂ | +| GasPump | 1×2 | Metal | +240W | 抽气 | +| GasFilter | 1×2 | Metal | +120W | 过滤气体 | +| WaterPump | 1×2 | Metal | +240W | 抽水 | +| LiquidPump | 1×2 | Metal | +240W | 抽任意液体 | +| LiquidFilter | 1×2 | Metal | +120W | 过滤液体 | +| WaterPurifier | 2×2 | Metal | +120W | 污水→清水 | +| PlanterBox | 1×1 | BuildableRaw | 0 | 种植食物 | +| FarmTile | 1×1 | BuildableRaw | 0 | 灌溉种植 | +| StorageLocker | 1×1 | BuildableRaw | 0 | 储存 | +| RationBox | 2×2 | BuildableRaw | 0 | 储食 | +| Cot | 1×1 | BuildableRaw | 0 | 床 | +| Lavatory | 1×2 | Metal | 0 | 抽水马桶 | +| WashBasin | 1×1 | BuildableRaw | 0 | 洗手 | +| Outhouse | 1×2 | BuildableRaw | 0 | 初期厕所 | +| Tile | 1×1 | BuildableRaw | 0 | 地板 | +| InsulationTile | 1×1 | BuildableRaw | 0 | 隔热墙 | +| Ladder | 1×1 | BuildableRaw | 0 | 梯子 | +| FirePole | 1×1 | Metal | 0 | 火棒 | +| PneumaticDoor | 1×2 | Metal | 0 | 气压门 | +| Battery | 1×1 | Metal | 0 | 电池(1000J) | +| Transformer | 2×1 | Metal | 0 | 变压器 | +| ResearchStation | 2×2 | BuildableRaw | +60W | 初级研究 | +| SuperComputer | 2×2 | Plastic | +120W | 高级研究 | +| SteamTurbine | 4×2 | Metal | -850W | 发电+热删除 | +| Aquatuner | 1×2 | RefinedMetal | +1200W | 液体冷却 | +| Kiln | 1×2 | BuildableRaw | 0 | 烧陶瓷/玻璃 | +| MetalRefinery | 3×2 | Metal | +1200W | 精炼金属 | +| PolymerPress | 2×3 | Metal | +240W | 石油→塑料 | +| Incubator | 2×2 | BuildableRaw | 0 | 孵化蛋 | + +--- + +## 四、AI 操作铁律 + +### 4.1 标准操作流程(每一步自动拉视角) + +``` +STEP 1: 暂停 + pause "我要做什么" ← 必须!任何决策前暂停 + +STEP 2: 感知 + status ← 游戏总览 + resources ← 资源清单 + events ← 最近事件 + explore x y w h ← 探索目标区域 + +STEP 3: 验证坐标 + cell x y ← 检查目标格子是否可建造/可挖掘 + registry buildings ID ← 确认buildingId正确 + +STEP 4: 执行 + dig x y w h ← 先挖(自动拉视角) + build ID x y ← 再建(自动拉视角) + deconstruct x y ← 拆除(自动拉视角) + +STEP 5: 验证与恢复 + events ← 检查执行结果 + unpause 1 ← 恢复运行 +``` + +### 4.2 错误处理 + +| 错误 | 原因 | 对策 | |------|------|------| -| `/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` | 区域气体分析 | 查看周围气体成分 | +| `nothing_to_dig` | 区域已经挖空 | 跳过挖掘,直接建造 | +| `cell_occupied` | 该格已有建筑 | `deconstruct` 或选相邻位置 | +| `unknown_building` | buildingId 不存在 | 查 `registry buildings <关键词>` | +| `material_shortage` | 材料不够 | `resources` 查看缺什么,先挖材料 | +| `bounds` | 坐标越界 | 确认坐标在 gridWidth×gridHeight 内 | +| `missing_prerequisites` | 科技未解锁 | 先 `research_select` 研发前置科技 | -### 实体注册?(GET,AI 参考用) +### 4.3 紧急处理 -| 端点 | 说明 | -|------|------| -| `/api/registry/buildings` | 所有建?ID 及尺?功?发热 | -| `/api/registry/elements` | 所有元?ID 及比热容/导热/熔沸?| -| `/api/registry/techs` | 所有科技 ID 及前?解锁内容 | - -### 操作 (POST) - -| 端点 | 请求?| 用?| -|------|--------|------| -| `/api/action/toggle` | `{x, y}` | 开关建筑(省电/控制流程?| -| `/api/action/set_recipe` | `{x, y, recipeId}` | 设置建筑配方 | -| `/api/action/empty` | `{x, y}` | 清空建筑储物 | -| `/api/action/cancel_errand` | `{x, y}` | 取消建筑处的任务 | -| `/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 + 导热液体管道 - ---- - -## 暂停与速度参? -游戏状态中?`isPaused` ?`gameSpeed` 字段? -``` -isPaused: true ?是否暂停 -gameSpeed: 0 ?0=暂停, 1=1x, 2=2x, 3=3x -``` - -暂停规则已集成在"统一操作协议"的标?SOP 中,详见下节。核心原则: -- **所有写操作前必须暂?*(dig/build/deconstruct/batch/pipe/wire?- **只读查询不需要暂?*(status/resources/events?- **操作完成后必须恢?* - ---- - ---- -## AI 统一操作协议 - -这是 AI 操作缺氧的标准协议。所有决策和操作必须遵循此协议? -### 铁律(必须遵守) +当事件流中出现以下情况: ``` -铁律 1: 任何时?AI 开始推?决策??必须先暂停游戏? 工具在获取游戏状态数据时会自动触发暂停, - 保证 AI 获取的信息是当前时刻的准确快照? ?工具自动执行 pause(无需手动调用? ?AI 完成所有操作后主动 unpause - -铁律 2: 任何时?AI 需要向用户提问,必须先暂停游戏? ?工具自动暂停,用户回答后 AI 恢复?unpause - -铁律 3: 任何时?AI 结束回答/退出操作状态,必须确保游戏处于暂停态, - 除非用户明确要求不暂停? ?防止游戏?AI 不监控时状态恶化(窒息/过载/高温/CO₂) - -铁律 4: 任何时?AI 执行写操作(dig/build/deconstruct/batch/pipe/wire), - 工具自动确保暂停态? -铁律 5: 重大操作前必须先 save 存档,失败后允许 load 回滚?``` - -### 核心操作循环 - -每次 AI 与游戏交互都必须遵循这个五步循环? -``` -┌─────────────────────────────────────────────────────────?? 1. 上下文感? ?? snapshot + diagnose + events ?? "我现在看到什么?当前状态是什么?发生了什么?" ?└──────────────────────┬──────────────────────────────────? ?┌─────────────────────────────────────────────────────────?? 2. 决策与规? ?? pause ?分析数据 ?确定目标 ?选择工具 ?? "基于现状,我需要做什么?用什么工具?在哪个坐标?" ?└──────────────────────┬──────────────────────────────────? ?┌─────────────────────────────────────────────────────────?? 3. 执行前保? ?? save ?camera ?cell ?snapshot ?? "先存档,然后把视野移过去,确认坐标正? ?└──────────────────────┬──────────────────────────────────? ?┌─────────────────────────────────────────────────────────?? 4. 执行操作 ?? dig/build/build_pipe_line/batch ?? 每一步检查反馈(success/fail + suggestion? ?└──────────────────────┬──────────────────────────────────? ?┌─────────────────────────────────────────────────────────?? 5. 验证与恢? ?? unpause ?snapshot ?检查状? ?? 恶化 ?load 回滚 ?换方? ?└─────────────────────────────────────────────────────────?``` - -### 标准操作 SOP - -任何时?AI 执行操作,必须按以下流程? -``` -步骤 0: 暂停 - python3 tools/oni_api.py pause "操作说明" - ?确认 success=true,否则重? -步骤 1: 视觉确认 - python3 tools/oni_api.py camera - python3 tools/oni_api.py snapshot - ?下载截图,确认目标位置正? -步骤 2: 数据确认 - python3 tools/oni_api.py cell - ?hasBuilding=true ??deconstruct 或换位置 - ?isSolid=true ??dig - ?isVacuum=true ?确认原因 - -步骤 3: 安全存档 - python3 tools/oni_api.py save "before_任务? - ?确认 success=true - -步骤 4: 执行 - dig / build / build_pipe_line / build_wire_line / batch - ?每次检查反馈: - success=true ?继续 - success=false ??suggestion ?调整重试 ?3次失败则 load 回滚 - -步骤 5: 验证 - snapshot ?截图对比 - resources ?资源变化 - 未改??load 回滚 - -步骤 6: 恢复 - unpause 1 +⚠️ 氧气<500kg → pause → emergency_o2 → build Electrolyzer +⚠️ 食物<200kkcal → pause → build PlanterBox ×5 +⚠️ CO₂积聚 → pause → fix_co2 → 挖排气管路 +⚠️ 温度>40°C → pause → 隔热+冷却模块 +⚠️ 污水满溢 → pause → build WaterPurifier ``` -### 决策框架 - -AI 面对任何问题按此框架思考: +### 4.4 工具调用速查 ``` -【数据收集? status ?diagnose ?power ?co2 ?temp_zones ?resources ?buildings +# 基本监控 +python tools/oni_api.py status +python tools/oni_api.py resources +python tools/oni_api.py events +python tools/oni_api.py registry buildings <关键词> -【定位分析? cells ?cell ?camera + snapshot +# 坐标操作 +python tools/oni_api.py cell # 看格子 +python tools/oni_api.py cells # 看区域 +python tools/oni_api.py explore # AI摘要 +python tools/oni_api.py gas # 气体 -【方案选择? 紧??emergency_o2 / fix_co2 / fix_overload - 建??定坐??build / build_pipe_line / build_wire_line - 批量 ?batch JSON - 扩展 ?expand_base - 检??diagnose - 回滚 ?load +# 执行 +python tools/oni_api.py pause "原因" +python tools/oni_api.py dig +python tools/oni_api.py build +python tools/oni_api.py deconstruct +python tools/oni_api.py prioritize <1-9> +python tools/oni_api.py research_select +python tools/oni_api.py batch +python tools/oni_api.py save +python tools/oni_api.py camera +python tools/oni_api.py unpause <1-3> + +# 高级 +python tools/oni_analyzer.py +python tools/oni_commander.py diagnose +python tools/oni_commander.py emergency_o2 +python tools/oni_commander.py fix_co2 +python tools/oni_commander.py fix_overload +python tools/oni_builder.py build spom +python tools/oni_builder.py build toilet_loop +python tools/oni_builder.py build bedroom +python tools/oni_builder.py build cooling ``` - -### 标准方案手册 - -#### 方案 A:初期基地(Cycle 1-20? -``` -1. pause "Initial base" -2. diagnose -3. expand_base <中心> -5. build ManualGenerator <坐标> -6. build Battery <坐标> -7. build OxygenDiffuser <坐标> -8. build PlanterBox x5 -9. build Cot x3 / LadderBed x3 -10. build Outhouse + WashBasin -11. research_select FarmingTech -12. research_select PowerRegulation -13. unpause 1 -``` - -#### 方案 B:CO2 危机 - -``` -症状:duplicants 窒息,co2 显示大量 CO2 -1. pause "CO2" -2. co2 ?找到最?CO2 聚集?3. camera <最?y> 20 -4. snapshot ?确认地形 -5. save "before_co2" -6. fix_co2 ?自动挖排气管 -7. snapshot ?确认挖?8. unpause 1 -9. 30s ?co2 确认下降 -``` - -#### 方案 C:电力过? -``` -症状:power 显示 *** OVERLOAD *** -1. pause "Power overload" -2. power ?哪个电路过载 -3. diagnose ?资源检?4. save "before_power" -5. fix_overload ?修复建议 -6. 如电力不??build <发电? ?build_wire_line <连接> -7. power ?确认过载消失 -8. unpause 1 -``` - -#### 方案 D:氧气危? -``` -症状:duplicants 显示 oxygen<20% -1. pause "O2" -2. snapshot ?视觉确认 -3. resources ?O2/Algae 存量 -4. buildings ?电解?扩散?5. save "before_o2" -6. 分支? 无设??build OxygenDiffuser - 无藻??build Electrolyzer + 接水管电? 设备不工??cell 检查供水和供电 -7. unpause 1 -8. 30s ?resources ?O2 回升?``` - -#### 方案 E:建?SPOM - -``` -1. pause "SPOM" -2. explore ?8x6 空地 -3. camera ?snapshot 确认 -4. save "before_spom" -5. oni_builder.py build spom -6. build_pipe_line liquid <水源> <电解? -7. build_wire_line heavy <发电? <电池> cross -8. unpause 1 -``` - -#### 方案 F:管道铺设(带交叉处理) - -``` -需求:液体管道横穿已有气体管道 -1. pause "Plumbing" -2. camera <起点> <终点> 25 -3. save "before_pipe" -4. 横穿段用 cross 模式? build_pipe_line liquid <起点> <终点> cross - ?Mod 自动在交叉处放跨接器 -5. pipes liquid ?确认流动 -6. unpause 1 -``` - -#### 方案 G:电线布? -``` -需求:为新建筑拉电线到电网 -1. pause "Wiring" -2. camera <建筑> <电源> 25 -3. power ?查空余容?4. save "before_wire" -5. 布线(横穿用 cross): - build_wire_line regular <建筑> <变压? cross -6. unpause 1 -``` - -### 错误恢复 - -``` -操作失败 ?AI 必须读取 error + errorMessage + suggestion - -错误处理表: -cell_occupied ?换坐标或 deconstruct -cell_solid ??dig -cell_occupied_by_dupe?等待 -material_shortage ?查资?+ 安排生产 -unknown_building ?registry buildings 查询 -unknown_tech ?registry techs 查询 -missing_prerequisites?先研究前置科技 -invalid_priority ??1-9 -save_not_found ?saves 列出 - -重试 3 次失??load 回滚 -``` - -### 坐标定位方法 - -``` -方法 1: 基于已有建筑偏移 - buildings ?查已有建筑坐??偏移放置 - -方法 2: 基于区域探索 - explore ?找空? -方法 3: 基于资源位置 - co2 ?CO2 聚集点下方挖排气? -方法 4: 基于 cell 验证 - cell ?确认 isSolid=false + hasBuilding=false + isVisible=true - -AI 习惯:头脑规划坐??cell 验证 ?确认无误再建?``` - -### 事件自动响应 - -event_daemon 运行?AI 自动响应规则? -``` -[CRITICAL] 窒息 - ?diagnose ?执行方案 D 或方?B - -[CRITICAL] 电力中断 - ?power ?执行方案 C - -[WARNING] 食物短缺 - ?Calories<200k ??PlanterBox x5 - -[WARNING] 温度过高 - ?temp_zones ?隔热?+ 冷却 - -[INFO] 新周? ?research ?继续科研 - ?resources ?安排生产 - -AI 不应等待指令——事件本身就是指令?``` - ---- - -## 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 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` | 环境初始化与检?| -| `scripts/event_daemon.py` | **事件守护进程** ?持续轮询事件 ?AI 输入?| -| `docs/AI_KNOWLEDGE_BASE.md` | 建筑/元素/科技 ID 注册表和游戏机制参?| -| `docs/batch_example.json` | 批量任务示例文件 | - ---- - -## 核心游戏知识 - -### 生存优先?1. **氧气** ?电解?> 藻类制氧(前期过渡) -2. **食物** ?浆果 > 烤肉 > 营养?3. **温度控制** ?液冷 + 蒸汽?4. **电力** ?氢气发电 > 煤炭 > 手动 -5. **水资源管?* ?净水器、污水过? -### 常用布局 -- SPOM: 电解制氧 + 氢气发电闭环 -- 卫生间水循环: 卫生??净水器 ?卫生?- 冷却系统: 液冷 + 蒸汽?+ 导热?- Ranch 模块: 养殖哈奇/滑鳞/飞鱼 - -### 关键事件预警 -- 氧气不足 (< 500g/tile) ?增加制氧 -- 温度超标 (> 40°C ?< -10°C) ?增加温控 -- 食物短缺 (< 5 周期余量) ?扩大种植/养殖 -- 电力不足 ?增加发电或减少负?- 污水满溢 ?增加净?扩大存储 diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs index 5e50d91..bb9526f 100644 --- a/mod/ONIAgentBridge.cs +++ b/mod/ONIAgentBridge.cs @@ -4,22 +4,56 @@ using Newtonsoft.Json; using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Specialized; using System.IO; using System.Linq; using System.Net; using System.Text; +using System.Threading; using UnityEngine; namespace ONIAgentBridge { + public class AgentEvent + { + public int id; + public string type, severity, title, message, category; + public long timestamp; + public int cycle; + } + + public class DigRequest { public int x; public int y; public int width; public int height; } + public class BuildRequest { public string buildingId; public int x; public int y; } + public class CoordRequest { public int x; public int y; } + public class CoordWidthRequest { public int x; public int y; public int width; public int height; } + public class ResearchRequest { public string techId; } + public class PriorReq { public int x; public int y; public int priority; } + public class SpeedReq { public int speed; } + public class CameraReq { public int x; public int y; public float zoom; } + public class PriorityGlobalReq { public string target; public int priority; } + public class PriorityTypeReq { public string buildingType; public int priority; } + public class SaveReq { public string name; } + public class BatchActionData { public string type; public int? x, y, width, height, priority; public string buildingId, techId; } + public class BatchReq { public List actions; } + public class Mod : UserMod2 { private HttpListener _listener; internal static ConcurrentQueue cmdQueue = new ConcurrentQueue(); - internal static List eventLog = new List(); + internal static List eventLog = new List(); internal static int eventSeq = 0; internal static object eventLock = new object(); + internal static string lastScreenshotPath; + + // Helper: enqueue action with optional auto-camera to position + static void Q(System.Action a) { cmdQueue.Enqueue(a); } + static void QWithCamera(int x, int y, System.Action a) + { + cmdQueue.Enqueue(() => + { + try { CameraController.Instance?.SetPosition(Grid.CellToPos(Grid.XYToCell(x, y))); } catch { } + try { a(); } catch (Exception ex) { LogEventStatic("error","critical","[Action]",ex.Message); } + }); + } public class QueueProcessor : UnityEngine.MonoBehaviour { @@ -27,21 +61,55 @@ namespace ONIAgentBridge { System.Action a; while (cmdQueue.TryDequeue(out a)) - try { a(); } catch (System.Exception ex) { lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[Q] "+ex.Message}); } } + { + try { a(); } + catch (Exception ex) { LogEventStatic("error", "critical", "[Queue]", ex.Message); } + } } } + static void LogEventStatic(string type, string severity, string title, string message, string category = "general") + { + lock (eventLock) + { + eventLog.Add(new AgentEvent + { + id = eventSeq++, + type = type, + severity = severity, + timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + cycle = GameClock.Instance?.GetCycle() ?? 0, + category = category, + title = title ?? "", + message = message ?? "" + }); + if (eventLog.Count > 500) + eventLog.RemoveRange(0, eventLog.Count - 500); + } + } + + void LogEvent(string type, string severity, string title, string message, string category = "general") + { + LogEventStatic(type, severity, title, message, category); + } + public override void OnLoad(Harmony harmony) { - base.OnLoad(harmony); - var go = new GameObject("ONI_Processor"); - go.AddComponent(); - UnityEngine.Object.DontDestroyOnLoad(go); - int port = 23876; - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); - _listener.Start(); - _listener.BeginGetContext(OnRequest, null); + try + { + base.OnLoad(harmony); + var go = new GameObject("ONI_Agent_Processor"); + go.AddComponent(); + UnityEngine.Object.DontDestroyOnLoad(go); + + int port = 23876; + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + _listener.Start(); + _listener.BeginGetContext(OnRequest, null); + LogEvent("info", "info", "[System]", $"ONI Agent Bridge started on port {port}"); + } + catch { } } void OnRequest(IAsyncResult ar) @@ -52,6 +120,7 @@ namespace ONIAgentBridge _listener.BeginGetContext(OnRequest, null); Process(ctx); } + catch (HttpListenerException) { } catch { } } @@ -62,180 +131,1105 @@ namespace ONIAgentBridge var path = ctx.Request.Url.AbsolutePath.TrimEnd('/'); var method = ctx.Request.HttpMethod; var q = ctx.Request.QueryString; + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); string json = null; - System.Action respond = () => { - var b = Encoding.UTF8.GetBytes(json ?? "{}"); - ctx.Response.ContentType = "application/json"; - ctx.Response.OutputStream.Write(b, 0, b.Length); - ctx.Response.OutputStream.Close(); - }; + // Health + if (path == "/health" && method == "GET") + json = J(new { success = true, data = new { status = "ok", service = "oni-agent-bridge", version = "2.0.0", marker = "ONI_AGENT_V2_LOADED" } }); - if (path == "/api/screenshot/latest" && method == "GET") { ServeScreenshot(ctx); return; } - else if (path == "/health" && method == "GET") json = J(new { status = "ok" }); - else if (path == "/api/state/game" && method == "GET") json = ReadGame(); - else if (path == "/api/state/resources" && method == "GET") json = ReadResources(); - else if (path == "/api/state/buildings" && method == "GET") json = ReadBuildings(); - else if (path == "/api/state/duplicants" && method == "GET") json = ReadDupes(); - else if (path == "/api/state/rooms" && method == "GET") json = ReadRooms(); - else if (path == "/api/state/cell" && method == "GET") json = ReadCell(q); - else if (path == "/api/state/cells" && method == "GET") json = ReadCells(q); - else if (path == "/api/state/gas" && method == "GET") json = ReadGas(q); - else if (path == "/api/state/co2" && method == "GET") json = ReadCO2(); - else if (path == "/api/state/temperature/zones" && method == "GET") json = ReadTemp(); - else if (path == "/api/state/power" && method == "GET") json = ReadPower(); - else if (path == "/api/state/pipes" && method == "GET") json = ReadPipes(q); - else if (path == "/api/state/events" && method == "GET") json = ReadEvents(q); - else if (path == "/api/state/alert" && method == "GET") json = ReadAlerts(); - else if (path == "/api/state/storage" && method == "GET") json = ReadStorage(); - else if (path == "/api/state/saves" && method == "GET") json = ReadSaves(); - else if (path == "/api/state/camera" && method == "GET") json = ReadCamera(); - else if (path == "/api/registry/buildings" && method == "GET") json = ReadBuildingReg(); - else if (path == "/api/registry/elements" && method == "GET") json = ReadElementReg(); - else if (path == "/api/action/pause" && method == "POST") { cmdQueue.Enqueue(() => { try { SpeedControlScreen.Instance.Pause(false, true); } catch { } }); json = Ok("paused"); } - else if (path == "/api/action/unpause" && method == "POST") { cmdQueue.Enqueue(() => { try { SpeedControlScreen.Instance.Unpause(true); SpeedControlScreen.Instance.SetSpeed(1); } catch { } }); json = Ok("unpaused"); } - else if (path == "/api/action/dig" && method == "POST") json = QueueDig(ctx); - else if (path == "/api/action/build" && method == "POST") json = QueueBuild(ctx); - else if (path == "/api/action/test" && method == "POST") { cmdQueue.Enqueue(() => { lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[Test] queue works!"}); } }); json = Ok("test_queued"); } - else json = J(new { error = "not_found", path }); + // State queries (GET) - queued to main thread for safety + else if (path == "/api/state/game" && method == "GET") + json = EnqueueRead(ReadGameState); + else if (path == "/api/state/resources" && method == "GET") + json = EnqueueRead(ReadResources); + else if (path == "/api/state/buildings" && method == "GET") + json = EnqueueRead(ReadBuildings); + else if (path == "/api/state/duplicants" && method == "GET") + json = EnqueueRead(ReadDuplicants); + else if (path == "/api/state/research" && method == "GET") + json = EnqueueRead(ReadResearch); + else if (path == "/api/state/rooms" && method == "GET") + json = EnqueueRead(ReadRooms); + else if (path == "/api/state/events" && method == "GET") + json = ReadEvents(q); + else if (path == "/api/state/storage" && method == "GET") + json = EnqueueRead(ReadStorage); + else if (path == "/api/state/saves" && method == "GET") + json = EnqueueRead(ReadSaves); + else if (path == "/api/state/alert" && method == "GET") + json = EnqueueRead(ReadAlerts); + else if (path == "/api/state/camera" && method == "GET") + json = EnqueueRead(ReadCamera); - respond(); + // Cell/map data (GET) - safe on background thread via raw Grid arrays + else if (path == "/api/state/cell" && method == "GET") + json = ReadCell(q); + else if (path == "/api/state/cells" && method == "GET") + json = ReadCells(q); + else if (path == "/api/state/cells/slice" && method == "GET") + json = ReadSlice(q); + else if (path == "/api/state/gas" && method == "GET") + json = ReadGas(q); + + // Registry (GET) + else if (path == "/api/registry/buildings" && method == "GET") + json = EnqueueRead(ReadBuildingRegistry); + else if (path == "/api/registry/elements" && method == "GET") + json = EnqueueRead(ReadElementRegistry); + else if (path == "/api/registry/techs" && method == "GET") + json = EnqueueRead(ReadTechRegistry); + + // Actions (POST) + else if (path == "/api/action/pause" && method == "POST") + { + string reason = null; + try { var d = JsonConvert.DeserializeAnonymousType(body, new { reason = "" }); reason = d?.reason; } catch { } + string r = reason; + Q(() => { var s = SpeedControlScreen.Instance; if (s != null && !s.IsPaused) { s.Pause(false, true); LogEvent("game_state","info","[Pause]",r??"AI paused"); } }); + json = J(new { success = true, data = new { result = "paused" } }); + } + else if (path == "/api/action/unpause" && method == "POST") + { + int sp = 1; + try { var d = JsonConvert.DeserializeAnonymousType(body, new { speed = 1 }); sp = d?.speed ?? 1; } catch { } + int sp2 = sp; + Q(() => { var s = SpeedControlScreen.Instance; if (s != null && s.IsPaused) { s.Unpause(true); s.SetSpeed(Mathf.Clamp(sp2,1,3)); LogEvent("game_state","info","[Unpause]",$"Speed: {sp2}x"); } }); + json = J(new { success = true, data = new { result = "unpaused" } }); + } + else if (path == "/api/action/speed" && method == "POST") + { + int sp = 1; + try { var d = JsonConvert.DeserializeAnonymousType(body, new { speed = 1 }); sp = d?.speed ?? 1; } catch { } + int sp2 = sp; + Q(() => { var s = SpeedControlScreen.Instance; if (s != null) s.SetSpeed(Mathf.Clamp(sp2,1,3)); }); + json = J(new { success = true, data = new { result = "speed_set", speed = sp2 } }); + } + else if (path == "/api/action/dig" && method == "POST") + json = QueueDig(body); + else if (path == "/api/action/build" && method == "POST") + json = QueueBuild(body); + else if (path == "/api/action/deconstruct" && method == "POST") + json = QueueDeconstruct(body); + else if (path == "/api/action/prioritize" && method == "POST") + json = QueuePrioritize(body); + else if (path == "/api/action/research" && method == "POST") + json = QueueResearch(body); + else if (path == "/api/action/mop" && method == "POST") + json = QueueMop(body); + else if (path == "/api/action/harvest" && method == "POST") + json = QueueHarvest(body); + else if (path == "/api/action/batch" && method == "POST") + json = QueueBatch(body); + else if (path == "/api/action/save" && method == "POST") + json = QueueSave(body); + else if (path == "/api/action/load" && method == "POST") + json = QueueLoad(body); + else if (path == "/api/action/priority_global" && method == "POST") + json = QueuePriorityGlobal(body); + else if (path == "/api/action/priority_type" && method == "POST") + json = QueuePriorityType(body); + else if (path == "/api/action/camera" && method == "POST") + json = QueueCamera(body); + + // Screenshot + else if (path == "/api/screenshot/latest" && method == "GET") + { ServeScreenshot(ctx); return; } + + else + json = J(new { success = false, error = "not_found", errorMessage = $"Unknown: {method} {path}" }); + + if (json != null) Respond(ctx, json); + } + catch (Exception ex) { Respond(ctx, J(new { success = false, error = "internal_error", errorMessage = ex.Message })); } + } + + void Respond(HttpListenerContext ctx, string json) + { + var b = Encoding.UTF8.GetBytes(json); + ctx.Response.ContentType = "application/json"; + ctx.Response.OutputStream.Write(b, 0, b.Length); + ctx.Response.OutputStream.Close(); + } + + static string J(object o) => JsonConvert.SerializeObject(o); + + string EnqueueRead(Func reader) + { + object result = new { error = "timeout" }; + var ev = new ManualResetEvent(false); + Q(() => { + try { result = reader(); } + catch (Exception ex) { result = new { error = "read_error", message = ex.Message }; } + ev.Set(); + }); + ev.WaitOne(10000); + return J(new { success = true, data = result }); + } + + // ── State Readers ───────────────────────────────────── + + object ReadGameState() + { + var gc = GameClock.Instance; + var scs = SpeedControlScreen.Instance; + return new + { + cycle = gc?.GetCycle() ?? 0, + duplicantCount = Components.MinionIdentities.Items.Count, + worldSize = Grid.CellCount, + gridWidth = Grid.WidthInCells, + gridHeight = Grid.HeightInCells, + isPaused = scs?.IsPaused ?? true, + gameSpeed = scs?.GetSpeed() ?? 0, + elapsedTime = gc?.GetTime() ?? 0f + }; + } + + object ReadResources() + { + var world = ClusterManager.Instance?.activeWorld; + if (world == null) return new List(); + var inv = world.worldInventory; + if (inv == null) return new List(); + var list = new List(); + foreach (var e in ElementLoader.elements) + { + float a = inv.GetAmount(e.tag, false); + if (a > 0) + { + string state = "solid"; + if (e.IsGas) state = "gas"; + else if (e.IsLiquid) state = "liquid"; + list.Add(new { id = e.id.ToString(), name = e.name, tag = e.tag.ToString(), amountKg = a, state }); + } + } + return list; + } + + object ReadBuildings() + { + var list = new List(); + foreach (var building in Components.BuildingCompletes.Items) + { + var def = building.Def; + var pos = building.transform.position; + list.Add(new + { + id = def.PrefabID, + name = def.Name, + x = (int)pos.x, + y = (int)pos.y, + width = def.WidthInCells, + height = def.HeightInCells + }); + } + return list; + } + + object ReadDuplicants() + { + var list = new List(); + foreach (var minion in Components.MinionIdentities.Items) + { + var pos = minion.transform.position; + var health = minion.gameObject.GetComponent(); + list.Add(new + { + name = minion.GetProperName(), + x = (int)pos.x, + y = (int)pos.y, + health = health?.hitPoints ?? 100 + }); + } + return list; + } + + object ReadResearch() + { + var research = Research.Instance; + if (research == null) return new { }; + var completed = new List(); + try + { + var f = research.GetType().GetField("completedTechs", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + if (f != null && f.GetValue(research) is System.Collections.IList list) + { + foreach (var t in list) + { + var idProp = t.GetType().GetProperty("Id") ?? t.GetType().GetField("Id") as System.Reflection.MemberInfo; + if (idProp is System.Reflection.PropertyInfo pi) completed.Add(pi.GetValue(t)?.ToString() ?? ""); + else if (idProp is System.Reflection.FieldInfo fi) completed.Add(fi.GetValue(t)?.ToString() ?? ""); + } + } } catch { } + return new { completedTechs = completed }; } - string J(object o) => JsonConvert.SerializeObject(o); - string Ok(string r, object d = null) => J(new { success = true, result = r, data = d ?? new { } }); - string Fail(string e, string m = null) => J(new { success = false, error = e, errorMessage = m ?? e }); - - void PushEvent(string t, string s, string title, string msg, string cat = "general") + object ReadRooms() { - lock (eventLock) { eventLog.Add(new GameEvent { id = eventSeq++, type = t, severity = s, timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), cycle = 0, category = cat, title = title, message = msg }); if (eventLog.Count > 500) eventLog.RemoveRange(0, eventLog.Count - 500); } - } - - // === READERS (safe from background threads) === - string ReadGame() => J(new { cycle = GameClock.Instance?.GetCycle() ?? 0, duplicantCount = Components.MinionIdentities.Count(), worldSize = Grid.CellCount, gridWidth = Grid.WidthInCells, gridHeight = Grid.HeightInCells, isPaused = SpeedControlScreen.Instance.IsPaused, gameSpeed = SpeedControlScreen.Instance.IsPaused ? 0 : SpeedControlScreen.Instance.GetSpeed() }); - string ReadResources() { try { var l = new List(); foreach (var e in ElementLoader.elements) { float a = 0; try { var w = ClusterManager.Instance?.activeWorld; if (w != null) a = w.worldInventory.GetAmount(e.tag, false); } catch { } if (a > 0) l.Add(new { id = e.id.ToString(), name = e.name, tag = e.tag.ToString(), amount = a, unit = "kg", state = e.IsGas ? "gas" : e.IsLiquid ? "liquid" : "solid" }); } return J(l); } catch { return J(new List()); } } - string ReadBuildings() { try { var l = new List(); foreach (var i in Components.BuildingCompletes) { var b = (BuildingComplete)i; var d = b.Def; l.Add(new { id = d.PrefabID, name = d.Name, x = (int)b.transform.position.x, y = (int)b.transform.position.y, width = d.WidthInCells, height = d.HeightInCells }); } return J(l); } catch { return J(new List()); } } - string ReadDupes() { try { var l = new List(); foreach (var i in Components.MinionIdentities) { var m = (MinionIdentity)i; l.Add(new { name = m.GetProperName(), x = (int)m.transform.position.x, y = (int)m.transform.position.y, health = m.gameObject.GetComponent()?.hitPoints ?? 100 }); } return J(l); } catch { return J(new List()); } } - string ReadRooms() { try { var l = new List(); var rp = Game.Instance.roomProber; if (rp != null) foreach (var r in rp.rooms) l.Add(new { id = r.roomType?.Id ?? "?", name = r.roomType?.Name ?? "?" }); return J(l); } catch { return J(new List()); } } - string ReadCell(NameValueCollection q) { try { int x = int.Parse(q["x"] ?? "-1"), y = int.Parse(q["y"] ?? "-1"); int c = Grid.XYToCell(x, y); if (c < 0 || c >= Grid.CellCount) return J(new { error = "bounds" }); var el = Grid.Element[c]; return J(new { x, y, cell = c, element = el?.name ?? "Vacuum", massKg = Grid.Mass[c], temperatureC = Grid.Temperature[c] > 0 ? Grid.Temperature[c] - 273.15f : -273.15f, isSolid = Grid.Solid[c], isVisible = true, hasBuilding = Grid.Objects[c, (int)ObjectLayer.Building] != null, buildingName = Grid.Objects[c, (int)ObjectLayer.Building]?.name, hasDuplicant = Grid.Objects[c, (int)ObjectLayer.Minion] != null, isVacuum = el == null, isDiggable = Grid.Solid[c] && el != null && el.id != SimHashes.Unobtanium }); } catch { return J(new { error = "err" }); } } - string ReadCells(NameValueCollection q) { try { int x = int.Parse(q["x"] ?? "0"), y = int.Parse(q["y"] ?? "0"), w = int.Parse(q["width"] ?? "10"), h = int.Parse(q["height"] ?? "10"); var cs = new List(); for (int cy = y; cy < y + h; cy++) for (int cx = x; cx < x + w; cx++) { int c = Grid.XYToCell(cx, cy); if (c >= 0 && c < Grid.CellCount) cs.Add(new { x = cx, y = cy, element = Grid.Element[c]?.name ?? "Vacuum", isSolid = Grid.Solid[c], hasBuilding = Grid.Objects[c, (int)ObjectLayer.Building] != null, hasDuplicant = Grid.Objects[c, (int)ObjectLayer.Minion] != null }); } return J(new { cells = cs }); } catch { return J(new { error = "err" }); } } - string ReadGas(NameValueCollection q) { try { int x = int.Parse(q["x"] ?? "0"), y = int.Parse(q["y"] ?? "0"), r = int.Parse(q["radius"] ?? "20"); var g = new Dictionary(); for (int cy = Math.Max(0, y - r); cy <= Math.Min(Grid.HeightInCells - 1, y + r); cy++) for (int cx = Math.Max(0, x - r); cx <= Math.Min(Grid.WidthInCells - 1, x + r); cx++) { int c = Grid.XYToCell(cx, cy); if (c < 0) continue; var el = Grid.Element[c]; if (el != null && el.IsGas) { float m = Grid.Mass[c]; if (g.ContainsKey(el.name)) { g[el.name].mass += m; g[el.name].count++; } else g[el.name] = new GasEntry { gas = el.name, mass = m, count = 1 }; } } return J(new { gases = g.Values }); } catch { return J(new { error = "err" }); } } - string ReadCO2() { try { var p = new List(); int step = Math.Max(1, Grid.CellCount / 500); for (int i = 0; i < Grid.CellCount; i += step) { var el = Grid.Element[i]; if (el != null && el.id == SimHashes.CarbonDioxide && Grid.Mass[i] > 0.5f) { int x, y; Grid.CellToXY(i, out x, out y); p.Add(new { x, y, mass = Grid.Mass[i] }); } } return J(new { pockets = p }); } catch { return J(new { error = "err" }); } } - string ReadTemp() { try { int s = Math.Max(1, Grid.CellCount / 300); float mn = float.MaxValue, mx = float.MinValue, sum = 0; int n = 0; for (int i = 0; i < Grid.CellCount; i += s) { float t = Grid.Temperature[i]; if (t <= 0) continue; float tc = t - 273.15f; sum += tc; n++; if (tc < mn) mn = tc; if (tc > mx) mx = tc; } return J(new { averageC = n > 0 ? sum / n : 0, minC = mn, maxC = mx }); } catch { return J(new { error = "err" }); } } - string ReadPower() { try { var c = new List(); var m = Game.Instance.circuitManager; if (m != null) for (ushort i = 1; i <= 16; i++) { float u = m.GetWattsUsedByCircuit(i); if (u > 0 || m.HasGenerators(i)) c.Add(new { id = (int)i, wattsUsed = u, maxWatts = m.GetMaxSafeWattageForCircuit(i), isOverloaded = u > m.GetMaxSafeWattageForCircuit(i) }); } return J(new { circuits = c }); } catch { return J(new { error = "err" }); } } - string ReadPipes(NameValueCollection q) { try { var s = new List(); string t = q["type"] ?? "all"; foreach (var f in new[] { new { n = "liquid", fl = Game.Instance.liquidConduitFlow }, new { n = "gas", fl = Game.Instance.gasConduitFlow } }) { if (t != "all" && t != f.n) continue; if (f.fl == null) continue; int cnt = 0; foreach (var i in Components.BuildingCompletes) { if (cnt > 100) break; var b = (BuildingComplete)i; var pid = b.Def.PrefabID; if (pid != (f.n == "gas" ? "GasConduit" : "LiquidConduit") && pid != (f.n == "gas" ? "GasConduitBridge" : "LiquidConduitBridge")) continue; var ct = f.fl.GetContents(b.GetCell()); if (ct.mass > 0) { s.Add(new { type = f.n, element = ct.element.ToString(), mass = ct.mass }); cnt++; } } } return J(new { segments = s }); } catch { return J(new { error = "err" }); } } - string ReadEvents(NameValueCollection q) { int s = int.Parse(q["since"] ?? "-1"), l = Math.Min(int.Parse(q["limit"] ?? "50"), 200); lock (eventLock) { var ev = eventLog.Where(e => e.id > s).Take(l).ToList(); return J(new { events = ev, next_seq = ev.Any() ? ev.Last().id : s }); } } - string ReadAlerts() { try { var l = new List(); var nm = global::NotificationManager.Instance; if (nm != null) { foreach (var fn in new[] { "notifications", "pendingNotifications" }) { try { var f = typeof(global::NotificationManager).GetField(fn, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (f == null) continue; var items = f.GetValue(nm) as System.Collections.IEnumerable; if (items != null) foreach (global::Notification n in items) l.Add(new { title = n.titleText, severity = n.Type.ToString() }); } catch { } } } return J(l); } catch { return J(new List()); } } - string ReadStorage() { try { var l = new List(); foreach (var i in Components.BuildingCompletes) { var b = (BuildingComplete)i; var s = b.gameObject.GetComponent(); if (s == null || s.MassStored() <= 0) continue; l.Add(new { building = b.Def?.Name ?? b.name, x = (int)b.transform.position.x, y = (int)b.transform.position.y, mass = s.MassStored(), capacity = s.capacityKg }); } return J(new { storages = l.Take(50).ToList() }); } catch { return J(new { error = "err" }); } } - string ReadSaves() { try { var l = new List(); string sp = SaveLoader.GetActiveSaveFilePath(); var d = Path.GetDirectoryName(sp); if (d != null && Directory.Exists(d)) foreach (var f in Directory.GetFiles(d, "*.sav")) { var fi = new FileInfo(f); l.Add(new { name = Path.GetFileNameWithoutExtension(f) }); } return J(new { saves = l }); } catch { return J(new { error = "err" }); } } - string ReadCamera() { try { var c = CameraController.Instance; var p = c.transform.position; return J(new { x = p.x, y = p.y }); } catch { return J(new { error = "err" }); } } - string ReadBuildingReg() { try { var l = new List(); foreach (var i in Assets.BuildingDefs) { var d = (BuildingDef)i; l.Add(new { id = d.PrefabID, name = d.Name, width = d.WidthInCells, height = d.HeightInCells }); } return J(l); } catch { return J(new { error = "err" }); } } - string ReadElementReg() { try { var l = new List(); foreach (var e in ElementLoader.elements) l.Add(new { id = e.id.ToString(), name = e.name }); return J(l); } catch { return J(new { error = "err" }); } } - - string QueueDig(HttpListenerContext c) - { - try + var rp = Game.Instance?.roomProber; + if (rp == null) return new List(); + var list = new List(); + foreach (var room in rp.rooms) { - var d = JsonConvert.DeserializeObject(new StreamReader(c.Request.InputStream).ReadToEnd()); - if (d == null) return Fail("invalid"); - int x = d.x, y = d.y, w = d.width, h = d.height; - cmdQueue.Enqueue(() => { - for (int dy = 0; dy < h; dy++) for (int dx = 0; dx < w; dx++) { int cell = Grid.XYToCell(x + dx, y + dy); if (cell >= 0 && cell < Grid.CellCount) try { DigTool.PlaceDig(cell, 0); } catch { } } - }); - return Ok("dig_queued"); + if (room?.roomType == null) continue; + list.Add(new { id = room.roomType.Id, name = room.roomType.Name }); } - catch { return Fail("error"); } + return list; } - string QueueBuild(HttpListenerContext c) + object ReadStorage() + { + var list = new List(); + foreach (var building in Components.BuildingCompletes.Items) + { + var storage = building.gameObject.GetComponent(); + if (storage == null || storage.MassStored() <= 0) continue; + list.Add(new + { + building = building.Def?.Name ?? building.name, + x = (int)building.transform.position.x, + y = (int)building.transform.position.y, + massStored = storage.MassStored(), + capacity = storage.capacityKg + }); + } + return list; + } + + object ReadSaves() { try { - var d = JsonConvert.DeserializeObject(new StreamReader(c.Request.InputStream).ReadToEnd()); - if (d == null) return Fail("invalid"); - int x = d.x, y = d.y; string bid = d.buildingId; - // Find available materials on HTTP thread (WorldInventory works from background) - var availableTags = new List(); + var saves = new List(); + string sp = SaveLoader.GetActiveSaveFilePath(); + var dir = Path.GetDirectoryName(sp); + if (dir != null && Directory.Exists(dir)) + foreach (var f in Directory.GetFiles(dir, "*.sav")) + saves.Add(new { name = Path.GetFileNameWithoutExtension(f) }); + return new { currentSave = sp, saves = saves }; + } + catch { return new { error = "cannot_read_saves" }; } + } + + object ReadAlerts() + { + var list = new List(); + try + { + var nm = NotificationManager.Instance; + if (nm != null) + { + var field = typeof(NotificationManager).GetField("notifications", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (field?.GetValue(nm) is System.Collections.IEnumerable notifications) + foreach (global::Notification n in notifications) + list.Add(new { title = n.titleText, severity = n.Type.ToString() }); + } + } + catch { } + return list; + } + + object ReadCamera() + { + var cc = CameraController.Instance; + if (cc == null) return new { }; + var p = cc.transform.position; + return new { x = p.x, y = p.y }; + } + + // ── Cell/Map readers (background-thread safe) ──────── + + string ReadCell(System.Collections.Specialized.NameValueCollection q) + { + try + { + int x = int.Parse(q["x"] ?? "-1"), y = int.Parse(q["y"] ?? "-1"); + int cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) return J(new { success = false, error = "bounds" }); + var el = Grid.Element[cell]; + return J(new + { + success = true, data = new + { + x, y, cell, + element = el?.name ?? "Vacuum", + elementId = el?.id.ToString() ?? "Vacuum", + massKg = Grid.Mass[cell], + temperatureC = Grid.Temperature[cell] > 0 ? Grid.Temperature[cell] - 273.15f : -273.15f, + isSolid = Grid.Solid[cell], + isLiquid = el != null && el.IsLiquid, + isGas = el != null && el.IsGas, + isVacuum = el == null, + hasBuilding = Grid.Objects[cell, (int)ObjectLayer.Building] != null, + hasDuplicant = Grid.Objects[cell, (int)ObjectLayer.Minion] != null, + isDiggable = Grid.Solid[cell] && el != null && el.id != SimHashes.Unobtanium + } + }); + } + catch (Exception ex) { return J(new { success = false, error = "read_error", message = ex.Message }); } + } + + string ReadCells(System.Collections.Specialized.NameValueCollection q) + { + try + { + int x = int.Parse(q["x"] ?? "0"), y = int.Parse(q["y"] ?? "0"); + int w = int.Parse(q["width"] ?? "10"), h = int.Parse(q["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) continue; + var el = Grid.Element[cell]; + cells.Add(new + { + x = cx, y = cy, + element = el?.name ?? "Vacuum", + isSolid = Grid.Solid[cell], + isLiquid = el != null && el.IsLiquid, + isGas = el != null && el.IsGas, + isVacuum = el == null, + massKg = Grid.Mass[cell], + temperatureC = Grid.Temperature[cell] > 0 ? Grid.Temperature[cell] - 273.15f : -273.15f, + hasBuilding = Grid.Objects[cell, (int)ObjectLayer.Building] != null, + isDiggable = Grid.Solid[cell] && el != null && el.id != SimHashes.Unobtanium + }); + } + return J(new { success = true, data = new { cells } }); + } + catch (Exception ex) { return J(new { success = false, error = "read_error", message = ex.Message }); } + } + + string ReadSlice(System.Collections.Specialized.NameValueCollection q) + { + try + { + string axis = q["axis"] ?? "x"; + int index = int.Parse(q["index"] ?? "0"); + int start = int.Parse(q["start"] ?? "0"); + int end = int.Parse(q["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) continue; + var el = Grid.Element[cell]; + cells.Add(new { x = cx, y = index, element = el?.name ?? "Vacuum", isSolid = Grid.Solid[cell], massKg = Grid.Mass[cell], temperatureC = Grid.Temperature[cell] > 0 ? Grid.Temperature[cell] - 273.15f : -273.15f, hasBuilding = Grid.Objects[cell, (int)ObjectLayer.Building] != null }); + } + else + for (int cy = start; cy <= end; cy++) + { + int cell = Grid.XYToCell(index, cy); + if (cell < 0 || cell >= Grid.CellCount) continue; + var el = Grid.Element[cell]; + cells.Add(new { x = index, y = cy, element = el?.name ?? "Vacuum", isSolid = Grid.Solid[cell], massKg = Grid.Mass[cell], temperatureC = Grid.Temperature[cell] > 0 ? Grid.Temperature[cell] - 273.15f : -273.15f, hasBuilding = Grid.Objects[cell, (int)ObjectLayer.Building] != null }); + } + return J(new { success = true, data = new { cells } }); + } + catch (Exception ex) { return J(new { success = false, error = "read_error", message = ex.Message }); } + } + + string ReadGas(System.Collections.Specialized.NameValueCollection q) + { + try + { + int x = int.Parse(q["x"] ?? "0"), y = int.Parse(q["y"] ?? "0"), r = int.Parse(q["radius"] ?? "20"); + var gases = new Dictionary(); + for (int cy = Math.Max(0, y - r); cy <= Math.Min(Grid.HeightInCells - 1, y + r); cy++) + for (int cx = Math.Max(0, x - r); cx <= Math.Min(Grid.WidthInCells - 1, x + r); cx++) + { + int cell = Grid.XYToCell(cx, cy); + if (cell < 0) continue; + var el = Grid.Element[cell]; + if (el != null && el.IsGas) + { + float m = Grid.Mass[cell]; + if (gases.TryGetValue(el.name, out var existing)) { existing.mass += m; existing.count++; } + else { gases[el.name] = new GasData { gas = el.name, mass = m, count = 1 }; } + } + } + return J(new { success = true, data = new { gases = gases.Values } }); + } + catch (Exception ex) { return J(new { success = false, error = "read_error", message = ex.Message }); } + } + + // ── Registry readers ────────────────────────────────── + + object ReadBuildingRegistry() + { + var list = new List(); + foreach (var def in Assets.BuildingDefs) + { + list.Add(new + { + id = def.PrefabID, + name = def.Name, + width = def.WidthInCells, + height = def.HeightInCells, + buildLocationRule = def.BuildLocationRule.ToString(), + materialCategory = def.MaterialCategory, + mass = def.Mass, + health = def.HitPoints + }); + } + return list; + } + + object ReadElementRegistry() + { + var list = new List(); + foreach (var e in ElementLoader.elements) + { + string state = "solid"; + if (e.IsGas) state = "gas"; + else if (e.IsLiquid) state = "liquid"; + list.Add(new { id = e.id.ToString(), name = e.name, state, tag = e.tag.ToString(), specificHeatCapacity = e.specificHeatCapacity, thermalConductivity = e.thermalConductivity }); + } + return list; + } + + object ReadTechRegistry() + { + var list = new List(); + try + { + var db = Db.Get(); + if (db == null) return list; + var techsProp = db.GetType().GetProperty("Techs"); + if (techsProp == null) return list; + var techs = techsProp.GetValue(db); + if (techs == null) return list; + // Try IEnumerable + var enumerable = techs as System.Collections.IEnumerable; + if (enumerable != null) + { + foreach (var tech in enumerable) + { + if (tech == null) continue; + var idProp = tech.GetType().GetProperty("Id") ?? tech.GetType().GetField("Id") as System.Reflection.MemberInfo; + var nameProp = tech.GetType().GetProperty("Name") ?? tech.GetType().GetField("Name") as System.Reflection.MemberInfo; + string id = "", name = ""; + if (idProp is System.Reflection.PropertyInfo pi) id = pi.GetValue(tech)?.ToString() ?? ""; + else if (idProp is System.Reflection.FieldInfo fi) id = fi.GetValue(tech)?.ToString() ?? ""; + if (nameProp is System.Reflection.PropertyInfo pi2) name = pi2.GetValue(tech)?.ToString() ?? ""; + else if (nameProp is System.Reflection.FieldInfo fi2) name = fi2.GetValue(tech)?.ToString() ?? ""; + list.Add(new { id, name }); + } + } + } + catch { } + return list; + } + + // ── Events ──────────────────────────────────────────── + + string ReadEvents(System.Collections.Specialized.NameValueCollection q) + { + int since = int.Parse(q["since"] ?? "-1"); + int limit = Math.Min(int.Parse(q["limit"] ?? "50"), 200); + lock (eventLock) + { + var ev = eventLog.Where(e => e.id > since).Take(limit).ToList(); + return J(new { success = true, data = new { events = ev, nextSeq = ev.Any() ? ev.Last().id : since } }); + } + } + + // ── Action Handlers ─────────────────────────────────── + + string QueueDig(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + int x = req.x, y = req.y, w = req.width, h = req.height; + int diggableCount = 0; + for (int dy = 0; dy < h; dy++) + for (int dx = 0; dx < w; dx++) + { + int cell = Grid.XYToCell(x + dx, y + dy); + if (cell >= 0 && cell < Grid.CellCount && Diggable.IsDiggable(cell)) + diggableCount++; + } + if (diggableCount == 0) + return J(new { success = false, error = "nothing_to_dig", errorMessage = "No diggable cells in area" }); + + QWithCamera(x, y, () => + { + int q = 0; + for (int dy = 0; dy < h; dy++) + for (int dx = 0; dx < w; dx++) + { + int cell = Grid.XYToCell(x + dx, y + dy); + if (cell >= 0 && cell < Grid.CellCount && Diggable.IsDiggable(cell)) + { DigTool.PlaceDig(cell, 0); q++; } + } + LogEvent("action", "info", "[Dig]", $"Queued {q} digs at ({x},{y}) {w}x{h}"); + }); + return J(new { success = true, data = new { result = "dig_queued", count = diggableCount } }); + } + catch (Exception ex) { return J(new { success = false, error = "dig_error", errorMessage = ex.Message }); } + } + + string QueueBuild(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + string buildingId = req.buildingId; + int x = req.x, y = req.y; + + var def = Assets.GetBuildingDef(buildingId); + if (def == null) + return J(new { success = false, error = "unknown_building", errorMessage = $"Building '{buildingId}' not found. Use /api/registry/buildings to list" }); + + int cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) + return J(new { success = false, error = "bounds", errorMessage = "Target cell out of bounds" }); + + // Check material availability + var world = ClusterManager.Instance?.activeWorld; + var inv = world?.worldInventory; + var materialInfo = new List(); + bool hasAllMaterials = true; + + if (def.MaterialCategory != null && inv != null) + { + foreach (var cat in def.MaterialCategory) + { + if (string.IsNullOrEmpty(cat)) continue; + var available = ElementLoader.elements + .Where(el => inv.GetAmount(el.tag, false) > 0) + .Where(el => ElementMatchesCategory(el, cat)) + .OrderByDescending(el => inv.GetAmount(el.tag, false)) + .ToList(); + + if (available.Count == 0) + { + hasAllMaterials = false; + materialInfo.Add(new { category = cat, available = false, suggestion = $"No '{cat}' materials available. Produce or dig more." }); + } + else + { + var best = available.First(); + materialInfo.Add(new { category = cat, available = true, bestElement = best.name, bestElementId = best.id.ToString(), availableKg = inv.GetAmount(best.tag, false) }); + } + } + } + + // Select materials for construction + var selectedElements = new List(); + if (def.MaterialCategory != null && inv != null) + { + foreach (var cat in def.MaterialCategory) + { + if (string.IsNullOrEmpty(cat)) continue; + var best = ElementLoader.elements + .Where(el => inv.GetAmount(el.tag, false) > 0) + .FirstOrDefault(el => ElementMatchesCategory(el, cat)); + if (best != null) selectedElements.Add(best.tag); + } + } + + // Validate we have real element tags before queuing + var validatedElements = new List(); + foreach (var t in selectedElements) + { + if (t.IsValid && ElementLoader.GetElement(t) != null) + validatedElements.Add(t); + } + // If no valid elements, use SandStone as default building material + if (validatedElements.Count == 0 && def.MaterialCategory != null && def.MaterialCategory.Length > 0) + { + var defaultEl = ElementLoader.elements.FirstOrDefault(e => e.id == SimHashes.SandStone); + if (defaultEl != null) validatedElements.Add(defaultEl.tag); + } + + var capturedElements = new List(validatedElements); + string capturedBid = buildingId; + QWithCamera(x, y, () => + { + try + { + int c = Grid.XYToCell(x, y); + if (c < 0 || c >= Grid.CellCount) return; + var d = Assets.GetBuildingDef(capturedBid); + if (d == null) return; + var pos = Grid.CellToPos(c); + var go = d.Instantiate(pos, Orientation.Neutral, capturedElements, (int)d.SceneLayer); + if (go != null) + { + go.SetActive(true); + LogEvent("action", "info", "[Build]", $"Queued {capturedBid} at ({x},{y})"); + } + } + catch (Exception ex) { LogEvent("build_error", "critical", "[Build]", $"{capturedBid} at ({x},{y}): {ex.Message}"); } + }); + + return J(new { success = true, data = new { result = hasAllMaterials ? "build_queued" : "build_queued_material_shortage", buildingId, x, y, materialInfo, hasAllMaterials } }); + } + catch (Exception ex) { return J(new { success = false, error = "build_error", errorMessage = ex.Message }); } + } + + static bool ElementMatchesCategory(Element el, string category) + { + switch (category) + { + case "RawMineral": return el.HasTag(GameTags.ConsumableOre); + case "Metal": return el.HasTag(GameTags.Metal); + case "RefinedMetal": return el.HasTag(GameTags.RefinedMetal); + case "Plastic": return el.HasTag(GameTags.Plastic); + case "Glass": return el.HasTag(GameTags.Glass); + case "BuildingFiber": return el.HasTag(GameTags.BuildingFiber); + case "Transparent": return el.HasTag(GameTags.Transparent); + default: return true; + } + } + + string QueueDeconstruct(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + int x = req.x, y = req.y, cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) return J(new { success = false, error = "bounds" }); + var buildingGo = Grid.Objects[cell, (int)ObjectLayer.Building]; + if (buildingGo == null) return J(new { success = false, error = "no_building", errorMessage = $"No building at ({x},{y})" }); + var deconstructable = buildingGo.GetComponent(); + if (deconstructable == null) return J(new { success = false, error = "cannot_deconstruct" }); + QWithCamera(x, y, () => { deconstructable.QueueDeconstruction(true); LogEvent("action", "info", "[Deconstruct]", $"Deconstruct at ({x},{y})"); }); + return J(new { success = true, data = new { result = "deconstruct_queued" } }); + } + catch (Exception ex) { return J(new { success = false, error = "deconstruct_error", errorMessage = ex.Message }); } + } + + string QueuePrioritize(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + int x = req.x, y = req.y, p = req.priority; + if (p < 1 || p > 9) return J(new { success = false, error = "invalid_priority", errorMessage = "Priority 1-9" }); + int cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) return J(new { success = false, error = "bounds" }); + var buildingGo = Grid.Objects[cell, (int)ObjectLayer.Building]; + if (buildingGo == null) return J(new { success = false, error = "no_building" }); + var prioritizable = buildingGo.GetComponent(); + if (prioritizable == null) return J(new { success = false, error = "cannot_prioritize" }); + int p2 = p; + QWithCamera(x, y, () => { SetPrioritizablePriority(prioritizable, p2); }); + return J(new { success = true, data = new { result = "priority_set", priority = p2 } }); + } + catch (Exception ex) { return J(new { success = false, error = "prioritize_error", errorMessage = ex.Message }); } + } + + string QueueResearch(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + string techId = req.techId; + // Find tech through Techs collection via reflection + object targetTech = null; + string targetTechId = techId; try { - var worldInv = ClusterManager.Instance?.activeWorld?.worldInventory; - if (worldInv != null) + var db = Db.Get(); + var techsProp = db.GetType().GetProperty("Techs"); + if (techsProp != null) { - var def = Assets.GetBuildingDef(bid); - if (def != null) - foreach (var cat in def.MaterialCategory ?? new string[0]) - foreach (var elem in ElementLoader.elements) - { - if (worldInv.GetAmount(elem.tag, false) <= 0) continue; - bool match = (cat == "RawMineral" && elem.HasTag(GameTags.ConsumableOre)); - match = match || (cat == "Metal" && elem.HasTag(GameTags.Metal)); - if (match) availableTags.Add(elem.tag); - } + var techs = techsProp.GetValue(db) as System.Collections.IEnumerable; + if (techs != null) + { + foreach (var t in techs) + { + if (t == null) continue; + var idProp = t.GetType().GetProperty("Id"); + var tid = idProp?.GetValue(t)?.ToString() ?? ""; + if (tid == techId) { targetTech = t; break; } + } + } } } catch { } - // If no materials found, use default sand - if (availableTags.Count == 0) availableTags.Add(new Tag("SandStone")); - List capturedTags = new List(availableTags); - cmdQueue.Enqueue(() => { - lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[Build] START "+bid}); } - try + + if (targetTech == null) + return J(new { success = false, error = "unknown_tech", errorMessage = $"Tech '{techId}' not found" }); + + var research = Research.Instance; + if (research == null) return J(new { success = false, error = "no_research" }); + + // Check prerequisites using reflection + var missingPrereqs = new List(); + var requiredTechsField = targetTech.GetType().GetField("requiredTech", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (requiredTechsField != null && requiredTechsField.GetValue(targetTech) is System.Collections.IEnumerable requiredTechs) + { + foreach (var t in requiredTechs) { - var def = Assets.GetBuildingDef(bid); - if (def == null) { lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] def null"}); } return; } - int cell = Grid.XYToCell(x, y); - if (cell < 0 || cell >= Grid.CellCount) { lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] bounds"}); } return; } - lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] cell="+cell}); } - var world = ClusterManager.Instance?.activeWorld; - lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] world ok"}); } - for (int dy = 0; dy < def.HeightInCells; dy++) - for (int dx = 0; dx < def.WidthInCells; dx++) { - int c2 = Grid.XYToCell(x+dx, y+dy); - if (c2 < 0 || c2 >= Grid.CellCount) continue; - if (Grid.Objects[c2, (int)ObjectLayer.Building] != null) { lock(eventLock){eventLog.Add(new GameEvent{id=eventSeq++,title="[B] occupied"});} return; } - } - lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] cells ok"}); } - var pos = Grid.CellToPos(cell); - // Use game's build system (handles all initialization) - lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] cells ok"}); } - try { - var loader = UnityEngine.Object.FindObjectOfType(); - GameObject go = null; - if (loader != null) go = loader.CreateBuildingUnderConstruction(def); - if (go == null) { - // Fallback: create complete building directly - go = def.Instantiate(Grid.CellToPos(cell), Orientation.Neutral, null, (int)def.SceneLayer); - } - if (go != null) go.SetActive(true); - lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[B] "+(go==null?"null":"ok")}); } - } catch (Exception ex2) { lock(eventLock){eventLog.Add(new GameEvent{id=eventSeq++,title="[B] ex:"+ex2.Message});} } + if (t == null) continue; + var idProp = t.GetType().GetProperty("Id"); + var tid = idProp?.GetValue(t)?.ToString() ?? ""; + if (!IsTechCompleteReflect(research, t)) + missingPrereqs.Add(tid); } - catch (Exception ex) { lock(eventLock) { eventLog.Add(new GameEvent{id=eventSeq++,title="[Build] EX:"+ex.Message}); } } - }); - return Ok("build_queued"); + } + + if (missingPrereqs.Count > 0) + return J(new { success = false, error = "missing_prerequisites", errorMessage = $"Requires: {string.Join(", ", missingPrereqs)}" }); + + if (IsTechCompleteReflect(research, targetTech)) + return J(new { success = true, data = new { result = "already_completed", techId } }); + + Q(() => { SetActiveResearch(research, targetTech); LogEvent("action", "info", "[Research]", $"Active: {targetTechId}"); }); + return J(new { success = true, data = new { result = "research_queued", techId } }); } - catch { return Fail("error"); } + catch (Exception ex) { return J(new { success = false, error = "research_error", errorMessage = ex.Message }); } } - static string _ss = null; + string QueueMop(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + int x = req.x, y = req.y, cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) return J(new { success = false, error = "bounds" }); + var el = Grid.Element[cell]; + if (el == null || !el.IsLiquid) return J(new { success = false, error = "not_liquid", errorMessage = $"No liquid at ({x},{y})" }); + if (Grid.Mass[cell] < 0.001f) return J(new { success = false, error = "too_little", errorMessage = "Mass too small" }); + int c2 = cell; + QWithCamera(x, y, () => { PlaceMopFallback(c2); LogEvent("action", "info", "[Mop]", $"Mop at ({x},{y})"); }); + return J(new { success = true, data = new { result = "mop_queued" } }); + } + catch (Exception ex) { return J(new { success = false, error = "mop_error", errorMessage = ex.Message }); } + } + + string QueueHarvest(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req == null) return J(new { success = false, error = "invalid_request" }); + int x = req.x, y = req.y, cell = Grid.XYToCell(x, y); + if (cell < 0 || cell >= Grid.CellCount) return J(new { success = false, error = "bounds" }); + var plantGo = Grid.Objects[cell, (int)ObjectLayer.Plants]; + if (plantGo == null) return J(new { success = false, error = "no_plant", errorMessage = $"No plant at ({x},{y})" }); + var harvestable = plantGo.GetComponent(); + if (harvestable == null) return J(new { success = false, error = "not_harvestable" }); + QWithCamera(x, y, () => { harvestable.Harvest(); LogEvent("action", "info", "[Harvest]", $"Harvest at ({x},{y})"); }); + return J(new { success = true, data = new { result = "harvest_queued" } }); + } + catch (Exception ex) { return J(new { success = false, error = "harvest_error", errorMessage = ex.Message }); } + } + + string QueueBatch(string body) + { + try + { + var req = JsonConvert.DeserializeObject(body); + if (req?.actions == null || req.actions.Count == 0) + return J(new { success = false, error = "invalid_request", errorMessage = "No actions" }); + var results = new List(); + int successCount = 0, failCount = 0; + foreach (var a in req.actions) + { + try + { + string subResult = null; + if (a.type == "dig") + subResult = QueueDig(J(new CoordWidthRequest { x = a.x ?? 0, y = a.y ?? 0, width = a.width ?? 1, height = a.height ?? 1 })); + else if (a.type == "build") + subResult = QueueBuild(J(new BuildRequest { buildingId = a.buildingId, x = a.x ?? 0, y = a.y ?? 0 })); + else if (a.type == "deconstruct") + subResult = QueueDeconstruct(J(new CoordRequest { x = a.x ?? 0, y = a.y ?? 0 })); + else if (a.type == "prioritize") + subResult = QueuePrioritize(J(new PriorReq { x = a.x ?? 0, y = a.y ?? 0, priority = a.priority ?? 5 })); + else if (a.type == "research") + subResult = QueueResearch(J(new ResearchRequest { techId = a.techId })); + else + { failCount++; results.Add(new { type = a.type, success = false, error = "unknown_type" }); continue; } + + if (subResult != null && subResult.Contains("\"success\":true")) + { successCount++; results.Add(new { type = a.type, buildingId = a.buildingId, success = true }); } + else + { failCount++; results.Add(new { type = a.type, buildingId = a.buildingId, success = false, error = subResult }); } + } + catch (Exception ex) { failCount++; results.Add(new { type = a.type, success = false, error = ex.Message }); } + } + return J(new { success = true, data = new { total = req.actions.Count, successCount, failCount, results } }); + } + catch (Exception ex) { return J(new { success = false, error = "batch_error", errorMessage = ex.Message }); } + } + + string QueueSave(string body) + { + string saveName = null; + try { var d = JsonConvert.DeserializeAnonymousType(body, new { name = "" }); saveName = d?.name; } catch { } + string sn = saveName; + Q(() => + { + try + { + string path; + if (!string.IsNullOrEmpty(sn)) + { var dir = Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()); path = Path.Combine(dir ?? ".", sn + ".sav"); } + else + { path = SaveLoader.GetActiveSaveFilePath(); var ts = System.DateTime.Now.ToString("yyyyMMdd_HHmmss"); path = path.Replace(".sav", $"_{ts}.sav"); } + SaveLoader.Instance.Save(path, false, false); + LogEvent("action", "info", "[Save]", $"Saved: {Path.GetFileName(path)}"); + } + catch (Exception ex) { LogEvent("action_error", "critical", "[Save]", ex.Message); } + }); + return J(new { success = true, data = new { result = "save_queued", saveName = sn } }); + } + + string QueueLoad(string body) + { + string saveName = null; + try { var d = JsonConvert.DeserializeAnonymousType(body, new { name = "" }); saveName = d?.name; } catch { } + if (string.IsNullOrEmpty(saveName)) return J(new { success = false, error = "invalid_request", errorMessage = "save name required" }); + string sn = saveName; + Q(() => + { + try + { + var dir = Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()); + string path = Path.Combine(dir ?? ".", sn + ".sav"); + if (File.Exists(path)) { LoadSave(path); LogEvent("action", "warning", "[Load]", $"Loading: {sn}"); } + else LogEvent("action_error", "critical", "[Load]", $"Save not found: {sn}"); + } + catch (Exception ex) { LogEvent("action_error", "critical", "[Load]", ex.Message); } + }); + return J(new { success = true, data = new { result = "load_queued", saveName = sn } }); + } + + string QueuePriorityGlobal(string body) + { + try + { + var d = JsonConvert.DeserializeAnonymousType(body, new { target = "", priority = 5 }); + if (d == null || string.IsNullOrEmpty(d.target)) return J(new { success = false, error = "invalid_request" }); + if (d.priority < 1 || d.priority > 9) return J(new { success = false, error = "invalid_priority" }); + int p = d.priority; string t = d.target; + int p2 = p; string t2 = t; + Q(() => + { + foreach (var b in Components.BuildingCompletes.Items) + if (b.Def.PrefabID == t2 || b.Def.Name == t2) + { + var pri = b.GetComponent(); + if (pri != null) SetPrioritizablePriority(pri, p2); + } + LogEvent("action", "info", "[PriorityGlobal]", $"{t2} -> {p2}"); + }); + return J(new { success = true, data = new { result = "priority_global_set", target = t, priority = p } }); + } + catch (Exception ex) { return J(new { success = false, error = "priority_error", errorMessage = ex.Message }); } + } + + string QueuePriorityType(string body) + { + try + { + var d = JsonConvert.DeserializeAnonymousType(body, new { buildingType = "", priority = 5 }); + if (d == null || string.IsNullOrEmpty(d.buildingType)) return J(new { success = false, error = "invalid_request" }); + if (d.priority < 1 || d.priority > 9) return J(new { success = false, error = "invalid_priority" }); + int p = d.priority; string t = d.buildingType; + int p2 = p; string t2 = t; + Q(() => + { + foreach (var b in Components.BuildingCompletes.Items) + if (b.Def.PrefabID == t2 || b.Def.Name == t2) + { + var pri = b.GetComponent(); + if (pri != null) SetPrioritizablePriority(pri, p2); + } + LogEvent("action", "info", "[PriorityType]", $"{t2} -> {p2}"); + }); + return J(new { success = true, data = new { result = "priority_type_set", buildingType = t, priority = p } }); + } + catch (Exception ex) { return J(new { success = false, error = "priority_error", errorMessage = ex.Message }); } + } + + string QueueCamera(string body) + { + try + { + int x = 0, y = 0; float zoom = -1; + var d = JsonConvert.DeserializeAnonymousType(body, new { x = 0, y = 0, zoom = -1f }); + if (d != null) { x = d.x; y = d.y; zoom = d.zoom; } + int cx = x, cy = y; float cz = zoom; + Q(() => + { + var cc = CameraController.Instance; + if (cc != null) + { + var pos = Grid.CellToPos(Grid.XYToCell(cx, cy)); + pos.z = -35f; + cc.transform.SetPosition(pos); + if (cz > 0 && Camera.main != null) Camera.main.orthographicSize = Mathf.Clamp(cz, 5f, 80f); + LogEvent("action", "info", "[Camera]", $"Moved to ({cx},{cy})"); + } + }); + return J(new { success = true, data = new { result = "camera_moved", x, y, zoom } }); + } + catch (Exception ex) { return J(new { success = false, error = "camera_error", errorMessage = ex.Message }); } + } + + // ── Screenshot ──────────────────────────────────────── + void ServeScreenshot(HttpListenerContext ctx) { try { - if (_ss != null && File.Exists(_ss)) { var b = File.ReadAllBytes(_ss); ctx.Response.ContentType = "image/png"; ctx.Response.ContentLength64 = b.Length; ctx.Response.OutputStream.Write(b, 0, b.Length); return; } + string ssPath = null; + var ev = new ManualResetEvent(false); + Q(() => + { + try + { + string dir = Path.Combine(Application.dataPath, "..", "oni_agent_screenshots"); + Directory.CreateDirectory(dir); + string path = Path.Combine(dir, $"oni_agent_cycle{GameClock.Instance.GetCycle()}.png"); + ScreenCapture.CaptureScreenshot(path); + ssPath = path; + lastScreenshotPath = path; + } + catch { } + ev.Set(); + }); + ev.WaitOne(15000); + + if (ssPath != null && File.Exists(ssPath)) + { + for (int i = 0; i < 30 && !IsFileReady(ssPath); i++) + Thread.Sleep(200); + if (IsFileReady(ssPath)) + { + var b = File.ReadAllBytes(ssPath); + ctx.Response.ContentType = "image/png"; + ctx.Response.ContentLength64 = b.Length; + ctx.Response.OutputStream.Write(b, 0, b.Length); + ctx.Response.OutputStream.Close(); + return; + } + } } catch { } + ctx.Response.StatusCode = 404; - ctx.Response.OutputStream.Close(); + try { ctx.Response.OutputStream.Close(); } catch { } + } + + static bool IsFileReady(string path) + { + try { using (var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None)) return true; } + catch { return false; } + } + + // ── Reflection helpers for APIs that may be internal ── + + static void SetActiveResearch(object research, object tech) + { + try + { + var method = research.GetType().GetMethod("SetActiveResearch", new[] { typeof(Tech), typeof(bool) }); + if (method != null) method.Invoke(research, new object[] { tech, true }); + else + { + method = research.GetType().GetMethod("SetActiveResearch", new[] { tech.GetType(), typeof(bool) }); + if (method != null) method.Invoke(research, new object[] { tech, true }); + } + } + catch { } + } + + static void SetPrioritizablePriority(Prioritizable p, int priority) + { + try + { + var setting = new PrioritySetting(PriorityScreen.PriorityClass.basic, priority); + var method = typeof(Prioritizable).GetMethod("SetPriority", new[] { typeof(PrioritySetting) }); + if (method != null) method.Invoke(p, new object[] { setting }); + else + { + // Try reflection-based approach + var field = typeof(Prioritizable).GetField("_prioritySetting", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (field != null) field.SetValue(p, setting); + } + } + catch { } + } + + static bool IsTechCompleteReflect(object research, object tech) + { + try + { + var method = research.GetType().GetMethod("IsTechComplete", new[] { tech.GetType() }); + if (method != null) return (bool)method.Invoke(research, new object[] { tech }); + // Try check completed list + var completedField = research.GetType().GetField("completedTechs", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (completedField != null && completedField.GetValue(research) is System.Collections.IList list) + { + var techIdProp = tech.GetType().GetProperty("Id"); + string techId = techIdProp?.GetValue(tech)?.ToString() ?? ""; + foreach (var t in list) + { + var idProp = t.GetType().GetProperty("Id"); + if (idProp != null && idProp.GetValue(t)?.ToString() == techId) return true; + } + } + } + catch { } + return false; + } + + static void PlaceMopFallback(int cell) + { + try + { + // Try MopTool.PlaceMop (may not exist in all versions) + var mopType = typeof(MopTool); + var placeMop = mopType.GetMethod("PlaceMop", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (placeMop != null) { placeMop.Invoke(null, new object[] { cell, 0 }); return; } + + // Fallback: place dig order + mop placer + var mopGo = Grid.Objects[cell, (int)ObjectLayer.MopPlacer]; + if (mopGo == null) + { + DigTool.PlaceDig(cell, 0); + } + } + catch { } + } + + static void LoadSave(string path) + { + try + { + var sl = SaveLoader.Instance; + if (sl == null) return; + var method = sl.GetType().GetMethod("Load", new[] { typeof(string), typeof(bool) }); + if (method != null) method.Invoke(sl, new object[] { path, false }); + else + { + method = sl.GetType().GetMethod("Load", new[] { typeof(string) }); + if (method != null) method.Invoke(sl, new object[] { path }); + } + } + catch (Exception ex) { LogEventStatic("action_error", "critical", "[Load]", ex.Message); } } } - - - public class GameEvent { public int id; public string type; public string severity; public long timestamp; public int cycle; public string category; public string title; public string message; } - public class GasEntry { public string gas; public float mass; public int count; } - public class DigReq { public int x; public int y; public int width; public int height; } - public class BuildReq { public string buildingId; public int x; public int y; } + public class GasData { public string gas; public float mass; public int count; } } diff --git a/mod/mod.yaml b/mod/mod.yaml index 2ac2a6f..fe1906b 100644 --- a/mod/mod.yaml +++ b/mod/mod.yaml @@ -1,3 +1,4 @@ title: "ONI Agent Bridge" staticID: "oni_agent_bridge" -description: "Expose HTTP API for AI agents to read ONI game state and perform actions" +description: "Expose HTTP RESTful API for AI agents to read ONI game state and perform actions (dig/build/deconstruct/research/camera)" +tags: ["Mod", "API", "AI"] diff --git a/mod/mod_info.yaml b/mod/mod_info.yaml index bc4180f..a88c9c2 100644 --- a/mod/mod_info.yaml +++ b/mod/mod_info.yaml @@ -1,4 +1,4 @@ supportedContent: ALL minimumSupportedBuild: 722606 -version: 1.0.0 +version: 2.0.0 APIVersion: 2 diff --git a/scripts/event_daemon.py b/scripts/event_daemon.py index ed13fa2..67eaa6c 100644 --- a/scripts/event_daemon.py +++ b/scripts/event_daemon.py @@ -2,294 +2,96 @@ """ ONI Agent Event Daemon ====================== -Continuous event poller that feeds game events to the AI's input stream. +Continuous event poller. Polls game events every N seconds and displays +critical/warning/info events to the console (for AI input stream). -Architecture: - Game Mod --> Event Queue (via HTTP) --> Event Daemon --> AI Input Stream - -The daemon: - 1. Polls GET /api/state/events?since= every N seconds - 2. Classifies events by severity (critical/warning/info) - 3. For critical events: immediately triggers full analysis + prints alert - 4. For warning events: logs and optionally triggers targeted checks - 5. For info events: accumulates and reports periodically - 6. Maintains a compact event log for AI context +Usage: + python event_daemon.py [interval_seconds] """ -import json -import os -import sys -import time -import datetime +import json, os, sys, time, datetime -# Add tools to path TOOLS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'tools') sys.path.insert(0, TOOLS_DIR) -# Disable auto-pause for event daemon — we poll frequently and should not pause the game -import oni_api -oni_api.AUTO_PAUSE_ENABLED = False +from oni_api import api_get, api_post, api_url, load_config -from oni_api import api_get, api_post, api_url - - -# ── Configuration ────────────────────────────────────────────────────────── - -POLL_INTERVAL = 5 # seconds between event polls -CRITICAL_POLL_INTERVAL = 2 # poll faster when critical events detected -MAX_EVENT_HISTORY = 200 # events kept in rolling buffer -CRITICAL_SEVERITIES = {'critical', 'duplicantdeath', 'buildingdamage', 'poweroutage'} -WARNING_SEVERITIES = {'warning', 'duplicantstress', 'lowoxygen', 'foodshortage'} - - -# ── Event History ────────────────────────────────────────────────────────── +POLL_INTERVAL = 5 +MAX_EVENTS = 200 class EventHistory: - """Rolling buffer of events + statistics for AI context.""" - - def __init__(self, maxlen=MAX_EVENT_HISTORY): + def __init__(self, maxlen=200): self.events = [] self.maxlen = maxlen - self.stats = { - 'total': 0, - 'critical': 0, - 'warning': 0, - 'info': 0, - 'by_category': {}, - 'by_type': {}, - 'last_poll_cycle': 0, - } + self.last_seq = -1 + self.stats = {"critical": 0, "warning": 0, "info": 0} - def push(self, events): - for e in events: - self.events.append(e) - self.stats['total'] += 1 - sev = (e.get('severity') or 'info').lower() - cat = e.get('category', 'unknown') - etype = e.get('type', 'unknown') - - if sev in ('critical', 'duplicantdeath', 'buildingdamage'): - self.stats['critical'] += 1 - elif sev in ('warning',): - self.stats['warning'] += 1 - else: - self.stats['info'] += 1 - - self.stats['by_category'][cat] = self.stats['by_category'].get(cat, 0) + 1 - self.stats['by_type'][etype] = self.stats['by_type'].get(etype, 0) + 1 - - self.stats['last_poll_cycle'] = e.get('cycle', 0) - - # Trim + def add(self, event): + self.events.append(event) if len(self.events) > self.maxlen: - self.events = self.events[-self.maxlen:] + self.events.pop(0) + sev = event.get('severity', 'info') + self.stats[sev] = self.stats.get(sev, 0) + 1 - def get_summary(self): - return { - 'total_events': self.stats['total'], - 'critical_count': self.stats['critical'], - 'warning_count': self.stats['warning'], - 'info_count': self.stats['info'], - 'categories': dict(sorted(self.stats['by_category'].items(), - key=lambda x: -x[1])[:10]), - 'last_cycle': self.stats['last_poll_cycle'], - 'recent_critical': [e for e in self.events[-20:] - if (e.get('severity') or '').lower() in CRITICAL_SEVERITIES][-5:], - } + def get_recent(self, n=10): + return self.events[-n:] - -# ── Event Classifier ────────────────────────────────────────────────────── - -def classify_event(e): - """Return the action type for a given event.""" - sev = (e.get('severity') or '').lower() - title = (e.get('title') or '').lower() - msg = (e.get('message') or '').lower() - cat = (e.get('category') or '').lower() - - if sev in CRITICAL_SEVERITIES: - return 'critical' - if sev in WARNING_SEVERITIES: - return 'warning' - if cat == 'action': - return 'action_feedback' - - # Content-based classification - combined = title + ' ' + msg - if any(w in combined for w in ['suffocat', 'choking', 'no oxygen', 'out of air']): - return 'critical' - if any(w in combined for w in ['starving', 'food', 'hungry']): - return 'warning' - if any(w in combined for w in ['heat', 'overheat', 'temperature', 'melt']): - return 'warning' - if any(w in combined for w in ['power', 'wattage', 'shutoff']): - return 'warning' - if any(w in combined for w in ['duplicant', 'stress', 'break']): - return 'warning' - if any(w in combined for w in ['research complete', 'research completed']): - return 'info_research' - if any(w in combined for w in ['printing pod']): - return 'info_printing_pod' - - return 'info' - - -def format_event_for_ai(e): - """Format an event as a structured string for AI input.""" - ts = datetime.datetime.fromtimestamp(e.get('timestamp', time.time())).strftime('%H:%M:%S') - cycle = e.get('cycle', '?') - severity = e.get('severity', 'info').upper() - title = e.get('title', '?') - message = e.get('message', '') - - lines = [f"[EVENT {severity}] Cycle {cycle} @ {ts}"] - lines.append(f" Title: {title}") - if message: - lines.append(f" Message: {message}") - entity = e.get('entity') - if entity: - lines.append(f" Entity: {entity}") - cell = e.get('cell') - if isinstance(cell, int) and cell >= 0: - lines.append(f" Cell: {cell}") - return '\n'.join(lines) - - -# ── Polling Loop ────────────────────────────────────────────────────────── - -def poll_loop(event_history): - seq = 0 +def main(): + interval = int(sys.argv[1]) if len(sys.argv) > 1 else POLL_INTERVAL + history = EventHistory(MAX_EVENTS) consecutive_errors = 0 - print("[ONI Event Daemon] Starting event poll...") - print(f"[ONI Event Daemon] Poll interval: {POLL_INTERVAL}s") - print() + print(f"[EventDaemon] Starting — poll interval {interval}s") + print(f"[EventDaemon] Config: {json.dumps(load_config())}") while True: try: - data = api_get(f"/api/state/events?since={seq}&limit=50") - - if 'error' in data: + r = api_get(f'/api/state/events?since={history.last_seq}') + if not r.get("success"): consecutive_errors += 1 - if consecutive_errors == 1: - print(f"[!] Cannot reach game: {data['error']}") - print(" Waiting for game connection...") - time.sleep(POLL_INTERVAL * 2) + if consecutive_errors > 3: + print(f"[EventDaemon] {consecutive_errors} consecutive errors — mod may be offline") + time.sleep(interval) continue consecutive_errors = 0 - events = data.get('events', []) - next_seq = data.get('next_seq', seq) + data = r.get("data", {}) + events = data.get("events", []) + next_seq = data.get("nextSeq", history.last_seq) + + for ev in events: + history.add(ev) + sev = ev.get('severity', 'info') + title = ev.get('title', '') + message = ev.get('message', '') + ts = ev.get('timestamp', 0) + dt = datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S') if ts else '' + + if sev in ('critical', 'error'): + print(f"\n⚠️ [{dt}] CRITICAL: {title} {message}") + elif sev in ('warning',): + print(f"\n🟡 [{dt}] WARNING: {title} {message}") + else: + print(f"🔵 [{dt}] INFO: {title} {message}") if events: - event_history.push(events) + history.last_seq = next_seq - # Classify and report - critical_events = [] - for e in events: - cls = classify_event(e) - if cls == 'critical': - critical_events.append(e) - # Print alert with clear marker - print("=" * 56) - print(" *** CRITICAL EVENT ***") - print(format_event_for_ai(e)) - print("=" * 56) - print() - - # Auto-trigger full analysis on critical events - _trigger_emergency_analysis(e) - elif cls == 'warning': - print(format_event_for_ai(e)) - print() - elif cls == 'info_research': - # Research completed — show with unlocks - print("=" * 40) - print(format_event_for_ai(e)) - print(" -> Check what's new: python3 tools/oni_api.py buildable") - print("=" * 40) - print() - elif cls == 'info_printing_pod': - # Printing pod ready - print("=" * 40) - print(format_event_for_ai(e)) - print(" -> View options: python3 tools/oni_api.py printing_pod") - print("=" * 40) - print() - else: - # Only print non-info events or batch feedback - cat = e.get('category', '') - if cat != 'general' or cls != 'info': - print(format_event_for_ai(e)) - print() - - # If critical events happened, poll faster for a bit - if critical_events: - seq = next_seq - time.sleep(CRITICAL_POLL_INTERVAL) - continue - - seq = next_seq - time.sleep(POLL_INTERVAL) + # Show summary every 30 seconds + if history.events and int(time.time()) % 30 < interval: + c = history.stats.get('critical', 0) + w = history.stats.get('warning', 0) + print(f"[EventDaemon] Stats | critical={c} warning={w} total={len(history.events)} | seq={history.last_seq}") except KeyboardInterrupt: - print("\n[ONI Event Daemon] Shutting down.") - summary = event_history.get_summary() - print(f" Total events seen: {summary['total_events']}") - print(f" Critical: {summary['critical_count']}, Warning: {summary['warning_count']}") + print("\n[EventDaemon] Stopped") break - except Exception as e: - consecutive_errors += 1 - if consecutive_errors <= 2: - print(f"[!] Poll error: {e}") - time.sleep(POLL_INTERVAL) + except Exception as ex: + print(f"[EventDaemon] Error: {ex}") + time.sleep(interval) + continue + time.sleep(interval) -def _trigger_emergency_analysis(event): - """On critical events, pull game state snapshot for AI context.""" - try: - print(" -> Triggering emergency snapshot...") - game = api_get('/api/state/game') - alerts = api_get('/api/state/alert') - dups = api_get('/api/state/duplicants') - - if 'error' not in game: - print(f" [SNAPSHOT] Cycle {game.get('cycle', '?')}, " - f"{game.get('duplicantCount', '?')} dupes, " - f"{game.get('suffocating', 0)} suffocating, " - f"{game.get('starving', 0)} starving, " - f"{game.get('stressed', 0)} stressed") - if isinstance(alerts, list) and alerts: - print(f" [ALERTS] {len(alerts)} active:") - for a in alerts[:3]: - print(f" - [{a.get('severity', '?')}] {a.get('title', '?')}") - print() - except: - pass - - -# ── Main ────────────────────────────────────────────────────────────────── - -def main(): - history = EventHistory() - try: - poll_loop(history) - except KeyboardInterrupt: - pass - - # Print final summary - summary = history.get_summary() - print() - print("=" * 56) - print(" Event Daemon Session Summary") - print("=" * 56) - print(f" Total events: {summary['total_events']}") - print(f" Critical: {summary['critical_count']}") - print(f" Warning: {summary['warning_count']}") - print(f" Info: {summary['info_count']}") - print(f" Top categories: {', '.join(summary['categories'].keys())}") - print("=" * 56) - - -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/skills/oni_agent.md b/skills/oni_agent.md index 5d3a136..67809ed 100644 --- a/skills/oni_agent.md +++ b/skills/oni_agent.md @@ -1,466 +1,110 @@ -# ONI Agent Skill - -AI 操作《缺氧》(Oxygen Not Included) 的完整技能定义。 - -## 触发条件 -- 用户提到《缺氧》/ Oxygen Not Included / ONI -- 用户询问游戏策略、建造方案、资源管理 -- 用户希望 Mod 工具链执行操作 -- 用户需要实时游戏操作协助 - -## 游戏核心玩法 - -缺氧是一款太空殖民模拟游戏。核心目标是在恶劣环境中维持复制人的生存并建设可持续基地。 - -### 生存要素优先级 - -| 优先级 | 要素 | 危险条件 | 后果 | 解决方案路线 | -|--------|------|---------|------|-------------| -| ★★★ | 氧气 | O₂ < 500 kg 或无制氧设备 | 复制人 110s 内窒息死亡 | 藻类制氧(前期) → 电解器+氢气发电(SPOM) | -| ★★★ | 食物 | Calories < 1000 kcal/人 | 复制人饿死 | 浆果种植 → 烤肉 → 营养膏棒 | -| ★★☆ | 温度 | > 40°C 或 < 10°C | 作物枯萎/复制人受伤 | 隔热/液冷+蒸汽机/加热 | -| ★★☆ | 电力 | 电量不足/电路过载 | 设备停摆/电线起火 | 手动→煤→氢→天然气→太阳能 | -| ★☆☆ | 士气 | Stress > 80% | 精神崩溃/破坏设备 | 房间奖励/装饰/按摩/高品质食物 | -| ★☆☆ | 病菌 | Slimelung/Food Poisoning | 生病死亡 | 洗手/净化/消毒/氧气服 | - -### 游戏世界规则 - -- 二维网格地图,原点 (0,0) 在左下角,x 向右 y 向上 -- 气体按密度分层:CO₂(沉底) < PollutedO₂ < O₂ < NaturalGas < H₂(上升) -- 建筑占用 w×h 格子,坐标指左下角锚点 -- 科技通过研究站→超级计算机逐步解锁新建筑 -- 每 3 周期传送舱提供一次新复制人或补给选择 - +--- +name: oni_agent +description: "Oxygen Not Included 完全游戏操控 — REST API + CLI 工具链,含分阶段目标/材料系统/可达性/建筑数据库" +version: "2.1.0" --- -## API 总览(92 个端点) +# ONI Agent Skill — 缺氧 AI 完全操控 -工具链让 AI 能像人类玩家一样理解游戏并执行操作。所有 API 通过 `tools/oni_api.py` 命令行调用。 +通过 `tools/oni_api.py` CLI + Mod HTTP API 完全控制游戏。**每次决策前必须先 pause**,所有操作自动拉视角到坐标。 -### 一、状态查询 — 理解"当前发生了什么" +## 一、坐标系与格子 -每次 AI 推理前工具自动暂停游戏,保证数据时效性。AI 操作完成后主动 unpause。 +- (0,0)=左下角,x→右,y→上,典型地图 256×384 +- 建筑坐标=左下角锚点 +- 通过 `cell ` 查看格子:element / massKg / temperatureC / isSolid / isLiquid / isGas / isDiggable / hasBuilding / hasDuplicant +- `explore ` AI友好区域摘要 +- 可达性:复制人只能在开放空间移动,固体阻挡需挖掘,液体/气体不阻挡 +- 梯子(Ladder)提供垂直移动,砖块(Tile)提供地面 + +## 二、材料系统 + +建筑需要特定类别材料: +- **BuildableRaw** = SandStone, Granite, IgneousRock, Obsidian (挖掘获得) +- **Metal** = Cuprite, IronOre, GoldAmalgam, Wolframite (挖掘→精炼) +- **RefinedMetal** = Copper, Iron, Gold, Steel (精炼生产) +- **Plastic** = Polypropylene (聚合压机制造) +- **Glass** = Glass (窑炉烧制) +- **Farmable** = Dirt (挖掘/堆肥) + +AI 必须检查世界库存中哪些元素满足建筑的材料类别,选择存量最多的。 + +## 三、分阶段目标 + +### Phase 1 (Cycle 1-20): 生存基础 +- **氧气**: OxygenDiffuser(藻类) 或 Electrolyzer(水→O₂+H₂) +- **电力**: ManualGenerator → [研究PowerRegulation] → CoalGenerator +- **食物**: PlanterBox×3-5 种 Mealwood (消耗Dirt) +- **卫生**: Outhouse + WashBasin → [研究Plumbing] → Lavatory循环 +- **科研**: ResearchStation → FarmingTech先 → PowerRegulation → Plumbing +- **执行**: `dig` 挖空间 → `build IDs` 建造 → `research_select ID` 研究 + +### Phase 2 (Cycle 20-100): 自持系统 +- **SPOM**: Electrolyzer+H₂发电机 → 无限O₂+部分电 (`oni_builder.py build spom`) +- **厕所循环**: Lavatory→WaterPurifier→Lavatory (`oni_builder.py build toilet_loop`) +- **电力升级**: 变压器(Transformer)防过载 + CoalGenerator +- **隔热**: InsulationTile 包围基地 +- **养殖**: RanchStation + Incubator (哈奇哈奇产煤+食物) + +### Phase 3 (Cycle 100+): 自动化 +- 液冷模块 Aquatuner+SteamTurbine +- 石油/天然气发电 +- 喷泉开发 +- 太空探索 + +## 四、关键建筑ID速查 + +| ID | 尺寸 | 材料 | 耗电 | 用途 | +|----|------|------|------|------| +| ManualGenerator | 2×2 | Metal | -400W | 人力发电 | +| CoalGenerator | 2×2 | Metal | -600W | 燃煤发电 | +| HydrogenGenerator | 2×2 | Metal | -800W | 燃氢发电 | +| Electrolyzer | 2×2 | Metal | +120W | 水→O₂+H₂ | +| GasPump | 1×2 | Metal | +240W | 抽气 | +| WaterPurifier | 2×2 | Metal | +120W | 污水净化 | +| PlanterBox | 1×1 | BuildableRaw | 0 | 种植 | +| StorageLocker | 1×1 | BuildableRaw | 0 | 储存 | +| RationBox | 2×2 | BuildableRaw | 0 | 储食 | +| Tile | 1×1 | BuildableRaw | 0 | 地板 | +| InsulationTile | 1×1 | BuildableRaw | 0 | 隔热墙 | +| Ladder | 1×1 | BuildableRaw | 0 | 梯子 | +| PneumaticDoor | 1×2 | Metal | 0 | 门 | +| Battery | 1×1 | Metal | 0 | 电池 | +| Transformer | 2×1 | Metal | 0 | 变压器 | +| ResearchStation | 2×2 | BuildableRaw | +60W | 初级科研 | +| SteamTurbine | 4×2 | Metal | -850W | 热删除+发电 | +| Aquatuner | 1×2 | RefinedMetal | +1200W | 液冷 | +| Cot | 1×1 | BuildableRaw | 0 | 床 | + +## 五、标准操作流程 + +``` +1. pause "原因" ← 必须暂停 +2. status / resources / events ← 感知 +3. explore / cell ← 验证坐标 +4. dig → build / deconstruct ← 执行(自动拉视角) +5. events ← 检查结果 +6. unpause 1 ← 恢复 +``` + +**紧急响应**: O₂<500→emergency_o2 | 食物<200k→建农场 | CO₂多→fix_co2 | 电力过载→fix_overload + +## 六、工具速查 ```bash -# 全局总览 — 最常用的命令 -python3 tools/oni_api.py status -# 输出:周期、窒息人数、饥饿人数、压力人数、是否暂停、游戏速度 +# 监控 +oni_api.py status | resources | events | events N +oni_api.py explore x y w h | cell x y | cells x y w h | gas x y r +oni_api.py registry buildings [filter] -# 全面诊断 -python3 tools/oni_commander.py diagnose -# 输出:电力+CO₂+温度+疾病+管道的完整报告 -``` - -**各状态查询命令的用途:** - -| 命令 | 等价于玩家做什么 | AI 用它做什么 | -|------|-----------------|-------------| -| `status` | 看一眼基地总览面板 | 快速评估危机:有人在窒息吗?在挨饿吗? | -| `resources` | 打开资源面板 | 检查氧气储量够多久、煤还剩多少 | -| `duplicants` | 逐一查看每个复制人 | 谁在窒息?谁压力爆了?谁没事干? | -| `buildings` | 按分类查看建筑 | 数一数有多少发电机、电解器、种植箱 | -| `power` | 打开电力覆盖层 | 哪个电路过载了?负载多少? | -| `co2` | 看 CO₂ 在哪聚集 | CO₂ 是前中期最大杀手,找到它并挖排气道 | -| `temp_zones` | 看温度覆盖层 | 找到过热区域(>50°C)和过冷区域(<5°C) | -| `pipes gas/liquid` | 点开管道看看 | 管道堵塞了吗?内容物在流动吗? | -| `rooms` | 打开房间覆盖层 | 确认宿舍/卫生间/餐厅生效了吗 | -| `morale` | 查看士气面板 | 士气够不够支撑当前技能数 | -| `diseases` | 打开病菌覆盖层 | 谁被感染了?环境病菌多吗? | -| `storage` | 查看储物建筑 | 储备粮在哪、存了多少 | -| `storages` | 同上 | 同 storage | -| `resources` | 资源面板 | 所有物资的精确余量 | -| `skills` | 技能面板 | 每个复制人的属性值和已学技能 | -| `research` | 科技树 | 哪些科技已完成、哪些可研究 | -| `research_detail` | 点开研究站 | 研究站在工作吗?现在在研究什么? | -| `buildable` | 建造菜单 | 当前科技解锁了哪些建筑 | -| `geysers` | 查看喷泉 | 找到水源/天然气/火山位置 | -| `critters` | 查看生物 | 哈奇/飞鱼/滑鳞的数量和位置 | -| `plants` | 查看植物 | 作物生长进度、是否枯萎 | - -### 二、格子地图 — 理解"每个格子有什么" - -所有建造操作前必须先用格子查询确认位置。 - -```bash -# 查看单个格子(最常用) -python3 tools/oni_api.py cell 45 48 -# 输出:元素、温度、有无建筑、有无复制人、是否可挖掘、是否安全 - -# 查看 10x10 区域 -python3 tools/oni_api.py cells 40 40 10 10 -# 输出:区域内所有格子的地图 + AI 友好摘要 - -# 扫描某一行/列 -python3 tools/oni_api.py slice y 20 0 50 -# 输出:第 20 行从 0 到 50 的每个格子 - -# 气体分析 -python3 tools/oni_api.py gas 50 50 30 -# 输出:以 50,50 为中心半径 30 内的气体分布 - -# AI 友好区域探索 -python3 tools/oni_api.py explore 40 40 20 20 -# 输出:区域内建筑列表+复制人+元素摘要+感兴趣格子 -``` - -**AI 坐标定位方法:** - -``` -方法 1: buildings → 查已有建筑坐标 → 在附近偏移找空地 -方法 2: explore → 找 buildings_in_region 为空的区域 -方法 3: cell → 确认 isSolid=false + hasBuilding=false + isVisible=true -方法 4: co2 → 在 CO₂ 聚集点下方挖排气通道 -方法 5: gas → 在气体聚集区放气体泵 -``` - -### 三、实体注册表 — "这个建筑是什么" - -AI 在运行时查询任何不懂的 ID: - -```bash -# 查建筑 -python3 tools/oni_api.py registry buildings Electrolyzer -# 输出:名称、分类、尺寸(2x2)、功耗(120W)、所需材料 - -# 查元素 -python3 tools/oni_api.py registry elements Water -# 输出:比热容、导热系数、沸点(99°C)、物态 - -# 查科技 -python3 tools/oni_api.py registry techs ImprovedOxygen -# 输出:前置科技、解锁建筑(Electrolyzer) - -# 查优先级含义 -python3 tools/oni_api.py registry priorities -# 输出:1=最低 5=默认 9=紧急 -``` - -### 四、建造操作 — "建造世界" - -建造 = 挖掘(dig) → 放置(build)。坐标是建筑左下角。 - -```bash -# 挖掘区域 -python3 tools/oni_api.py dig 40 40 10 10 -# 在 (40,40) 处挖一个 10x10 的空间 - -# 放置建筑 -python3 tools/oni_api.py build Electrolyzer 45 42 -# 在 (45,42) 放电解器(左下角定位,占 2x2 格) - -# 铺设管路 -python3 tools/oni_api.py build_pipe_line liquid 42 40 48 40 line -# 从 (42,40) 到 (48,40) 铺液体管道 -# mode=line: 与已有管线合并 -# mode=cross: 交叉处自动用跨接器跳过 -# mode=single: 单段 - -# 铺设电线 -python3 tools/oni_api.py build_wire_line heavy 30 20 45 20 cross -# 从 (30,20) 到 (45,20) 铺重载电线 -# 交叉处用跨接器不连通 - -# 拆除 -python3 tools/oni_api.py deconstruct Tile 42 45 -# 拆除 (42,45) 处的 Tile - -# 旋转 -python3 tools/oni_api.py rotate 45 48 -# 旋转 (45,48) 处的建筑 - -# 清碎片 -python3 tools/oni_api.py clear 40 40 5 -# 清除 (40,40) 半径 5 内的碎片 - -# 复制设置 -python3 tools/oni_api.py copy_settings 45 48 50 48 -# 把 (45,48) 建筑的设置复制到 (50,48) -``` - -### 五、建筑操作 — "使用已建成的建筑" - -相当于玩家点击建筑后选择功能。 - -```bash -# 开关 -python3 tools/oni_api.py toggle 45 42 -# 关闭/打开电解器(省电或恢复运行) - -# 设优先级 -python3 tools/oni_api.py set_building_priority 45 42 9 -# 电解器优先级设为 9(紧急) - -# 设配方 -python3 tools/oni_api.py set_recipe 30 25 Crush -# 碎岩机设为粉碎模式 - -# 开关自动化 -python3 tools/oni_api.py set_automation 45 42 on -# 电解器开启自动化控制 - -# 清空储物 -python3 tools/oni_api.py empty 50 50 -# 清空 (50,50) 储物箱全部内容 - -# 设储物过滤 -python3 tools/oni_api.py storage_filter 50 50 Coal -# 储物箱只收煤炭 - -# 设冰箱温度 -python3 tools/oni_api.py fridge_temp 60 30 2 -# 冰箱设为 2°C - -# 设电池充放电阈值 -python3 tools/oni_api.py battery_charge 35 25 90 20 -# 智能电池 90% 停充、20% 启动 - -# 设阀门流量 -python3 tools/oni_api.py valve_flow 42 42 1000 -# 阀门限流 1000g/s - -# 设排气口压力 -python3 tools/oni_api.py vent_pressure 45 50 2000 -# 高压排气口设为 2000g - -# 设孵化器 -python3 tools/oni_api.py incubator_setting 50 40 HatchEgg -# 孵化器优先孵哈奇蛋 - -# 设传感器阈值 -python3 tools/oni_api.py sensor_threshold 70 30 500 -# 气压传感器阈值 500g - -# 消毒 -python3 tools/oni_api.py disinfect 42 42 -# 对 (42,42) 消毒 - -# 清扫 -python3 tools/oni_api.py sweep 40 40 10 -# 标记 (40,40) 半径 10 内的物品为待清扫 - -# 取消任务 -python3 tools/oni_api.py cancel_errand 45 42 -# 取消电解器处的排队任务 -``` - -### 六、门控制 - -```bash -# 锁门 -python3 tools/oni_api.py door_lock 42 45 on -# 锁上 (42,45) 的门,复制人不能通过 - -# 单向通行 -python3 tools/oni_api.py door_one_way 42 45 left -# 门只允许向左通过 - -# 手动开门/关门 -python3 tools/oni_api.py door_open 42 45 off -# 手动关闭 (42,45) 的门 -``` - -### 七、复制人管理 - -```bash -# 移动复制人 -python3 tools/oni_api.py dupe_move Dup1 30 25 -# 命令 Dup1 移动到 (30,25) - -# 取消任务 -python3 tools/oni_api.py dupe_cancel_task Dup1 -# 命令 Dup1 取消当前正在做的事 - -# 分配工作 -python3 tools/oni_api.py assign_job Dup1 Dig -# 让 Dup1 专注挖掘工作 - -# 个人优先级 -python3 tools/oni_api.py dupe_personal_priority Dup1 Dig 9 -# Dup1 的挖掘优先级设为 9(最高) -``` - -### 八、科研管理 - -```bash -# 查看研究状态 -python3 tools/oni_api.py research_detail -# 输出:研究站是否工作、当前研究什么、超级计算机是否造好 - -# 选择研究 -python3 tools/oni_api.py research_select ImprovedOxygen -# 开始研究 ImprovedOxygen - -# 取消研究 -python3 tools/oni_api.py research_cancel -# 取消全部当前研究 -``` - -### 九、生物/植物管理 - -```bash -# 攻击生物 -python3 tools/oni_api.py critter_attack 120 80 -# 标记 (120,80) 的生物为攻击目标 - -# 抓捕生物 -python3 tools/oni_api.py critter_wrangle 120 80 -# 抓捕 (120,80) 的生物运到存放点 - -# 收获植物 -python3 tools/oni_api.py harvest 50 45 -# 收获 (50,45) 的成熟作物 - -# 拔除植物 -python3 tools/oni_api.py plant_uproot 50 45 -# 拔掉 (50,45) 的植物 -``` - -### 十、游戏控制 - -```bash -# 暂停/恢复 -python3 tools/oni_api.py pause "Building SPOM" -python3 tools/oni_api.py unpause 1 - -# 速度 -python3 tools/oni_api.py speed 3 - -# 存档回滚 -python3 tools/oni_api.py save "before_experiment" -python3 tools/oni_api.py saves -python3 tools/oni_api.py load "before_experiment" - -# 截图视角 -python3 tools/oni_api.py snapshot /tmp/now.png -python3 tools/oni_api.py camera 120 80 20 - -# 覆盖层切换 -python3 tools/oni_api.py overlay power -python3 tools/oni_api.py overlay temp -python3 tools/oni_api.py overlay gas -python3 tools/oni_api.py overlay rooms - -# 传送舱 -python3 tools/oni_api.py printing_pod -python3 tools/oni_api.py printing_pod_select 0 -``` - -### 十一、批量操作 - -```bash -python3 tools/oni_api.py batch docs/batch_example.json -# 一次执行多个动作,逐个报告结果 -``` - -### 十二、高级指令 - -```bash -# 全面诊断 -python3 tools/oni_commander.py diagnose - -# 自动处理 CO₂ -python3 tools/oni_commander.py fix_co2 - -# 处理过载电路 -python3 tools/oni_commander.py fix_overload - -# 紧急制氧 -python3 tools/oni_commander.py emergency_o2 - -# 一键拓展房间 -python3 tools/oni_commander.py expand_base 40 40 10 8 -``` - ---- - -## 常见场景查询路线 - -### "复制人要窒息了" -``` -status → 看 suffocating 计数 -duplicants → 找 oxygen < 20 的复制人 -cell <窒息复制人坐标> → 所在格子的元素 -co2 → 如果是 CO₂ 窒息,fix_co2 挖排气道 -resources → O₂ 存量和 Algae 存量 -buildings → 过滤 Oxygen 分类看有无制氧设备 -无设备 → emergency_o2 或 build OxygenDiffuser -``` - -### "基地没电了" -``` -power → 看各电路负载和过载 -resources → Coal/Hydrogen/NaturalGas 存量 -buildings → Power 分类有哪些发电机 -过载 → fix_overload -缺电 → 根据资源选发电机类型 -``` - -### "研究卡住了" -``` -research_detail → 研究站通不通电、有没有复制人在研究 -building_detail <研究站坐标> → 供上电了吗 -resources → Dirt 够吗(研究站消耗) -需超级计算机 → build SuperComputer <坐标> -``` - -### "管道不走了" -``` -pipes liquid → 看管道内容 -building_detail <水泵> → 泵有没有电 -building_detail <目的地建筑> → 输入是否已满 -build_pipe_line liquid <起点> <终点> cross → 重建 -``` - -### "传送舱来了" -``` -event_daemon 显示 [EVENT] Printing Pod ready -printing_pod → 看选项 -printing_pod_select 0|1|2 → 选择 -``` - ---- - -## AI 操作铁律 - -``` -铁律 1: AI 开始推理/决策前 → 工具自动暂停游戏 -铁律 2: AI 需要问用户时 → 工具自动暂停 -铁律 3: AI 退出回答时 → 确保游戏已暂停(无人监控时不能运行) -铁律 4: 写操作前 → 工具自动确保暂停态 -铁律 5: 重大操作前 → save 存档,失败可 load 回滚 -铁律 6: 每次操作后检查反馈 → 读 error + errorMessage + suggestion -``` - -## 工具列表 - -| 工具 | 用途 | -|------|------| -| `tools/oni_api.py` | 92 个 API 端点的 CLI 客户端(所有查询/操作) | -| `tools/oni_commander.py` | 高级指令(diagnose/fix_co2/expand_base 等) | -| `tools/oni_analyzer.py` | 自动分析+6 维度预警建议 | -| `tools/oni_builder.py` | 预置 SPOM/农场/养殖等蓝图 | -| `scripts/event_daemon.py` | 事件守护进程,持续轮询推送游戏事件到 AI | - -## 常用操作速查 - -```bash -# 每分钟检查 -python3 scripts/event_daemon.py - -# 紧急三步 -python3 tools/oni_api.py pause "Emergency" -python3 tools/oni_commander.py diagnose -python3 tools/oni_api.py status -# ...操作... -python3 tools/oni_api.py unpause 1 - -# 存档保护 -python3 tools/oni_api.py save "before_操作" -# ...操作... -# 如果搞砸了: -python3 tools/oni_api.py load "before_操作" - -# 截图确认 -python3 tools/oni_api.py camera 50 50 20 -python3 tools/oni_api.py snapshot +# 操作(自动拉视角) +oni_api.py pause "r" | unpause [speed] +oni_api.py dig x y w h | build id x y | deconstruct x y +oni_api.py prioritize x y 1-9 | research_select id | batch file +oni_api.py camera x y [zoom] | save [name] + +# 分析 +oni_analyzer.py | oni_commander.py diagnose +oni_commander.py emergency_o2 | fix_co2 | fix_overload +oni_builder.py build spom/toilet_loop/bedroom x y ``` diff --git a/tools/oni_analyzer.py b/tools/oni_analyzer.py index f1f4122..5bda405 100644 --- a/tools/oni_analyzer.py +++ b/tools/oni_analyzer.py @@ -1,281 +1,149 @@ -import json -import sys +#!/usr/bin/env python3 +""" +ONI Agent — Game State Analyzer +================================ +Fetches comprehensive game state and produces actionable analysis across +6 dimensions: oxygen, food, power, temperature, water, research. +""" + +import json, sys, io +# Fix GBK encoding +if sys.stdout.encoding and sys.stdout.encoding.upper() in ('GBK', 'GB2312', 'CP936'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + from oni_api import api_get +def get_data(endpoint): + r = api_get(endpoint) + if r.get("success"): + return r.get("data", r) + return None -def get_game_state(): - return { - 'game': api_get('/api/state/game'), - 'resources': api_get('/api/state/resources'), - 'duplicants': api_get('/api/state/duplicants'), - 'buildings': api_get('/api/state/buildings'), - 'research': api_get('/api/state/research'), - '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'), - } +def main(): + game = get_data('/api/state/game') + resources = get_data('/api/state/resources') or [] + buildings = get_data('/api/state/buildings') or [] + dups = get_data('/api/state/duplicants') or [] + alert_data = get_data('/api/state/alert') or [] + research = get_data('/api/state/research') or {} + rdict = {} + if isinstance(resources, list): + for r in resources: + rdict[r.get('name', '')] = r.get('amountKg', 0) -def as_dict(resources): - if not isinstance(resources, list): - return {} - return {r.get('name'): r for r in 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)!")) - 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 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")) - - return warnings - - -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)!")) - elif cal < 500000: - warnings.append(("WARN", f"Food declining ({cal:.0f} kcal)")) - elif cal > 2000000: - 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, 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 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)")) - - return warnings - - -def analyze_temp(): - from oni_api import api_get - data = api_get('/api/state/temperature/zones', auto_pause=False) - warnings = [] - if 'error' in data: - return warnings - avg = data.get('averageC', 0) - if avg > 50: - warnings.append(("CRITICAL", f"Overheating ({avg:.0f}°C)")) - elif avg > 35: - warnings.append(("WARN", f"High temperature ({avg:.0f}°C)")) - elif avg < -5: - warnings.append(("WARN", f"Too cold ({avg:.0f}°C)")) - if data.get('hotSpots'): - warnings.append(("WARN", f"{len(data['hotSpots'])} hot spots (>50°C)")) - if data.get('coldSpots'): - warnings.append(("WARN", f"{len(data['coldSpots'])} cold spots (<5°C)")) - return warnings - - -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 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 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 = [] - - 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 [])) - - 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)) - - -def print_report(state): - g = state.get('game', {}) - if 'error' in g: - print(f"[!] Cannot connect to game: {g['error']}") - return False - - resources = state.get('resources', []) - buildings = state.get('buildings', []) - alerts = state.get('alerts', []) - - all_warnings = [] - all_warnings += analyze_o2(resources, buildings) - all_warnings += analyze_food(resources, buildings) - all_warnings += analyze_power(resources, buildings) - all_warnings += analyze_temp() - 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, buildings) + # Building type counts + btypes = {} + for b in buildings if isinstance(buildings, list) else []: + bid = b.get('id', '') + btypes[bid] = btypes.get(bid, 0) + 1 print("=" * 56) - print(" ONI Analysis Report") + print(" ONI Agent — Game Analysis") print("=" * 56) - print(f" Cycle: {g.get('cycle', '?')}") - print(f" Duplicants: {g.get('duplicantCount', '?')}") - print(f" World: {g.get('worldName', '?')}") - 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" 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 [])}") + if game: + speed_str = f"{game.get('gameSpeed','?')}x speed" if not game.get('isPaused') else "PAUSED" + print(f" Cycle {game.get('cycle','?')} | {game.get('duplicantCount','?')} dupes | " + f"{game.get('gridWidth','?')}x{game.get('gridHeight','?')} grid | {speed_str}") print() - if critical: - print(f" [CRITICAL] {len(critical)} issues — act immediately!") - for _, msg in critical: - print(f" ! {msg}") - print() + # 1. Oxygen + print("─── Oxygen ───") + o2 = rdict.get('Oxygen', 0) + algae = rdict.get('Algae', 0) + pw = rdict.get('PollutedWater', 0) + water = rdict.get('Water', 0) + has_elec = 'Electrolyzer' in btypes + has_diff = 'OxygenDiffuser' in btypes - if warns: - print(f" [WARN] {len(warns)} issues") - for _, msg in warns: - print(f" * {msg}") - print() + print(f" O2: {o2:.0f} kg Algae: {algae:.0f} kg Water: {water:.0f} kg") + if o2 < 500: + print(f" ⚠ CRITICAL: Oxygen low! {o2:.0f} kg remaining") + if not has_elec and not has_diff: + print(f" ⚠ No oxygen production. Build OxygenDiffuser or plan SPOM") + elif not has_elec and algae < 1000: + print(f" ⚠ Algae running out ({algae:.0f} kg). Build Electrolyzer (SPOM)") + if water > 50000 and not has_elec: + print(f" ℹ Water abundant ({water:.0f} kg). Good time for SPOM") + if pw > 100000: + print(f" ℹ Polluted Water: {pw:.0f} kg — sieve into water or use for thimble reed") - if infos: - print(f" [INFO] {len(infos)} notes") - for _, msg in infos: - print(f" i {msg}") - print() + # 2. Food + print(f"\n─── Food ───") + calories = rdict.get('Calories', 0) + dirt = rdict.get('Dirt', 0) + has_farm = any(bid in ('PlanterBox', 'FarmTile') for bid in btypes) + has_grill = 'ElectricGrill' in btypes + print(f" Calories: {calories:.0f} kcal Dirt: {dirt:.0f} kg") + if calories < 200000: + print(f" ⚠ CRITICAL: Food shortage! {calories:.0f} kcal") + elif calories < 500000: + print(f" ⚠ Food declining ({calories:.0f} kcal). Build farm") + if not has_farm: + print(f" ℹ No farm. Build 5x PlanterBox + plant Mealwood (uses Dirt)") + if not has_grill and has_farm: + print(f" ℹ No grill. Build ElectricGrill for better food quality") - if not all_warnings: - print(" Status: All stable") - print() + # 3. Power + print(f"\n─── Power ───") + coal = rdict.get('Carbon', 0) + hydrogen = rdict.get('Hydrogen', 0) + has_manual = 'ManualGenerator' in btypes + has_coal = 'CoalGenerator' in btypes + has_hydro = 'HydrogenGenerator' in btypes + has_solar = 'SolarPanel' in btypes + has_natgas = 'NaturalGasGenerator' in btypes + print(f" Coal: {coal:.0f} kg Hydrogen: {hydrogen:.0f} kg") + gen_list = [] + if has_manual: gen_list.append('Manual') + if has_coal: gen_list.append('Coal') + if has_hydro: gen_list.append('Hydrogen') + if has_natgas: gen_list.append('NaturalGas') + if has_solar: gen_list.append('Solar') + print(f" Generators: {', '.join(gen_list) if gen_list else 'None'}") + if coal < 1000 and has_coal: + print(f" ⚠ Coal low ({coal:.0f} kg). Diversify power production") + if not has_hydro and 'Electrolyzer' in btypes: + print(f" ℹ Have Electrolyzer but no HydrogenGenerator — wasting H2!") + if not any([has_manual, has_coal, has_hydro, has_natgas, has_solar]): + print(f" ⚠ No power generation! Build ManualGenerator or CoalGenerator") - if suggestions: - print(f" Suggestions ({len(suggestions)}):") - for s in suggestions: - print(f" -> {s}") - print() + # 4. Temperature + print(f"\n─── Temperature ───") + ice = rdict.get('Ice', 0) + rdict.get('CrushedIce', 0) + rdict.get('Snow', 0) + granite = rdict.get('Granite', 0) + igneous = rdict.get('IgneousRock', 0) + print(f" Ice/Snow: {ice:.0f} kg") + if ice > 0: + print(f" ℹ Ice available for cooling if melted") - 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', '?')}") + # 5. Water + print(f"\n─── Water ───") + salt_water = rdict.get('SaltWater', 0) + brine = rdict.get('Brine', 0) + print(f" Water: {water:.0f} kg Polluted Water: {pw:.0f} kg Salt Water: {salt_water:.0f} kg") + if water < 10000 and pw < 10000: + print(f" ⚠ Low water! Collect from geysers or filter polluted water") + if water < 1000: + print(f" ⚠ CRITICAL: Water nearly empty!") + + # 6. Research + print(f"\n─── Research ───") + completed = research.get('completedTechs', []) + print(f" Completed: {len(completed)} techs") + if completed: + print(f" Last: {completed[-1] if completed else 'none'}") + + # 7. Alerts + if alert_data: + print(f"\n─── Active Alerts ───") + for a in alert_data: + print(f" [{a.get('severity','?')}] {a.get('title','?')}") + + print() + print("=" * 56) + print(" Analysis complete.") print("=" * 56) - return True - - -if __name__ == '__main__': - state = get_game_state() - if not print_report(state): - sys.exit(1) +if __name__ == "__main__": + main() diff --git a/tools/oni_api.py b/tools/oni_api.py index 13397e7..9c67743 100644 --- a/tools/oni_api.py +++ b/tools/oni_api.py @@ -1,55 +1,85 @@ -import json -import os -import sys -import inspect -import urllib.request -import urllib.error +#!/usr/bin/env python3 +""" +ONI Agent API Client +==================== +CLI tool for interacting with the ONI Agent Bridge Mod (v2). +All commands return structured JSON data. + +Usage: + python oni_api.py [args...] + +Commands: + # Status + health Check mod connection + status Full game overview + resources All resource amounts + buildings All buildings + duplicants Duplicant details + research Tech progress + rooms Room layout + + # Map data + cell Single cell details + cells Rectangular area + slice Row/column scan + gas Gas distribution + + # Registry + registry buildings [filter] Building definitions + registry elements [filter] Element definitions + registry techs Tech tree + + # Events + events [since] Poll events since sequence number + + # Actions + dig Dig area + build Build structure + deconstruct Deconstruct + prioritize

Set priority (1-9) + research Set active research + mop Mop liquid + harvest Harvest plant + pause [reason] Pause game + unpause [speed] Resume game + speed <1|2|3> Set game speed + batch Execute batch operations + camera [zoom] Move camera + save [name] Save game + load Load save + priority_global

Set global priority + priority_type

Set type priority + + # Utility + explore AI-friendly area summary +""" + +import json, os, sys, urllib.request, urllib.error + +# Fix GBK encoding on Windows CJK consoles +if sys.stdout.encoding and sys.stdout.encoding.upper() in ('GBK', 'GB2312', 'CP936'): + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json') -# Auto-pause control -AUTO_PAUSE_ENABLED = True -"""When True, api_get and api_post auto-pause game before executing. -Set to False for event daemon polling (lightweight, frequent checks).""" - def load_config(): with open(CONFIG_PATH) as f: return json.load(f) -def api_url(endpoint): - cfg = load_config() - return f"http://{cfg['modHost']}:{cfg['modPort']}{endpoint}" - -def _auto_pause(): - """Auto-pause the game before any AI operation, ensuring data freshness.""" - if not AUTO_PAUSE_ENABLED: - return - try: - cfg = load_config() - url = f"http://{cfg['modHost']}:{cfg['modPort']}/api/action/pause" - req = urllib.request.Request(url, data=b'{}', headers={'Content-Type': 'application/json'}, method='POST') - with urllib.request.urlopen(req, timeout=3): - pass - except: - pass - -def api_get(endpoint, auto_pause=True): - if auto_pause and AUTO_PAUSE_ENABLED: - _auto_pause() - url = api_url(endpoint) +def api_get(endpoint): cfg = load_config() + url = f"http://{cfg['modHost']}:{cfg['modPort']}{endpoint}" try: req = urllib.request.Request(url, method='GET') with urllib.request.urlopen(req, timeout=cfg['timeout']) as resp: return json.loads(resp.read().decode()) except urllib.error.URLError as e: - return {"error": str(e)} + return {"success": False, "error": "connection_error", "errorMessage": str(e)} -def api_post(endpoint, data, auto_pause=True): - if auto_pause and AUTO_PAUSE_ENABLED: - _auto_pause() - url = api_url(endpoint) +def api_post(endpoint, data): cfg = load_config() + url = f"http://{cfg['modHost']}:{cfg['modPort']}{endpoint}" try: req = urllib.request.Request( url, data=json.dumps(data).encode(), @@ -59,1482 +89,390 @@ def api_post(endpoint, data, auto_pause=True): with urllib.request.urlopen(req, timeout=cfg['timeout']) as resp: return json.loads(resp.read().decode()) except urllib.error.URLError as e: - return {"error": str(e)} + return {"success": False, "error": "connection_error", "errorMessage": str(e)} -# --------------------------------------------------------------------------- -# Command implementations -# --------------------------------------------------------------------------- +def get_data(endpoint): + """Get the data field from a response, handling the wrapper.""" + r = api_get(endpoint) + if r.get("success"): + return r.get("data", r) + print(f"Error: {r.get('errorMessage', r.get('error', 'unknown'))}", file=sys.stderr) + return None -def cmd_health(*args): - print(json.dumps(api_get('/health'), indent=2, ensure_ascii=False)) - -def cmd_status(): - game = api_get('/api/state/game') - resources = api_get('/api/state/resources') - dups = api_get('/api/state/duplicants') - alerts = api_get('/api/state/alert') - - if 'error' in game: - print(f"Error: {game['error']}") - return - - print("=== Game ===") - 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]: - 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: - 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): - if alerts: - for a in alerts: - print(f" [{a.get('severity', '?')}] {a.get('title', '?')}: {a.get('message', '')}") +def post_action(endpoint, data=None): + """Post and print result.""" + r = api_post(endpoint, data or {}) + if r.get("success"): + d = r.get("data", {}) + if d: + print(json.dumps(d, indent=2, ensure_ascii=False)) 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): - 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', '?')}]") + print("OK") else: + print(f"Error: {r.get('errorMessage', r.get('error', 'unknown'))}", file=sys.stderr) + return r + +# ── Commands ───────────────────────────────────────────────── + +def cmd_health(args): + r = api_get('/health') + # Support both new format {success, data} and old format {status} + if r.get("success"): + d = r.get("data", r) + print(f"Status: {d.get('status', '?')}") + print(f"Version: {d.get('version', 'unknown — old mod, deploy v2')}") + elif r.get("status") == "ok": + print(f"Status: ok (old mod v1 — restart game to load v2)") + else: + print(f"Connection failed: {r}") + +def cmd_status(args): + game = get_data('/api/state/game') + if not game: return + resources = get_data('/api/state/resources') or [] + dups = get_data('/api/state/duplicants') or [] + alerts = get_data('/api/state/alert') or [] + + print(f"=== Game ===") + print(f" Cycle: {game.get('cycle', '?')}") + print(f" Dupes: {game.get('duplicantCount', '?')}") + print(f" Grid: {game.get('gridWidth', '?')} x {game.get('gridHeight', '?')}") + print(f" Paused: {game.get('isPaused', '?')}") + print(f" Speed: {game.get('gameSpeed', '?')}x") + + print(f"\n=== Top Resources ===") + if isinstance(resources, list): + for r in sorted(resources, key=lambda x: x.get('amountKg', 0), reverse=True)[:20]: + icon = {'solid': '■', 'liquid': '≈', 'gas': '◌'}.get(r.get('state', ''), '?') + print(f" {icon} {r.get('name', '?'):20s} {r.get('amountKg', 0):>10.1f} kg") + + print(f"\n=== Duplicants ===") + for d in dups if isinstance(dups, list) else []: + print(f" {d.get('name', '?'):12s} at ({d.get('x', '?'):4d},{d.get('y', '?'):4d}) hp={d.get('health', 0)}") + + if alerts: + print(f"\n=== Alerts ===") + for a in alerts: + print(f" [{a.get('severity', '?')}] {a.get('title', '?')}") + +def cmd_resources(args): + data = get_data('/api/state/resources') + if data: print(json.dumps(data, indent=2, ensure_ascii=False)) -def cmd_duplicants(): - 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: +def cmd_buildings(args): + data = get_data('/api/state/buildings') + if data: 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: - 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: +def cmd_duplicants(args): + data = get_data('/api/state/duplicants') + if data: print(json.dumps(data, indent=2, ensure_ascii=False)) -def cmd_research(): - data = api_get('/api/state/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:5s}] ({t.get('category', '?')})") - else: +def cmd_research(args): + data = get_data('/api/state/research') + if data: print(json.dumps(data, indent=2, ensure_ascii=False)) -def cmd_geysers(): - data = api_get('/api/state/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', '?'):8s} rate={g.get('emitRate', 0):.1f} g/s") - else: - print(json.dumps(data, indent=2, ensure_ascii=False)) - -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', 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: +def cmd_rooms(args): + data = get_data('/api/state/rooms') + if data: 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)}") + if len(args) < 2: print("Usage: cell ", file=sys.stderr); return + x, y = int(args[0]), int(args[1]) + r = api_get(f'/api/state/cell?x={x}&y={y}') + if r.get("success"): + d = r.get("data", {}) + print(f"Cell ({d.get('x')}, {d.get('y')}):") + print(f" Element: {d.get('element', '?')} ({d.get('elementId', '?')})") + print(f" Mass: {d.get('massKg', 0):.2f} kg") + print(f" Temp: {d.get('temperatureC', 0):.1f} °C") + print(f" State: {'Solid' if d.get('isSolid') else 'Liquid' if d.get('isLiquid') else 'Gas' if d.get('isGas') else 'Vacuum'}") + print(f" Building: {d.get('buildingName', 'None')}") + print(f" Diggable: {d.get('isDiggable', False)}") + print(f" Duplicant: {d.get('hasDuplicant', False)}") + else: + print(f"Error: {r.get('errorMessage', r.get('error', 'unknown'))}", file=sys.stderr) 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) + if len(args) < 4: print("Usage: cells ", file=sys.stderr); return + x, y, w, h = int(args[0]), int(args[1]), int(args[2]), int(args[3]) + r = api_get(f'/api/state/cells?x={x}&y={y}&width={w}&height={h}') + if r.get("success"): + d = r.get("data", {}) + cells = d.get("cells", []) + print(f"{len(cells)} cells in ({x},{y})-({x+w},{y+h}):") + for c in cells: + icon = '■' if c.get('isSolid') else '≈' if c.get('isLiquid') else '◌' if c.get('isGas') else ' ' + b = '⚙' if c.get('hasBuilding') else ' ' + d = '🧑' if c.get('hasDuplicant') else ' ' + print(f" ({c['x']:3d},{c['y']:3d}) {icon} {c.get('element','?'):15s} {c.get('massKg',0):6.1f}kg {c.get('temperatureC',0):5.1f}°C{b}{d}") + else: + print(f"Error: {r.get('errorMessage', r.get('error', 'unknown'))}", file=sys.stderr) -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_slice(args): + if len(args) < 3: print("Usage: slice ", file=sys.stderr); return + axis, idx, start, end = args[0], int(args[1]), int(args[2]), int(args[3]) + r = api_get(f'/api/state/cells/slice?axis={axis}&index={idx}&start={start}&end={end}') + if r.get("success"): + d = r.get("data", {}) + cells = d.get("cells", []) + for c in cells: + icon = '■' if c.get('isSolid') else '≈' if c.get('isLiquid') else '◌' if c.get('isGas') else ' ' + print(f" ({c['x']:3d},{c['y']:3d}) {icon} {c.get('element','?'):15s} {c.get('massKg',0):6.1f}kg {c.get('temperatureC',0):5.1f}°C") + else: + print(f"Error: {r.get('errorMessage', r.get('error', 'unknown'))}", file=sys.stderr) 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)") + if len(args) < 3: print("Usage: gas ", file=sys.stderr); return + x, y, r = int(args[0]), int(args[1]), int(args[2]) + result = api_get(f'/api/state/gas?x={x}&y={y}&radius={r}') + if result.get("success"): + d = result.get("data", {}) + gases = d.get("gases", []) + total = sum(g.get('mass', 0) for g in gases) + print(f"Gas analysis around ({x},{y}) radius {r}:") + for g in sorted(gases, key=lambda x: x.get('mass', 0), reverse=True): + pct = g['mass'] / total * 100 if total > 0 else 0 + print(f" {g['gas']:20s} {g['mass']:8.1f} kg ({pct:4.1f}%) across {g.get('count', 0)} cells") + else: + print(f"Error: {result.get('errorMessage', result.get('error', 'unknown'))}", file=sys.stderr) 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 not args: + print("Usage: registry [filter]", file=sys.stderr); return + sub = args[0] + filt = args[1] if len(args) > 1 else None + if sub == "buildings": + data = get_data('/api/registry/buildings') + elif sub == "elements": + data = get_data('/api/registry/elements') + elif sub == "techs": + data = get_data('/api/registry/techs') + else: + print(f"Unknown registry: {sub}", file=sys.stderr); return + + if not data: return + if filt: + filt_lower = filt.lower() + data = [x for x in data if filt_lower in x.get('id', '').lower() or filt_lower in x.get('name', '').lower()] + 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") + if sub == "buildings": + for b in sorted(data, key=lambda x: x.get('id', '')): + print(f" {b.get('id', '?'):30s} {b.get('name', '?'):30s} {b.get('width',0)}x{b.get('height',0)} rule={b.get('buildLocationRule','?')} mat={b.get('materialCategory','?')}") + elif sub == "elements": + for e in sorted(data, key=lambda x: x.get('name', '')): + print(f" {e.get('name', '?'):20s} id={e.get('id', '?'):30s} state={e.get('state','?'):8s} SHC={e.get('specificHeatCapacity',0):.1f} TC={e.get('thermalConductivity',0):.2f}") + elif sub == "techs": + for t in sorted(data, key=lambda x: x.get('id', '')): + reqs = ",".join(t.get('requiredTechs', [])) + print(f" {t.get('id', '?'):30s} {t.get('name', '?'):30s} req={reqs}") else: print(json.dumps(data, indent=2, ensure_ascii=False)) +def cmd_events(args): + since = int(args[0]) if args else -1 + r = api_get(f'/api/state/events?since={since}') + if r.get("success"): + d = r.get("data", {}) + events = d.get("events", []) + for e in events: + print(f" [{e.get('id',0):4d}] [{e.get('severity','?'):8s}] [{e.get('category','?')}] {e.get('title','')} {e.get('message','')}") + if events: + print(f" --- next_seq={d.get('nextSeq', since)} (showing {len(events)} events) ---") + else: + print(f"Error: {r.get('errorMessage', r.get('error', 'unknown'))}", file=sys.stderr) + def cmd_dig(args): - if len(args) < 4: - print("Usage: dig ") - return - result = api_post('/api/action/dig', { - "x": int(args[0]), "y": int(args[1]), - "width": int(args[2]), "height": int(args[3]) - }) - print(json.dumps(result, indent=2, ensure_ascii=False)) + if len(args) < 4: print("Usage: dig ", file=sys.stderr); return + x, y, w, h = int(args[0]), int(args[1]), int(args[2]), int(args[3]) + post_action('/api/action/dig', {"x": x, "y": y, "width": w, "height": h}) def cmd_build(args): - if len(args) < 3: - print("Usage: build [rotation]") - return - data = {"buildingId": args[0], "x": int(args[1]), "y": int(args[2])} - if len(args) >= 4: - data["rotation"] = args[3] - result = api_post('/api/action/build', data) - print(json.dumps(result, indent=2, ensure_ascii=False)) + if len(args) < 3: print("Usage: build ", file=sys.stderr); return + bid, x, y = args[0], int(args[1]), int(args[2]) + r = post_action('/api/action/build', {"buildingId": bid, "x": x, "y": y}) + if r.get("success"): + d = r.get("data", {}) + if d.get("materialInfo"): + print("\nMaterial check:") + for m in d["materialInfo"]: + status = "ok" if m.get("available") else "missing" + print(f" [{status}] {m.get('category','?')}: {m.get('bestElement','none available')}") def cmd_deconstruct(args): - if len(args) < 3: - print("Usage: deconstruct ") - return - result = api_post('/api/action/deconstruct', { - "buildingId": args[0], "x": int(args[1]), "y": int(args[2]) - }) - print(json.dumps(result, indent=2, ensure_ascii=False)) + if len(args) < 2: print("Usage: deconstruct ", file=sys.stderr); return + x, y = int(args[0]), int(args[1]) + post_action('/api/action/deconstruct', {"x": x, "y": y}) def cmd_prioritize(args): - if len(args) < 3: - print("Usage: prioritize ") - return - result = api_post('/api/action/prioritize', { - "x": int(args[0]), "y": int(args[1]), "priority": int(args[2]) - }) - print(json.dumps(result, indent=2, ensure_ascii=False)) + if len(args) < 3: print("Usage: prioritize ", file=sys.stderr); return + x, y, p = int(args[0]), int(args[1]), int(args[2]) + post_action('/api/action/prioritize', {"x": x, "y": y, "priority": p}) def cmd_research_select(args): - if len(args) < 1: - print("Usage: research_select ") - return - result = api_post('/api/action/research', {"techId": args[0]}) - print(json.dumps(result, indent=2, ensure_ascii=False)) + if not args: print("Usage: research ", file=sys.stderr); return + post_action('/api/action/research', {"techId": args[0]}) 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)) + if len(args) < 2: print("Usage: mop ", file=sys.stderr); return + x, y = int(args[0]), int(args[1]) + post_action('/api/action/mop', {"x": x, "y": y}) 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)) - -# --------------------------------------------------------------------------- -# Event / Queue / Batch / Priority -# --------------------------------------------------------------------------- - -def cmd_events(args): - """Poll game events. Usage: events [since] [limit]""" - since = args[0] if len(args) > 0 else "0" - limit = args[1] if len(args) > 1 else "50" - data = api_get(f"/api/state/events?since={since}&limit={limit}") - if 'error' in data: - print(f"Error: {data['error']}") - return - events = data.get('events', []) - next_seq = data.get('next_seq', 0) - has_more = data.get('has_more', False) - - if not events: - print("No new events.") - print(f"Next sequence: {next_seq}") - return - - print(f"Events ({len(events)} new, next_seq={next_seq}, has_more={has_more}):") - print() - for e in events: - severity = e.get('severity', '?') - sev_mark = {'critical': '!!!', 'warning': '!!', 'info': 'i'}.get(severity.lower(), '?') - cat = e.get('category', '?') - title = e.get('title', '?') - msg = e.get('message', '') - cycle = e.get('cycle', '?') - entity = e.get('entity', '') - cell = e.get('cell', '') - print(f" [{sev_mark}] ({cycle}) {title}") - if msg: - print(f" {msg}") - if entity: - print(f" entity: {entity}") - if isinstance(cell, int) and cell >= 0: - print(f" cell index: {cell}") - print() - -def cmd_queue(args): - """View pending task queue. Usage: queue [batch_id]""" - params = "" - if args: - params = f"?batch_id={args[0]}" - data = api_get(f"/api/state/queue{params}") - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Task Queue:") - print(f" Length: {data.get('queue_length', '?')}") - print(f" Batch ID: {data.get('batch_id', 'none')}") - for t in data.get('tasks', []): - print(f" - {t.get('type', '?')}: {t.get('value', '?')}") - -def cmd_batch(args): - """Execute a batch of actions. Usage: batch """ - if not args: - print("Usage: batch ") - print(" JSON format: { \"actions\": [ { \"type\": \"build|dig|...\", ... } ] }") - return - try: - with open(args[0]) as f: - plan = json.load(f) - except Exception as e: - print(f"Error reading file: {e}") - return - - result = api_post('/api/action/batch', plan) - if 'error' in result: - print(f"Error: {result['error']}") - return - - print(f"Batch: {result.get('batchId', '?')}") - print(f" Total: {result.get('total', 0)}") - print(f" OK: {result.get('successCount', 0)}") - print(f" Failed: {result.get('failCount', 0)}") - print(f" Summary: {result.get('summary', '?')}") - print() - - for action in result.get('actions', []): - status = 'OK' if action.get('success') else 'FAIL' - result_type = action.get('result', '?') - error = action.get('error', '') - err_msg = action.get('errorMessage', '') - suggestion = action.get('suggestion', '') - - print(f" [{status}] {result_type}") - if error: - print(f" error: {error}") - if err_msg: - print(f" msg: {err_msg}") - if suggestion: - print(f" -> {suggestion}") - print() - -def cmd_priority_global(args): - """Set global priority. Usage: priority_global """ - if len(args) < 2: - print("Usage: priority_global ") - print(" target: 'dig', 'build', 'clear', or 'all'") - print(" priority: 1 (lowest) to 9 (emergency)") - return - result = api_post('/api/action/priority_global', { - "target": args[0], - "priority": int(args[1]) - }) - _print_feedback(result) - -def cmd_priority_type(args): - """Set priority for a building type. Usage: priority_type """ - if len(args) < 2: - print("Usage: priority_type ") - return - result = api_post('/api/action/priority_type', { - "buildingType": args[0], - "priority": int(args[1]) - }) - _print_feedback(result) + if len(args) < 2: print("Usage: harvest ", file=sys.stderr); return + x, y = int(args[0]), int(args[1]) + post_action('/api/action/harvest', {"x": x, "y": y}) def cmd_pause(args): - """Pause the game. Usage: pause [reason]""" - reason = ' '.join(args) if args else 'AI operation in progress' - result = api_post('/api/action/pause', {} if not args else {"reason": reason}) - _print_feedback(result) + reason = " ".join(args) if args else None + r = api_post('/api/action/pause', {"reason": reason or "user request"}) + if r.get("success"): print("Paused") + else: print(f"Error: {r.get('errorMessage', r.get('error'))}", file=sys.stderr) def cmd_unpause(args): - """Unpause the game. Usage: unpause [speed]""" speed = int(args[0]) if args else 1 - result = api_post('/api/action/unpause', {"speed": speed}) - _print_feedback(result) + r = api_post('/api/action/unpause', {"speed": speed}) + if r.get("success"): print(f"Unpaused at {speed}x") + else: print(f"Error: {r.get('errorMessage', r.get('error'))}", file=sys.stderr) def cmd_speed(args): - """Set game speed. Usage: speed <1|2|3>""" - if not args: - print("Usage: speed <1|2|3>") - return - speed = int(args[0]) - if speed < 1 or speed > 3: - print("Speed must be 1, 2, or 3") - return - result = api_post('/api/action/speed', {"speed": speed}) - _print_feedback(result) + if not args: print("Usage: speed <1|2|3>", file=sys.stderr); return + s = int(args[0]) + r = api_post('/api/action/speed', {"speed": s}) + if r.get("success"): print(f"Speed set to {s}x") + else: print(f"Error: {r.get('errorMessage', r.get('error'))}", file=sys.stderr) -# --------------------------------------------------------------------------- -# Save / Load -# --------------------------------------------------------------------------- - -def cmd_saves(args): - """List saves. Usage: saves""" - data = api_get('/api/state/saves') - if 'error' in data: - print(f"Error: {data['error']}") - return - for s in data.get('saves', []): - print(f" {s.get('name', '?'):40s} {s.get('size', 0)/1024:.0f} KB") +def cmd_batch(args): + if not args: print("Usage: batch ", file=sys.stderr); return + with open(args[0]) as f: + actions = json.load(f) + if isinstance(actions, list): + actions = {"actions": actions} + r = api_post('/api/action/batch', actions) + if r.get("success"): + d = r.get("data", {}) + print(f"Batch: {d.get('successCount',0)} succeeded, {d.get('failCount',0)} failed of {d.get('total',0)}") + for res in d.get("results", []): + st = "✓" if res.get("success") else "✗" + print(f" {st} {res.get('type','?'):15s} {res.get('buildingId','')}") + else: + print(f"Error: {r.get('errorMessage', r.get('error'))}", file=sys.stderr) def cmd_save(args): - """Save game. Usage: save [name]""" - name = ' '.join(args) if args else None - result = api_post('/api/action/save', {"name": name} if name else {}) - _print_feedback(result) - -def cmd_save_as(args): - """Save with specific name. Usage: save_as """ - if not args: - print("Usage: save_as ") - return - result = api_post('/api/action/save_as', {"name": ' '.join(args)}) - _print_feedback(result) + name = args[0] if args else None + post_action('/api/action/save', {"name": name}) def cmd_load(args): - """Load a save. Usage: load """ - if not args: - print("Usage: load ") - return - name = ' '.join(args) - print(f"[!] Loading save '{name}' — game will restart!") - result = api_post('/api/action/load', {"name": name}) - _print_feedback(result) + if not args: print("Usage: load ", file=sys.stderr); return + post_action('/api/action/load', {"name": args[0]}) -# --------------------------------------------------------------------------- -# Power Grid -# --------------------------------------------------------------------------- +def cmd_priority_global(args): + if len(args) < 2: print("Usage: priority_global ", file=sys.stderr); return + post_action('/api/action/priority_global', {"target": args[0], "priority": int(args[1])}) -def cmd_power(args): - """Analyze power grid. Usage: power""" - data = api_get('/api/state/power') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Power Grid — {data.get('circuitCount', 0)} circuits") - print() - for c in data.get('circuits', []): - overload = " *** OVERLOAD ***" if c.get('isOverloaded') else "" - print(f" Circuit {c.get('id', '?')}: {c.get('wattsUsed', 0):.0f}W / {c.get('maxWatts', 0):.0f}W used{overload}") - print() - print(f"Generators ({len(data.get('generators', []))}):") - for g in data.get('generators', []): - status = 'ON' if g.get('isActive') else 'OFF' - print(f" {g.get('name', '?'):25s} {g.get('watts', 0):.0f}W [{status}]") - -# --------------------------------------------------------------------------- -# Pipes -# --------------------------------------------------------------------------- - -def cmd_pipes(args): - """Inspect pipe contents. Usage: pipes [gas|liquid]""" - ptype = args[0] if args else 'all' - if ptype not in ('gas', 'liquid', 'all'): - print("Usage: pipes [gas|liquid|all]") - return - data = api_get(f"/api/state/pipes?type={ptype}") - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Pipe Contents ({data.get('pipeType', '?')}): {data.get('segmentCount', 0)} segments") - for s in data.get('segments', [])[:20]: - t = s.get('type', '?') - el = s.get('element', '?') - m = s.get('mass', 0) - temp = s.get('temperature', 0) - print(f" [{t}] {el:20s} {m:8.1f} kg {temp:6.1f} K") - -# --------------------------------------------------------------------------- -# CO2 -# --------------------------------------------------------------------------- - -def cmd_co2(args): - """Find CO2 pockets. Usage: co2""" - data = api_get('/api/state/co2') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"CO2 Pockets: {data.get('pocketCount', 0)} Total: {data.get('totalMassKg', 0):.0f} kg") - for p in data.get('pockets', [])[:10]: - print(f" ({p.get('x', '?')},{p.get('y', '?')}) {p.get('mass', 0):.1f} kg {p.get('temp', 0):.0f}°C") - -# --------------------------------------------------------------------------- -# Temperature -# --------------------------------------------------------------------------- - -def cmd_temp_zones(args): - """Analyze temperature zones. Usage: temp_zones""" - data = api_get('/api/state/temperature/zones') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Temperature Analysis:") - print(f" Average: {data.get('averageC', 0):.0f}°C") - print(f" Min: {data.get('minC', 0):.0f}°C") - print(f" Max: {data.get('maxC', 0):.0f}°C") - print(f" Samples: {data.get('sampleCount', 0)}") - print() - hotspots = data.get('hotSpots', []) - if hotspots: - print(f"Hot spots (>50°C):") - for h in hotspots: - print(f" ({h.get('x', '?')},{h.get('y', '?')}) {h.get('tempC', 0):.0f}°C") - coldspots = data.get('coldSpots', []) - if coldspots: - print(f"Cold spots (<5°C):") - for c in coldspots: - print(f" ({c.get('x', '?')},{c.get('y', '?')}) {c.get('tempC', 0):.0f}°C") - -# --------------------------------------------------------------------------- -# Morale -# --------------------------------------------------------------------------- - -def cmd_morale(args): - """Check morale. Usage: morale""" - data = api_get('/api/state/morale') - if 'error' in data: - print(f"Error: {data['error']}") - return - for m in data if isinstance(data, list) else []: - print(f" {m.get('name', '?'):12s} morale={m.get('morale', 0):.0f} qol={m.get('qualityOfLife', 0):.0f}") - -# --------------------------------------------------------------------------- -# Diseases -# --------------------------------------------------------------------------- - -def cmd_diseases(args): - """Check disease status. Usage: diseases""" - data = api_get('/api/state/diseases') - if 'error' in data: - print(f"Error: {data['error']}") - return - infected = data.get('infectedDuplicants', []) - if infected: - print(f"Infected dupes: {len(infected)}") - for d in infected: - print(f" {d.get('duplicant', '?')} - {d.get('disease', '?')} ({d.get('severity', '?')})") - else: - print("No infected duplicants.") - germs = data.get('environmentGerms', {}) - if germs: - print(f"Environmental germs:") - for name, count in sorted(germs.items(), key=lambda x: -x[1])[:5]: - print(f" {name}: {count:.0f}") - -# --------------------------------------------------------------------------- -# Storage -# --------------------------------------------------------------------------- - -def cmd_storage(args): - """Check storage. Usage: storage""" - data = api_get('/api/state/storage') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Storage buildings: {data.get('storageCount', 0)}") - for s in data.get('storages', [])[:10]: - print(f" {s.get('building', '?'):25s} ({s.get('x', '?')},{s.get('y', '?')}) " - f"{s.get('totalMass', 0):.0f}/{s.get('capacity', 0):.0f} kg") - -# --------------------------------------------------------------------------- -# Skills / Job -# --------------------------------------------------------------------------- - -def cmd_skills(args): - """Show duplicant skills. Usage: skills""" - data = api_get('/api/state/duplicants/skills') - if 'error' in data: - print(f"Error: {data['error']}") - return - for d in data if isinstance(data, list) else []: - print(f" {d.get('name', '?'):12s} skill_pts={d.get('skillPoints', 0)} total_pts={d.get('totalSkillPointsGained', 0)}") - for a in d.get('attributes', [])[:5]: - print(f" {a.get('name', '?'):15s} = {a.get('value', 0)}") - -def cmd_assign_job(args): - """Assign dupe to a job. Usage: assign_job """ - if len(args) < 2: - print("Usage: assign_job ") - print(" chore groups: Build, Dig, Cook, Farm, Ranch, Operate, Research, Store, Tidy, LifeSupport") - return - result = api_post('/api/action/assign_job', { - "duplicantId": args[0], - "choreGroup": args[1] - }) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Pipe / Wire Lines -# --------------------------------------------------------------------------- - -def cmd_build_pipe_line(args): - """Build a pipe line with crossing mode. Usage: build_pipe_line [mode]""" - if len(args) < 5: - print("Usage: build_pipe_line gas|liquid [mode]") - print(" mode: 'line' (default, auto-merge), 'cross' (use bridges at intersections), 'single' (one segment)") - return - ptype = args[0] - x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4]) - mode = args[5] if len(args) > 5 else 'line' - - if mode not in ('line', 'cross', 'single'): - print("[!] mode must be 'line', 'cross', or 'single'") - return - - result = api_post('/api/action/build_pipe', { - "type": ptype, "x1": x1, "y1": y1, - "x2": x2, "y2": y2, "mode": mode - }) - if result.get('success'): - d = result.get('data', {}) - segs = d.get('segmentCount', 0) - bridges = d.get('bridgesPlaced', 0) - xing = sum(1 for s in d.get('segments', []) if s.get('crossing')) - print(f"[OK] {ptype} pipe: {segs} segments, {bridges} bridges, {xing} crossings handled") - if xing > 0: - print(f" Mode '{mode}': {'bridges used at crossings' if mode == 'cross' else 'merged at crossings'}") - else: - _print_feedback(result) - -def cmd_build_wire_line(args): - """Build a wire line with crossing mode. Usage: build_wire_line [mode]""" - if len(args) < 5: - print("Usage: build_wire_line regular|heavy|conductive [mode]") - print(" mode: 'line' (default, auto-merge), 'cross' (use bridges at intersections), 'single' (one segment)") - return - wtype = args[0] - x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4]) - mode = args[5] if len(args) > 5 else 'line' - - if mode not in ('line', 'cross', 'single'): - print("[!] mode must be 'line', 'cross', or 'single'") - return - - result = api_post('/api/action/build_wire', { - "type": wtype, "x1": x1, "y1": y1, - "x2": x2, "y2": y2, "mode": mode - }) - if result.get('success'): - d = result.get('data', {}) - segs = d.get('segmentCount', 0) - bridges = d.get('bridgesPlaced', 0) - xing = sum(1 for s in d.get('segments', []) if s.get('crossing')) - print(f"[OK] {wtype} wire: {segs} segments, {bridges} bridges, {xing} crossings handled") - else: - _print_feedback(result) - -def _print_feedback(result): - """Pretty-print action feedback.""" - if result.get('success'): - print(f"OK: {result.get('result', 'done')}") - for k, v in result.get('data', {}).items(): - print(f" {k}: {v}") - else: - print(f"FAIL: {result.get('error', 'unknown_error')}") - print(f" {result.get('errorMessage', '')}") - sug = result.get('suggestion') - if sug: - print(f" -> {sug}") - -# --------------------------------------------------------------------------- -# Buildable / Research / Building Interaction -# --------------------------------------------------------------------------- - -def cmd_buildable(args): - """List all buildable buildings. Usage: buildable [filter]""" - filt = args[0].lower() if args else "" - data = api_get('/api/state/buildable') - if 'error' in data: - print(f"Error: {data['error']}") - return - total = data.get('total', 0) - unlocked = data.get('unlocked', 0) - print(f"Buildings: {unlocked}/{total} unlocked") - print() - for b in data.get('buildings', []): - name = b.get('name', '?') - bid = b.get('id', '') - if filt and filt not in name.lower() and filt not in bid.lower(): - continue - mark = "✓" if b.get('unlocked') else "✗" - cat = b.get('category', '?') - pw = f" {b.get('powerCost', 0)}W" if b.get('powerCost', 0) > 0 else "" - print(f" [{mark}] {name:30s} {bid:30s} {cat:15s}{pw}") - -def cmd_toggle(args): - """Toggle building on/off. Usage: toggle """ - if len(args) < 2: - print("Usage: toggle ") - return - result = api_post('/api/action/toggle', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -def cmd_set_recipe(args): - """Set a building's recipe. Usage: set_recipe """ - if len(args) < 3: - print("Usage: set_recipe ") - return - result = api_post('/api/action/set_recipe', { - "x": int(args[0]), "y": int(args[1]), "recipeId": args[2] - }) - _print_feedback(result) - -def cmd_empty(args): - """Empty a building's storage. Usage: empty """ - if len(args) < 2: - print("Usage: empty ") - return - result = api_post('/api/action/empty', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -def cmd_cancel_errand(args): - """Cancel errands at a building. Usage: cancel_errand """ - if len(args) < 2: - print("Usage: cancel_errand ") - return - result = api_post('/api/action/cancel_errand', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Research Enhancements -# --------------------------------------------------------------------------- - -def cmd_research_detail(args): - """Detailed research status. Usage: research_detail""" - data = api_get('/api/state/research/detail') - if 'error' in data: - print(f"Error: {data['error']}") - return - print("=== Research Status ===") - print(f" Research Station: {'✓' if data.get('researchStationOperational') else '✗'} " - f"(built={data.get('hasResearchStation', False)})") - print(f" Super Computer: {'✓' if data.get('hasSuperComputer') else '✗'}") - active = data.get('activeResearch', []) - if active: - for a in active: - print(f" Active: {a.get('name', '?')} ({a.get('progress', 0)*100:.0f}%)") - else: - print(f" Active: none {'[all complete]' if data.get('researchComplete') else '[idle]'}") - stations = data.get('stations', []) - if stations: - print(f" Stations: {len(stations)}") - for s in stations: - op = 'ON' if s.get('isOperational') else 'OFF' - print(f" {s.get('name', '?')} at ({s.get('x', '?')},{s.get('y', '?')}) [{op}]") - -def cmd_research_cancel(args): - """Cancel research. Usage: research_cancel [techId]""" - payload = {"techId": args[0]} if args else {} - result = api_post('/api/action/research_cancel', payload) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Building Detail -# --------------------------------------------------------------------------- - -def cmd_building_detail(args): - """Detailed info about a building. Usage: building_detail """ - if len(args) < 2: - print("Usage: building_detail ") - return - data = api_get(f"/api/state/building_detail?x={args[0]}&y={args[1]}") - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Building: {data.get('name', '?')} ({data.get('id', '?')})") - print(f" Position: ({data.get('x', '?')},{data.get('y', '?')}) size={data.get('width')}x{data.get('height')}") - print(f" Operational: {'ON' if data.get('isOperational') else 'OFF'}") - print(f" Powered: {'YES' if data.get('isPowered') else 'NO'} {data.get('powerWatt', 0)}W") - print(f" Health: {data.get('health', '?')}/{data.get('maxHealth', '?')}") - storage = data.get('storageItems', []) - if storage: - print(f" Storage: {data.get('storageMass', 0):.0f}/{data.get('storageCapacity', 0):.0f} kg") - for s in storage[:5]: - print(f" {s.get('name', '?')}: {s.get('mass', 0):.1f} kg") - print(f" Automation: {'YES' if data.get('hasAutomation') else 'NO'}") - print(f" Materials: {', '.join(data.get('material', []))}") - -def cmd_set_building_priority(args): - """Set a building's priority. Usage: set_building_priority """ - if len(args) < 3: - print("Usage: set_building_priority ") - return - result = api_post('/api/action/set_building_priority', { - "x": int(args[0]), "y": int(args[1]), "priority": int(args[2]) - }) - _print_feedback(result) - -def cmd_set_automation(args): - """Toggle automation on a building. Usage: set_automation """ - if len(args) < 3: - print("Usage: set_automation ") - return - enabled = args[2].lower() in ('on', 'true', '1', 'yes') - result = api_post('/api/action/set_automation', { - "x": int(args[0]), "y": int(args[1]), "enabled": enabled - }) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Printing Pod -# --------------------------------------------------------------------------- - -def cmd_printing_pod(args): - """Check Printing Pod status. Usage: printing_pod""" - data = api_get('/api/state/printing_pod') - if 'error' in data: - print(f"Error: {data['error']}") - return - if data.get('isReady'): - print("=== Printing Pod: READY ===") - print(f" Options available:") - for opt in data.get('options', []): - print(f" [{opt.get('index')}] {opt.get('description', '?')} ({opt.get('type', '?')})") - print() - print(" Select with: python3 tools/oni_api.py printing_pod_select <0|1|2>") - else: - cycles = data.get('cyclesUntilNext', 0) - if cycles > 0: - print(f"Printing Pod: not ready ({cycles:.1f} cycles remaining)") - else: - print("Printing Pod: checking...") - -def cmd_printing_pod_select(args): - """Select a Printing Pod option. Usage: printing_pod_select <0|1|2>""" - if not args: - print("Usage: printing_pod_select <0|1|2>") - return - index = int(args[0]) - if index < 0 or index > 2: - print("Index must be 0, 1, or 2") - return - result = api_post('/api/action/printing_pod_select', {"index": index}) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Atmo Suits / Critter Attack / Door Control -# --------------------------------------------------------------------------- - -def cmd_atmo_suits(args): - """Check atmo suit docks. Usage: atmo_suits""" - data = api_get('/api/state/atmo_suits') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Atmo Suit Docks: {data.get('dockCount', 0)}") - has = data.get('hasAtmoSuits', False) - print(f"Has suits available: {'YES' if has else 'NO'}") - for d in data.get('docks', []): - suit = "HAS SUIT" if d.get('hasSuit') else "empty" - o2 = d.get('o2Level', 0) - print(f" {d.get('name', '?'):20s} at ({d.get('x', '?')},{d.get('y', '?')}) " - f"{suit:10s} O2={o2:.0f} {'ON' if d.get('isOperational') else 'OFF'}") - -def cmd_critter_attack(args): - """Toggle critter attack. Usage: critter_attack """ - if len(args) < 2: - print("Usage: critter_attack ") - return - result = api_post('/api/action/critter_attack', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -def cmd_door_lock(args): - """Lock/unlock a door. Usage: door_lock """ - if len(args) < 3: - print("Usage: door_lock ") - return - locked = args[2].lower() in ('on', 'true', '1', 'lock', 'locked', 'yes') - result = api_post('/api/action/door_lock', { - "x": int(args[0]), "y": int(args[1]), "locked": locked - }) - _print_feedback(result) - -def cmd_door_one_way(args): - """Set door one-way. Usage: door_one_way """ - if len(args) < 3: - print("Usage: door_one_way ") - return - result = api_post('/api/action/door_one_way', { - "x": int(args[0]), "y": int(args[1]), "direction": args[2] - }) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Dupe / Critter / Plant / Sensor / Storage / Sweep / Disinfect -# --------------------------------------------------------------------------- - -def cmd_dupe_move(args): - """Move a duplicant. Usage: dupe_move """ - if len(args) < 3: - print("Usage: dupe_move ") - return - result = api_post('/api/action/dupe_move', { - "duplicantId": args[0], "x": int(args[1]), "y": int(args[2]) - }) - _print_feedback(result) - -def cmd_dupe_cancel_task(args): - """Cancel a dupe's current task. Usage: dupe_cancel_task """ - if not args: - print("Usage: dupe_cancel_task ") - return - result = api_post('/api/action/dupe_cancel_task', {"duplicantId": args[0]}) - _print_feedback(result) - -def cmd_critter_wrangle(args): - """Wrangle a critter. Usage: critter_wrangle """ - if len(args) < 2: - print("Usage: critter_wrangle ") - return - result = api_post('/api/action/critter_wrangle', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -def cmd_plant_uproot(args): - """Uproot a plant. Usage: plant_uproot """ - if len(args) < 2: - print("Usage: plant_uproot ") - return - result = api_post('/api/action/plant_uproot', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -def cmd_storage_filter(args): - """Set storage filter. Usage: storage_filter """ - if len(args) < 3: - print("Usage: storage_filter ") - return - result = api_post('/api/action/storage_filter', { - "x": int(args[0]), "y": int(args[1]), "filter": args[2] - }) - _print_feedback(result) - -def cmd_door_open(args): - """Open/close a door. Usage: door_open """ - if len(args) < 3: - print("Usage: door_open ") - return - open_door = args[2].lower() in ('on', 'true', '1', 'open', 'yes') - result = api_post('/api/action/door_open', { - "x": int(args[0]), "y": int(args[1]), "open": open_door - }) - _print_feedback(result) - -def cmd_sensors(args): - """List all sensors. Usage: sensors [filter]""" - filt = args[0].lower() if args else "" - data = api_get('/api/state/sensors') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Sensors: {data.get('sensorCount', 0)}") - for s in data.get('sensors', []): - sname = f"{s.get('type', '?')}Sensor" - if filt and filt not in sname.lower() and filt not in str(s.get('threshold', '')): - continue - thr = f" threshold={s.get('threshold')}" if s.get('threshold') is not None else "" - print(f" {sname:15s} at ({s.get('x', '?')},{s.get('y', '?')}){thr} {'ON' if s.get('isOperational') else 'OFF'}") - -# --------------------------------------------------------------------------- -# Extended interactions -# --------------------------------------------------------------------------- - -def cmd_clear(args): - """Clear debris. Usage: clear [radius]""" - if len(args) < 2: - print("Usage: clear [radius]") - return - payload = {"x": int(args[0]), "y": int(args[1]), "radius": int(args[2]) if len(args) > 2 else 1} - result = api_post('/api/action/clear', payload) - _print_feedback(result) - -def cmd_rotate(args): - """Rotate a building. Usage: rotate """ - if len(args) < 2: - print("Usage: rotate ") - return - result = api_post('/api/action/rotate', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -def cmd_copy_settings(args): - """Copy building settings. Usage: copy_settings """ - if len(args) < 4: - print("Usage: copy_settings ") - return - result = api_post('/api/action/copy_settings', { - "x1": int(args[0]), "y1": int(args[1]), "x2": int(args[2]), "y2": int(args[3]) - }) - _print_feedback(result) - -def cmd_overlay(args): - """Set overlay view. Usage: overlay """ - if not args: - print("Usage: overlay ") - return - result = api_post('/api/action/overlay', {"type": args[0]}) - _print_feedback(result) - -def cmd_dupe_personal_priority(args): - """Set dupe personal priority. Usage: dupe_personal_priority """ - if len(args) < 3: - print("Usage: dupe_personal_priority ") - print(" taskType: Dig, Build, Cook, Farm, Ranch, Operate, Research, Store, Tidy, LifeSupport, Supply") - return - result = api_post('/api/action/dupe_personal_priority', { - "duplicantId": args[0], "taskType": args[1], "priority": int(args[2]) - }) - _print_feedback(result) - -def cmd_battery_charge(args): - """Set battery charge limits. Usage: battery_charge """ - if len(args) < 4: - print("Usage: battery_charge ") - return - result = api_post('/api/action/battery_charge', { - "x": int(args[0]), "y": int(args[1]), - "high": int(args[2]), "low": int(args[3]) - }) - _print_feedback(result) - -def cmd_valve_flow(args): - """Set valve flow limit. Usage: valve_flow """ - if len(args) < 3: - print("Usage: valve_flow ") - return - result = api_post('/api/action/valve_flow', { - "x": int(args[0]), "y": int(args[1]), "flow": float(args[2]) - }) - _print_feedback(result) - -def cmd_vent_pressure(args): - """Set vent overpressure. Usage: vent_pressure """ - if len(args) < 3: - print("Usage: vent_pressure ") - return - result = api_post('/api/action/vent_pressure', { - "x": int(args[0]), "y": int(args[1]), "pressure": float(args[2]) - }) - _print_feedback(result) - -def cmd_incubator_setting(args): - """Set incubator egg priority. Usage: incubator_setting """ - if len(args) < 3: - print("Usage: incubator_setting ") - return - result = api_post('/api/action/incubator_setting', { - "x": int(args[0]), "y": int(args[1]), "egg": args[2] - }) - _print_feedback(result) - -def cmd_fridge_temp(args): - """Set fridge temperature. Usage: fridge_temp """ - if len(args) < 3: - print("Usage: fridge_temp ") - return - result = api_post('/api/action/fridge_temp', { - "x": int(args[0]), "y": int(args[1]), "temperature": float(args[2]) - }) - _print_feedback(result) - -def cmd_sensor_threshold(args): - """Set sensor threshold. Usage: sensor_threshold """ - if len(args) < 3: - print("Usage: sensor_threshold ") - return - result = api_post('/api/action/sensor_threshold', { - "x": int(args[0]), "y": int(args[1]), "threshold": float(args[2]) - }) - _print_feedback(result) - -def cmd_sweep(args): - """Mark for sweeping. Usage: sweep [radius]""" - if len(args) < 2: - print("Usage: sweep [radius]") - return - payload = {"x": int(args[0]), "y": int(args[1])} - if len(args) > 2: - payload["radius"] = int(args[2]) - result = api_post('/api/action/sweep', payload) - _print_feedback(result) - -def cmd_disinfect(args): - """Disinfect a cell. Usage: disinfect """ - if len(args) < 2: - print("Usage: disinfect ") - return - result = api_post('/api/action/disinfect', {"x": int(args[0]), "y": int(args[1])}) - _print_feedback(result) - -# --------------------------------------------------------------------------- -# Screenshot / Camera -# --------------------------------------------------------------------------- - -def cmd_snapshot(args): - """Take a screenshot and save locally. Usage: snapshot [output.png]""" - output = args[0] if args else f"oni_snapshot_{int(__import__('time').time())}.png" - result = api_post('/api/action/screenshot', {}) - if not result.get('success'): - print(f"[!] Screenshot failed: {result.get('errorMessage', '')}") - return - filename = result.get('data', {}).get('filename', '?') - print(f"[OK] Screenshot taken: {filename}") - - # Download the image - try: - import urllib.request - url = api_url('/api/screenshot/latest') - urllib.request.urlretrieve(url, output) - print(f"[OK] Saved to {output} ({os.path.getsize(output)} bytes)") - except Exception as e: - print(f"[!] Download failed: {e}") +def cmd_priority_type(args): + if len(args) < 2: print("Usage: priority_type ", file=sys.stderr); return + post_action('/api/action/priority_type', {"buildingType": args[0], "priority": int(args[1])}) def cmd_camera(args): - """Control camera. Usage: camera [zoom]""" - if len(args) < 2: - print("Usage: camera [zoom]") - print(" zoom: 5 (close) to 80 (far), default 30") - return + if len(args) < 2: print("Usage: camera [zoom]", file=sys.stderr); return x, y = int(args[0]), int(args[1]) - zoom = float(args[2]) if len(args) > 2 else None - payload = {"x": x, "y": y} - if zoom is not None: - payload["zoom"] = zoom - result = api_post('/api/action/camera', payload) - _print_feedback(result) - -def cmd_camera_status(args): - """Get current camera position. Usage: camera_status""" - data = api_get('/api/state/camera') - if 'error' in data: - print(f"Error: {data['error']}") - return - print(f"Camera at ({data.get('x', 0):.0f}, {data.get('y', 0):.0f}), zoom={data.get('zoom', 30):.0f}") + body = {"x": x, "y": y} + if len(args) > 2: body["zoom"] = float(args[2]) + post_action('/api/action/camera', body) 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 + """AI-friendly area summary: combines cell data with building/dupe info.""" + if len(args) < 4: print("Usage: explore ", file=sys.stderr); return + x, y, w, h = int(args[0]), int(args[1]), int(args[2]), int(args[3]) - 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") + cells_r = api_get(f'/api/state/cells?x={x}&y={y}&width={w}&height={h}') + buildings_r = api_get('/api/state/buildings') + dups_r = 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": [] - } + print(f"\n=== Explore ({x},{y}) to ({x+w},{y+h}) ===") - region_cells = cells_data.get('cells', []) + # Element distribution + if cells_r.get("success"): + cells = cells_r["data"].get("cells", []) + dist = {} + for c in cells: + el = c.get("element", "Vacuum") + if el not in dist: dist[el] = {"count": 0, "mass": 0, "state": "solid"} + dist[el]["count"] += 1 + dist[el]["mass"] += c.get("massKg", 0) + if c.get("isLiquid"): dist[el]["state"] = "liquid" + elif c.get("isGas"): dist[el]["state"] = "gas" + elif c.get("isVacuum"): dist[el]["state"] = "vacuum" - # 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) - }) + print("\nElement distribution:") + for el, info in sorted(dist.items(), key=lambda x: x[1]["count"], reverse=True): + print(f" {info['state']:8s} {el:20s} {info['count']:4d} cells {info['mass']:8.1f} kg") - # 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', '?') - }) + diggable = sum(1 for c in cells if c.get("isDiggable")) + print(f"\nDiggable cells: {diggable}/{len(cells)}") - # 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 + # Buildings in area + if buildings_r.get("success"): + buildings = buildings_r.get("data", buildings_r) + if isinstance(buildings, list): + area_buildings = [b for b in buildings + if x <= b.get('x', -1) < x + w and y <= b.get('y', -1) < y + h] + if area_buildings: + print(f"\nBuildings in area:") + for b in area_buildings: + print(f" {b.get('id','?'):25s} at ({b.get('x')},{b.get('y')}) {b.get('width',1)}x{b.get('height',1)}") - # 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') - }) + # Dupes in area + if dups_r.get("success"): + dups = dups_r.get("data", dups_r) + if isinstance(dups, list): + area_dupes = [d for d in dups + if x <= d.get('x', -1) < x + w and y <= d.get('y', -1) < y + h] + if area_dupes: + print(f"\nDuplicants in area:") + for d in area_dupes: + print(f" {d.get('name','?'):15s} at ({d.get('x')},{d.get('y')}) hp={d.get('health',0)}") - print(json.dumps(summary, indent=2, ensure_ascii=False)) + print() - -COMMANDS = { - 'health': cmd_health, - 'status': cmd_status, - 'resources': cmd_resources, - 'duplicants': cmd_duplicants, - 'buildings': cmd_buildings, - 'research': cmd_research, - 'geysers': cmd_geysers, - 'critters': cmd_critters, - 'plants': cmd_plants, - 'rooms': cmd_rooms, - 'queue': cmd_queue, - 'events': cmd_events, - '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, - 'batch': cmd_batch, - 'priority_global': cmd_priority_global, - 'priority_type': cmd_priority_type, - 'pause': cmd_pause, - 'unpause': cmd_unpause, - 'speed': cmd_speed, - 'save': cmd_save, - 'save_as': cmd_save_as, - 'load': cmd_load, - 'saves': cmd_saves, - 'power': cmd_power, - 'pipes': cmd_pipes, - 'co2': cmd_co2, - 'temp_zones': cmd_temp_zones, - 'morale': cmd_morale, - 'diseases': cmd_diseases, - 'storage': cmd_storage, - 'storages': cmd_storage, - 'skills': cmd_skills, - 'assign_job': cmd_assign_job, - 'build_pipe_line': cmd_build_pipe_line, - 'build_wire_line': cmd_build_wire_line, - 'snapshot': cmd_snapshot, - 'camera': cmd_camera, - 'camera_status': cmd_camera_status, - 'buildable': cmd_buildable, - 'toggle': cmd_toggle, - 'set_recipe': cmd_set_recipe, - 'empty': cmd_empty, - 'cancel_errand': cmd_cancel_errand, - 'research_detail': cmd_research_detail, - 'research_cancel': cmd_research_cancel, - 'building_detail': cmd_building_detail, - 'set_building_priority': cmd_set_building_priority, - 'set_automation': cmd_set_automation, - 'printing_pod': cmd_printing_pod, - 'printing_pod_select': cmd_printing_pod_select, - 'atmo_suits': cmd_atmo_suits, - 'critter_attack': cmd_critter_attack, - 'door_lock': cmd_door_lock, - 'door_one_way': cmd_door_one_way, - 'dupe_move': cmd_dupe_move, - 'dupe_cancel_task': cmd_dupe_cancel_task, - 'critter_wrangle': cmd_critter_wrangle, - 'plant_uproot': cmd_plant_uproot, - 'storage_filter': cmd_storage_filter, - 'door_open': cmd_door_open, - 'sensors': cmd_sensors, - 'sensor_threshold': cmd_sensor_threshold, - 'sweep': cmd_sweep, - 'disinfect': cmd_disinfect, - 'clear': cmd_clear, - 'rotate': cmd_rotate, - 'copy_settings': cmd_copy_settings, - 'overlay': cmd_overlay, - 'dupe_personal_priority': cmd_dupe_personal_priority, - 'battery_charge': cmd_battery_charge, - 'valve_flow': cmd_valve_flow, - 'vent_pressure': cmd_vent_pressure, - 'incubator_setting': cmd_incubator_setting, - 'fridge_temp': cmd_fridge_temp, +CMD_MAP = { + "health": cmd_health, "status": cmd_status, + "resources": cmd_resources, "buildings": cmd_buildings, + "duplicants": cmd_duplicants, "research": cmd_research, + "rooms": cmd_rooms, + "cell": cmd_cell, "cells": cmd_cells, "slice": cmd_slice, + "gas": cmd_gas, "explore": cmd_explore, + "registry": cmd_registry, + "events": cmd_events, + "dig": cmd_dig, "build": cmd_build, "deconstruct": cmd_deconstruct, + "prioritize": cmd_prioritize, "research_select": cmd_research_select, + "mop": cmd_mop, "harvest": cmd_harvest, + "pause": cmd_pause, "unpause": cmd_unpause, "speed": cmd_speed, + "batch": cmd_batch, "camera": cmd_camera, + "save": cmd_save, "load": cmd_load, + "priority_global": cmd_priority_global, "priority_type": cmd_priority_type, } -if __name__ == '__main__': - cmd = sys.argv[1] if len(sys.argv) > 1 else 'help' +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"): + print(__doc__) + return - if cmd == 'help' or cmd not in COMMANDS: - print("ONI Agent API Client") - print("=" * 60) - print("") - 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("=== Door / Critter / Suits ===") - print(" door_lock on|off Lock/unlock a door") - print(" door_one_way

Set door to one-way (left/right/up/down/none)") - print(" door_open on|off Open/close a door manually") - print(" critter_attack Toggle critter attack mode") - print(" critter_wrangle Wrangle a critter") - print(" atmo_suits Check atmo suit dock status") - print("") - print("=== Duplicant / Plant ===") - print(" dupe_move Move a duplicant to coordinates") - print(" dupe_cancel_task Cancel a dupe's current task") - print(" plant_uproot Uproot a plant") - print("") - print("=== Sensors / Automation ===") - print(" sensors [filter] List all sensors and thresholds") - print(" sensor_threshold Set sensor threshold") - print("") - print("=== Storage / Cleaning ===") - print(" storage_filter Set storage building filter") - print(" sweep [r] Mark area for sweeping") - print(" disinfect Disinfect a cell/building") - print(" empty Empty building storage") - print(" events [since] [limit] Poll new game events") - print(" printing_pod Check Printing Pod status (ready/options)") - print(" printing_pod_select <0|1|2> Select Printing Pod option") - 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(" registry priorities Show priority level meanings") - print("") - print("=== Research / Buildable ===") - print(" buildable [filter] List buildings unlocked by current research") - print(" research Show research tree progress") - print(" research_detail Detailed research status (active tech / stations)") - print(" research_select Select tech to research") - print(" research_cancel [id] Cancel research (all or specific tech)") - print("") - print("=== Building Interaction ===") - print(" building_detail Full detail for a building (health/contents/automation)") - print(" toggle Toggle building on/off") - print(" set_recipe Set building recipe") - print(" empty Empty building storage") - print(" cancel_errand Cancel errands at building") - print(" set_building_priority

Set building priority 1-9") - print(" set_automation on|off Toggle automation input") - print("") - print("=== Game Speed Control ===") - print(" pause [reason] Pause the game (AI should always pause before ops)") - print(" unpause [speed] Unpause the game at 1x/2x/3x speed") - print(" speed <1|2|3> Set game speed while running") - print("") - print("=== Save / Load ===") - print(" save [name] Save game (AI undo button)") - print(" save_as Save with custom name") - print(" saves List save files") - print(" load Load a save (rollback)") - print("") - print("=== Grid Analysis ===") - print(" power Power grid (circuits, load, overloads)") - print(" pipes [gas|liquid|all] Pipe contents (debug plumbing)") - print(" co2 Find CO2 pockets (colony killer!)") - print(" temp_zones Temperature hot/cold spots") - print("") - print("=== Colony Management ===") - print(" storages Storage building contents") - print(" diseases Disease/infection overview") - print(" skills Duplicant skills and attributes") - print(" assign_job Assign dupe to a chore group") - print(" morale Duplicant morale levels") - print("") - print("=== Screenshot / Camera ===") - print(" snapshot [file.png] Take screenshot + save locally") - print(" camera [zoom] Move camera view to coordinates") - print(" camera_status Get current camera position/zoom") - print("") - print("=== Batch / Priority (Advanced) ===") - print(" batch Execute batch plan") - print(" priority_global

Set global default priority") - print(" priority_type

Set per-building-type priority") + cmd = sys.argv[1] + args = sys.argv[2:] + + if cmd in CMD_MAP: + CMD_MAP[cmd](args) else: - args = sys.argv[2:] - fn = COMMANDS[cmd] - # Check if function accepts arguments - sig = inspect.signature(fn) - if len(sig.parameters) > 0: - fn(args) - else: - fn() + print(f"Unknown command: {cmd}", file=sys.stderr) + print("Available:", ", ".join(sorted(CMD_MAP.keys())), file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/tools/oni_builder.py b/tools/oni_builder.py index 29520f9..a6dc2d5 100644 --- a/tools/oni_builder.py +++ b/tools/oni_builder.py @@ -1,163 +1,185 @@ -import json -import sys -from oni_api import api_post +#!/usr/bin/env python3 +""" +ONI Agent — Blueprint Builder +============================== +Pre-built building modules for one-click deployment. + +Usage: + python oni_builder.py list List all blueprints + python oni_builder.py show Show blueprint details + python oni_builder.py build Build blueprint at anchor point +""" + +import json, sys +from oni_api import api_post, api_get BLUEPRINTS = { 'spom': { 'name': 'SPOM (Self-Powered Oxygen Module)', - 'description': 'Standard Rodriguez SPOM: electrolyzer + hydrogen generators', - 'size': {'width': 8, 'height': 6}, - 'dig': {'x': -1, 'y': -1, 'width': 10, 'height': 8}, - 'buildings': [ - {'id': 'Electrolyzer', 'x': 3, 'y': 2}, - {'id': 'GasPump', 'x': 1, 'y': 2}, - {'id': 'GasPump', 'x': 5, 'y': 2}, - {'id': 'HydrogenGenerator', 'x': 1, 'y': 0}, - {'id': 'HydrogenGenerator', 'x': 4, 'y': 0}, - {'id': 'GasFilter', 'x': 3, 'y': 0}, + 'desc': 'Standard Rodriguez SPOM: electrolyzer + H2 generators + gas pumps', + 'size': {'w': 8, 'h': 6}, + 'dig': {'dx': -1, 'dy': -1, 'w': 10, 'h': 8}, + 'steps': [ + {'type': 'build', 'id': 'Electrolyzer', 'x': 3, 'y': 2}, + {'type': 'build', 'id': 'GasPump', 'x': 1, 'y': 2}, + {'type': 'build', 'id': 'GasPump', 'x': 5, 'y': 2}, + {'type': 'build', 'id': 'HydrogenGenerator', 'x': 1, 'y': 0}, + {'type': 'build', 'id': 'HydrogenGenerator', 'x': 4, 'y': 0}, ], }, 'spom_mini': { 'name': 'Mini SPOM', - 'description': 'Compact electrolyzer + 1 hydrogen generator for early game', - 'size': {'width': 5, 'height': 4}, - 'dig': {'x': -1, 'y': -1, 'width': 7, 'height': 6}, - 'buildings': [ - {'id': 'Electrolyzer', 'x': 2, 'y': 1}, - {'id': 'GasPump', 'x': 1, 'y': 1}, - {'id': 'HydrogenGenerator', 'x': 2, 'y': 0}, + 'desc': 'Compact electrolyzer + 1 hydrogen generator for early game', + 'size': {'w': 5, 'h': 4}, + 'dig': {'dx': -1, 'dy': -1, 'w': 7, 'h': 6}, + 'steps': [ + {'type': 'build', 'id': 'Electrolyzer', 'x': 2, 'y': 1}, + {'type': 'build', 'id': 'GasPump', 'x': 1, 'y': 1}, + {'type': 'build', 'id': 'HydrogenGenerator', 'x': 2, 'y': 0}, ], }, 'toilet_loop': { 'name': 'Bathroom Water Loop', - 'description': 'Lavatory -> Water Purifier -> Lavatory closed loop', - 'size': {'width': 8, 'height': 4}, - 'dig': {'x': -1, 'y': -1, 'width': 10, 'height': 6}, - 'buildings': [ - {'id': 'Lavatory', 'x': 1, 'y': 1}, - {'id': 'Lavatory', 'x': 3, 'y': 1}, - {'id': 'WaterPurifier', 'x': 6, 'y': 1}, - {'id': 'LiquidPump', 'x': 6, 'y': 2}, - ], - }, - 'ranch_hatch': { - 'name': 'Hatch Ranch', - 'description': 'Standard hatch ranching module with feeder and incubator', - 'size': {'width': 10, 'height': 6}, - 'dig': {'x': -1, 'y': -1, 'width': 12, 'height': 8}, - 'buildings': [ - {'id': 'RanchStation', 'x': 1, 'y': 1}, - {'id': 'Incubator', 'x': 4, 'y': 1}, - {'id': 'StorageLocker', 'x': 8, 'y': 1}, + 'desc': '2 Lavatories -> Water Purifier closed loop', + 'size': {'w': 8, 'h': 4}, + 'dig': {'dx': -1, 'dy': -1, 'w': 10, 'h': 6}, + 'steps': [ + {'type': 'build', 'id': 'Lavatory', 'x': 1, 'y': 1}, + {'type': 'build', 'id': 'Lavatory', 'x': 3, 'y': 1}, + {'type': 'build', 'id': 'WaterPurifier', 'x': 6, 'y': 1}, ], }, 'farm_mealwood': { 'name': 'Mealwood Farm', - 'description': 'Basic mealwood farm: 5 planter boxes + storage', - 'size': {'width': 6, 'height': 4}, - 'dig': {'x': -1, 'y': -1, 'width': 8, 'height': 6}, - 'buildings': [ - {'id': 'PlanterBox', 'x': 1, 'y': 1}, - {'id': 'PlanterBox', 'x': 3, 'y': 1}, - {'id': 'PlanterBox', 'x': 5, 'y': 1}, - {'id': 'PlanterBox', 'x': 1, 'y': 3}, - {'id': 'PlanterBox', 'x': 3, 'y': 3}, - {'id': 'StorageLocker', 'x': 5, 'y': 3}, + 'desc': '5 PlanterBox + 1 StorageLocker', + 'size': {'w': 6, 'h': 4}, + 'dig': {'dx': -1, 'dy': -1, 'w': 8, 'h': 6}, + 'steps': [ + {'type': 'build', 'id': 'PlanterBox', 'x': 1, 'y': 1}, + {'type': 'build', 'id': 'PlanterBox', 'x': 3, 'y': 1}, + {'type': 'build', 'id': 'PlanterBox', 'x': 5, 'y': 1}, + {'type': 'build', 'id': 'PlanterBox', 'x': 1, 'y': 3}, + {'type': 'build', 'id': 'PlanterBox', 'x': 3, 'y': 3}, + {'type': 'build', 'id': 'StorageLocker', 'x': 5, 'y': 3}, + ], + }, + 'bedroom': { + 'name': 'Standard Bedroom', + 'desc': '4 Cots + decor for bedroom room bonus', + 'size': {'w': 8, 'h': 4}, + 'dig': {'dx': -1, 'dy': -1, 'w': 10, 'h': 6}, + 'steps': [ + {'type': 'build', 'id': 'Cot', 'x': 1, 'y': 1}, + {'type': 'build', 'id': 'Cot', 'x': 3, 'y': 1}, + {'type': 'build', 'id': 'Cot', 'x': 5, 'y': 1}, + {'type': 'build', 'id': 'Cot', 'x': 7, 'y': 1}, ], }, 'cooling': { 'name': 'Steam Turbine Cooler', - 'description': 'Liquid cooling loop with steam turbine + aquatuner', - 'size': {'width': 8, 'height': 6}, - 'dig': {'x': -1, 'y': -1, 'width': 10, 'height': 8}, - 'buildings': [ - {'id': 'SteamTurbine', 'x': 1, 'y': 4}, - {'id': 'SteamTurbine', 'x': 5, 'y': 4}, - {'id': 'Aquatuner', 'x': 2, 'y': 1}, - {'id': 'LiquidPump', 'x': 5, 'y': 1}, + 'desc': 'Aquatuner + Steam Turbine cooling loop', + 'size': {'w': 8, 'h': 6}, + 'dig': {'dx': -1, 'dy': -1, 'w': 10, 'h': 8}, + 'steps': [ + {'type': 'build', 'id': 'SteamTurbine', 'x': 1, 'y': 4}, + {'type': 'build', 'id': 'SteamTurbine', 'x': 5, 'y': 4}, + {'type': 'build', 'id': 'Aquatuner', 'x': 2, 'y': 1}, ], }, - 'bedroom': { - 'name': 'Barracks / Bedroom', - 'description': 'Basic bedroom module with cots and decorations', - 'size': {'width': 8, 'height': 4}, - 'dig': {'x': -1, 'y': -1, 'width': 10, 'height': 6}, - 'buildings': [ - {'id': 'Bed', 'x': 1, 'y': 1}, - {'id': 'Bed', 'x': 3, 'y': 1}, - {'id': 'Bed', 'x': 5, 'y': 1}, - {'id': 'LadderBed', 'x': 1, 'y': 3}, - {'id': 'LadderBed', 'x': 3, 'y': 3}, - {'id': 'FlowerVase', 'x': 6, 'y': 1}, + 'ranch_hatch': { + 'name': 'Hatch Ranch', + 'desc': 'RanchStation + Incubator + StorageLocker', + 'size': {'w': 10, 'h': 6}, + 'dig': {'dx': -1, 'dy': -1, 'w': 12, 'h': 8}, + 'steps': [ + {'type': 'build', 'id': 'RanchStation', 'x': 1, 'y': 1}, + {'type': 'build', 'id': 'Incubator', 'x': 4, 'y': 1}, + {'type': 'build', 'id': 'StorageLocker', 'x': 8, 'y': 1}, ], }, } - -def list_blueprints(): - print(f"Available Blueprints ({len(BLUEPRINTS)}):") - print("=" * 60) - for key, bp in BLUEPRINTS.items(): - print(f" {key:16s} {bp['name']:28s} {bp['size']['width']}x{bp['size']['height']}") - print(f" {'':16s} {bp['description']}") +def cmd_list(args): + print(f"Available blueprints ({len(BLUEPRINTS)}):") + for name, bp in sorted(BLUEPRINTS.items()): + print(f" {name:15s} {bp['name']}") + print(f" {'':15s} {bp['desc']}") + print(f" {'':15s} Size: {bp['size']['w']}x{bp['size']['h']} | {len(bp['steps'])} buildings") print() +def cmd_show(args): + if not args: + print("Usage: show ", file=sys.stderr) + return + name = args[0] + if name not in BLUEPRINTS: + print(f"Unknown blueprint: {name}", file=sys.stderr) + return + bp = BLUEPRINTS[name] + print(f"Blueprint: {bp['name']}") + print(f" {bp['desc']}") + print(f" Size: {bp['size']['w']}x{bp['size']['h']}") + if bp.get('dig'): + d = bp['dig'] + print(f" Dig area: offset ({d['dx']},{d['dy']}) {d['w']}x{d['h']}") + print(f" Buildings ({len(bp['steps'])}):") + for s in bp['steps']: + print(f" {s['id']:25s} at anchor+({s['x']},{s['y']})") -def apply_blueprint(name, origin_x, origin_y): - bp = BLUEPRINTS.get(name) - if not bp: - print(f"[!] Blueprint '{name}' not found") - print(f" Use 'list' to see available blueprints") - return False +def cmd_build(args): + if len(args) < 3: + print("Usage: build ", file=sys.stderr) + return + name, ax, ay = args[0], int(args[1]), int(args[2]) + if name not in BLUEPRINTS: + print(f"Unknown blueprint: {name}", file=sys.stderr) + return + bp = BLUEPRINTS[name] - print(f"Applying blueprint: {bp['name']}") - print(f" Origin: ({origin_x}, {origin_y})") - print(f" Size: {bp['size']['width']} x {bp['size']['height']}") - print() + # 1. Dig the area first + if bp.get('dig'): + d = bp['dig'] + dx, dy = ax + d['dx'], ay + d['dy'] + print(f"Digging area: ({dx},{dy}) {d['w']}x{d['h']}...") + r = api_post('/api/action/dig', {"x": dx, "y": dy, "width": d['w'], "height": d['h']}) + if r.get("success"): + info = r.get("data", {}) + print(f" Queued {info.get('count', '?')} dig orders") + else: + print(f" Dig warning: {r.get('errorMessage', r.get('error', 'unknown'))}") + # 2. Build each structure results = [] + for step in bp['steps']: + bx, by = ax + step['x'], ay + step['y'] + print(f"Building {step['id']} at ({bx},{by})...") + r = api_post('/api/action/build', {"buildingId": step['id'], "x": bx, "y": by}) + if r.get("success"): + info = r.get("data", {}) + has_mat = info.get('hasAllMaterials', False) + results.append(f" ✓ {step['id']} at ({bx},{by}) {'(may wait for materials)' if not has_mat else ''}") + else: + results.append(f" ✗ {step['id']} at ({bx},{by}): {r.get('errorMessage', r.get('error', 'unknown'))}") - dig = bp.get('dig', {'x': -1, 'y': -1, 'width': bp['size']['width'] + 2, 'height': bp['size']['height'] + 2}) - dig_result = api_post('/api/action/dig', { - 'x': origin_x + dig['x'], - 'y': origin_y + dig['y'], - 'width': dig['width'], - 'height': dig['height'], - }) - status = 'OK' if 'error' not in dig_result else dig_result.get('error', 'fail') - print(f" [Dig] area ({dig['width']}x{dig['height']}): {status}") + print(f"\n--- {bp['name']} build results ---") + for r in results: + print(r) - for b in bp['buildings']: - x = origin_x + b['x'] - y = origin_y + b['y'] - result = api_post('/api/action/build', { - 'buildingId': b['id'], - 'x': x, - 'y': y, - }) - ok = 'error' not in result - results.append({'building': b['id'], 'x': x, 'y': y, 'ok': ok}) - status = 'OK' if ok else result.get('error', 'fail') - print(f" [Build] {b['id']:20s} at ({x:3d}, {y:3d}): {status}") - - ok_count = sum(1 for r in results if r['ok']) - print() - print(f" Result: {ok_count}/{len(results)} buildings placed") - return ok_count == len(results) - - -if __name__ == '__main__': - cmd = sys.argv[1] if len(sys.argv) > 1 else 'list' - - if cmd == 'list': - list_blueprints() - elif cmd == 'build': - if len(sys.argv) < 4: - print("Usage: python oni_builder.py build ") - print() - list_blueprints() - sys.exit(1) - success = apply_blueprint(sys.argv[2], int(sys.argv[3]), int(sys.argv[4])) - sys.exit(0 if success else 1) +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"): + print(__doc__) + return + cmd = sys.argv[1] + args = sys.argv[2:] + if cmd == "list": + cmd_list(args) + elif cmd == "show": + cmd_show(args) + elif cmd == "build": + cmd_build(args) else: - print("Usage: python oni_builder.py ") + print(f"Unknown: {cmd}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/tools/oni_commander.py b/tools/oni_commander.py index 3680ca9..eb118fa 100644 --- a/tools/oni_commander.py +++ b/tools/oni_commander.py @@ -1,355 +1,219 @@ #!/usr/bin/env python3 """ -ONI Commander — 高级指令封装 -============================ -把多个底层 API 调用组合成一条"指挥官指令",AI 一句话就能执行复杂操作。 +ONI Commander — High-Level Game Operations +=========================================== +Combines multiple low-level API calls into one "commander directive". + +Usage: + python oni_commander.py diagnose Full game diagnostic + python oni_commander.py fix_co2 Auto-vent CO2 pockets + python oni_commander.py fix_overload Diagnose power overloads + python oni_commander.py emergency_o2 Emergency oxygen setup + python oni_commander.py expand_base Dig expansion area """ -import json -import sys -import os -import inspect +import json, sys, io +# Fix GBK encoding +if sys.stdout.encoding and sys.stdout.encoding.upper() in ('GBK', 'GB2312', 'CP936'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') -TOOLS_DIR = os.path.dirname(__file__) -sys.path.insert(0, TOOLS_DIR) - -from oni_api import api_get, api_post, _print_feedback - - -def cmd_diagnose(args): - """全面诊断:O2、食物、电力、CO2、温度、管道。""" - print("=" * 56) - print(" ONI Full Diagnostic") - print("=" * 56) - - pause_before() - pause_after() - - # 1. 游戏总览 - game = api_get('/api/state/game') - if 'error' in game: - print(f"[!] Cannot connect: {game['error']}") - return False - print(f" Cycle {game.get('cycle', '?')} | {game.get('duplicantCount', '?')} dupes | " - f"{game.get('suffocating', 0)} suffocating | {game.get('starving', 0)} starving | " - f"{game.get('stressed', 0)} stressed") - - # 2. 电力 - print("\n--- Power ---") - power = api_get('/api/state/power') - if 'circuits' in power: - for c in power['circuits']: - mark = " *** OVERLOAD ***" if c.get('isOverloaded') else "" - print(f" Circuit {c.get('id')}: {c.get('wattsUsed', 0):.0f}W / {c.get('maxWatts', 0):.0f}W{mark}") - - # 3. CO2 - print("\n--- CO2 ---") - co2 = api_get('/api/state/co2') - if co2.get('pocketCount', 0) > 0: - print(f" {co2.get('pocketCount')} pockets ({co2.get('totalMassKg', 0):.0f} kg CO2)") - for p in co2.get('pockets', [])[:3]: - print(f" ({p.get('x')},{p.get('y')}) {p.get('mass', 0):.0f} kg") - else: - print(" No CO2 pockets detected") - - # 4. 温度 - print("\n--- Temperature ---") - temp = api_get('/api/state/temperature/zones') - if 'averageC' in temp: - print(f" Avg: {temp['averageC']:.0f}°C Min: {temp.get('minC', 0):.0f}°C Max: {temp.get('maxC', 0):.0f}°C") - if temp.get('hotSpots'): - print(f" {len(temp['hotSpots'])} hot spots (>50°C) — risk!") - if temp.get('coldSpots'): - print(f" {len(temp['coldSpots'])} cold spots (<5°C)") - - # 5. 疾病 - print("\n--- Diseases ---") - diseases = api_get('/api/state/diseases') - infected = diseases.get('infectedDuplicants', []) - if infected: - for d in infected: - print(f" {d.get('duplicant')} — {d.get('disease')} ({d.get('severity')})") - else: - print(" No infections") - - # 6. 管道 - print("\n--- Pipes ---") - for pt in ('gas', 'liquid'): - pipes = api_get(f'/api/state/pipes?type={pt}') - segs = pipes.get('segmentCount', 0) - if segs: - first = pipes.get('segments', [{}])[0] - print(f" {pt}: {segs} segments (e.g. {first.get('element', '?')})") - else: - print(f" {pt}: empty") - - print() - print("=" * 56) - print(" Diagnostic complete") - print("=" * 56) - - -def cmd_fix_co2(args): - """找到 CO2 并挖掘排气通道。""" - print("[CO2 Fix] Scanning for CO2 pockets...") - co2 = api_get('/api/state/co2') - pockets = co2.get('pockets', []) - if not pockets: - print("[OK] No CO2 pockets found.") - return - - pause_before() - - # Find the lowest y-level pocket and dig below it - bottom = min(pockets, key=lambda p: p.get('y', 0)) - x, y = bottom.get('x', 0), bottom.get('y', 0) - print(f"[CO2 Fix] Largest pocket at ({x},{y}), {bottom.get('mass', 0):.0f} kg") - - # Dig a 1-wide shaft down - dig_y = max(0, y - 5) - result = api_post('/api/action/dig', {"x": x, "y": dig_y, "width": 1, "height": y - dig_y + 1}) - if result.get('success'): - print(f"[CO2 Fix] Dug vent shaft at x={x}, y={dig_y}..{y}") - else: - print(f"[CO2 Fix] Dig failed: {result.get('errorMessage', 'unknown')}") - - pause_after() - - -def cmd_fix_overload(args): - """检测过载电路并给出修复建议。""" - print("[Power Fix] Analyzing circuits...") - power = api_get('/api/state/power') - overloaded = [c for c in power.get('circuits', []) if c.get('isOverloaded')] - if not overloaded: - print("[OK] No overloaded circuits.") - return - - pause_before() - - print(f"[Power Fix] {len(overloaded)} overloaded circuits:") - for c in overloaded: - print(f" Circuit {c.get('id')}: {c.get('wattsUsed', 0):.0f}W / {c.get('maxWatts', 0):.0f}W") - - # Suggest fixes - print() - print(" Suggested fixes:") - print(" 1. Move heavy consumers (MetalRefinery, Aquatuner) to separate circuit") - print(" 2. Upgrade wire to HeaviWatt or split into 2 transformers") - print(" 3. Add PowerTransformer to isolate high-load branches") - - pause_after() - - -def cmd_build_pipe_line(args): - """铺设管道路径(带交叉模式)。""" - if len(args) < 5: - print("Usage: build_pipe_line gas|liquid [mode]") - print(" mode: 'line'(默认,与已有管线合并) | 'cross'(跨接器跳过) | 'single'(单段)") - return - ptype = args[0] - x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4]) - mode = args[5] if len(args) > 5 else 'line' - - pause_before() - - if ptype not in ('gas', 'liquid'): - print("[!] Type must be 'gas' or 'liquid'") - return - - result = api_post('/api/action/build_pipe', { - "type": ptype, "x1": x1, "y1": y1, - "x2": x2, "y2": y2, "mode": mode - }) - if result.get('success'): - d = result.get('data', {}) - segs = d.get('segmentCount', 0) - bridges = d.get('bridgesPlaced', 0) - print(f"[OK] {ptype} pipe: {segs} segments from ({x1},{y1}) to ({x2},{y2})") - if bridges > 0: - print(f" {bridges} bridges placed at crossings (mode={mode})") - else: - print(f"[!] Failed: {result.get('errorMessage', 'unknown')}") - - pause_after() - - -def cmd_build_wire_line(args): - """铺设电线路径。""" - if len(args) < 5: - print("Usage: build_wire_line regular|heavy|conductive [mode]") - print(" mode: 'line' (default), 'cross' (use bridges), 'single'") - return - wtype = args[0] - x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4]) - mode = args[5] if len(args) > 5 else 'line' - - pause_before() - - if wtype not in ('regular', 'heavy', 'conductive', 'heavy_conductive'): - print("[!] Type must be 'regular', 'heavy', 'conductive', or 'heavy_conductive'") - return - - result = api_post('/api/action/build_wire', { - "type": wtype, "x1": x1, "y1": y1, - "x2": x2, "y2": y2, "mode": mode - }) - if result.get('success'): - segs = result.get('data', {}).get('segmentCount', 0) - print(f"[OK] {wtype} wire: {segs} segments from ({x1},{y1}) to ({x2},{y2})") - else: - print(f"[!] Failed: {result.get('errorMessage', 'unknown')}") - - pause_after() - - -def cmd_expand_base(args): - """拓展基地:挖掘 + 建造墙壁。""" - if len(args) < 4: - print("Usage: expand_base ") - return - x, y, w, h = int(args[0]), int(args[1]), int(args[2]), int(args[3]) - - pause_before() - - # Dig - r1 = api_post('/api/action/dig', {"x": x - 1, "y": y - 1, "width": w + 2, "height": h + 2}) - if not r1.get('success'): - print(f"[!] Dig failed: {r1.get('errorMessage', 'unknown')}") - pause_after() - return - print(f"[Expand] Dug ({x},{y}) {w}x{h}") - - # Build floor tiles - for fx in range(x, x + w): - api_post('/api/action/build', {"buildingId": "Tile", "x": fx, "y": y}) - print(f"[Expand] Built floor: {w} tiles") - - # Build walls - for wx in range(x, x + w): - api_post('/api/action/build', {"buildingId": "Tile", "x": wx, "y": y + h}) - for wy in range(y + 1, y + h): - api_post('/api/action/build', {"buildingId": "Tile", "x": x, "y": wy}) - api_post('/api/action/build', {"buildingId": "Tile", "x": x + w - 1, "y": wy}) - print(f"[Expand] Built walls") - - pause_after() - print(f"[OK] Room expanded to ({x},{y}) {w}x{h}") - - -def cmd_emergency_o2(args): - """紧急制氧:检查 O2 并自动建造。""" - print("[Emergency O2] Checking oxygen status...") - - pause_before() - - resources = api_get('/api/state/resources') - buildings = api_get('/api/state/buildings') - game = api_get('/api/state/game') - - if isinstance(resources, list): - o2 = next((r for r in resources if r.get('name') == 'Oxygen'), {}) - algae = next((r for r in resources if r.get('name') == 'Algae'), {}) - o2_kg = o2.get('amount', 0) - algae_kg = algae.get('amount', 0) - print(f" O2: {o2_kg:.0f} kg | Algae: {algae_kg:.0f} kg") - else: - o2_kg, algae_kg = 0, 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 [])) - - if o2_kg < 500: - print("[CRITICAL] Oxygen critical!") - if has_diffuser and algae_kg > 500: - print(" OxygenDiffuser already exists, checking Algae supply...") - elif not has_electrolyzer and not has_diffuser: - # Find a spot near base and build - game_info = api_get('/api/state/game') - print(" No O2 production! Building OxygenDiffuser...") - result = api_post('/api/action/build', {"buildingId": "OxygenDiffuser", "x": 30, "y": 20}) - if result.get('success'): - print(" [OK] OxygenDiffuser queued at (30,20)") - else: - print(f" [!] {result.get('errorMessage', 'build failed')}") - elif o2_kg < 2000 and not has_electrolyzer: - print("[WARN] Low O2, recommend SPOM build") - else: - print("[OK] Oxygen stable") - - pause_after() - - -# ── Helpers ────────────────────────────────────────────────────────────── +from oni_api import api_get, api_post, get_data def pause_before(): - """High-level ops always pause first.""" - api_post('/api/action/pause', {"reason": "High-level operation"}) + api_post('/api/action/pause', {"reason": "commander operation"}) - -def pause_after(): - """Resume after operation.""" +def unpause_after(): api_post('/api/action/unpause', {"speed": 1}) +def cmd_diagnose(args): + pause_before() + game = get_data('/api/state/game') + resources = get_data('/api/state/resources') or [] + buildings = get_data('/api/state/buildings') or [] + dups = get_data('/api/state/duplicants') or [] + alerts = get_data('/api/state/alert') or [] -# ── Command Registry ───────────────────────────────────────────────────── + rdict = {} + for r in resources if isinstance(resources, list) else []: + rdict[r.get('name','')] = r.get('amountKg', 0) -COMMANDS = { - 'diagnose': cmd_diagnose, - 'fix_co2': cmd_fix_co2, - 'fix_overload': cmd_fix_overload, - 'expand_base': cmd_expand_base, - 'emergency_o2': cmd_emergency_o2, - 'build_pipe_line': cmd_build_pipe_line, - 'build_wire_line': cmd_build_wire_line, - 'snapshot': cmd_snapshot, - 'camera': cmd_camera, -} + btypes = {} + for b in buildings if isinstance(buildings, list) else []: + bid = b.get('id', '') + btypes[bid] = btypes.get(bid, 0) + 1 -def cmd_snapshot(args): - """Take screenshot + view. Usage: snapshot [name]""" - from oni_api import cmd_snapshot as api_snapshot - api_snapshot(args) + print("=" * 60) + print(" ONI Full Diagnostic") + print("=" * 60) + if game: + speed_str = f"{game.get('gameSpeed','?')}x" if not game.get('isPaused') else "PAUSED" + print(f" Cycle {game.get('cycle','?')} | {game.get('duplicantCount','?')} dupes | {speed_str}") -def cmd_camera(args): - """Move camera. Usage: camera [zoom]""" - from oni_api import cmd_camera as api_camera - api_camera(args) + print(f"\n── Resources ──") + for name in ['Oxygen', 'Water', 'PollutedWater', 'Dirt', 'Carbon', 'Hydrogen']: + v = rdict.get(name, 0) + print(f" {name:20s} {v:>10.1f} kg") -if __name__ == '__main__': - cmd = sys.argv[1] if len(sys.argv) > 1 else 'help' + print(f"\n── Buildings ({len(btypes)} types) ──") + for bid, cnt in sorted(btypes.items()): + print(f" {bid:30s} x{cnt}") - if cmd == 'help' or cmd not in COMMANDS: - print("ONI Commander — 高级指令") - print("=" * 56) - print() - print("=== Diagnostics ===") - print(" diagnose 全面诊断(O2/电力/CO2/温度/疾病/管道)") - print() - print("=== Automated Fixes ===") - print(" fix_co2 找到 CO2 并挖掘排气通道") - print(" fix_overload 检测过载电路并建议修复") - print(" emergency_o2 紧急制氧(检查+自动建造)") - print() - print("=== Room Expansion ===") - print(" expand_base 挖掘+建造墙壁(一键拓展房间)") - print() - print("=== Pipe / Wire Lines ===") - print(" build_pipe_line [mode]") - print(" t: gas | liquid | mode: line(merge) | cross(bridge)") - print(" build_wire_line [mode]") - print(" t: regular | heavy | conductive | heavy_conductive") - print() - print("=== Screenshot / Camera ===") - print(" snapshot [file.png] Take screenshot") - print(" camera [zoom] Move camera view") - print() - print("All high-level commands auto-pause/resume the game.") - else: - args = sys.argv[2:] - fn = COMMANDS[cmd] - sig = inspect.signature(fn) - if len(sig.parameters) > 0: - fn(args) + print(f"\n── Dupes ({len(dups)}) ──") + for d in dups if isinstance(dups, list) else []: + print(f" {d.get('name','?'):12s} ({d.get('x',0)},{d.get('y',0)}) hp={d.get('health',0)}") + + print(f"\n── Alerts ({len(alerts)}) ──") + for a in alerts: + print(f" [{a.get('severity','?')}] {a.get('title','?')}") + + # Suggestions + print(f"\n── Suggestions ──") + o2 = rdict.get('Oxygen', 0) + cal = rdict.get('Calories', 0) + water = rdict.get('Water', 0) + coal = rdict.get('Carbon', 0) + + if o2 < 500: print(f" 🔴 O2 CRISIS: {o2:.0f} kg — build SPOM immediately") + elif o2 < 2000: print(f" 🟡 O2 low: {o2:.0f} kg — plan oxygen production") + + if cal < 200000: print(f" 🔴 FOOD CRISIS: {cal:.0f} kcal — build farm") + elif cal < 500000: print(f" 🟡 Food low: {cal:.0f} kcal") + + if water < 5000: print(f" 🔴 WATER CRISIS: {water:.0f} kg") + + if coal < 1000 and 'CoalGenerator' in btypes: + print(f" 🟡 Coal low ({coal:.0f} kg) — diversify power") + + unpause_after() + +def cmd_fix_co2(args): + """Find CO2 pockets below base and dig to let it settle.""" + pause_before() + game = get_data('/api/state/game') + if not game: + print("Cannot connect") + return + + gw, gh = game.get('gridWidth', 256), game.get('gridHeight', 384) + print("Scanning for CO2 pockets...") + + # Scan bottom portion of the map for CO2 + scan_y = max(0, gh - 40) + r = api_get(f'/api/state/cells/slice?axis=y&index={scan_y}&start=0&end={gw-1}') + if not r.get("success"): + print("Cannot scan area") + unpause_after() + return + + cells = r.get("data", {}).get("cells", []) + co2_cells = [c for c in cells if c.get('element') == 'CarbonDioxide' and c.get('isSolid') == False] + if not co2_cells: + print("No accessible CO2 pockets found in scan area") + unpause_after() + return + + print(f"Found {len(co2_cells)} CO2 cells. Digging to the right for ventilation...") + for c in co2_cells[:5]: + dig_x = c['x'] + 1 + r2 = api_post('/api/action/dig', {"x": dig_x, "y": scan_y, "width": 3, "height": 3}) + if r2.get("success"): + print(f" Dig at ({dig_x},{scan_y}) 3x3 — queued") + + unpause_after() + +def cmd_fix_overload(args): + pause_before() + print("Diagnosing power...") + buildings = get_data('/api/state/buildings') or [] + resources = get_data('/api/state/resources') or [] + rdict = {} + for r in resources if isinstance(resources, list) else []: + rdict[r.get('name','')] = r.get('amountKg', 0) + + btypes = {} + for b in buildings if isinstance(buildings, list) else []: + bid = b.get('id', '') + btypes[bid] = btypes.get(bid, 0) + 1 + + has_coal = 'CoalGenerator' in btypes + has_hydro = 'HydrogenGenerator' in btypes + has_manual = 'ManualGenerator' in btypes + coal = rdict.get('Carbon', 0) + + print(f" Coal: {coal:.0f} kg") + print(f" Generators: Manual={has_manual} Coal={has_coal} Hydrogen={has_hydro}") + print(f" Coal plants: {btypes.get('CoalGenerator', 0)}") + print(f" Total buildings: {len(buildings)}") + + if has_coal and coal < 2000: + print(f" ⚠ Low coal — supplement with manual generators") + if not has_hydro and 'Electrolyzer' in btypes: + print(f" ⚠ Wasteful: Electrolyzer running without HydrogenGenerator") + unpause_after() + +def cmd_emergency_o2(args): + pause_before() + print("Emergency O2 response...") + resources = get_data('/api/state/resources') or [] + buildings = get_data('/api/state/buildings') or [] + rdict = {} + for r in resources if isinstance(resources, list) else []: + rdict[r.get('name','')] = r.get('amountKg', 0) + + has_diffuser = any(b.get('id') == 'OxygenDiffuser' for b in buildings if isinstance(buildings, list)) + algae = rdict.get('Algae', 0) + + if has_diffuser: + print(" ✓ OxygenDiffuser exists — ensure it has power and algae") + elif algae > 200: + print(" Building OxygenDiffuser (uses algae)...") + r = api_post('/api/action/build', {"buildingId": "OxygenDiffuser", "x": 30, "y": 30}) + if r.get("success"): + print(" → OxygenDiffuser queued") else: - fn() + print(f" → Build failed: {r.get('errorMessage', r.get('error', 'unknown'))}") + else: + print(" No algae for diffuser. Need SPOM (Electrolyzer)") + water = rdict.get('Water', 0) + if water > 5000: + print(f" Water: {water:.0f} kg — enough for SPOM") + else: + print(f" Water: {water:.0f} kg — insufficient for electrolysis") + unpause_after() + +def cmd_expand_base(args): + if len(args) < 4: + print("Usage: expand_base ", file=sys.stderr) + return + cx, cy, w, h = int(args[0]), int(args[1]), int(args[2]), int(args[3]) + dig_x, dig_y = cx - w // 2, cy - h // 2 + pause_before() + print(f"Expanding: dig ({dig_x},{dig_y}) {w}x{h}") + r = api_post('/api/action/dig', {"x": dig_x, "y": dig_y, "width": w, "height": h}) + if r.get("success"): + info = r.get("data", {}) + print(f" Queued {info.get('count', '?')} dig orders") + else: + print(f" Error: {r.get('errorMessage', r.get('error', 'unknown'))}") + unpause_after() + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): + print(__doc__) + return + cmd = sys.argv[1] + args = sys.argv[2:] + + cmds = { + "diagnose": cmd_diagnose, "fix_co2": cmd_fix_co2, + "fix_overload": cmd_fix_overload, "emergency_o2": cmd_emergency_o2, + "expand_base": cmd_expand_base, + } + if cmd in cmds: + cmds[cmd](args) + else: + print(f"Unknown: {cmd}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main()