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
This commit is contained in:
root
2026-05-22 08:44:56 +08:00
commit 0e17e8a6ac
14 changed files with 1579 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
__pycache__/
*.pyc
.DS_Store
.env

96
SKILL.md Normal file
View File

@ -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 周期余量) 扩大种植/养殖
- 电力不足 增加发电或减少负载
- 污水满溢 增加净水/扩大存储

5
config.json Normal file
View File

@ -0,0 +1,5 @@
{
"modHost": "127.0.0.1",
"modPort": 23876,
"timeout": 10
}

235
docs/MOD_DEV_GUIDE.md Normal file
View File

@ -0,0 +1,235 @@
# ONI Agent Mod 开发指南
## 概述
本文档规定了一个与外部 AgentAI 助手)对接的《缺氧》(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` 声明了正确的游戏版本

488
mod/ONIAgentBridge.cs Normal file
View File

@ -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<string, object>
{
{"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<object>();
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<object>();
foreach (var minion in Components.MinionIdentities)
{
var go = minion.gameObject;
var stress = go.GetComponent<StressMonitor>();
var calories = go.GetComponent<CaloriesMonitor>();
var stamina = go.GetComponent<StaminaMonitor>();
var breath = go.GetComponent<OxygenBreather>();
var diseases = go.GetComponent<SicknessMonitor>();
var skills = go.GetComponent<MinionResume>();
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<object>();
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<object>();
foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List<Tech>())
{
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<object>();
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<object>();
foreach (var notification in AlertManager.Instance?.notifications ?? new List<Notification>())
{
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<object>();
foreach (var critter in Components.CreatureIdentities)
{
if (critter == null) continue;
var go = critter.gameObject;
var pos = go.transform.position;
var age = go.GetComponent<AgeMonitor>();
var happiness = go.GetComponent<HappyMonitor>();
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<DigRequest>(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<BuildRequest>(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<DeconstructRequest>(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<PrioritizeRequest>(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<ResearchRequest>(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<ScheduleRequest>(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<WardrobeRequest>(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; }
}
}

8
mod/mod_info.yaml Normal file
View File

@ -0,0 +1,8 @@
{
"title": "ONI Agent Bridge",
"description": "暴露 HTTP API 供外部 agent 读取游戏状态和执行操作",
"staticID": "oni_agent_bridge",
"version": 1,
"supportedContent": "base_game,expanded",
"minimumSupportedVersion": 612000
}

30
scripts/auto_analyze.sh Executable file
View File

@ -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."

28
scripts/auto_repair.sh Executable file
View File

@ -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

28
scripts/setup.sh Executable file
View File

@ -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"

26
scripts/watch.sh Executable file
View File

@ -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

42
skills/oni_agent.md Normal file
View File

@ -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电解制氧 + 氢气发电闭环
- 卫生间水循环:卫生间 → 净水器 → 卫生间
- 冷却系统:液冷机 + 蒸汽机 + 导热管
- 养殖模块:哈奇/滑鳞/飞鱼

221
tools/oni_analyzer.py Normal file
View File

@ -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)

204
tools/oni_api.py Normal file
View File

@ -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 <x> <y> <width> <height>")
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 <buildingId> <x> <y> [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 <buildingId> <x> <y>")
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 <x> <y> <priority>")
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 <techId>")
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 <x> <y> <w> <h> Dig area")
print(" build <id> <x> <y> Place building")
print(" deconstruct <id> <x> <y> Remove building")
print(" prioritize <x> <y> <p> Set priority")
print(" research_select <id> Select tech to research")
else:
COMMANDS[cmd](sys.argv[2:])

163
tools/oni_builder.py Normal file
View File

@ -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 <blueprint_name> <origin_x> <origin_y>")
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 <list|build>")