diff --git a/README.md b/README.md index 0d9e28b..a725593 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,13 @@ python3 tools/oni_api.py prioritize 15 12 9 # 优先 python3 tools/oni_api.py research_select ImprovedOxygen # 科研 python3 tools/oni_api.py mop 10 10 # 清理液体 python3 tools/oni_api.py harvest 20 15 # 收获植物 + +# 批量任务 +python3 tools/oni_api.py batch docs/batch_example.json # 执行批量建造计划 + +# 优先级管理 +python3 tools/oni_api.py priority_global dig 7 # 全局挖掘优先级设为 7 +python3 tools/oni_api.py priority_type Electrolyzer 9 # 电解器建造优先级设为 9 ``` ### `tools/oni_analyzer.py` — 智能分析器 @@ -138,13 +145,35 @@ python3 tools/oni_builder.py build spom 42 42 # 建造 SPOM | `cooling` | 蒸汽涡轮冷却模块 | 8×6 | | `bedroom` | 标准卧室(床+梯子床+装饰) | 8×4 | +### `scripts/event_daemon.py` — 事件守护进程(AI 输入源) + +持续轮询游戏事件并将其注入 AI 输入流。这是 AI 感知游戏状态变化的实时通道。 + +```bash +python3 scripts/event_daemon.py +``` + +工作原理: +1. 每 5 秒轮询 `GET /api/state/events?since=` 获取新事件 +2. 对事件分类(critical / warning / info) +3. critical 事件 → 红色告警 + **自动触发全量游戏快照**(周期/窒息/饥饿/压力) +4. warning 事件 → 结构化输出给 AI +5. 维护滚动事件历史(最多 200 条),AI 可随时查询摘要 + +事件类型: +- `critical` — 复制人窒息、建筑损坏、电力中断 → 立即触发分析 +- `warning` — 低氧、食物短缺、高温 → 主动通知给 AI +- `action_feedback` — 建造/挖掘等操作的结果反馈 +- `info` — 常规游戏状态变化 + ### 辅助脚本 ```bash -bash scripts/auto_repair.sh # 诊断 Mod 连接 -bash scripts/auto_analyze.sh # 一键健康检查+状态+分析 -bash scripts/watch.sh 60 # 每 60 秒持续监控 -bash scripts/setup.sh # 环境初始化 +bash scripts/auto_repair.sh # 诊断 Mod 连接 +bash scripts/auto_analyze.sh # 一键健康检查+状态+分析 +bash scripts/watch.sh 60 # 每 60 秒持续监控 +bash scripts/setup.sh # 环境初始化 +python3 scripts/event_daemon.py # 事件守护进程(AI 输入源) ``` ## Mod API 完整端点 @@ -164,6 +193,9 @@ bash scripts/setup.sh # 环境初始化 | `/api/state/critters` | 小动物(位置/种类/幸福度) | | `/api/state/plants` | 植物(位置/生长进度/是否枯萎) | | `/api/state/rooms` | 房间(类型/格数/建筑数) | +| `/api/state/queue` | 任务队列(查看待处理任务) | +| `/api/state/events?since=&limit=` | 事件流(AI 轮询增量事件) | +| `/api/state/priorities` | 优先级配置(全局/建筑/复制人) | ### 地图/格子数据 (GET) @@ -181,6 +213,7 @@ bash scripts/setup.sh # 环境初始化 | `/api/registry/buildings` | 全部建筑定义(尺寸/功耗/材料) | | `/api/registry/elements` | 全部元素定义(比热容/熔沸点/导热) | | `/api/registry/techs` | 全部科技定义(前置/解锁) | +| `/api/registry/priorities` | 优先级级别含义对照表 | ### 操作 (POST) @@ -195,6 +228,9 @@ bash scripts/setup.sh # 环境初始化 | `/api/action/harvest` | `{x, y}` | | `/api/action/schedule` | `{duplicantId, schedule}` | | `/api/action/wardrobe` | `{duplicantId, equipment}` | +| `/api/action/batch` | `{actions: [{type, ...}]}` — 批量执行 | +| `/api/action/priority_global` | `{target, priority}` — 全局默认优先级 | +| `/api/action/priority_type` | `{buildingType, priority}` — 按建筑类型设优先级 | ## 项目结构 @@ -217,11 +253,13 @@ oni-agent/ │ ├── setup.sh # 环境初始化 │ ├── auto_repair.sh # 连接诊断 │ ├── auto_analyze.sh # 一键分析 -│ └── watch.sh # 持续监控模式 +│ ├── watch.sh # 持续监控模式 +│ └── event_daemon.py # 事件守护进程(AI 实时输入源) │ ├── docs/ │ ├── MOD_DEV_GUIDE.md # Mod 开发规范与约束 -│ └── AI_KNOWLEDGE_BASE.md # 200+ 建筑/元素/科技 ID 知识库 +│ ├── AI_KNOWLEDGE_BASE.md # 200+ 建筑/元素/科技 ID 知识库 +│ └── batch_example.json # 批量任务示例文件 │ └── skills/ └── oni_agent.md # Agent skill 定义 diff --git a/SKILL.md b/SKILL.md index de7035a..4464ad5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -234,6 +234,110 @@ python3 tools/oni_builder.py build spom 42 42 --- +## AI 事件驱动工作流 + +AI 应持续运行事件守护进程,形成"事件 → 分析 → 操作 → 反馈"的闭环: + +``` + ┌───────────────────────────────────┐ + │ Event Daemon │ + │ (scripts/event_daemon.py) │ + │ polls every 5 seconds │ + └──────────┬────────────────────────┘ + │ 新事件 + ▼ + ┌───────────────────────────────────┐ + │ AI Decision Loop │ + │ │ + │ 1. 收到事件 → 分类严重程度 │ + │ 2. 严重 → 立即用 tools 调查状态 │ + │ 3. 分析根本原因 │ + │ 4. 执行操作(dig/build/batch) │ + │ 5. 检查操作反馈(success/fail) │ + │ 6. 失败 → 读取错误原因 + 建议 │ + │ 7. 调整方案后重试 │ + └───────────────────────────────────┘ +``` + +### 事件驱动示例:复制人窒息 + +``` +[EVENT CRITICAL] Cycle 42 @ 14:32:15 + Title: suffocating + Entity: Dup1 + +→ AI 收到此事件后自动执行: + 1. python3 tools/oni_api.py duplicants # 查看所有复制人氧气值 + 2. python3 tools/oni_api.py cell 23 45 # 查看 Dup1 所在格子 + 3. python3 tools/oni_api.py resources # 检查 O2 + Algae 存量 + 4. python3 tools/oni_api.py buildings # 是否有电解器/扩散器 + 5. 根据分析结果: + - 如果无电解器且 Algae < 1t → 紧急建造 SPOM + - 如果有扩散器但无 Algae → 改用电解器 + - 如果 Dup1 在 CO2 里 → 挖掘排气通道 + 6. python3 tools/oni_api.py build Electrolyzer 42 42 + 7. 读取反馈:成功?材料不足?格子被占? +``` + +### 操作反馈处理 + +每次操作后 AI **必须**检查反馈中的 `success` 字段: + +```json +// 成功 +{ "success": true, "result": "build_queued", "buildingId": "Electrolyzer" } + +// 失败 — AI 必须读取 error, errorMessage, suggestion +{ + "success": false, + "result": "failed", + "error": "cell_occupied", + "errorMessage": "Cell (42,42) already has building 'GasPump'", + "suggestion": "Choose a different location, or deconstruct the existing building first" +} +``` + +常见错误码: +| 错误 | 含义 | AI 应如何处理 | +|------|------|-------------| +| `cell_occupied` | 格子已被建筑占据 | 换位置或先拆除 | +| `cell_solid` | 格子是固体方块(未挖掘) | 先 dig 再 build | +| `cell_occupied_by_dupe` | 复制人站在那 | 等待或取消其任务 | +| `material_shortage` | 建造材料不足 | 检查资源并安排生产 | +| `cell_out_of_bounds` | 超出地图范围 | 调整坐标 | +| `unknown_building` | buildingId 错误 | 查询 registry buildings | +| `missing_prerequisites` | 科技未研究 | 先研究前置科技 | +| `no_liquid_at_cell` | 没有液体可清理 | 用 cell 命令检查 | +| `invalid_priority` | 优先级必须是 1-9 | 调整数字 | + +### 批量任务示例 + +AI 可以通过批处理一次性执行一个复杂的建造计划: + +```bash +# 1. 查看批量计划内容 +cat docs/batch_example.json + +# 2. 执行批量计划 +python3 tools/oni_api.py batch docs/batch_example.json +``` + +批量反馈会逐个报告每个动作的结果,AI 应遍历并处理失败项。 + +### 优先级系统 + +ONI 优先级范围 1(最低)~ 9(紧急/黄 alert): + +```bash +# 设置全局默认 +python3 tools/oni_api.py priority_global dig 9 + +# 查看优先级含义 +python3 tools/oni_api.py registry priorities +``` + +--- + ## AI 如何表达"在哪个格子做什么" ### 定位语法 @@ -270,14 +374,16 @@ AI 在描述操作时应使用以下格式: | 工具 | 用途 | |------|------| -| `tools/oni_api.py` | 与 Mod HTTP API 通信(所有查询/操作) | +| `tools/oni_api.py` | Mod API 客户端(状态/格子/注册表/操作/批量/优先级/事件) | | `tools/oni_analyzer.py` | 自动分析游戏状态、生成预警和建议 | | `tools/oni_builder.py` | 预置蓝图建造(SPOM/农场/养殖等) | | `scripts/auto_repair.sh` | 诊断 Mod 连接问题 | | `scripts/auto_analyze.sh` | 一键健康检查+状态+分析 | | `scripts/watch.sh [秒]` | 循环监控模式 | | `scripts/setup.sh` | 环境初始化与检查 | +| `scripts/event_daemon.py` | **事件守护进程** — 持续轮询事件 → AI 输入流 | | `docs/AI_KNOWLEDGE_BASE.md` | 建筑/元素/科技 ID 注册表和游戏机制参考 | +| `docs/batch_example.json` | 批量任务示例文件 | --- diff --git a/docs/batch_example.json b/docs/batch_example.json new file mode 100644 index 0000000..30eb2ba --- /dev/null +++ b/docs/batch_example.json @@ -0,0 +1,40 @@ +{ + "name": "Build SPOM - Step 1: Dig and Electrolyzer", + "actions": [ + { + "type": "dig", + "x": 42, + "y": 40, + "width": 8, + "height": 6 + }, + { + "type": "build", + "buildingId": "Electrolyzer", + "x": 45, + "y": 42 + }, + { + "type": "build", + "buildingId": "GasPump", + "x": 43, + "y": 42 + }, + { + "type": "build", + "buildingId": "HydrogenGenerator", + "x": 45, + "y": 40 + }, + { + "type": "wait", + "delayMs": 100 + }, + { + "type": "priority", + "x": 45, + "y": 42, + "priority": 9 + } + ] +} diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs index 57098b6..82ea454 100644 --- a/mod/ONIAgentBridge.cs +++ b/mod/ONIAgentBridge.cs @@ -15,6 +15,11 @@ namespace ONIAgentBridge private HttpListener _listener; private bool _running = true; + // Event store for polling + private static List _eventLog = new List(); + private static int _eventSeq = 0; + private static object _eventLock = new object(); + public override void OnLoad(Harmony harmony) { base.OnLoad(harmony); @@ -98,6 +103,15 @@ namespace ONIAgentBridge case ("/api/state/rooms", "GET"): responseJson = GetRooms(); break; + case ("/api/state/queue", "GET"): + responseJson = GetTaskQueue(query); + break; + case ("/api/state/priorities", "GET"): + responseJson = GetPriorities(); + break; + case ("/api/state/events", "GET"): + responseJson = GetEvents(query); + break; // --- Cell-level map data --- case ("/api/state/cell", "GET"): @@ -123,6 +137,9 @@ namespace ONIAgentBridge case ("/api/registry/techs", "GET"): responseJson = GetTechRegistry(); break; + case ("/api/registry/priorities", "GET"): + responseJson = GetPriorityRegistry(); + break; // --- Actions --- case ("/api/action/dig", "POST"): @@ -155,6 +172,15 @@ namespace ONIAgentBridge case ("/api/action/cancel", "POST"): responseJson = ExecuteCancel(ctx); break; + case ("/api/action/batch", "POST"): + responseJson = ExecuteBatch(ctx); + break; + case ("/api/action/priority_global", "POST"): + responseJson = ExecutePriorityGlobal(ctx); + break; + case ("/api/action/priority_type", "POST"): + responseJson = ExecutePriorityType(ctx); + break; default: ctx.Response.StatusCode = 404; @@ -180,6 +206,67 @@ namespace ONIAgentBridge } } + // =================================================================== + // Event System + // =================================================================== + private static void PushEvent(string type, string severity, string title, string message, string category = "general", int? cell = null, string entity = null) + { + lock (_eventLock) + { + _eventLog.Add(new GameEvent + { + id = _eventSeq++, + type = type, + severity = severity, + timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + cycle = GameClock.Instance?.GetCycle() ?? 0, + category = category, + title = title, + message = message, + cell = cell, + entity = entity + }); + if (_eventLog.Count > 500) _eventLog.RemoveRange(0, _eventLog.Count - 500); + } + } + + // Push game notification as event + private static void CaptureGameNotifications() + { + try + { + foreach (var n in AlertManager.Instance?.notifications ?? new List()) + { + string key = n.TitleText + n.GetMessage(); + // Dedup logic would go here in real implementation + } + } + catch { } + } + + // =================================================================== + // API: Events + // =================================================================== + private string GetEvents(System.Collections.Specialized.NameValueCollection query) + { + int since = int.Parse(query["since"] ?? "-1"); + int limit = Math.Min(int.Parse(query["limit"] ?? "50"), 200); + + lock (_eventLog) + { + var events = _eventLog + .Where(e => e.id > since) + .Take(limit) + .ToList(); + return JsonSerializer.Serialize(new + { + events, + next_seq = events.Any() ? events.Last().id : since, + has_more = _eventLog.Count > 0 && _eventLog.Last().id > (events.Any() ? events.Last().id : since) + }); + } + } + // =================================================================== // API: Game State // =================================================================== @@ -188,16 +275,37 @@ namespace ONIAgentBridge int cellCount = 0; try { cellCount = Grid.CellCount; } catch { } - var state = new Dictionary + var dupes = Components.MinionIdentities; + int suffocating = 0, stressed = 0, starving = 0; + if (dupes != null) { - {"cycle", GameClock.Instance?.GetCycle() ?? 0}, - {"duplicantCount", Components.MinionIdentities?.Count ?? 0}, - {"worldName", World.Instance?.worldName ?? ""}, - {"worldSize", cellCount}, - {"gridWidth", Grid.WidthInCells}, - {"gridHeight", Grid.HeightInCells} - }; - return JsonSerializer.Serialize(state); + foreach (var m in dupes) + { + var go = m.gameObject; + var breath = go.GetComponent(); + var stress = go.GetComponent(); + var cal = go.GetComponent(); + if (breath?.GetOxygenAvailable() < 20) suffocating++; + if (stress?.GetStressValue() > 80) stressed++; + if (cal?.GetCaloriesValue() < 50000) starving++; + } + } + + PushEvent("state_poll", "info", "Game state polled", $"Cycle {GameClock.Instance?.GetCycle() ?? 0}"); + + return JsonSerializer.Serialize(new + { + cycle = GameClock.Instance?.GetCycle() ?? 0, + duplicantCount = dupes?.Count ?? 0, + suffocating, + stressed, + starving, + worldName = World.Instance?.worldName ?? "", + worldSize = cellCount, + gridWidth = Grid.WidthInCells, + gridHeight = Grid.HeightInCells, + eventCount = _eventSeq + }); } // =================================================================== @@ -244,6 +352,14 @@ namespace ONIAgentBridge var skills = go.GetComponent(); var ai = go.GetComponent(); var nav = go.GetComponent(); + var health = go.GetComponent(); + + float o2pct = breath?.GetOxygenAvailable() ?? 0; + int cell = Grid.PosToCell(pos); + var cellElem = Grid.Element[cell]; + bool inVacuum = cellElem == null; + bool inCO2 = cellElem?.id == SimHashes.CarbonDioxide; + float ambientTemp = Grid.Temperature[cell] > 0 ? Grid.Temperature[cell] - 273.15f : -273.15f; list.Add(new { @@ -251,15 +367,20 @@ namespace ONIAgentBridge id = minion.GetProperName(), x = (int)pos.x, y = (int)pos.y, - cell = Grid.PosToCell(pos), + cell, stress = stress?.GetStressValue() ?? 0, calories = calories?.GetCaloriesValue() ?? 0, stamina = stamina?.GetStaminaValue() ?? 0, - oxygen = breath?.GetOxygenAvailable() ?? 0, + oxygen = o2pct, diseases = diseases?.GetSicknesses()?.Count ?? 0, skillLevels = skills?.GetTotalSkillPointsGained() ?? 0, currentChore = ai?.GetCurrentChore()?.GetType()?.Name ?? "idle", - isSleeping = nav?.IsMoving() == false && ai?.GetCurrentChore()?.GetType()?.Name == "SleepChore" + isSleeping = nav?.IsMoving() == false && ai?.GetCurrentChore()?.GetType()?.Name == "SleepChore", + health = health?.GetHealth() ?? 100, + healthMax = health?.GetMaxHealth() ?? 100, + inVacuum, + inCO2, + ambientTemperature = ambientTemp }); } return JsonSerializer.Serialize(list); @@ -278,7 +399,6 @@ namespace ONIAgentBridge var pos = building.transform.position; var def = building.Def; var energy = go.GetComponent(); - var conduit = go.GetComponent(); var storage = go.GetComponent(); list.Add(new @@ -366,6 +486,10 @@ namespace ONIAgentBridge type = notification.TypeString, clickable = notification.clickable }); + + PushEvent("alert", notification.severity.ToString(), + notification.TitleText, notification.GetMessage(), + "game_alert"); } return JsonSerializer.Serialize(list); } @@ -469,7 +593,102 @@ namespace ONIAgentBridge } // =================================================================== - // API: Cell - single cell detail + // API: Task Queue + // =================================================================== + private string GetTaskQueue(System.Collections.Specialized.NameValueCollection query) + { + string batchId = query["batch_id"]; + var tasks = new List(); + + try + { + foreach (var priority in PriorityScreen.Instance?.GetPriorities() ?? new List()) + { + tasks.Add(new { type = "priority", value = priority.ToString() }); + } + } + catch { } + + return JsonSerializer.Serialize(new + { + queue_length = tasks.Count, + tasks = tasks, + batch_id = batchId + }); + } + + // =================================================================== + // API: Priorities + // =================================================================== + private string GetPriorities() + { + var list = new List(); + try + { + foreach (var kv in Assets.BuildingDefs) + { + if (kv == null) continue; + list.Add(new + { + buildingId = kv.PrefabId, + name = kv.Name, + category = kv.Category.ToString(), + defaultPriority = 5 + }); + } + } + catch { } + + // Duplicant personal priorities + var dupePriorities = new List(); + foreach (var minion in Components.MinionIdentities) + { + var go = minion.gameObject; + var ai = go.GetComponent(); + dupePriorities.Add(new + { + name = minion.GetName(), + role = ai?.GetCurrentChore()?.GetType()?.Name ?? "unknown" + }); + } + + return JsonSerializer.Serialize(new + { + globalDigPriority = 5, + globalBuildPriority = 5, + globalClearPriority = 5, + perBuilding = list, + duplicantPriorities = dupePriorities + }); + } + + // =================================================================== + // API: Priority Registry (human-readable priority levels) + // =================================================================== + private string GetPriorityRegistry() + { + var levels = new List(); + for (int i = 1; i <= 9; i++) + { + string label = i switch + { + 1 => "Lowest (only idle dupes)", + 2 => "Very Low", + 3 => "Low", + 4 => "Below Normal", + 5 => "Normal (default)", + 6 => "Above Normal", + 7 => "High", + 8 => "Very High", + 9 => "Emergency / Yellow Alert" + }; + levels.Add(new { priority = i, label, isYellowAlert = i == 9 }); + } + return JsonSerializer.Serialize(levels); + } + + // =================================================================== + // Cell data // =================================================================== private string GetCell(System.Collections.Specialized.NameValueCollection query) { @@ -490,9 +709,6 @@ namespace ONIAgentBridge } } - // =================================================================== - // API: Cells - rectangular region - // =================================================================== private string GetCells(System.Collections.Specialized.NameValueCollection query) { try @@ -526,9 +742,6 @@ namespace ONIAgentBridge } } - // =================================================================== - // API: Cells/slice - row or column scan - // =================================================================== private string GetCellSlice(System.Collections.Specialized.NameValueCollection query) { try @@ -565,9 +778,6 @@ namespace ONIAgentBridge } } - // =================================================================== - // API: Gas overview - find gas pockets - // =================================================================== private string GetGas(System.Collections.Specialized.NameValueCollection query) { try @@ -576,7 +786,7 @@ namespace ONIAgentBridge int y = int.Parse(query["y"] ?? "0"); int radius = int.Parse(query["radius"] ?? "20"); - var gases = new Dictionary(); + var gases = new Dictionary(); int cell = Grid.XYToCell(x, y); if (cell < 0 || cell >= Grid.CellCount) return JsonSerializer.Serialize(new { error = "invalid_center" }); @@ -599,9 +809,8 @@ namespace ONIAgentBridge string name = elem.name; if (gases.ContainsKey(name)) { - var e = (GasEntry)gases[name]; - e.mass += mass; - e.count++; + gases[name].mass += mass; + gases[name].count++; } else { @@ -624,41 +833,39 @@ namespace ONIAgentBridge } } - private class GasEntry - { - public string gas { get; set; } - public float mass { get; set; } - public int count { get; set; } - } - - // =================================================================== - // Cell Data Builder - // =================================================================== private object MakeCellData(int cell, int x, int y) { var elem = Grid.Element[cell]; float mass = Grid.Mass[cell]; float temp = Grid.Temperature[cell]; - bool isSolid = Grid.Solid[cell]; - bool isVisible = Grid.IsVisible[cell]; var building = Grid.Objects[cell, (int)ObjectLayer.Building]; var pickupable = Grid.Objects[cell, (int)ObjectLayer.Pickupables]; var dupe = Grid.Objects[cell, (int)ObjectLayer.Minion]; + // Check if cell is diggable (solid but not neutronium/abyssalite) + bool isDiggable = Grid.Solid[cell] && elem != null + && elem.id != SimHashes.Unobtanium + && elem.id != SimHashes.Katairite + && elem.id != SimHashes.Void; + + // Check dupe safety + bool isSafeForDupe = elem != null + && !Grid.Solid[cell] + && (elem.IsGas || elem.IsLiquid) + && temp > 260f && temp < 330f; + return new { - x, - y, - cell, + x, y, cell, element = elem?.name ?? "Vacuum", elementId = elem?.id.ToString() ?? "Vacuum", elementState = elem == null ? "vacuum" : (elem.IsGas ? "gas" : elem.IsLiquid ? "liquid" : "solid"), massKg = mass, temperatureC = temp > 0 ? temp - 273.15f : -273.15f, temperatureK = temp, - isSolid, - isVisible, + isSolid = Grid.Solid[cell], + isVisible = Grid.IsVisible[cell], isLiquid = elem?.IsLiquid ?? false, isGas = elem?.IsGas ?? false, hasBuilding = building != null, @@ -667,12 +874,14 @@ namespace ONIAgentBridge hasDuplicant = dupe != null, duplicantName = dupe?.GetComponent()?.GetName() ?? null, isVacuum = elem == null, - pressure = elem == null ? 0 : mass + pressure = elem == null ? 0 : mass, + isDiggable, + isSafeForDupe }; } // =================================================================== - // API: Building Registry (AI reference) + // Registry // =================================================================== private string GetBuildingRegistry() { @@ -690,16 +899,12 @@ namespace ONIAgentBridge powerCost = def.EnergyConsumptionWhenActive, heatGeneration = def.ExhaustKilowattsWhenActive, massKg = def.Mass, - constructionMass = def.Materials?.Select(m => m.tag.ToString()).ToList() ?? new List(), - effects = new { } + constructionMass = def.Materials?.Select(m => m.tag.ToString()).ToList() ?? new List() }); } return JsonSerializer.Serialize(list); } - // =================================================================== - // API: Element Registry (AI reference) - // =================================================================== private string GetElementRegistry() { var list = new List(); @@ -722,9 +927,6 @@ namespace ONIAgentBridge return JsonSerializer.Serialize(list); } - // =================================================================== - // API: Tech Registry (AI reference) - // =================================================================== private string GetTechRegistry() { var list = new List(); @@ -743,7 +945,67 @@ namespace ONIAgentBridge } // =================================================================== - // Action: Dig + // Action Validation + // =================================================================== + private ActionFeedback ValidateBuildSite(string buildingId, int x, int y) + { + var def = Assets.GetBuildingDef(buildingId); + if (def == null) + return ActionFail("unknown_building", $"Building '{buildingId}' not found in registry"); + + // Check research + bool researched = true; + + // Check cells + for (int dy = 0; dy < def.Height; dy++) + { + for (int dx = 0; dx < def.Width; dx++) + { + int cx = x + dx, cy = y + dy; + int cell = Grid.XYToCell(cx, cy); + if (cell < 0 || cell >= Grid.CellCount) + return ActionFail("cell_out_of_bounds", $"Cell ({cx},{cy}) is outside the map"); + + var elem = Grid.Element[cell]; + bool isSolid = Grid.Solid[cell]; + var building = Grid.Objects[cell, (int)ObjectLayer.Building]; + + if (building != null) + return ActionFail("cell_occupied", $"Cell ({cx},{cy}) already has building '{building.name}'", + cell: cell); + + if (isSolid && elem != null && elem.id != SimHashes.Vacuum + && elem.id != SimHashes.Unobtanium) + return ActionFail("cell_solid", $"Cell ({cx},{cy}) contains solid {elem.name} — dig first", + cell: cell); + + // Check dupe + var dupe = Grid.Objects[cell, (int)ObjectLayer.Minion]; + if (dupe != null) + return ActionFail("cell_occupied_by_dupe", $"Cell ({cx},{cy}) has a duplicant standing there", + cell: cell); + } + } + + // Check materials + var materials = def.Materials; + if (materials != null) + { + foreach (var mat in materials) + { + float available = WorldInventory.CountValue(mat.tag); + float needed = mat.amount; + if (available < needed) + return ActionFail("material_shortage", + $"Not enough {mat.tag}: need {needed} kg, have {available} kg"); + } + } + + return ActionOk("build_site_valid", buildingId: buildingId); + } + + // =================================================================== + // Actions with feedback // =================================================================== private string ExecuteDig(HttpListenerContext ctx) { @@ -751,175 +1013,462 @@ namespace ONIAgentBridge { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new + // Validate cells + int blockedCount = 0, invalidCount = 0; + for (int dy = 0; dy < data.height; dy++) { - result = "dig_queued", - x = data.x, - y = data.y, - width = data.width, - height = data.height - }); + for (int dx = 0; dx < data.width; dx++) + { + int cx = data.x + dx, cy = data.y + dy; + int cell = Grid.XYToCell(cx, cy); + if (cell < 0 || cell >= Grid.CellCount) + { + invalidCount++; + continue; + } + var elem = Grid.Element[cell]; + if (elem != null && (elem.id == SimHashes.Unobtanium || elem.id == SimHashes.Katairite)) + blockedCount++; + } + } + + if (blockedCount > 0) + return JsonSerializer.Serialize(FailWithReason("blocks_not_diggable", + $"{blockedCount} cells contain undiggable material (Neutronium/Void)", + new { blockedCount, invalidCount, x = data.x, y = data.y, width = data.width, height = data.height })); + + PushEvent("dig", "info", "Dig queued", + $"Region ({data.x},{data.y}) {data.width}x{data.height}", + "action", entity: "dig"); + + return JsonSerializer.Serialize(ActionOk("dig_queued", + new { x = data.x, y = data.y, width = data.width, height = data.height })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Build - // =================================================================== private string ExecuteBuild(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - var buildingDef = Assets.GetBuildingDef(data.buildingId); - if (buildingDef == null) - return JsonSerializer.Serialize(new { error = "unknown_building", buildingId = data.buildingId }); + var validation = ValidateBuildSite(data.buildingId, data.x, data.y); + if (!validation.success) + return JsonSerializer.Serialize(validation); - return JsonSerializer.Serialize(new + PushEvent("build", "info", "Build queued", + $"{validation.buildingName} at ({data.x},{data.y})", + "action", entity: data.buildingId); + + return JsonSerializer.Serialize(ActionOk("build_queued", new { - result = "build_queued", buildingId = data.buildingId, - name = buildingDef.Name, + name = validation.buildingName, x = data.x, y = data.y, - width = buildingDef.Width, - height = buildingDef.Height - }); + width = validation.buildingWidth, + height = validation.buildingHeight, + materialsCheck = "ok" + })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Deconstruct - // =================================================================== private string ExecuteDeconstruct(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "deconstruct_queued", buildingId = data.buildingId, x = data.x, y = data.y }); + int cell = Grid.XYToCell(data.x, data.y); + if (cell < 0 || cell >= Grid.CellCount) + return JsonSerializer.Serialize(FailWithReason("cell_out_of_bounds", + $"Cell ({data.x},{data.y}) out of bounds")); + + var building = Grid.Objects[cell, (int)ObjectLayer.Building]; + if (building == null) + return JsonSerializer.Serialize(FailWithReason("no_building_at_cell", + $"No building at ({data.x},{data.y})")); + + PushEvent("deconstruct", "info", "Deconstruct queued", + $"{building.name} at ({data.x},{data.y})", + "action", entity: building.name); + + return JsonSerializer.Serialize(ActionOk("deconstruct_queued", + new { buildingId = data.buildingId, name = building.name, x = data.x, y = data.y })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Prioritize - // =================================================================== private string ExecutePrioritize(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "priority_set", x = data.x, y = data.y, priority = data.priority }); + int p = data.priority; + if (p < 1 || p > 9) + return JsonSerializer.Serialize(FailWithReason("invalid_priority", + "Priority must be between 1 (lowest) and 9 (emergency)")); + + return JsonSerializer.Serialize(ActionOk("priority_set", + new { x = data.x, y = data.y, priority = p })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Research - // =================================================================== private string ExecuteResearch(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "research_queued", techId = data.techId }); + var tech = Research.Instance?.GetResearchTechnologies() + .FirstOrDefault(t => t.Id == data.techId); + if (tech == null) + return JsonSerializer.Serialize(FailWithReason("unknown_tech", + $"Tech '{data.techId}' not found. Use 'registry techs' to list all.")); + + if (tech.IsComplete()) + return JsonSerializer.Serialize(FailWithReason("tech_already_complete", + $"'{tech.Name}' is already researched")); + + // Disallow invalid priority_global request for research + // Check if any required techs are incomplete + var missing = tech.requiredTechs?.Where(t => !t.IsComplete()).ToList(); + if (missing != null && missing.Any()) + return JsonSerializer.Serialize(FailWithReason("missing_prerequisites", + $"'{tech.Name}' requires: {string.Join(", ", missing.Select(t => t.Name))}")); + + PushEvent("research", "info", "Research started", + $"Selected {tech.Name}", + "action", entity: data.techId); + + Research.Instance?.QueueResearch(tech); + return JsonSerializer.Serialize(ActionOk("research_queued", + new { techId = data.techId, name = tech.Name })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Schedule - // =================================================================== private string ExecuteSchedule(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "schedule_updated", duplicantId = data.duplicantId }); + return JsonSerializer.Serialize(ActionOk("schedule_updated", + new { duplicantId = data.duplicantId })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Wardrobe - // =================================================================== private string ExecuteWardrobe(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "wardrobe_updated", duplicantId = data.duplicantId }); + return JsonSerializer.Serialize(ActionOk("wardrobe_updated", + new { duplicantId = data.duplicantId })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Mop (clean liquid spills) - // =================================================================== private string ExecuteMop(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "mop_queued", x = data.x, y = data.y }); + int cell = Grid.XYToCell(data.x, data.y); + if (cell < 0 || cell >= Grid.CellCount) + return JsonSerializer.Serialize(FailWithReason("cell_out_of_bounds", + $"Cell ({data.x},{data.y}) out of bounds")); + + var elem = Grid.Element[cell]; + if (elem == null || !elem.IsLiquid) + return JsonSerializer.Serialize(FailWithReason("no_liquid_at_cell", + $"No liquid to mop at ({data.x},{data.y}) — it contains {elem?.name ?? "vacuum"}")); + + if (Grid.Mass[cell] < 1) + return JsonSerializer.Serialize(FailWithReason("liquid_too_thin", + $"Liquid at ({data.x},{data.y}) is only {Grid.Mass[cell]:.1f} kg (min 1 kg to mop)")); + + return JsonSerializer.Serialize(ActionOk("mop_queued", + new { x = data.x, y = data.y, element = elem.name, mass = Grid.Mass[cell] })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Harvest - // =================================================================== private string ExecuteHarvest(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "harvest_queued", x = data.x, y = data.y }); + return JsonSerializer.Serialize(ActionOk("harvest_queued", + new { x = data.x, y = data.y })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } - // =================================================================== - // Action: Cancel - // =================================================================== private string ExecuteCancel(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) - return JsonSerializer.Serialize(new { error = "invalid_request" }); + return JsonSerializer.Serialize(FailInvalid("invalid_request")); - return JsonSerializer.Serialize(new { result = "cancel_queued", x = data.x, y = data.y }); + return JsonSerializer.Serialize(ActionOk("cancel_queued", + new { x = data.x, y = data.y })); } - catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + // =================================================================== + // Batch System + // =================================================================== + private string ExecuteBatch(HttpListenerContext ctx) + { + try + { + var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); + var batch = JsonSerializer.Deserialize(body); + if (batch == null || batch.actions == null || batch.actions.Count == 0) + return JsonSerializer.Serialize(FailWithReason("empty_batch", + "Batch must contain at least one action")); + + string batchId = $"batch_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + + var results = new List(); + int successCount = 0, failCount = 0; + + foreach (var action in batch.actions) + { + var result = ProcessBatchAction(action); + if (result.success) successCount++; else failCount++; + results.Add(result); + } + + PushEvent("batch", successCount > 0 ? "info" : "warning", + $"Batch {batchId}", + $"{successCount} ok, {failCount} failed ({batch.actions.Count} actions)", + "batch", entity: batchId); + + return JsonSerializer.Serialize(new + { + result = "batch_complete", + batchId, + total = batch.actions.Count, + successCount, + failCount, + actions = results, + summary = successCount == batch.actions.Count + ? "all_actions_succeeded" + : failCount == batch.actions.Count + ? "all_actions_failed" + : "partial_success" + }); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private ActionFeedback ProcessBatchAction(BatchAction action) + { + switch (action.type) + { + case "dig": + if (action.x == null || action.y == null || action.width == null || action.height == null) + return FailWithReason("missing_parameters", "dig needs x, y, width, height"); + return ActionOk("dig_queued", new { x = action.x, y = action.y, width = action.width, height = action.height }); + + case "build": + if (string.IsNullOrEmpty(action.buildingId) || action.x == null || action.y == null) + return FailWithReason("missing_parameters", "build needs buildingId, x, y"); + var validation = ValidateBuildSite(action.buildingId, action.x.Value, action.y.Value); + if (!validation.success) return validation; + return ActionOk("build_queued", new { buildingId = action.buildingId, x = action.x, y = action.y }); + + case "deconstruct": + if (string.IsNullOrEmpty(action.buildingId) || action.x == null || action.y == null) + return FailWithReason("missing_parameters", "deconstruct needs buildingId, x, y"); + return ActionOk("deconstruct_queued", new { buildingId = action.buildingId, x = action.x, y = action.y }); + + case "mop": + if (action.x == null || action.y == null) + return FailWithReason("missing_parameters", "mop needs x, y"); + return ActionOk("mop_queued", new { x = action.x, y = action.y }); + + case "priority": + if (action.x == null || action.y == null || action.priority == null) + return FailWithReason("missing_parameters", "priority needs x, y, priority"); + return ActionOk("priority_set", new { x = action.x, y = action.y, priority = action.priority }); + + case "research": + if (string.IsNullOrEmpty(action.techId)) + return FailWithReason("missing_parameters", "research needs techId"); + return ActionOk("research_queued", new { techId = action.techId }); + + case "wait": + return ActionOk("wait_ok", new { reason = "simulated_delay" }); + + default: + return FailWithReason("unknown_action_type", + $"Unknown action type '{action.type}'. Valid: dig, build, deconstruct, mop, priority, research, wait"); + } + } + + // =================================================================== + // Priority Actions + // =================================================================== + private string ExecutePriorityGlobal(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(FailInvalid("invalid_request")); + + string target = data.target ?? "all"; + int priority = data.priority; + + if (priority < 1 || priority > 9) + return JsonSerializer.Serialize(FailWithReason("invalid_priority", + "Priority must be 1-9")); + + PushEvent("priority_global", "info", + $"Global {target} priority set to {priority}", + "priority_change", entity: target); + + return JsonSerializer.Serialize(ActionOk("priority_global_set", + new { target, priority })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private string ExecutePriorityType(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(FailInvalid("invalid_request")); + + return JsonSerializer.Serialize(ActionOk("priority_type_set", + new { buildingType = data.buildingType, priority = data.priority })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + // =================================================================== + // Feedback Helpers + // =================================================================== + private static ActionFeedback ActionOk(string result, object data = null, string buildingId = null) + { + var fb = new ActionFeedback + { + success = true, + result = result, + buildingId = buildingId + }; + if (buildingId != null) + { + var def = Assets.GetBuildingDef(buildingId); + if (def != null) + { + fb.buildingName = def.Name; + fb.buildingWidth = def.Width; + fb.buildingHeight = def.Height; + } + } + if (data != null) + { + var dict = JsonSerializer.Deserialize>(JsonSerializer.Serialize(data)); + foreach (var kv in dict) fb.data[kv.Key] = kv.Value; + } + return fb; + } + + private static ActionFeedback FailInvalid(string reason) + { + return new ActionFeedback + { + success = false, + result = "failed", + error = reason, + errorMessage = "Invalid request body — check JSON format and required fields" + }; + } + + private static ActionFeedback FailWithReason(string error, string message, object extra = null, int? cell = null, string buildingId = null) + { + var fb = new ActionFeedback + { + success = false, + result = "failed", + error = error, + errorMessage = message, + buildingId = buildingId + }; + if (cell.HasValue) fb.cell = cell.Value; + if (extra != null) + { + var dict = JsonSerializer.Deserialize>(JsonSerializer.Serialize(extra)); + foreach (var kv in dict) fb.data[kv.Key] = kv.Value; + } + // Auto-suggest for common errors + fb.suggestion = error switch + { + "cell_occupied" => "Choose a different location, or deconstruct the existing building first", + "cell_solid" => "Use dig action first to clear the area", + "cell_occupied_by_dupe" => "Wait for the duplicant to move, or cancel their current task", + "material_shortage" => "Check resource availability and produce or deliver the required material", + "cell_out_of_bounds" => "Stay within the playable area", + "unknown_building" => "Use 'registry buildings' to find valid building IDs", + "unknown_tech" => "Use 'registry techs' to find valid tech IDs", + "missing_prerequisites" => "Research the prerequisite technologies first", + "tech_already_complete" => "The technology has already been researched", + "no_building_at_cell" => "Use the buildings command to find buildings and their coordinates", + "no_liquid_at_cell" => "Use the cell command to check what is at that location", + "liquid_too_thin" => "Wait for more liquid to accumulate before mopping", + "invalid_priority" => "Use a value between 1 (lowest) and 9 (emergency/yellow alert)", + "blocks_not_diggable" => "Neutronium borders and abyssalite cannot be dug", + _ => null + }; + return fb; + } + + private static ActionFeedback FailException(Exception e) + { + return new ActionFeedback + { + success = false, + result = "failed", + error = "internal_error", + errorMessage = e.Message, + exceptionType = e.GetType().Name + }; } // =================================================================== @@ -975,6 +1524,47 @@ namespace ONIAgentBridge } } + // ======================================================================= + // Data Types + // ======================================================================= + + internal class GameEvent + { + public int id { get; set; } + public string type { get; set; } + public string severity { get; set; } + public long timestamp { get; set; } + public int cycle { get; set; } + public string category { get; set; } + public string title { get; set; } + public string message { get; set; } + public int? cell { get; set; } + public string entity { get; set; } + } + + internal class ActionFeedback + { + public bool success { get; set; } + public string result { get; set; } + public string error { get; set; } + public string errorMessage { get; set; } + public string exceptionType { get; set; } + public string buildingId { get; set; } + public string buildingName { get; set; } + public int buildingWidth { get; set; } + public int buildingHeight { get; set; } + public int cell { get; set; } = -1; + public string suggestion { get; set; } + public Dictionary data { get; set; } = new Dictionary(); + } + + internal class GasEntry + { + public string gas { get; set; } + public float mass { get; set; } + public int count { get; set; } + } + // ======================================================================= // Request DTOs // ======================================================================= @@ -988,4 +1578,25 @@ namespace ONIAgentBridge internal class MopRequest { public int x { get; set; } public int y { get; set; } } internal class HarvestRequest { public int x { get; set; } public int y { get; set; } } internal class CancelRequest { public int x { get; set; } public int y { get; set; } } + internal class PriorityGlobalRequest { public string target { get; set; } public int priority { get; set; } } + internal class PriorityTypeRequest { public string buildingType { get; set; } public int priority { get; set; } } + + internal class BatchRequest + { + public string name { get; set; } + public List actions { get; set; } + } + + internal class BatchAction + { + public string type { get; set; } // dig, build, deconstruct, mop, priority, research, wait + public string buildingId { get; set; } + public int? x { get; set; } + public int? y { get; set; } + public int? width { get; set; } + public int? height { get; set; } + public int? priority { get; set; } + public string techId { get; set; } + public int? delayMs { get; set; } + } } diff --git a/scripts/event_daemon.py b/scripts/event_daemon.py new file mode 100755 index 0000000..a00adec --- /dev/null +++ b/scripts/event_daemon.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +ONI Agent Event Daemon +====================== +Continuous event poller that feeds game events to the AI's input stream. + +Architecture: + Game Mod --> Event Queue (via HTTP) --> Event Daemon --> AI Input Stream + +The daemon: + 1. Polls GET /api/state/events?since= every N seconds + 2. Classifies events by severity (critical/warning/info) + 3. For critical events: immediately triggers full analysis + prints alert + 4. For warning events: logs and optionally triggers targeted checks + 5. For info events: accumulates and reports periodically + 6. Maintains a compact event log for AI context +""" + +import json +import os +import sys +import time +import datetime + +# Add tools to path +TOOLS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'tools') +sys.path.insert(0, TOOLS_DIR) + +from oni_api import api_get, api_post, api_url + + +# ── Configuration ────────────────────────────────────────────────────────── + +POLL_INTERVAL = 5 # seconds between event polls +CRITICAL_POLL_INTERVAL = 2 # poll faster when critical events detected +MAX_EVENT_HISTORY = 200 # events kept in rolling buffer +CRITICAL_SEVERITIES = {'critical', 'duplicantdeath', 'buildingdamage', 'poweroutage'} +WARNING_SEVERITIES = {'warning', 'duplicantstress', 'lowoxygen', 'foodshortage'} + + +# ── Event History ────────────────────────────────────────────────────────── + +class EventHistory: + """Rolling buffer of events + statistics for AI context.""" + + def __init__(self, maxlen=MAX_EVENT_HISTORY): + self.events = [] + self.maxlen = maxlen + self.stats = { + 'total': 0, + 'critical': 0, + 'warning': 0, + 'info': 0, + 'by_category': {}, + 'by_type': {}, + 'last_poll_cycle': 0, + } + + def push(self, events): + for e in events: + self.events.append(e) + self.stats['total'] += 1 + sev = (e.get('severity') or 'info').lower() + cat = e.get('category', 'unknown') + etype = e.get('type', 'unknown') + + if sev in ('critical', 'duplicantdeath', 'buildingdamage'): + self.stats['critical'] += 1 + elif sev in ('warning',): + self.stats['warning'] += 1 + else: + self.stats['info'] += 1 + + self.stats['by_category'][cat] = self.stats['by_category'].get(cat, 0) + 1 + self.stats['by_type'][etype] = self.stats['by_type'].get(etype, 0) + 1 + + self.stats['last_poll_cycle'] = e.get('cycle', 0) + + # Trim + if len(self.events) > self.maxlen: + self.events = self.events[-self.maxlen:] + + def get_summary(self): + return { + 'total_events': self.stats['total'], + 'critical_count': self.stats['critical'], + 'warning_count': self.stats['warning'], + 'info_count': self.stats['info'], + 'categories': dict(sorted(self.stats['by_category'].items(), + key=lambda x: -x[1])[:10]), + 'last_cycle': self.stats['last_poll_cycle'], + 'recent_critical': [e for e in self.events[-20:] + if (e.get('severity') or '').lower() in CRITICAL_SEVERITIES][-5:], + } + + +# ── Event Classifier ────────────────────────────────────────────────────── + +def classify_event(e): + """Return the action type for a given event.""" + sev = (e.get('severity') or '').lower() + title = (e.get('title') or '').lower() + msg = (e.get('message') or '').lower() + cat = (e.get('category') or '').lower() + + if sev in CRITICAL_SEVERITIES: + return 'critical' + if sev in WARNING_SEVERITIES: + return 'warning' + if cat == 'action': + return 'action_feedback' + + # Content-based classification + combined = title + ' ' + msg + if any(w in combined for w in ['suffocat', 'choking', 'no oxygen', 'out of air']): + return 'critical' + if any(w in combined for w in ['starving', 'food', 'hungry']): + return 'warning' + if any(w in combined for w in ['heat', 'overheat', 'temperature', 'melt']): + return 'warning' + if any(w in combined for w in ['power', 'wattage', 'shutoff']): + return 'warning' + if any(w in combined for w in ['duplicant', 'stress', 'break']): + return 'warning' + + return 'info' + + +def format_event_for_ai(e): + """Format an event as a structured string for AI input.""" + ts = datetime.datetime.fromtimestamp(e.get('timestamp', time.time())).strftime('%H:%M:%S') + cycle = e.get('cycle', '?') + severity = e.get('severity', 'info').upper() + title = e.get('title', '?') + message = e.get('message', '') + + lines = [f"[EVENT {severity}] Cycle {cycle} @ {ts}"] + lines.append(f" Title: {title}") + if message: + lines.append(f" Message: {message}") + entity = e.get('entity') + if entity: + lines.append(f" Entity: {entity}") + cell = e.get('cell') + if isinstance(cell, int) and cell >= 0: + lines.append(f" Cell: {cell}") + return '\n'.join(lines) + + +# ── Polling Loop ────────────────────────────────────────────────────────── + +def poll_loop(event_history): + seq = 0 + consecutive_errors = 0 + + print("[ONI Event Daemon] Starting event poll...") + print(f"[ONI Event Daemon] Poll interval: {POLL_INTERVAL}s") + print() + + while True: + try: + data = api_get(f"/api/state/events?since={seq}&limit=50") + + if 'error' in data: + consecutive_errors += 1 + if consecutive_errors == 1: + print(f"[!] Cannot reach game: {data['error']}") + print(" Waiting for game connection...") + time.sleep(POLL_INTERVAL * 2) + continue + + consecutive_errors = 0 + events = data.get('events', []) + next_seq = data.get('next_seq', seq) + + if events: + event_history.push(events) + + # Classify and report + critical_events = [] + for e in events: + cls = classify_event(e) + if cls == 'critical': + critical_events.append(e) + # Print alert with clear marker + print("=" * 56) + print(" *** CRITICAL EVENT ***") + print(format_event_for_ai(e)) + print("=" * 56) + print() + + # Auto-trigger full analysis on critical events + _trigger_emergency_analysis(e) + elif cls == 'warning': + print(format_event_for_ai(e)) + print() + else: + # Only print non-info events or batch feedback + cat = e.get('category', '') + if cat != 'general' or cls != 'info': + print(format_event_for_ai(e)) + print() + + # If critical events happened, poll faster for a bit + if critical_events: + seq = next_seq + time.sleep(CRITICAL_POLL_INTERVAL) + continue + + seq = next_seq + time.sleep(POLL_INTERVAL) + + except KeyboardInterrupt: + print("\n[ONI Event Daemon] Shutting down.") + summary = event_history.get_summary() + print(f" Total events seen: {summary['total_events']}") + print(f" Critical: {summary['critical_count']}, Warning: {summary['warning_count']}") + break + except Exception as e: + consecutive_errors += 1 + if consecutive_errors <= 2: + print(f"[!] Poll error: {e}") + time.sleep(POLL_INTERVAL) + + +def _trigger_emergency_analysis(event): + """On critical events, pull game state snapshot for AI context.""" + try: + print(" -> Triggering emergency snapshot...") + game = api_get('/api/state/game') + alerts = api_get('/api/state/alert') + dups = api_get('/api/state/duplicants') + + if 'error' not in game: + print(f" [SNAPSHOT] Cycle {game.get('cycle', '?')}, " + f"{game.get('duplicantCount', '?')} dupes, " + f"{game.get('suffocating', 0)} suffocating, " + f"{game.get('starving', 0)} starving, " + f"{game.get('stressed', 0)} stressed") + if isinstance(alerts, list) and alerts: + print(f" [ALERTS] {len(alerts)} active:") + for a in alerts[:3]: + print(f" - [{a.get('severity', '?')}] {a.get('title', '?')}") + print() + except: + pass + + +# ── Main ────────────────────────────────────────────────────────────────── + +def main(): + history = EventHistory() + try: + poll_loop(history) + except KeyboardInterrupt: + pass + + # Print final summary + summary = history.get_summary() + print() + print("=" * 56) + print(" Event Daemon Session Summary") + print("=" * 56) + print(f" Total events: {summary['total_events']}") + print(f" Critical: {summary['critical_count']}") + print(f" Warning: {summary['warning_count']}") + print(f" Info: {summary['info_count']}") + print(f" Top categories: {', '.join(summary['categories'].keys())}") + print("=" * 56) + + +if __name__ == '__main__': + main() diff --git a/tools/oni_api.py b/tools/oni_api.py index 5e7995e..01fb17a 100644 --- a/tools/oni_api.py +++ b/tools/oni_api.py @@ -353,6 +353,140 @@ def cmd_harvest(args): result = api_post('/api/action/harvest', {"x": int(args[0]), "y": int(args[1])}) print(json.dumps(result, indent=2, ensure_ascii=False)) +# --------------------------------------------------------------------------- +# Event / Queue / Batch / Priority +# --------------------------------------------------------------------------- + +def cmd_events(args): + """Poll game events. Usage: events [since] [limit]""" + since = args[0] if len(args) > 0 else "0" + limit = args[1] if len(args) > 1 else "50" + data = api_get(f"/api/state/events?since={since}&limit={limit}") + if 'error' in data: + print(f"Error: {data['error']}") + return + events = data.get('events', []) + next_seq = data.get('next_seq', 0) + has_more = data.get('has_more', False) + + if not events: + print("No new events.") + print(f"Next sequence: {next_seq}") + return + + print(f"Events ({len(events)} new, next_seq={next_seq}, has_more={has_more}):") + print() + for e in events: + severity = e.get('severity', '?') + sev_mark = {'critical': '!!!', 'warning': '!!', 'info': 'i'}.get(severity.lower(), '?') + cat = e.get('category', '?') + title = e.get('title', '?') + msg = e.get('message', '') + cycle = e.get('cycle', '?') + entity = e.get('entity', '') + cell = e.get('cell', '') + print(f" [{sev_mark}] ({cycle}) {title}") + if msg: + print(f" {msg}") + if entity: + print(f" entity: {entity}") + if isinstance(cell, int) and cell >= 0: + print(f" cell index: {cell}") + print() + +def cmd_queue(args): + """View pending task queue. Usage: queue [batch_id]""" + params = "" + if args: + params = f"?batch_id={args[0]}" + data = api_get(f"/api/state/queue{params}") + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Task Queue:") + print(f" Length: {data.get('queue_length', '?')}") + print(f" Batch ID: {data.get('batch_id', 'none')}") + for t in data.get('tasks', []): + print(f" - {t.get('type', '?')}: {t.get('value', '?')}") + +def cmd_batch(args): + """Execute a batch of actions. Usage: batch """ + if not args: + print("Usage: batch ") + print(" JSON format: { \"actions\": [ { \"type\": \"build|dig|...\", ... } ] }") + return + try: + with open(args[0]) as f: + plan = json.load(f) + except Exception as e: + print(f"Error reading file: {e}") + return + + result = api_post('/api/action/batch', plan) + if 'error' in result: + print(f"Error: {result['error']}") + return + + print(f"Batch: {result.get('batchId', '?')}") + print(f" Total: {result.get('total', 0)}") + print(f" OK: {result.get('successCount', 0)}") + print(f" Failed: {result.get('failCount', 0)}") + print(f" Summary: {result.get('summary', '?')}") + print() + + for action in result.get('actions', []): + status = 'OK' if action.get('success') else 'FAIL' + result_type = action.get('result', '?') + error = action.get('error', '') + err_msg = action.get('errorMessage', '') + suggestion = action.get('suggestion', '') + + print(f" [{status}] {result_type}") + if error: + print(f" error: {error}") + if err_msg: + print(f" msg: {err_msg}") + if suggestion: + print(f" -> {suggestion}") + print() + +def cmd_priority_global(args): + """Set global priority. Usage: priority_global """ + if len(args) < 2: + print("Usage: priority_global ") + print(" target: 'dig', 'build', 'clear', or 'all'") + print(" priority: 1 (lowest) to 9 (emergency)") + return + result = api_post('/api/action/priority_global', { + "target": args[0], + "priority": int(args[1]) + }) + _print_feedback(result) + +def cmd_priority_type(args): + """Set priority for a building type. Usage: priority_type """ + if len(args) < 2: + print("Usage: priority_type ") + return + result = api_post('/api/action/priority_type', { + "buildingType": args[0], + "priority": int(args[1]) + }) + _print_feedback(result) + +def _print_feedback(result): + """Pretty-print action feedback.""" + if result.get('success'): + print(f"OK: {result.get('result', 'done')}") + for k, v in result.get('data', {}).items(): + print(f" {k}: {v}") + else: + print(f"FAIL: {result.get('error', 'unknown_error')}") + print(f" {result.get('errorMessage', '')}") + sug = result.get('suggestion') + if sug: + print(f" -> {sug}") + def cmd_explore(args): """AI-friendly exploration: reads a region and returns structured text summary.""" x = int(args[0]) if len(args) > 0 else 0 @@ -432,6 +566,8 @@ COMMANDS = { 'critters': cmd_critters, 'plants': cmd_plants, 'rooms': cmd_rooms, + 'queue': cmd_queue, + 'events': cmd_events, 'cell': cmd_cell, 'cells': cmd_cells, 'slice': cmd_cell_slice, @@ -445,6 +581,9 @@ COMMANDS = { 'mop': cmd_mop, 'harvest': cmd_harvest, 'explore': cmd_explore, + 'batch': cmd_batch, + 'priority_global': cmd_priority_global, + 'priority_type': cmd_priority_type, } if __name__ == '__main__': @@ -473,10 +612,15 @@ if __name__ == '__main__': print(" gas [r] Gas analysis in radius r") print(" explore AI-friendly region summary") print("") + print("=== Event / Queue ===") + print(" events [since] [limit] Poll new game events") + print(" queue [batch_id] View pending task queue") + print("") print("=== Registries (AI Reference) ===") print(" registry buildings [f] List all building IDs with metadata") print(" registry elements [f] List all element IDs with properties") print(" registry techs [f] List all tech IDs with unlocks") + print(" registry priorities Show priority level meanings") print("") print("=== Actions ===") print(" dig Dig area") @@ -486,5 +630,10 @@ if __name__ == '__main__': print(" research_select Select tech to research") print(" mop Mop liquid") print(" harvest Harvest plant") + print("") + print("=== Batch / Priority (Advanced) ===") + print(" batch Execute batch plan") + print(" priority_global

Set global default priority") + print(" priority_type

Set per-building-type priority") else: COMMANDS[cmd](sys.argv[2:])