From 0e17e8a6ac3137d56604323f8256ebbc9446d4a7 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 22 May 2026 08:44:56 +0800 Subject: [PATCH] feat: complete ONI Agent project with full Mod API, Python toolchain, and development guide - Implement all Mod API endpoints (buildings, research, geysers, alerts, critters, deconstruct, prioritize, research, schedule, wardrobe) - Enhance Python tools with comprehensive CLI, analysis (O2/food/power/temp/water), and 7 blueprints (SPOM, toilet, ranch, farm, cooling, bedroom) - Add utility scripts: auto_repair, auto_analyze, watch mode, setup - Write Agent-Mod integration constraints and development guide - Create skills directory with ONI agent skill definition --- .gitignore | 5 + SKILL.md | 96 ++++++++ config.json | 5 + docs/MOD_DEV_GUIDE.md | 235 +++++++++++++++++++ mod/ONIAgentBridge.cs | 488 ++++++++++++++++++++++++++++++++++++++++ mod/mod_info.yaml | 8 + scripts/auto_analyze.sh | 30 +++ scripts/auto_repair.sh | 28 +++ scripts/setup.sh | 28 +++ scripts/watch.sh | 26 +++ skills/oni_agent.md | 42 ++++ tools/oni_analyzer.py | 221 ++++++++++++++++++ tools/oni_api.py | 204 +++++++++++++++++ tools/oni_builder.py | 163 ++++++++++++++ 14 files changed, 1579 insertions(+) create mode 100644 .gitignore create mode 100644 SKILL.md create mode 100644 config.json create mode 100644 docs/MOD_DEV_GUIDE.md create mode 100644 mod/ONIAgentBridge.cs create mode 100644 mod/mod_info.yaml create mode 100755 scripts/auto_analyze.sh create mode 100755 scripts/auto_repair.sh create mode 100755 scripts/setup.sh create mode 100755 scripts/watch.sh create mode 100644 skills/oni_agent.md create mode 100644 tools/oni_analyzer.py create mode 100644 tools/oni_api.py create mode 100644 tools/oni_builder.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9666e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +__pycache__/ +*.pyc +.DS_Store +.env diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..03ab235 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,96 @@ +# Oxygen Not Included (ONI) Agent + +## 职责 +协助玩家操作和管理游戏"缺氧"(Oxygen Not Included),提供游戏知识、策略建议,并通过 Mod API 直接操控游戏。 + +## 通信方式 +- Mod 在游戏内启动 HTTP 服务,暴露 RESTful API +- 通过 `http://127.0.0.1:PORT` 与游戏通信 +- 端口在 `oni-agent/config.json` 中配置(默认 23876) + +## 可用 API 端点 + +### 状态查询 (GET) +| 端点 | 返回内容 | +|------|---------| +| `/api/state/game` | 周期、复制人数量、全局资源 | +| `/api/state/resources` | 所有主要资源存量 | +| `/api/state/duplicants` | 所有复制人状态 | +| `/api/state/buildings` | 所有建筑列表 | +| `/api/state/research` | 科技树进度 | +| `/api/state/geysers` | 喷泉状态 | +| `/api/state/alert` | 当前警报 | +| `/api/state/critters` | 小动物状态 | + +### 操作 (POST) +| 端点 | 请求体 | +|------|--------| +| `/api/action/dig` | `{x, y, width, height}` | +| `/api/action/build` | `{buildingId, x, y, rotation?}` | +| `/api/action/deconstruct` | `{buildingId, x, y}` | +| `/api/action/prioritize` | `{x, y, priority}` | +| `/api/action/research` | `{techId}` | +| `/api/action/schedule` | `{duplicantId, schedule}` | +| `/api/action/wardrobe` | `{duplicantId, equipment}` | + +## 工具列表 + +| 工具 | 用途 | +|------|------| +| `tools/oni_api.py` | 与 Mod HTTP API 通信 | +| `tools/oni_analyzer.py` | 分析游戏状态、生成建议 | +| `tools/oni_builder.py` | 蓝图/建造规划 | +| `scripts/auto_repair.sh` | 诊断连接问题 | +| `scripts/auto_analyze.sh` | 一键拉取状态+分析 | +| `scripts/watch.sh` | 持续监控模式 | +| `scripts/setup.sh` | 环境初始化 | + +## 使用方式 + +```bash +# 检查游戏连接 +python3 tools/oni_api.py health + +# 获取游戏状态概览 +python3 tools/oni_api.py status + +# 列出所有资源 +python3 tools/oni_api.py resources + +# 分析并生成建议 +python3 tools/oni_analyzer.py + +# 查看可用蓝图 +python3 tools/oni_builder.py list + +# 执行建造计划 +python3 tools/oni_builder.py build spom 15 10 + +# 持续监控(每 60 秒) +bash scripts/watch.sh 60 + +# 诊断连接 +bash scripts/auto_repair.sh +``` + +## 核心游戏知识 + +### 生存优先级 +1. **氧气** — 电解器 > 藻类制氧(前期过渡) +2. **食物** — 浆果 > 烤肉 > 营养膏 +3. **温度控制** — 液冷 + 蒸汽机 +4. **电力** — 氢气发电 > 煤炭 > 手动 +5. **水资源管理** — 净水器、污水过滤 + +### 常用布局 +- SPOM (Self-Powered Oxygen Module): 电解制氧 + 氢气发电闭环 +- 卫生间水循环: 卫生间 → 净水器 → 卫生间 +- 冷却系统: 液冷 + 蒸汽机 + 导热管 +- Ranch 模块: 养殖哈奇/滑鳞/飞鱼 + +### 关键事件预警 +- 氧气不足 (< 500g/tile) → 增加制氧 +- 温度超标 (> 40°C 或 < -10°C) → 增加温控 +- 食物短缺 (< 5 周期余量) → 扩大种植/养殖 +- 电力不足 → 增加发电或减少负载 +- 污水满溢 → 增加净水/扩大存储 diff --git a/config.json b/config.json new file mode 100644 index 0000000..6d61125 --- /dev/null +++ b/config.json @@ -0,0 +1,5 @@ +{ + "modHost": "127.0.0.1", + "modPort": 23876, + "timeout": 10 +} diff --git a/docs/MOD_DEV_GUIDE.md b/docs/MOD_DEV_GUIDE.md new file mode 100644 index 0000000..31bb751 --- /dev/null +++ b/docs/MOD_DEV_GUIDE.md @@ -0,0 +1,235 @@ +# ONI Agent Mod 开发指南 + +## 概述 + +本文档规定了一个与外部 Agent(AI 助手)对接的《缺氧》(Oxygen Not Included) Mod 应遵循的接口规范、约束条件和最佳实践。遵循此规范开发的 Mod 可与 `oni-agent` 工具链无缝协作。 + +--- + +## 1. 通信协议 + +### 1.1 传输层 +- **协议**: HTTP 1.1 +- **地址**: `127.0.0.1`(仅本地回环,禁止暴露到外部网络) +- **端口**: 由配置文件指定(默认 `23876`) +- **编码**: 所有请求和响应均为 UTF-8 + +### 1.2 数据格式 +- 所有请求和响应使用 `application/json` +- 响应必须包含有效的 JSON +- 错误响应必须包含 `error` 字段 + +```json +// 成功响应 +{ "result": "ok", ... } + +// 错误响应 +{ "error": "error_message" } +``` + +### 1.3 请求超时 +- 服务端应在 5 秒内响应 +- 长时间操作应排队后立即返回 `{ "result": "queued" }` + +--- + +## 2. API 端点规范 + +### 2.1 健康检查 + +``` +GET /health +``` + +用于 Agent 探测 Mod 是否存活。必须始终可达,不依赖游戏状态。 + +**响应**: +```json +{ "status": "ok", "service": "mod_name" } +``` + +### 2.2 状态查询端点 + +所有状态查询为 `GET` 请求,路径前缀 `/api/state/`。 + +| 端点 | 返回内容 | 必需 | +|------|---------|------| +| `/api/state/game` | 周期、复制人数量、世界名称 | **是** | +| `/api/state/resources` | 所有主要资源存量列表 | **是** | +| `/api/state/duplicants` | 每个复制人的压力/食物/体力/氧气 | **是** | +| `/api/state/buildings` | 已建造的建筑列表 | 推荐 | +| `/api/state/research` | 科技树进度 | 推荐 | +| `/api/state/geysers` | 喷泉位置和状态 | 可选 | +| `/api/state/alert` | 当前游戏警报 | 推荐 | +| `/api/state/critters` | 小动物状态 | 可选 | + +#### `GET /api/state/game` + +```json +{ + "cycle": 42, + "duplicantCount": 6, + "worldName": "Terra", + "worldSize": 256 +} +``` + +#### `GET /api/state/duplicants` + +```json +[ + { + "name": "Dup1", + "stress": 12.5, + "calories": 850000, + "stamina": 98.2, + "oxygen": 85.0, + "diseases": 0, + "skillLevels": 3 + } +] +``` + +#### `GET /api/state/resources` + +```json +[ + { "name": "Oxygen", "tag": "Oxygen", "amount": 12345.6, "unit": "kg" } +] +``` + +#### `GET /api/state/buildings` + +```json +[ + { + "name": "Manual Generator", + "id": "ManualGenerator", + "x": 10, + "y": 5, + "isOperational": true + } +] +``` + +### 2.3 操作端点 + +所有操作为 `POST` 请求,路径前缀 `/api/action/`。 + +| 端点 | 作用 | 必需 | +|------|------|------| +| `/api/action/dig` | 挖掘指定区域 | **是** | +| `/api/action/build` | 建造建筑 | **是** | +| `/api/action/deconstruct` | 拆除建筑 | 推荐 | +| `/api/action/prioritize` | 设置优先级 | 可选 | +| `/api/action/research` | 选择研究方向 | 推荐 | +| `/api/action/schedule` | 修改复制人日程 | 可选 | +| `/api/action/wardrobe` | 修改复制人装备 | 可选 | + +#### `POST /api/action/dig` + +**请求体**: +```json +{ "x": 10, "y": 5, "width": 8, "height": 6 } +``` + +**响应**: +```json +{ "result": "dig_queued", "x": 10, "y": 5, "width": 8, "height": 6 } +``` + +#### `POST /api/action/build` + +**请求体**: +```json +{ "buildingId": "Electrolyzer", "x": 10, "y": 5, "rotation": null } +``` + +**响应**: +```json +{ "result": "build_queued", "buildingId": "Electrolyzer", "x": 10, "y": 5 } +``` + +--- + +## 3. Mod 约束 + +### 3.1 安全性 +1. **仅绑定本地回环地址** `127.0.0.1`,不得监听 `0.0.0.0` +2. **不得实现认证/授权**——回环地址默认安全 +3. **不得执行文件 I/O**(读取 Mod 自带配置除外) +4. **输入校验**——所有用户输入必须校验类型和范围 + +### 3.2 性能 +1. 状态查询必须是**只读操作**,不得持有锁 +2. 资源列表等大数据量接口应考虑分页(未来扩展) +3. 建造/挖掘等操作应返回 `queued` 后异步执行 +4. HTTP 服务器应在**单独线程**运行,不得阻塞游戏主线程 + +### 3.3 兼容性 +1. 使用 `KMod.UserMod2` 基类 +2. 使用 `Harmony` 进行补丁(如果使用) +3. 引用 `UnityEngine` 和 `Assembly-CSharp` 程序集 +4. 最小支持游戏版本应在 `mod_info.yaml` 中声明(当前推荐 `612000`) + +### 3.4 错误处理 +```json +// Mod 内部错误的通用格式 +{ "error": "error_type", "details": "human readable message" } + +// 常见错误类型 +{ "error": "not_found" } // 404 端点不存在 +{ "error": "invalid_request" } // 请求体解析失败 +{ "error": "not_implemented" } // 功能尚未实现 +{ "error": "unknown_building", "buildingId": "..." } // 建筑 ID 不识别 +{ "error": "unknown_tech", "techId": "..." } // 科技 ID 不识别 +``` + +--- + +## 4. 配置文件规范 + +Agent 侧通过 `config.json` 定位 Mod: + +```json +{ + "modHost": "127.0.0.1", + "modPort": 23876, + "timeout": 10 +} +``` + +- `modHost`: 始终为 `127.0.0.1` +- `modPort`: 应与 Mod 中监听的端口一致 +- `timeout`: HTTP 请求超时秒数 + +--- + +## 5. 扩展建议 + +### 5.1 添加新端点 +1. 在 `ProcessRequest` 中添加路由匹配 +2. 实现对应的处理方法 +3. 更新本指南和 `SKILL.md` + +### 5.2 蓝图系统 +预置的建造方案(蓝图)应满足: +- 坐标相对于原点,便于偏移 +- 包含预先挖掘区域 +- 建筑顺序隐含依赖关系 + +### 5.3 事件推送(未来) +考虑支持 WebSocket 或 SSE,用于游戏事件实时推送(如警报触发)。 + +--- + +## 6. 开发检查清单 + +- [ ] Mod 继承 `UserMod2`,在 `OnLoad` 中启动 HTTP 服务 +- [ ] 在 `OnUnload` 中停止 HTTP 服务 +- [ ] 实现全部必需端点(health, game, resources, duplicants, dig, build) +- [ ] 错误响应包含 `error` 字段 +- [ ] 端口号与 `config.json` 一致 +- [ ] 仅绑定 `127.0.0.1` +- [ ] 使用 `System.Text.Json` 而不是 `Newtonsoft.Json`(减少依赖) +- [ ] `mod_info.yaml` 声明了正确的游戏版本 diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs new file mode 100644 index 0000000..5dd6ed3 --- /dev/null +++ b/mod/ONIAgentBridge.cs @@ -0,0 +1,488 @@ +using HarmonyLib; +using KMod; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Collections.Generic; + +namespace ONIAgentBridge +{ + public class Mod : UserMod2 + { + private HttpListener _listener; + private bool _running = true; + + public override void OnLoad(Harmony harmony) + { + base.OnLoad(harmony); + StartServer(); + } + + private void StartServer() + { + int port = 23876; + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + try + { + _listener.Start(); + Console.WriteLine($"[ONIAgentBridge] Server started on port {port}"); + _listener.BeginGetContext(HandleRequest, null); + } + catch (Exception e) + { + Console.WriteLine($"[ONIAgentBridge] Failed to start server: {e.Message}"); + } + } + + private void HandleRequest(IAsyncResult result) + { + if (!_running) return; + try + { + var ctx = _listener.EndGetContext(result); + _listener.BeginGetContext(HandleRequest, null); + ProcessRequest(ctx); + } + catch { } + } + + private void ProcessRequest(HttpListenerContext ctx) + { + try + { + var path = ctx.Request.Url.AbsolutePath.TrimEnd('/'); + var method = ctx.Request.HttpMethod; + + string responseJson; + + if (path == "/health" && method == "GET") + { + responseJson = JsonSerializer.Serialize(new { status = "ok", service = "oni-agent-bridge" }); + } + else if (path == "/api/state/game" && method == "GET") + { + responseJson = GetGameState(); + } + else if (path == "/api/state/resources" && method == "GET") + { + responseJson = GetResources(); + } + else if (path == "/api/state/duplicants" && method == "GET") + { + responseJson = GetDuplicants(); + } + else if (path == "/api/state/buildings" && method == "GET") + { + responseJson = GetBuildings(); + } + else if (path == "/api/state/research" && method == "GET") + { + responseJson = GetResearch(); + } + else if (path == "/api/state/geysers" && method == "GET") + { + responseJson = GetGeysers(); + } + else if (path == "/api/state/alert" && method == "GET") + { + responseJson = GetAlerts(); + } + else if (path == "/api/state/critters" && method == "GET") + { + responseJson = GetCritters(); + } + else if (path == "/api/action/dig" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecuteDig(body); + } + else if (path == "/api/action/build" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecuteBuild(body); + } + else if (path == "/api/action/deconstruct" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecuteDeconstruct(body); + } + else if (path == "/api/action/prioritize" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecutePrioritize(body); + } + else if (path == "/api/action/research" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecuteResearch(body); + } + else if (path == "/api/action/schedule" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecuteSchedule(body); + } + else if (path == "/api/action/wardrobe" && method == "POST") + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + responseJson = ExecuteWardrobe(body); + } + else + { + ctx.Response.StatusCode = 404; + responseJson = JsonSerializer.Serialize(new { error = "not_found" }); + } + + var buffer = Encoding.UTF8.GetBytes(responseJson); + ctx.Response.ContentType = "application/json"; + ctx.Response.OutputStream.Write(buffer, 0, buffer.Length); + } + catch (Exception e) + { + var err = JsonSerializer.Serialize(new { error = e.Message }); + var buf = Encoding.UTF8.GetBytes(err); + ctx.Response.ContentType = "application/json"; + ctx.Response.StatusCode = 500; + ctx.Response.OutputStream.Write(buf, 0, buf.Length); + } + finally + { + ctx.Response.OutputStream.Close(); + } + } + + private string GetGameState() + { + var state = new Dictionary + { + {"cycle", GameClock.Instance?.GetCycle() ?? 0}, + {"duplicantCount", Components.MinionIdentities?.Count ?? 0}, + {"worldName", World.Instance?.worldName ?? ""}, + {"worldSize", World.Instance?.WorldGrid?.WorldSize ?? 0} + }; + return JsonSerializer.Serialize(state); + } + + private string GetResources() + { + var list = new List(); + foreach (var elem in ElementLoader.elements) + { + var worldCount = WorldInventory.CountValue(elem.tag); + if (worldCount > 0) + { + list.Add(new { name = elem.name, tag = elem.tag.ToString(), amount = worldCount, unit = "kg" }); + } + } + return JsonSerializer.Serialize(list); + } + + private string GetDuplicants() + { + var list = new List(); + foreach (var minion in Components.MinionIdentities) + { + var go = minion.gameObject; + var stress = go.GetComponent(); + var calories = go.GetComponent(); + var stamina = go.GetComponent(); + var breath = go.GetComponent(); + var diseases = go.GetComponent(); + var skills = go.GetComponent(); + list.Add(new + { + name = minion.GetName(), + stress = stress?.GetStressValue() ?? 0, + calories = calories?.GetCaloriesValue() ?? 0, + stamina = stamina?.GetStaminaValue() ?? 0, + oxygen = breath?.GetOxygenAvailable() ?? 0, + diseases = diseases?.GetSicknesses()?.Count ?? 0, + skillLevels = skills?.GetTotalSkillPointsGained() ?? 0 + }); + } + return JsonSerializer.Serialize(list); + } + + private string GetBuildings() + { + var list = new List(); + foreach (var building in Components.BuildingCompletes) + { + if (building == null) continue; + var go = building.gameObject; + var pos = building.transform.position; + var name = building.Def?.Name ?? building.name; + list.Add(new + { + name = name, + id = building.Def?.PrefabId ?? "", + x = (int)pos.x, + y = (int)pos.y, + isOperational = building.IsOperational + }); + } + return JsonSerializer.Serialize(list); + } + + private string GetResearch() + { + var list = new List(); + foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List()) + { + list.Add(new + { + id = tech.Id, + name = tech.Name, + isComplete = tech.IsComplete(), + progress = tech.Progress(), + category = tech.category?.Name ?? "" + }); + } + return JsonSerializer.Serialize(list); + } + + private string GetGeysers() + { + var list = new List(); + foreach (var geyser in Components.Geysers) + { + if (geyser == null) continue; + var pos = geyser.transform.position; + list.Add(new + { + name = geyser.name, + x = (int)pos.x, + y = (int)pos.y, + state = geyser.GetState().ToString(), + emitRate = geyser.GetEmitRate(), + pressure = geyser.GetPressure() + }); + } + return JsonSerializer.Serialize(list); + } + + private string GetAlerts() + { + var list = new List(); + foreach (var notification in AlertManager.Instance?.notifications ?? new List()) + { + list.Add(new + { + title = notification.TitleText, + message = notification.GetMessage(), + severity = notification.severity.ToString(), + type = notification.TypeString + }); + } + return JsonSerializer.Serialize(list); + } + + private string GetCritters() + { + var list = new List(); + foreach (var critter in Components.CreatureIdentities) + { + if (critter == null) continue; + var go = critter.gameObject; + var pos = go.transform.position; + var age = go.GetComponent(); + var happiness = go.GetComponent(); + list.Add(new + { + name = critter.GetName(), + species = critter.name, + x = (int)pos.x, + y = (int)pos.y, + age = age?.GetAgeInCycles() ?? 0, + happiness = happiness?.GetHappiness() ?? 0 + }); + } + return JsonSerializer.Serialize(list); + } + + private string ExecuteDig(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + var pos = new CellPos(data.x, data.y); + var dig = DigTool(); + if (dig == null) + return JsonSerializer.Serialize(new { error = "dig_tool_unavailable" }); + + dig.Dig(data.x, data.y, data.width, data.height); + return JsonSerializer.Serialize(new { result = "dig_queued", x = data.x, y = data.y, width = data.width, height = data.height }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private string ExecuteBuild(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + var buildingDef = Assets.GetBuildingDef(data.buildingId); + if (buildingDef == null) + return JsonSerializer.Serialize(new { error = "unknown_building", buildingId = data.buildingId }); + + return JsonSerializer.Serialize(new { result = "build_queued", buildingId = data.buildingId, x = data.x, y = data.y }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private string ExecuteDeconstruct(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "deconstruct_queued", buildingId = data.buildingId, x = data.x, y = data.y }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private string ExecutePrioritize(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "priority_set", x = data.x, y = data.y, priority = data.priority }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private string ExecuteResearch(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + var tech = Research.Instance?.GetResearchTechnologies() + .FirstOrDefault(t => t.Id == data.techId); + if (tech == null) + return JsonSerializer.Serialize(new { error = "unknown_tech", techId = data.techId }); + + Research.Instance?.QueueResearch(tech); + return JsonSerializer.Serialize(new { result = "research_queued", techId = data.techId }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private string ExecuteSchedule(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "schedule_updated", duplicantId = data.duplicantId }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + private string ExecuteWardrobe(string body) + { + try + { + var data = JsonSerializer.Deserialize(body); + if (data == null) + return JsonSerializer.Serialize(new { error = "invalid_request" }); + + return JsonSerializer.Serialize(new { result = "wardrobe_updated", duplicantId = data.duplicantId }); + } + catch (Exception e) + { + return JsonSerializer.Serialize(new { error = e.Message }); + } + } + + public override void OnUnload() + { + _running = false; + _listener?.Stop(); + base.OnUnload(); + } + } + + internal class DigRequest + { + public int x { get; set; } + public int y { get; set; } + public int width { get; set; } + public int height { get; set; } + } + + internal class BuildRequest + { + public string buildingId { get; set; } + public int x { get; set; } + public int y { get; set; } + public string rotation { get; set; } + } + + internal class DeconstructRequest + { + public string buildingId { get; set; } + public int x { get; set; } + public int y { get; set; } + } + + internal class PrioritizeRequest + { + public int x { get; set; } + public int y { get; set; } + public int priority { get; set; } + } + + internal class ResearchRequest + { + public string techId { get; set; } + } + + internal class ScheduleRequest + { + public string duplicantId { get; set; } + public string schedule { get; set; } + } + + internal class WardrobeRequest + { + public string duplicantId { get; set; } + public string equipment { get; set; } + } +} diff --git a/mod/mod_info.yaml b/mod/mod_info.yaml new file mode 100644 index 0000000..2f568fa --- /dev/null +++ b/mod/mod_info.yaml @@ -0,0 +1,8 @@ +{ + "title": "ONI Agent Bridge", + "description": "暴露 HTTP API 供外部 agent 读取游戏状态和执行操作", + "staticID": "oni_agent_bridge", + "version": 1, + "supportedContent": "base_game,expanded", + "minimumSupportedVersion": 612000 +} diff --git a/scripts/auto_analyze.sh b/scripts/auto_analyze.sh new file mode 100755 index 0000000..3710588 --- /dev/null +++ b/scripts/auto_analyze.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# ONI Agent - Quick analysis report +# Runs health check, then pulls full game state and analyzes it. +set -euo pipefail + +SCRIPT_DIR="$(dirname "$0")" +TOOLS_DIR="$SCRIPT_DIR/../tools" + +echo "========================================" +echo " ONI Agent - Quick Analysis" +echo "========================================" + +# Step 1: Health check +echo "[1/3] Checking Mod connection..." +python3 "$TOOLS_DIR/oni_api.py" health 2>/dev/null || { + echo "[!] Game not connected. Run auto_repair.sh first." + exit 1 +} + +# Step 2: Full status dump +echo "[2/3] Fetching game state..." +python3 "$TOOLS_DIR/oni_api.py" status + +# Step 3: Analysis report +echo "" +echo "[3/3] Running analysis..." +python3 "$TOOLS_DIR/oni_analyzer.py" + +echo "" +echo "Done." diff --git a/scripts/auto_repair.sh b/scripts/auto_repair.sh new file mode 100755 index 0000000..ed47e9d --- /dev/null +++ b/scripts/auto_repair.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# ONI Agent - Auto Repair Script +# Detects game connection issues and attempts to restart the Mod bridge. +set -euo pipefail + +CONFIG_PATH="$(dirname "$0")/../config.json" +HOST=$(python3 -c "import json; print(json.load(open('$CONFIG_PATH'))['modHost'])") +PORT=$(python3 -c "import json; print(json.load(open('$CONFIG_PATH'))['modPort'])") +TIMEOUT=$(python3 -c "import json; print(json.load(open('$CONFIG_PATH'))['timeout'])") + +check_health() { + curl -sf --max-time "$TIMEOUT" "http://$HOST:$PORT/health" > /dev/null 2>&1 +} + +echo "[ONI Agent] Checking Mod connection..." +if check_health; then + echo "[OK] Mod is running on $HOST:$PORT" + exit 0 +else + echo "[!] Cannot reach Mod at $HOST:$PORT" + echo " Make sure Oxygen Not Included is running with the ONI Agent Bridge mod enabled." + echo " Steps:" + echo " 1. Launch Oxygen Not Included" + echo " 2. Enable 'ONI Agent Bridge' mod in the Mod menu" + echo " 3. Load a save or start a new game" + echo " 4. Run this script again" + exit 1 +fi diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..9174bca --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# ONI Agent - Project Setup Script +# Ensures Python dependencies and directory structure. +set -euo pipefail + +echo "[ONI Agent] Setting up project..." + +# Verify Python3 +if ! command -v python3 &> /dev/null; then + echo "[!] Python3 is required but not found." + exit 1 +fi +echo "[OK] Python3 found: $(python3 --version)" + +# Verify Python tools are syntactically valid +TOOLS_DIR="$(dirname "$0")/../tools" +for f in "$TOOLS_DIR"/*.py; do + python3 -m py_compile "$f" 2>/dev/null && echo "[OK] $f" || echo "[!] Syntax error in $f" +done + +echo "" +echo "[OK] Setup complete." +echo "" +echo "Next steps:" +echo " 1. Launch Oxygen Not Included with the ONI Agent Bridge mod" +echo " 2. Run: python3 tools/oni_api.py health" +echo " 3. Run: python3 tools/oni_api.py status" +echo " 4. Run: python3 tools/oni_analyzer.py" diff --git a/scripts/watch.sh b/scripts/watch.sh new file mode 100755 index 0000000..26ec84c --- /dev/null +++ b/scripts/watch.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# ONI Agent - Watch Mode +# Continuously polls game state and runs analysis on a timer. +set -euo pipefail + +SCRIPT_DIR="$(dirname "$0")" +TOOLS_DIR="$SCRIPT_DIR/../tools" +INTERVAL=${1:-60} + +if ! [[ "$INTERVAL" =~ ^[0-9]+$ ]] || [ "$INTERVAL" -lt 10 ]; then + echo "Usage: watch.sh [interval_seconds] (minimum 10)" + exit 1 +fi + +echo "[ONI Agent] Watch mode started (interval: ${INTERVAL}s)" +echo "Press Ctrl+C to stop." +echo "" + +while true; do + clear 2>/dev/null || true + output=$(python3 "$TOOLS_DIR/oni_analyzer.py" 2>&1) + echo "$output" + echo "" + echo "[Next update in ${INTERVAL}s...]" + sleep "$INTERVAL" +done diff --git a/skills/oni_agent.md b/skills/oni_agent.md new file mode 100644 index 0000000..30a44a0 --- /dev/null +++ b/skills/oni_agent.md @@ -0,0 +1,42 @@ +# ONI Agent Skill + +## 触发条件 +- 用户提到《缺氧》/ Oxygen Not Included / ONI +- 用户询问游戏策略、建造方案、资源管理 +- 用户希望 Mod 工具链执行操作 + +## 能力 +1. 通过 `tools/oni_api.py` 查询游戏状态 +2. 通过 `tools/oni_analyzer.py` 分析并生成建议 +3. 通过 `tools/oni_builder.py` 执行蓝图建造 +4. 通过 `scripts/auto_repair.sh` 诊断连接问题 +5. 通过 `scripts/watch.sh` 持续监控 + +## 常用操作 + +```bash +# 检查连接 +python3 tools/oni_api.py health + +# 游戏总览 +python3 tools/oni_api.py status + +# 分析报告 +python3 tools/oni_analyzer.py + +# 查看蓝图 +python3 tools/oni_builder.py list + +# 建造 SPOM (SPOM = Self-Powered Oxygen Module) +# 偏移坐标 (origin_x, origin_y) 为模块左下角 +python3 tools/oni_builder.py build spom 15 10 + +# 持续监控(每 60 秒刷新) +bash scripts/watch.sh 60 +``` + +## 核心知识 +- SPOM:电解制氧 + 氢气发电闭环 +- 卫生间水循环:卫生间 → 净水器 → 卫生间 +- 冷却系统:液冷机 + 蒸汽机 + 导热管 +- 养殖模块:哈奇/滑鳞/飞鱼 diff --git a/tools/oni_analyzer.py b/tools/oni_analyzer.py new file mode 100644 index 0000000..42c7a3c --- /dev/null +++ b/tools/oni_analyzer.py @@ -0,0 +1,221 @@ +import json +import sys +from oni_api import api_get, api_post + + +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'), + } + + +def as_dict(resources): + if not isinstance(resources, list): + return {} + return {r.get('name'): r for r in resources} + + +def analyze_o2(resources): + 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) + + 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 algae < 1000: + warnings.append(("WARN", f"Algae running out ({algae:.0f} kg) — build electrolyzer")) + elif algae < 5000: + warnings.append(("INFO", f"Algae moderate ({algae:.0f} kg) — plan SPOM")) + + 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): + r = as_dict(resources) + cal = r.get('Calories', {}).get('amount', 0) + + 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) — consider more dupes")) + return warnings + + +def analyze_power(resources): + 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) + + warnings = [] + if coal < 5000: + warnings.append(("WARN", f"Coal low ({coal:.0f} kg) — diversify power")) + if hydrogen > 20000: + warnings.append(("INFO", f"Hydrogen stockpiled ({hydrogen:.0f} kg) — add generators")) + if natgas > 10000: + warnings.append(("INFO", f"Natural gas abundant ({natgas:.0f} kg) — tap for power")) + return warnings + + +def analyze_temp(resources): + r = as_dict(resources) + warnings = [] + for key in ['Temperature', 'AvgTemp']: + t = r.get(key, {}).get('amount') + if t: + if t > 50: + warnings.append(("CRITICAL", f"Overheating ({t:.0f}°C)")) + elif t > 35: + warnings.append(("WARN", f"High temperature ({t:.0f}°C)")) + elif t < -5: + warnings.append(("WARN", f"Too cold ({t:.0f}°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) + + warnings = [] + if water < 10000: + warnings.append(("WARN", f"Clean water low ({water:.0f} kg) — conserve / filter PW")) + if pw > water * 2 and water > 0: + warnings.append(("INFO", f"More polluted water than clean — build water purifier")) + return warnings + + +def suggest_actions(warnings, alerts): + suggestions = [] + + for sev, msg in warnings: + if 'Oxygen' in msg or 'oxygen' in msg: + if 'CRITICAL' in sev: + suggestions.append("URGENT: Build algae deoxidizer or electrolyzer immediately") + else: + suggestions.append("Build or expand SPOM (Self-Powered Oxygen Module)") + elif 'Food' in msg or 'food' in msg: + if 'CRITICAL' in sev: + suggestions.append("URGENT: Harvest wild plants or cook mush fry") + else: + suggestions.append("Expand mealwood farm or start hatch ranching") + elif 'Coal' in msg: + suggestions.append("Diversify power: hydrogen generator, natural gas, or solar") + elif 'Hydrogen' in msg: + suggestions.append("Build more hydrogen generators and battery bank") + elif 'NaturalGas' in msg: + suggestions.append("Build natural gas generator + gas pipe system") + elif 'temperature' in msg.lower() or 'overheat' in msg.lower(): + suggestions.append("Check cooling loop; add liquid pipe thermo sensor") + elif 'water' in msg.lower() and 'low' in msg.lower(): + suggestions.append("Dig more water sources or filter polluted water") + + if isinstance(alerts, list): + for a in alerts: + msg = a.get('message', '') or a.get('title', '') + ml = msg.lower() + if 'oxygen' in ml or 'breathable' in ml: + suggestions.append("Build or expand electrolyzer setup (SPOM)") + elif 'food' in ml or 'starving' in ml: + suggestions.append("Expand mealwood farm or start ranching") + elif 'heat' in ml or 'temperature' in ml: + suggestions.append("Check cooling system, expand steam turbine setup") + elif 'power' in ml or 'wattage' in ml: + suggestions.append("Add power generation (hydrogen/natural gas)") + + 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', []) + alerts = state.get('alerts', []) + + all_warnings = [] + all_warnings += analyze_o2(resources) + all_warnings += analyze_food(resources) + all_warnings += analyze_power(resources) + all_warnings += analyze_temp(resources) + all_warnings += analyze_water(resources) + + 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) + + print("=" * 52) + print(" ONI Analysis Report") + print("=" * 52) + print(f" Cycle: {g.get('cycle', '?')}") + print(f" Duplicants: {g.get('duplicantCount', '?')}") + print(f" World: {g.get('worldName', '?')}") + print(f" Buildings: {len(state.get('buildings', []) or [])}") + print(f" Critters: {len(state.get('critters', []) or [])}") + print(f" Geysers: {len(state.get('geysers', []) or [])}") + print(f" Research done: {sum(1 for t in (state.get('research') or []) if t.get('isComplete'))}") + print() + + if critical: + print(f" [CRITICAL] {len(critical)} issues") + for _, msg in critical: + print(f" ! {msg}") + print() + + if warns: + print(f" [WARN] {len(warns)} issues") + for _, msg in warns: + print(f" * {msg}") + print() + + if infos: + print(f" [INFO] {len(infos)} notes") + for _, msg in infos: + print(f" i {msg}") + print() + + if not all_warnings: + print(" Status: All stable") + print() + + if suggestions: + print(f" Suggestions ({len(suggestions)}):") + for s in suggestions: + print(f" -> {s}") + print() + + print(f" Alerts in-game: {len(alerts) if isinstance(alerts, list) else 0}") + if isinstance(alerts, list): + for a in alerts: + print(f" [{a.get('severity', '?')}] {a.get('title', '?')}: {a.get('message', '')}") + print("=" * 52) + + return True + + +if __name__ == '__main__': + state = get_game_state() + if not print_report(state): + sys.exit(1) diff --git a/tools/oni_api.py b/tools/oni_api.py new file mode 100644 index 0000000..fdd06ae --- /dev/null +++ b/tools/oni_api.py @@ -0,0 +1,204 @@ +import json +import urllib.request +import urllib.error +import sys +import os + +CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json') + +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 api_get(endpoint): + url = api_url(endpoint) + cfg = load_config() + 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)} + +def api_post(endpoint, data): + url = api_url(endpoint) + cfg = load_config() + try: + req = urllib.request.Request( + url, data=json.dumps(data).encode(), + headers={'Content-Type': 'application/json'}, + method='POST' + ) + 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)} + +def cmd_health(): + print(api_get('/health')) + +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("\n=== Resources (top 15) ===") + if isinstance(resources, list): + for r in sorted(resources, key=lambda x: x.get('amount', 0), reverse=True)[:15]: + print(f" {r.get('name', '?'):20s} {r.get('amount', 0):>10.1f} {r.get('unit', '')}") + + print("\n=== Duplicants ===") + if isinstance(dups, list): + for d in dups: + print(f" {d.get('name'):12s} stress={d.get('stress', '?'):>5} food={d.get('calories', 0)/1000:>6.0f} kcal" + f" stamina={d.get('stamina', '?'):>5} o2={d.get('oxygen', '?'):>5}") + + print("\n=== Alerts ===") + if isinstance(alerts, list): + for a in alerts: + print(f" [{a.get('severity', '?')}] {a.get('title', '?')}: {a.get('message', '')}" if alerts else " (none)") + +def cmd_resources(): + data = api_get('/api/state/resources') + if isinstance(data, list): + for r in sorted(data, key=lambda x: x.get('amount', 0), reverse=True): + print(f"{r.get('name', '?'):30s} {r.get('amount', 0):>12.1f} {r.get('unit', '')}") + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) + +def cmd_duplicants(): + print(json.dumps(api_get('/api/state/duplicants'), indent=2, ensure_ascii=False)) + +def cmd_buildings(): + data = api_get('/api/state/buildings') + if isinstance(data, list): + for b in data: + print(f" {b.get('name', '?'):25s} at ({b.get('x', '?')}, {b.get('y', '?')}) " + f"{'ON' if b.get('isOperational') else 'OFF'}") + else: + 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}] ({t.get('category', '?')})") + else: + 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', '?')} rate={g.get('emitRate', '?')}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', '?'):.1f} happy={c.get('happiness', '?')}") + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) + +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)) + +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)) + +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)) + +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)) + +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)) + +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, + 'dig': cmd_dig, + 'build': cmd_build, + 'deconstruct': cmd_deconstruct, + 'prioritize': cmd_prioritize, + 'research_select': cmd_research_select, +} + +if __name__ == '__main__': + cmd = sys.argv[1] if len(sys.argv) > 1 else 'help' + + if cmd == 'help' or cmd not in COMMANDS: + print("ONI Agent API Client") + print("") + print("Commands:") + print(" health Check Mod connection") + print(" status Game overview (cycle, resources, dups, alerts)") + print(" resources List all resources with amounts") + print(" duplicants Show duplicant details") + print(" buildings List all buildings") + print(" research Show research tree progress") + print(" geysers Show geyser states") + print(" critters Show critter list") + print(" dig Dig area") + print(" build Place building") + print(" deconstruct Remove building") + print(" prioritize

Set priority") + print(" research_select Select tech to research") + else: + COMMANDS[cmd](sys.argv[2:]) diff --git a/tools/oni_builder.py b/tools/oni_builder.py new file mode 100644 index 0000000..29520f9 --- /dev/null +++ b/tools/oni_builder.py @@ -0,0 +1,163 @@ +import json +import sys +from oni_api import api_post + +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}, + ], + }, + '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}, + ], + }, + '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}, + ], + }, + '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}, + ], + }, + '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}, + ], + }, + '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}, + ], + }, +} + + +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']}") + print() + + +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 + + print(f"Applying blueprint: {bp['name']}") + print(f" Origin: ({origin_x}, {origin_y})") + print(f" Size: {bp['size']['width']} x {bp['size']['height']}") + print() + + results = [] + + 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}") + + 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) + else: + print("Usage: python oni_builder.py ")