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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.env
|
||||
96
SKILL.md
Normal file
96
SKILL.md
Normal 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
5
config.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"modHost": "127.0.0.1",
|
||||
"modPort": 23876,
|
||||
"timeout": 10
|
||||
}
|
||||
235
docs/MOD_DEV_GUIDE.md
Normal file
235
docs/MOD_DEV_GUIDE.md
Normal file
@ -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` 声明了正确的游戏版本
|
||||
488
mod/ONIAgentBridge.cs
Normal file
488
mod/ONIAgentBridge.cs
Normal 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
8
mod/mod_info.yaml
Normal 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
30
scripts/auto_analyze.sh
Executable 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
28
scripts/auto_repair.sh
Executable 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
28
scripts/setup.sh
Executable 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
26
scripts/watch.sh
Executable 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
42
skills/oni_agent.md
Normal 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
221
tools/oni_analyzer.py
Normal 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
204
tools/oni_api.py
Normal 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
163
tools/oni_builder.py
Normal 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>")
|
||||
Reference in New Issue
Block a user