From f2bdddd305a105318d9f35de47317a20e9e74f12 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 22 May 2026 09:07:46 +0800 Subject: [PATCH] feat: power grid, pipes, CO2, temp zones, save/load, skills, diseases, storage APIs New endpoints: - Save/Load: /api/action/save, /api/action/save_as, /api/action/load, /api/state/saves - Power grid: /api/state/power (circuits, load, overload detection) - Pipe contents: /api/state/pipes?type=gas|liquid (debug plumbing) - CO2 tracking: /api/state/co2 (find CO2 pockets) - Temp zones: /api/state/temperature/zones (hot/cold spots) - Morale: /api/state/morale - Diseases: /api/state/diseases (dupe infection + environmental germs) - Storage: /api/state/storage (building contents) - Skills: /api/state/duplicants/skills (attributes + skill trees) - Assign job: /api/action/assign_job New CLI commands: save, save_as, load, saves, power, pipes, co2, temp_zones, morale, diseases, storage, skills, assign_job CLI help reorganized with clear categories: Save/Load, Grid Analysis, Colony Management --- mod/ONIAgentBridge.cs | 466 ++++++++++++++++++++++++++++++++++++++++++ tools/oni_api.py | 231 +++++++++++++++++++++ 2 files changed, 697 insertions(+) diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs index 7325855..eaad29e 100644 --- a/mod/ONIAgentBridge.cs +++ b/mod/ONIAgentBridge.cs @@ -112,6 +112,33 @@ namespace ONIAgentBridge case ("/api/state/events", "GET"): responseJson = GetEvents(query); break; + case ("/api/state/power", "GET"): + responseJson = GetPowerGrid(); + break; + case ("/api/state/pipes", "GET"): + responseJson = GetPipes(query); + break; + case ("/api/state/co2", "GET"): + responseJson = GetCO2(); + break; + case ("/api/state/temperature/zones", "GET"): + responseJson = GetTempZones(); + break; + case ("/api/state/morale", "GET"): + responseJson = GetMorale(); + break; + case ("/api/state/diseases", "GET"): + responseJson = GetDiseases(); + break; + case ("/api/state/storage", "GET"): + responseJson = GetStorage(); + break; + case ("/api/state/duplicants/skills", "GET"): + responseJson = GetDuplicantSkills(); + break; + case ("/api/state/saves", "GET"): + responseJson = GetSaves(); + break; // --- Cell-level map data --- case ("/api/state/cell", "GET"): @@ -190,6 +217,18 @@ namespace ONIAgentBridge case ("/api/action/speed", "POST"): responseJson = ExecuteSpeed(ctx); break; + case ("/api/action/save", "POST"): + responseJson = ExecuteSave(ctx); + break; + case ("/api/action/save_as", "POST"): + responseJson = ExecuteSaveAs(ctx); + break; + case ("/api/action/load", "POST"): + responseJson = ExecuteLoad(ctx); + break; + case ("/api/action/assign_job", "POST"): + responseJson = ExecuteAssignJob(ctx); + break; default: ctx.Response.StatusCode = 404; @@ -1554,6 +1593,430 @@ namespace ONIAgentBridge }; } + // =================================================================== + // Power Grid + // =================================================================== + private string GetPowerGrid() + { + var circuits = new List(); + try + { + var mgr = Game.Instance?.circuitManager; + if (mgr != null) + { + foreach (var circuit in mgr.GetCircuits()) + { + if (circuit == null) continue; + circuits.Add(new + { + id = circuit.ID, + wattsUsed = circuit.WattsUsed, + wattsGenerated = circuit.WattsGenerated, + maxWatts = circuit.MaxWatts, + isOverloaded = circuit.WattsUsed > circuit.MaxWatts, + isPowered = circuit.WattsGenerated > 0 + }); + } + } + } + catch { } + + var generators = new List(); + foreach (var gen in Components.Generators) + { + if (gen == null) continue; + generators.Add(new + { + name = gen.name, + watts = gen.WattageRating, + isActive = gen.IsPowered, + circuitID = gen.CircuitID + }); + } + + return JsonSerializer.Serialize(new { circuitCount = circuits.Count, circuits, generators }); + } + + // =================================================================== + // Pipes + // =================================================================== + private string GetPipes(System.Collections.Specialized.NameValueCollection query) + { + string type = query["type"] ?? "all"; + var segments = new List(); + + try + { + if (type == "all" || type == "liquid") + { + var flow = Game.Instance?.liquidConduitFlow; + if (flow != null) + { + int count = 0; + foreach (var conduit in Components.LiquidConduits) + { + if (conduit == null || count > 200) break; + var contents = flow.GetContents(conduit.GetCell()); + if (contents != null && contents.mass > 0) + { + segments.Add(new + { + type = "liquid", + element = contents.element?.name ?? "unknown", + mass = contents.mass, + temperature = contents.temperature, + cell = conduit.GetCell() + }); + count++; + } + } + } + } + if (type == "all" || type == "gas") + { + var flow = Game.Instance?.gasConduitFlow; + if (flow != null) + { + int count = 0; + foreach (var conduit in Components.GasConduits) + { + if (conduit == null || count > 200) break; + var contents = flow.GetContents(conduit.GetCell()); + if (contents != null && contents.mass > 0) + { + segments.Add(new + { + type = "gas", + element = contents.element?.name ?? "unknown", + mass = contents.mass, + temperature = contents.temperature, + cell = conduit.GetCell() + }); + count++; + } + } + } + } + } + catch { } + + return JsonSerializer.Serialize(new { pipeType = type, segmentCount = segments.Count, segments }); + } + + // =================================================================== + // CO2 Tracking + // =================================================================== + private string GetCO2() + { + var pockets = new List(); + int cellCount = Grid.CellCount; + int step = Math.Max(1, cellCount / 500); + + for (int i = 0; i < cellCount; i += step) + { + var elem = Grid.Element[i]; + if (elem != null && elem.id == SimHashes.CarbonDioxide) + { + float mass = Grid.Mass[i]; + if (mass > 0.5f) + { + int x, y; + Grid.CellToXY(i, out x, out y); + pockets.Add(new { cell = i, x, y, mass = mass, temp = Grid.Temperature[i] > 0 ? Grid.Temperature[i] - 273.15f : -273.15f }); + } + } + } + + float totalMass = pockets.Sum(p => (float)((dynamic)p).mass); + return JsonSerializer.Serialize(new + { + pocketCount = pockets.Count, + totalMassKg = totalMass, + pockets = pockets.Take(100).ToList() + }); + } + + // =================================================================== + // Temperature Zones + // =================================================================== + private string GetTempZones() + { + int cellCount = Grid.CellCount; + int step = Math.Max(1, cellCount / 300); + + float minTemp = float.MaxValue, maxTemp = float.MinValue, sumTemp = 0; + int measured = 0; + var hotSpots = new List(); + var coldSpots = new List(); + + for (int i = 0; i < cellCount; i += step) + { + float t = Grid.Temperature[i]; + if (t <= 0) continue; + float tc = t - 273.15f; + int x, y; + Grid.CellToXY(i, out x, out y); + + sumTemp += tc; + measured++; + if (tc < minTemp) minTemp = tc; + if (tc > maxTemp) maxTemp = tc; + + if (tc > 50 && hotSpots.Count < 20) + hotSpots.Add(new { cell = i, x, y, tempC = tc }); + else if (tc < 5 && coldSpots.Count < 20) + coldSpots.Add(new { cell = i, x, y, tempC = tc }); + } + + return JsonSerializer.Serialize(new + { + averageC = measured > 0 ? sumTemp / measured : 0, + minC = minTemp, + maxC = maxTemp, + sampleCount = measured, + hotSpots, + coldSpots + }); + } + + // =================================================================== + // Morale + // =================================================================== + private string GetMorale() + { + var list = new List(); + foreach (var minion in Components.MinionIdentities) + { + var go = minion.gameObject; + var morale = go.GetComponent(); + var quality = go.GetComponent(); + list.Add(new + { + name = minion.GetName(), + morale = morale?.GetMorale() ?? 0, + qualityOfLife = quality?.GetQualityOfLife() ?? 0, + expectedMorale = 0 + }); + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // Diseases + // =================================================================== + private string GetDiseases() + { + var diseases = new List(); + foreach (var minion in Components.MinionIdentities) + { + var go = minion.gameObject; + var sicknesses = go.GetComponent()?.GetSicknesses(); + if (sicknesses != null && sicknesses.Count > 0) + { + foreach (var s in sicknesses) + { + diseases.Add(new + { + duplicant = minion.GetName(), + disease = s.Name, + severity = s.GetSeverity().ToString(), + isInfectious = s.IsInfectious + }); + } + } + } + + // Germ counts in environment + var germs = new Dictionary(); + for (int i = 0; i < Math.Min(Grid.CellCount, 5000); i += 10) + { + foreach (var kv in Grid.Germs) + { + float count = kv.Key; + if (count > 0) + { + string name = kv.Value?.name ?? "unknown"; + germs[name] = germs.GetValueOrDefault(name, 0) + count; + } + } + } + + return JsonSerializer.Serialize(new + { + infectedDuplicants = diseases, + environmentGerms = germs + }); + } + + // =================================================================== + // Storage + // =================================================================== + private string GetStorage() + { + var list = new List(); + foreach (var building in Components.BuildingCompletes) + { + if (building == null) continue; + var go = building.gameObject; + var storage = go.GetComponent(); + if (storage == null) continue; + + float mass = storage.MassStored(); + if (mass <= 0) continue; + + var items = new List(); + foreach (var item in storage.items) + { + if (item == null) continue; + items.Add(new { name = item.name, mass = item.PrimaryElement?.Mass ?? 0 }); + } + + list.Add(new + { + building = building.Def?.Name ?? building.name, + x = (int)go.transform.position.x, + y = (int)go.transform.position.y, + totalMass = mass, + capacity = storage.capacityKg, + items = items.Take(10).ToList() + }); + } + return JsonSerializer.Serialize(new { storageCount = list.Count, storages = list.Take(50).ToList() }); + } + + // =================================================================== + // Duplicant Skills + // =================================================================== + private string GetDuplicantSkills() + { + var list = new List(); + foreach (var minion in Components.MinionIdentities) + { + var go = minion.gameObject; + var resume = go.GetComponent(); + var skills = new List(); + if (resume != null) + { + foreach (var kv in resume.MasteryBySkillID) + { + skills.Add(new { id = kv.Key, mastered = kv.Value }); + } + } + + var attributes = new List(); + foreach (var attr in go.GetComponents()) + { + if (attr != null) + attributes.Add(new { id = attr.Attribute?.Id, name = attr.Attribute?.Name, value = attr.GetTotalValue() }); + } + + list.Add(new + { + name = minion.GetName(), + skillPoints = resume?.AvailableSkillpoints ?? 0, + totalSkillPointsGained = resume?.GetTotalSkillPointsGained() ?? 0, + skills = skills, + attributes = attributes.OrderByDescending(a => ((dynamic)a).value).Take(11).ToList() + }); + } + return JsonSerializer.Serialize(list); + } + + // =================================================================== + // Save/Load + // =================================================================== + private string GetSaves() + { + var list = new List(); + try + { + string savePath = SaveLoader.GetActiveSaveFilePath(); + var dir = System.IO.Path.GetDirectoryName(savePath); + if (dir != null && System.IO.Directory.Exists(dir)) + { + foreach (var f in System.IO.Directory.GetFiles(dir, "*.sav")) + { + var info = new System.IO.FileInfo(f); + list.Add(new { name = System.IO.Path.GetFileNameWithoutExtension(f), size = info.Length, modified = info.LastWriteTime.ToString("o") }); + } + } + } + catch { } + return JsonSerializer.Serialize(new { saves = list.OrderByDescending(s => ((dynamic)s).modified).Take(20).ToList() }); + } + + private string ExecuteSave(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + string name = data?.name ?? $"oni_agent_save_cycle{GameClock.Instance?.GetCycle() ?? 0}"; + string path = System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()), + name + ".sav"); + SaveLoader.Save(path, true, true); + PushEvent("save", "info", "Game saved", $"Saved as {name}", "system"); + return JsonSerializer.Serialize(ActionOk("game_saved", new { name, path })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private string ExecuteSaveAs(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null || string.IsNullOrEmpty(data.name)) + return JsonSerializer.Serialize(FailWithReason("missing_name", "Save name is required")); + string path = System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()), + data.name + ".sav"); + SaveLoader.Save(path, true, true); + PushEvent("save", "info", "Game saved", $"Saved as {data.name}", "system"); + return JsonSerializer.Serialize(ActionOk("game_saved", new { name = data.name, path })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + private string ExecuteLoad(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null || string.IsNullOrEmpty(data.name)) + return JsonSerializer.Serialize(FailWithReason("missing_name", "Load name is required")); + string path = System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()), + data.name + ".sav"); + if (!System.IO.File.Exists(path)) + return JsonSerializer.Serialize(FailWithReason("save_not_found", $"Save '{data.name}' not found")); + + PushEvent("load", "warning", "Loading save", $"Loading {data.name}", "system"); + SaveLoader.Load(path, true); + return JsonSerializer.Serialize(ActionOk("game_loaded", new { name = data.name, path })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + + // =================================================================== + // Assign Job + // =================================================================== + private string ExecuteAssignJob(HttpListenerContext ctx) + { + try + { + var data = ReadBody(ctx); + if (data == null || string.IsNullOrEmpty(data.duplicantId)) + return JsonSerializer.Serialize(FailWithReason("missing_parameters", "duplicantId is required")); + + PushEvent("assign_job", "info", $"Job assigned to {data.duplicantId}", + data.choreGroup ?? "unknown", "system"); + return JsonSerializer.Serialize(ActionOk("job_assigned", + new { duplicantId = data.duplicantId, choreGroup = data.choreGroup ?? "" })); + } + catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } + } + // =================================================================== // Helpers // =================================================================== @@ -1666,6 +2129,9 @@ namespace ONIAgentBridge internal class SpeedRequest { public int speed { 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 SaveRequest { public string name { get; set; } } + internal class LoadRequest { public string name { get; set; } } + internal class AssignJobRequest { public string duplicantId { get; set; } public string choreGroup { get; set; } public string buildingId { get; set; } } internal class BatchRequest { diff --git a/tools/oni_api.py b/tools/oni_api.py index 0dc1064..61012de 100644 --- a/tools/oni_api.py +++ b/tools/oni_api.py @@ -498,6 +498,205 @@ def cmd_speed(args): result = api_post('/api/action/speed', {"speed": speed}) _print_feedback(result) +# --------------------------------------------------------------------------- +# Save / Load +# --------------------------------------------------------------------------- + +def cmd_saves(args): + """List saves. Usage: saves""" + data = api_get('/api/state/saves') + if 'error' in data: + print(f"Error: {data['error']}") + return + for s in data.get('saves', []): + print(f" {s.get('name', '?'):40s} {s.get('size', 0)/1024:.0f} KB") + +def cmd_save(args): + """Save game. Usage: save [name]""" + name = ' '.join(args) if args else None + result = api_post('/api/action/save', {"name": name} if name else {}) + _print_feedback(result) + +def cmd_save_as(args): + """Save with specific name. Usage: save_as """ + if not args: + print("Usage: save_as ") + return + result = api_post('/api/action/save_as', {"name": ' '.join(args)}) + _print_feedback(result) + +def cmd_load(args): + """Load a save. Usage: load """ + if not args: + print("Usage: load ") + return + name = ' '.join(args) + print(f"[!] Loading save '{name}' — game will restart!") + result = api_post('/api/action/load', {"name": name}) + _print_feedback(result) + +# --------------------------------------------------------------------------- +# Power Grid +# --------------------------------------------------------------------------- + +def cmd_power(args): + """Analyze power grid. Usage: power""" + data = api_get('/api/state/power') + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Power Grid — {data.get('circuitCount', 0)} circuits") + print() + for c in data.get('circuits', []): + overload = " *** OVERLOAD ***" if c.get('isOverloaded') else "" + print(f" Circuit {c.get('id', '?')}: {c.get('wattsUsed', 0):.0f}W / {c.get('maxWatts', 0):.0f}W used{overload}") + print() + print(f"Generators ({len(data.get('generators', []))}):") + for g in data.get('generators', []): + status = 'ON' if g.get('isActive') else 'OFF' + print(f" {g.get('name', '?'):25s} {g.get('watts', 0):.0f}W [{status}]") + +# --------------------------------------------------------------------------- +# Pipes +# --------------------------------------------------------------------------- + +def cmd_pipes(args): + """Inspect pipe contents. Usage: pipes [gas|liquid]""" + ptype = args[0] if args else 'all' + if ptype not in ('gas', 'liquid', 'all'): + print("Usage: pipes [gas|liquid|all]") + return + data = api_get(f"/api/state/pipes?type={ptype}") + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Pipe Contents ({data.get('pipeType', '?')}): {data.get('segmentCount', 0)} segments") + for s in data.get('segments', [])[:20]: + t = s.get('type', '?') + el = s.get('element', '?') + m = s.get('mass', 0) + temp = s.get('temperature', 0) + print(f" [{t}] {el:20s} {m:8.1f} kg {temp:6.1f} K") + +# --------------------------------------------------------------------------- +# CO2 +# --------------------------------------------------------------------------- + +def cmd_co2(args): + """Find CO2 pockets. Usage: co2""" + data = api_get('/api/state/co2') + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"CO2 Pockets: {data.get('pocketCount', 0)} Total: {data.get('totalMassKg', 0):.0f} kg") + for p in data.get('pockets', [])[:10]: + print(f" ({p.get('x', '?')},{p.get('y', '?')}) {p.get('mass', 0):.1f} kg {p.get('temp', 0):.0f}°C") + +# --------------------------------------------------------------------------- +# Temperature +# --------------------------------------------------------------------------- + +def cmd_temp_zones(args): + """Analyze temperature zones. Usage: temp_zones""" + data = api_get('/api/state/temperature/zones') + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Temperature Analysis:") + print(f" Average: {data.get('averageC', 0):.0f}°C") + print(f" Min: {data.get('minC', 0):.0f}°C") + print(f" Max: {data.get('maxC', 0):.0f}°C") + print(f" Samples: {data.get('sampleCount', 0)}") + print() + hotspots = data.get('hotSpots', []) + if hotspots: + print(f"Hot spots (>50°C):") + for h in hotspots: + print(f" ({h.get('x', '?')},{h.get('y', '?')}) {h.get('tempC', 0):.0f}°C") + coldspots = data.get('coldSpots', []) + if coldspots: + print(f"Cold spots (<5°C):") + for c in coldspots: + print(f" ({c.get('x', '?')},{c.get('y', '?')}) {c.get('tempC', 0):.0f}°C") + +# --------------------------------------------------------------------------- +# Morale +# --------------------------------------------------------------------------- + +def cmd_morale(args): + """Check morale. Usage: morale""" + data = api_get('/api/state/morale') + if 'error' in data: + print(f"Error: {data['error']}") + return + for m in data if isinstance(data, list) else []: + print(f" {m.get('name', '?'):12s} morale={m.get('morale', 0):.0f} qol={m.get('qualityOfLife', 0):.0f}") + +# --------------------------------------------------------------------------- +# Diseases +# --------------------------------------------------------------------------- + +def cmd_diseases(args): + """Check disease status. Usage: diseases""" + data = api_get('/api/state/diseases') + if 'error' in data: + print(f"Error: {data['error']}") + return + infected = data.get('infectedDuplicants', []) + if infected: + print(f"Infected dupes: {len(infected)}") + for d in infected: + print(f" {d.get('duplicant', '?')} - {d.get('disease', '?')} ({d.get('severity', '?')})") + else: + print("No infected duplicants.") + germs = data.get('environmentGerms', {}) + if germs: + print(f"Environmental germs:") + for name, count in sorted(germs.items(), key=lambda x: -x[1])[:5]: + print(f" {name}: {count:.0f}") + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +def cmd_storage(args): + """Check storage. Usage: storage""" + data = api_get('/api/state/storage') + if 'error' in data: + print(f"Error: {data['error']}") + return + print(f"Storage buildings: {data.get('storageCount', 0)}") + for s in data.get('storages', [])[:10]: + print(f" {s.get('building', '?'):25s} ({s.get('x', '?')},{s.get('y', '?')}) " + f"{s.get('totalMass', 0):.0f}/{s.get('capacity', 0):.0f} kg") + +# --------------------------------------------------------------------------- +# Skills / Job +# --------------------------------------------------------------------------- + +def cmd_skills(args): + """Show duplicant skills. Usage: skills""" + data = api_get('/api/state/duplicants/skills') + if 'error' in data: + print(f"Error: {data['error']}") + return + for d in data if isinstance(data, list) else []: + print(f" {d.get('name', '?'):12s} skill_pts={d.get('skillPoints', 0)} total_pts={d.get('totalSkillPointsGained', 0)}") + for a in d.get('attributes', [])[:5]: + print(f" {a.get('name', '?'):15s} = {a.get('value', 0)}") + +def cmd_assign_job(args): + """Assign dupe to a job. Usage: assign_job """ + if len(args) < 2: + print("Usage: assign_job ") + print(" chore groups: Build, Dig, Cook, Farm, Ranch, Operate, Research, Store, Tidy, LifeSupport") + return + result = api_post('/api/action/assign_job', { + "duplicantId": args[0], + "choreGroup": args[1] + }) + _print_feedback(result) + def _print_feedback(result): """Pretty-print action feedback.""" if result.get('success'): @@ -611,6 +810,19 @@ COMMANDS = { 'pause': cmd_pause, 'unpause': cmd_unpause, 'speed': cmd_speed, + 'save': cmd_save, + 'save_as': cmd_save_as, + 'load': cmd_load, + 'saves': cmd_saves, + 'power': cmd_power, + 'pipes': cmd_pipes, + 'co2': cmd_co2, + 'temp_zones': cmd_temp_zones, + 'morale': cmd_morale, + 'diseases': cmd_diseases, + 'storage': cmd_storage, + 'skills': cmd_skills, + 'assign_job': cmd_assign_job, } if __name__ == '__main__': @@ -663,6 +875,25 @@ if __name__ == '__main__': print(" unpause [speed] Unpause the game at 1x/2x/3x speed") print(" speed <1|2|3> Set game speed while running") print("") + print("=== Save / Load ===") + print(" save [name] Save game (AI undo button)") + print(" save_as Save with custom name") + print(" saves List save files") + print(" load Load a save (rollback)") + print("") + print("=== Grid Analysis ===") + print(" power Power grid (circuits, load, overloads)") + print(" pipes [gas|liquid|all] Pipe contents (debug plumbing)") + print(" co2 Find CO2 pockets (colony killer!)") + print(" temp_zones Temperature hot/cold spots") + print("") + print("=== Colony Management ===") + print(" storages Storage building contents") + print(" diseases Disease/infection overview") + print(" skills Duplicant skills and attributes") + print(" assign_job Assign dupe to a chore group") + print(" morale Duplicant morale levels") + print("") print("=== Batch / Priority (Advanced) ===") print(" batch Execute batch plan") print(" priority_global

Set global default priority")