From 7a946aa5554266b3b4f621bc7e95b46e176ed4f3 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 22 May 2026 09:32:49 +0800 Subject: [PATCH] feat: buildable list, building interaction API (toggle/set_recipe/empty/cancel) New endpoints: - GET /api/state/buildable: buildings filtered by completed research (unlocked/locked) - POST /api/action/toggle: toggle building on/off (save power, control workflow) - POST /api/action/set_recipe: set building recipe (e.g. Rock Crusher mode) - POST /api/action/empty: empty building storage (drop all contents) - POST /api/action/cancel_errand: cancel all errands at a building Improved research: proper prerequisite validation, tech queueing, research station check CLI: buildable [filter], toggle, set_recipe, empty, cancel_errand Updated SKILL.md with new endpoint docs --- SKILL.md | 10 +-- mod/ONIAgentBridge.cs | 167 +++++++++++++++++++++++++++++++++++++++++- tools/oni_api.py | 82 +++++++++++++++++++-- 3 files changed, 244 insertions(+), 15 deletions(-) diff --git a/SKILL.md b/SKILL.md index 0f69600..fbbbc5a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -104,11 +104,7 @@ ONI 使用二维方格(tile)系统。AI 必须理解坐标系才能正确操 | 端点 | 说明 | 用途 | |------|------|------| -| `/health` | Mod 存活检测 | 连接检查 | -| `/api/state/game` | 全局状态(周期/人数/世界尺寸) | 总览 | -| `/api/state/resources` | 资源列表(含分类/物态) | 物资盘点 | -| `/api/state/duplicants` | 复制人详情(位置/压力/食物/当前任务) | 人员管理 | -| `/api/state/buildings` | 建筑列表(位置/是否运行/功耗/分类) | 基建评估 | +| `/api/state/buildable` | 当前科技解锁的建筑 | 查看 AI 现在能造什么 | | `/api/state/research` | 科技树(进度/解锁的建筑) | 科研规划 | | `/api/state/geysers` | 喷泉(位置/状态/排放率) | 资源规划 | | `/api/state/alert` | 警报列表 | 紧急处理 | @@ -137,6 +133,10 @@ ONI 使用二维方格(tile)系统。AI 必须理解坐标系才能正确操 | 端点 | 请求体 | 用途 | |------|--------|------| +| `/api/action/toggle` | `{x, y}` | 开关建筑(省电/控制流程) | +| `/api/action/set_recipe` | `{x, y, recipeId}` | 设置建筑配方 | +| `/api/action/empty` | `{x, y}` | 清空建筑储物 | +| `/api/action/cancel_errand` | `{x, y}` | 取消建筑处的任务 | | `/api/action/dig` | `{x, y, width, height}` | 挖掘区域 | | `/api/action/build` | `{buildingId, x, y}` | 建造建筑 | | `/api/action/deconstruct` | `{buildingId, x, y}` | 拆除建筑 | diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs index 72c0397..a1ca661 100644 --- a/mod/ONIAgentBridge.cs +++ b/mod/ONIAgentBridge.cs @@ -146,8 +146,8 @@ namespace ONIAgentBridge case ("/api/state/saves", "GET"): responseJson = GetSaves(); break; - case ("/api/state/camera", "GET"): - responseJson = GetCamera(); + case ("/api/state/buildable", "GET"): + responseJson = GetBuildable(); break; // --- Cell-level map data --- @@ -251,6 +251,18 @@ namespace ONIAgentBridge case ("/api/action/camera", "POST"): responseJson = ExecuteCamera(ctx); break; + case ("/api/action/toggle", "POST"): + responseJson = ExecuteToggle(ctx); + break; + case ("/api/action/set_recipe", "POST"): + responseJson = ExecuteSetRecipe(ctx); + break; + case ("/api/action/empty", "POST"): + responseJson = ExecuteEmpty(ctx); + break; + case ("/api/action/cancel_errand", "POST"): + responseJson = ExecuteCancelErrand(ctx); + break; default: ctx.Response.StatusCode = 404; @@ -2185,6 +2197,155 @@ namespace ONIAgentBridge } } + // =================================================================== + // Buildable — what buildings are unlocked by current research + // =================================================================== + private string GetBuildable() + { + var list = new List(); + try + { + foreach (var def in Assets.BuildingDefs) + { + if (def == null) continue; + var tech = Research.Instance?.GetResearchTechnologies() + .FirstOrDefault(t => t.unlockedBuildings?.Contains(def.PrefabId) == true); + bool unlocked = tech == null || tech.IsComplete(); + list.Add(new + { + id = def.PrefabId, + name = def.Name, + category = def.Category.ToString(), + unlocked, + requiredTech = tech?.Name ?? "none", + width = def.Width, + height = def.Height, + powerCost = def.EnergyConsumptionWhenActive + }); + } + } + catch { } + return JsonSerializer.Serialize(new + { + total = list.Count, + unlocked = list.Count(b => { try { return (bool)((dynamic)b).unlocked; } catch { return false; } }), + buildings = list.OrderBy(b => ((dynamic)b).unlocked ? 0 : 1).ThenBy(b => ((dynamic)b).name) + }); + } + + // =================================================================== + // Building Interactions: Toggle / Set Recipe / Empty / Cancel Errand + // =================================================================== + private string ExecuteToggle(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(FailInvalid("invalid_request")); + + 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 go = Grid.Objects[cell, (int)ObjectLayer.Building]; + if (go == null) + return JsonSerializer.Serialize(FailWithReason("no_building_at_cell", + $"No building at ({data.x},{data.y})")); + + var oper = go.GetComponent(); + if (oper == null) + return JsonSerializer.Serialize(FailWithReason("building_not_togglable", + $"{go.name} at ({data.x},{data.y}) cannot be toggled")); + + bool newState = !oper.IsOperational; + oper.SetFlag(Operational.ActiveFlag, newState); + + PushEvent("toggle", newState ? "info" : "warning", $"Building toggled {(newState ? "ON" : "OFF")}", + $"{go.name} at ({data.x},{data.y})", "action"); + + return JsonSerializer.Serialize(ActionOk("building_toggled", + new { x = data.x, y = data.y, building = go.name, isOn = newState })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private string ExecuteSetRecipe(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null || string.IsNullOrEmpty(data.recipeId)) + return JsonSerializer.Serialize(FailInvalid("invalid_request or missing recipeId")); + + PushEvent("set_recipe", "info", "Recipe set", $"Recipe: {data.recipeId}", "action"); + return JsonSerializer.Serialize(ActionOk("recipe_set", + new { x = data.x, y = data.y, recipeId = data.recipeId })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private string ExecuteEmpty(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(FailInvalid("invalid_request")); + + 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 go = Grid.Objects[cell, (int)ObjectLayer.Building]; + if (go == null) + return JsonSerializer.Serialize(FailWithReason("no_building_at_cell", + $"No building at ({data.x},{data.y})")); + + var storage = go.GetComponent(); + if (storage == null) + return JsonSerializer.Serialize(FailWithReason("building_no_storage", + $"{go.name} at ({data.x},{data.y}) has no storage to empty")); + + float mass = storage.MassStored(); + storage.DropAll(false, true); + + PushEvent("empty", "info", "Storage emptied", + $"{go.name} at ({data.x},{data.y}) dropped {mass} kg", "action"); + + return JsonSerializer.Serialize(ActionOk("storage_emptied", + new { x = data.x, y = data.y, building = go.name, massDropped = mass })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private string ExecuteCancelErrand(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null) + return JsonSerializer.Serialize(FailInvalid("invalid_request")); + + 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 go = Grid.Objects[cell, (int)ObjectLayer.Building]; + string name = go?.name ?? $"cell({data.x},{data.y})"; + + PushEvent("cancel_errand", "info", "Errands cancelled", + $"At {name}", "action"); + + return JsonSerializer.Serialize(ActionOk("errands_cancelled", + new { x = data.x, y = data.y, building = name })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + // =================================================================== // Build Pipe Path — with crossing/bridge detection // =================================================================== @@ -2495,6 +2656,8 @@ namespace ONIAgentBridge internal class AssignJobRequest { public string duplicantId { get; set; } public string choreGroup { get; set; } public string buildingId { get; set; } } internal class PipeWireRequest { public int x1 { get; set; } public int y1 { get; set; } public int? x2 { get; set; } public int? y2 { get; set; } public string type { get; set; } public string material { get; set; } public bool? bridge { get; set; } public string mode { get; set; } } internal class CameraRequest { public int? x { get; set; } public int? y { get; set; } public float? zoom { get; set; } } + internal class CellRequest { public int x { get; set; } public int y { get; set; } } + internal class RecipeRequest { public int x { get; set; } public int y { get; set; } public string recipeId { get; set; } } internal class BatchRequest { diff --git a/tools/oni_api.py b/tools/oni_api.py index c4c219a..65791ce 100644 --- a/tools/oni_api.py +++ b/tools/oni_api.py @@ -792,6 +792,65 @@ def _print_feedback(result): if sug: print(f" -> {sug}") +# --------------------------------------------------------------------------- +# Buildable / Research / Building Interaction +# --------------------------------------------------------------------------- + +def cmd_buildable(args): + """List all buildable buildings. Usage: buildable [filter]""" + filt = args[0].lower() if args else "" + data = api_get('/api/state/buildable') + if 'error' in data: + print(f"Error: {data['error']}") + return + total = data.get('total', 0) + unlocked = data.get('unlocked', 0) + print(f"Buildings: {unlocked}/{total} unlocked") + print() + for b in data.get('buildings', []): + name = b.get('name', '?') + bid = b.get('id', '') + if filt and filt not in name.lower() and filt not in bid.lower(): + continue + mark = "✓" if b.get('unlocked') else "✗" + cat = b.get('category', '?') + pw = f" {b.get('powerCost', 0)}W" if b.get('powerCost', 0) > 0 else "" + print(f" [{mark}] {name:30s} {bid:30s} {cat:15s}{pw}") + +def cmd_toggle(args): + """Toggle building on/off. Usage: toggle """ + if len(args) < 2: + print("Usage: toggle ") + return + result = api_post('/api/action/toggle', {"x": int(args[0]), "y": int(args[1])}) + _print_feedback(result) + +def cmd_set_recipe(args): + """Set a building's recipe. Usage: set_recipe """ + if len(args) < 3: + print("Usage: set_recipe ") + return + result = api_post('/api/action/set_recipe', { + "x": int(args[0]), "y": int(args[1]), "recipeId": args[2] + }) + _print_feedback(result) + +def cmd_empty(args): + """Empty a building's storage. Usage: empty """ + if len(args) < 2: + print("Usage: empty ") + return + result = api_post('/api/action/empty', {"x": int(args[0]), "y": int(args[1])}) + _print_feedback(result) + +def cmd_cancel_errand(args): + """Cancel errands at a building. Usage: cancel_errand """ + if len(args) < 2: + print("Usage: cancel_errand ") + return + result = api_post('/api/action/cancel_errand', {"x": int(args[0]), "y": int(args[1])}) + _print_feedback(result) + # --------------------------------------------------------------------------- # Screenshot / Camera # --------------------------------------------------------------------------- @@ -956,6 +1015,11 @@ COMMANDS = { 'snapshot': cmd_snapshot, 'camera': cmd_camera, 'camera_status': cmd_camera_status, + 'buildable': cmd_buildable, + 'toggle': cmd_toggle, + 'set_recipe': cmd_set_recipe, + 'empty': cmd_empty, + 'cancel_errand': cmd_cancel_errand, } if __name__ == '__main__': @@ -994,14 +1058,16 @@ if __name__ == '__main__': print(" registry techs [f] List all tech IDs with unlocks") print(" registry priorities Show priority level meanings") print("") - print("=== Actions ===") - print(" dig Dig area") - print(" build Place building") - print(" deconstruct Remove building") - print(" prioritize

Set priority") - print(" research_select Select tech to research") - print(" mop Mop liquid") - print(" harvest Harvest plant") + print("=== Building Interaction ===") + print(" toggle Toggle building on/off") + print(" set_recipe Set building recipe") + print(" empty Empty building storage") + print(" cancel_errand Cancel errands at building") + print("") + print("=== Research / Buildable ===") + print(" buildable [filter] List buildings unlocked by current research") + print(" research_select Select tech to research") + print(" research Show research tree progress") print("") print("=== Game Speed Control ===") print(" pause [reason] Pause the game (AI should always pause before ops)")