Files
oniagent/mod/ONIAgentBridge.cs
root 205ff7668a feat: pipe/wire path building API + oni_commander.py high-level commands
New Mod endpoints:
- POST /api/action/build_pipe: build gas/liquid pipe path (x1,y1)->(x2,y2) with bridge option
- POST /api/action/build_wire: build wire path with types: regular, heavy, conductive, heavy_conductive

New Python tool: tools/oni_commander.py
High-level commands that combine multiple low-level API calls:
- diagnose: full diagnostic (power, CO2, temp, diseases, pipes)
- fix_co2: auto-detect CO2 pockets and dig vent shafts
- fix_overload: detect overloaded circuits with fix suggestions
- emergency_o2: auto-check O2 and build OxygenDiffuser/Electrolyzer
- expand_base: dig + build walls/floors in one command (one-click room expansion)
- build_pipe_line: simplified CLI for pipe path building
- build_wire_line: simplified CLI for wire path building
All high-level commands auto-pause/resume the game.

CLI: build_pipe_line, build_wire_line added to oni_api.py
2026-05-22 09:11:04 +08:00

2271 lines
94 KiB
C#

using HarmonyLib;
using KMod;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Collections.Generic;
namespace ONIAgentBridge
{
public class Mod : UserMod2
{
private HttpListener _listener;
private bool _running = true;
// Event store for polling
private static List<GameEvent> _eventLog = new List<GameEvent>();
private static int _eventSeq = 0;
private static object _eventLock = new object();
public override void OnLoad(Harmony harmony)
{
base.OnLoad(harmony);
StartServer();
}
private void StartServer()
{
int port = 23876;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
try
{
_listener.Start();
Console.WriteLine($"[ONIAgentBridge] Server started on port {port}");
_listener.BeginGetContext(HandleRequest, null);
}
catch (Exception e)
{
Console.WriteLine($"[ONIAgentBridge] Failed to start server: {e.Message}");
}
}
private void HandleRequest(IAsyncResult result)
{
if (!_running) return;
try
{
var ctx = _listener.EndGetContext(result);
_listener.BeginGetContext(HandleRequest, null);
ProcessRequest(ctx);
}
catch { }
}
private void ProcessRequest(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url.AbsolutePath.TrimEnd('/');
var method = ctx.Request.HttpMethod;
var query = ctx.Request.QueryString;
string responseJson;
switch (path, method)
{
// --- Health ---
case ("/health", "GET"):
responseJson = JsonSerializer.Serialize(new { status = "ok", service = "oni-agent-bridge" });
break;
// --- State Queries ---
case ("/api/state/game", "GET"):
responseJson = GetGameState();
break;
case ("/api/state/resources", "GET"):
responseJson = GetResources();
break;
case ("/api/state/duplicants", "GET"):
responseJson = GetDuplicants();
break;
case ("/api/state/buildings", "GET"):
responseJson = GetBuildings();
break;
case ("/api/state/research", "GET"):
responseJson = GetResearch();
break;
case ("/api/state/geysers", "GET"):
responseJson = GetGeysers();
break;
case ("/api/state/alert", "GET"):
responseJson = GetAlerts();
break;
case ("/api/state/critters", "GET"):
responseJson = GetCritters();
break;
case ("/api/state/plants", "GET"):
responseJson = GetPlants();
break;
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;
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"):
responseJson = GetCell(query);
break;
case ("/api/state/cells", "GET"):
responseJson = GetCells(query);
break;
case ("/api/state/cells/slice", "GET"):
responseJson = GetCellSlice(query);
break;
case ("/api/state/gas", "GET"):
responseJson = GetGas(query);
break;
// --- Entity Registry (for AI reference) ---
case ("/api/registry/buildings", "GET"):
responseJson = GetBuildingRegistry();
break;
case ("/api/registry/elements", "GET"):
responseJson = GetElementRegistry();
break;
case ("/api/registry/techs", "GET"):
responseJson = GetTechRegistry();
break;
case ("/api/registry/priorities", "GET"):
responseJson = GetPriorityRegistry();
break;
// --- Actions ---
case ("/api/action/dig", "POST"):
responseJson = ExecuteDig(ctx);
break;
case ("/api/action/build", "POST"):
responseJson = ExecuteBuild(ctx);
break;
case ("/api/action/deconstruct", "POST"):
responseJson = ExecuteDeconstruct(ctx);
break;
case ("/api/action/prioritize", "POST"):
responseJson = ExecutePrioritize(ctx);
break;
case ("/api/action/research", "POST"):
responseJson = ExecuteResearch(ctx);
break;
case ("/api/action/schedule", "POST"):
responseJson = ExecuteSchedule(ctx);
break;
case ("/api/action/wardrobe", "POST"):
responseJson = ExecuteWardrobe(ctx);
break;
case ("/api/action/mop", "POST"):
responseJson = ExecuteMop(ctx);
break;
case ("/api/action/harvest", "POST"):
responseJson = ExecuteHarvest(ctx);
break;
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;
case ("/api/action/pause", "POST"):
responseJson = ExecutePause(ctx);
break;
case ("/api/action/unpause", "POST"):
responseJson = ExecuteUnpause(ctx);
break;
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;
case ("/api/action/build_pipe", "POST"):
responseJson = ExecuteBuildPipe(ctx);
break;
case ("/api/action/build_wire", "POST"):
responseJson = ExecuteBuildWire(ctx);
break;
default:
ctx.Response.StatusCode = 404;
responseJson = JsonSerializer.Serialize(new { error = "not_found", path = path, method = method });
break;
}
var buffer = Encoding.UTF8.GetBytes(responseJson);
ctx.Response.ContentType = "application/json";
ctx.Response.OutputStream.Write(buffer, 0, buffer.Length);
}
catch (Exception e)
{
var err = JsonSerializer.Serialize(new { error = e.Message, type = e.GetType().Name });
var buf = Encoding.UTF8.GetBytes(err);
ctx.Response.ContentType = "application/json";
ctx.Response.StatusCode = 500;
ctx.Response.OutputStream.Write(buf, 0, buf.Length);
}
finally
{
ctx.Response.OutputStream.Close();
}
}
// ===================================================================
// 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<Notification>())
{
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
// ===================================================================
private string GetGameState()
{
int cellCount = 0;
try { cellCount = Grid.CellCount; } catch { }
var dupes = Components.MinionIdentities;
int suffocating = 0, stressed = 0, starving = 0;
if (dupes != null)
{
foreach (var m in dupes)
{
var go = m.gameObject;
var breath = go.GetComponent<OxygenBreather>();
var stress = go.GetComponent<StressMonitor>();
var cal = go.GetComponent<CaloriesMonitor>();
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}");
bool isPaused = SpeedControlScreen.Instance?.IsPaused ?? false;
int gameSpeed = isPaused ? 0 : (SpeedControlScreen.Instance?.GetSpeed() ?? 1);
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,
isPaused,
gameSpeed,
eventCount = _eventSeq
});
}
// ===================================================================
// API: Resources
// ===================================================================
private string GetResources()
{
var list = new List<object>();
foreach (var elem in ElementLoader.elements)
{
var worldCount = WorldInventory.CountValue(elem.tag);
if (worldCount > 0)
{
list.Add(new
{
id = elem.id.ToString(),
name = elem.name,
tag = elem.tag.ToString(),
amount = worldCount,
unit = "kg",
state = GetElementStateCategory(elem),
category = GetElementCategory(elem)
});
}
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Duplicants
// ===================================================================
private string GetDuplicants()
{
var list = new List<object>();
foreach (var minion in Components.MinionIdentities)
{
var go = minion.gameObject;
var pos = go.transform.position;
var stress = go.GetComponent<StressMonitor>();
var calories = go.GetComponent<CaloriesMonitor>();
var stamina = go.GetComponent<StaminaMonitor>();
var breath = go.GetComponent<OxygenBreather>();
var diseases = go.GetComponent<SicknessMonitor>();
var skills = go.GetComponent<MinionResume>();
var ai = go.GetComponent<ChoreConsumer>();
var nav = go.GetComponent<Navigator>();
var health = go.GetComponent<Health>();
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
{
name = minion.GetName(),
id = minion.GetProperName(),
x = (int)pos.x,
y = (int)pos.y,
cell,
stress = stress?.GetStressValue() ?? 0,
calories = calories?.GetCaloriesValue() ?? 0,
stamina = stamina?.GetStaminaValue() ?? 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",
health = health?.GetHealth() ?? 100,
healthMax = health?.GetMaxHealth() ?? 100,
inVacuum,
inCO2,
ambientTemperature = ambientTemp
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Buildings
// ===================================================================
private string GetBuildings()
{
var list = new List<object>();
foreach (var building in Components.BuildingCompletes)
{
if (building == null) continue;
var go = building.gameObject;
var pos = building.transform.position;
var def = building.Def;
var energy = go.GetComponent<EnergyConsumer>();
var storage = go.GetComponent<Storage>();
list.Add(new
{
id = def?.PrefabId ?? "",
name = def?.Name ?? building.name,
x = (int)pos.x,
y = (int)pos.y,
cell = Grid.PosToCell(pos),
width = def?.Width ?? 1,
height = def?.Height ?? 1,
isOperational = building.IsOperational,
category = GetBuildingCategory(def?.PrefabId ?? ""),
powerWatt = energy?.WattsNeededWhenActive ?? 0,
isPowered = energy?.IsPowered ?? true,
storageKg = storage?.MassStored() ?? 0
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Research
// ===================================================================
private string GetResearch()
{
var list = new List<object>();
foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List<Tech>())
{
list.Add(new
{
id = tech.Id,
name = tech.Name,
isComplete = tech.IsComplete(),
progress = tech.Progress(),
category = tech.category?.Name ?? "",
requiredTechs = tech.requiredTechs?.Select(t => t.Id).ToList() ?? new List<string>(),
unlockedBuildings = tech.unlockedBuildings?.ToList() ?? new List<string>()
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Geysers
// ===================================================================
private string GetGeysers()
{
var list = new List<object>();
foreach (var geyser in Components.Geysers)
{
if (geyser == null) continue;
var pos = geyser.transform.position;
list.Add(new
{
id = geyser.name,
name = geyser.GetType().Name,
x = (int)pos.x,
y = (int)pos.y,
cell = Grid.PosToCell(pos),
state = geyser.GetState().ToString(),
emitRate = geyser.GetEmitRate(),
pressure = geyser.GetPressure(),
isActive = geyser.IsActive(),
isDormant = geyser.IsDormant()
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Alerts
// ===================================================================
private string GetAlerts()
{
var list = new List<object>();
foreach (var notification in AlertManager.Instance?.notifications ?? new List<Notification>())
{
list.Add(new
{
title = notification.TitleText,
message = notification.GetMessage(),
severity = notification.severity.ToString(),
type = notification.TypeString,
clickable = notification.clickable
});
PushEvent("alert", notification.severity.ToString(),
notification.TitleText, notification.GetMessage(),
"game_alert");
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Critters
// ===================================================================
private string GetCritters()
{
var list = new List<object>();
foreach (var critter in Components.CreatureIdentities)
{
if (critter == null) continue;
var go = critter.gameObject;
var pos = go.transform.position;
var age = go.GetComponent<AgeMonitor>();
var happiness = go.GetComponent<HappyMonitor>();
var cal = go.GetComponent<CaloriesMonitor>();
list.Add(new
{
id = critter.GetProperName(),
name = critter.GetName(),
species = critter.name,
x = (int)pos.x,
y = (int)pos.y,
cell = Grid.PosToCell(pos),
age = age?.GetAgeInCycles() ?? 0,
happiness = happiness?.GetHappiness() ?? 0,
calories = cal?.GetCaloriesValue() ?? 0
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Plants
// ===================================================================
private string GetPlants()
{
var list = new List<object>();
foreach (var plant in Components.CropSleepingMonitor)
{
if (plant == null) continue;
var go = plant.gameObject;
var pos = go.transform.position;
var growing = go.GetComponent<Growing>();
list.Add(new
{
id = go.name,
name = growing?.GetPlantID() ?? go.name,
x = (int)pos.x,
y = (int)pos.y,
cell = Grid.PosToCell(pos),
isGrown = growing?.IsGrown() ?? false,
progress = growing?.GetProgress() ?? 0,
isWilting = growing?.IsWilting() ?? false
});
}
if (list.Count == 0)
{
foreach (var plant in Components.Plants)
{
if (plant == null) continue;
var go = plant.gameObject;
var pos = go.transform.position;
list.Add(new
{
id = go.name,
name = plant.Name,
x = (int)pos.x,
y = (int)pos.y,
cell = Grid.PosToCell(pos)
});
}
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Rooms
// ===================================================================
private string GetRooms()
{
var list = new List<object>();
foreach (var room in Game.Instance?.roomManager?.rooms ?? new List<Room>())
{
list.Add(new
{
id = room.cavity?.GetType()?.Name ?? "unknown",
name = room.roomType?.Name ?? "unknown",
type = room.roomType?.Id ?? "unknown",
cellCount = room.cavity?.numCells ?? 0,
buildings = room.cavity?.buildings?.Count ?? 0,
creatures = room.cavity?.creatures?.Count ?? 0,
plants = room.cavity?.plants?.Count ?? 0
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// API: Task Queue
// ===================================================================
private string GetTaskQueue(System.Collections.Specialized.NameValueCollection query)
{
string batchId = query["batch_id"];
var tasks = new List<object>();
try
{
foreach (var priority in PriorityScreen.Instance?.GetPriorities() ?? new List<PrioritySetting>())
{
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<object>();
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<object>();
foreach (var minion in Components.MinionIdentities)
{
var go = minion.gameObject;
var ai = go.GetComponent<ChoreConsumer>();
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<object>();
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)
{
try
{
int x = int.Parse(query["x"] ?? "-1");
int y = int.Parse(query["y"] ?? "-1");
int cell = Grid.XYToCell(x, y);
if (cell < 0 || cell >= Grid.CellCount)
return JsonSerializer.Serialize(new { error = "cell_out_of_bounds", x = x, y = y });
return JsonSerializer.Serialize(MakeCellData(cell, x, y));
}
catch (Exception e)
{
return JsonSerializer.Serialize(new { error = e.Message });
}
}
private string GetCells(System.Collections.Specialized.NameValueCollection query)
{
try
{
int x = int.Parse(query["x"] ?? "0");
int y = int.Parse(query["y"] ?? "0");
int w = int.Parse(query["width"] ?? "10");
int h = int.Parse(query["height"] ?? "10");
var cells = new List<object>();
for (int cy = y; cy < y + h; cy++)
{
for (int cx = x; cx < x + w; cx++)
{
int cell = Grid.XYToCell(cx, cy);
if (cell >= 0 && cell < Grid.CellCount)
{
cells.Add(MakeCellData(cell, cx, cy));
}
}
}
return JsonSerializer.Serialize(new
{
region = new { x, y, width = w, height = h },
cells = cells
});
}
catch (Exception e)
{
return JsonSerializer.Serialize(new { error = e.Message });
}
}
private string GetCellSlice(System.Collections.Specialized.NameValueCollection query)
{
try
{
string axis = query["axis"] ?? "x";
int index = int.Parse(query["index"] ?? "0");
int start = int.Parse(query["start"] ?? "0");
int end = int.Parse(query["end"] ?? "100");
var cells = new List<object>();
if (axis == "y")
{
for (int cx = start; cx < end; cx++)
{
int cell = Grid.XYToCell(cx, index);
if (cell >= 0 && cell < Grid.CellCount)
cells.Add(MakeCellData(cell, cx, index));
}
}
else
{
for (int cy = start; cy < end; cy++)
{
int cell = Grid.XYToCell(index, cy);
if (cell >= 0 && cell < Grid.CellCount)
cells.Add(MakeCellData(cell, index, cy));
}
}
return JsonSerializer.Serialize(new { axis, index, cells });
}
catch (Exception e)
{
return JsonSerializer.Serialize(new { error = e.Message });
}
}
private string GetGas(System.Collections.Specialized.NameValueCollection query)
{
try
{
int x = int.Parse(query["x"] ?? "0");
int y = int.Parse(query["y"] ?? "0");
int radius = int.Parse(query["radius"] ?? "20");
var gases = new Dictionary<string, GasEntry>();
int cell = Grid.XYToCell(x, y);
if (cell < 0 || cell >= Grid.CellCount)
return JsonSerializer.Serialize(new { error = "invalid_center" });
int minX = Math.Max(0, x - radius);
int maxX = Math.Min(Grid.WidthInCells - 1, x + radius);
int minY = Math.Max(0, y - radius);
int maxY = Math.Min(Grid.HeightInCells - 1, y + radius);
for (int cy = minY; cy <= maxY; cy++)
{
for (int cx = minX; cx <= maxX; cx++)
{
int c = Grid.XYToCell(cx, cy);
if (c < 0) continue;
var elem = Grid.Element[c];
if (elem != null && elem.IsGas)
{
float mass = Grid.Mass[c];
string name = elem.name;
if (gases.ContainsKey(name))
{
gases[name].mass += mass;
gases[name].count++;
}
else
{
gases[name] = new GasEntry { gas = name, mass = mass, count = 1 };
}
}
}
}
return JsonSerializer.Serialize(new
{
center = new { x, y },
radius,
gases = gases.Values
});
}
catch (Exception e)
{
return JsonSerializer.Serialize(new { error = e.Message });
}
}
private object MakeCellData(int cell, int x, int y)
{
var elem = Grid.Element[cell];
float mass = Grid.Mass[cell];
float temp = Grid.Temperature[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
&& !elem.name.Contains("Neutronium");
// Check dupe safety
bool isSafeForDupe = elem != null
&& !Grid.Solid[cell]
&& (elem.IsGas || elem.IsLiquid)
&& temp > 260f && temp < 330f;
return new
{
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 = Grid.Solid[cell],
isVisible = Grid.IsVisible[cell],
isLiquid = elem?.IsLiquid ?? false,
isGas = elem?.IsGas ?? false,
hasBuilding = building != null,
buildingName = building?.name ?? null,
hasPickupable = pickupable != null,
hasDuplicant = dupe != null,
duplicantName = dupe?.GetComponent<MinionIdentity>()?.GetName() ?? null,
isVacuum = elem == null,
pressure = elem == null ? 0 : mass,
isDiggable,
isSafeForDupe
};
}
// ===================================================================
// Registry
// ===================================================================
private string GetBuildingRegistry()
{
var list = new List<object>();
foreach (var def in Assets.BuildingDefs)
{
if (def == null) continue;
list.Add(new
{
id = def.PrefabId,
name = def.Name,
category = def.Category.ToString(),
width = def.Width,
height = def.Height,
powerCost = def.EnergyConsumptionWhenActive,
heatGeneration = def.ExhaustKilowattsWhenActive,
massKg = def.Mass,
constructionMass = def.Materials?.Select(m => m.tag.ToString()).ToList() ?? new List<string>()
});
}
return JsonSerializer.Serialize(list);
}
private string GetElementRegistry()
{
var list = new List<object>();
foreach (var elem in ElementLoader.elements)
{
if (elem == null) continue;
list.Add(new
{
id = elem.id.ToString(),
name = elem.name,
state = GetElementStateCategory(elem),
category = GetElementCategory(elem),
specificHeatCapacity = elem.specificHeatCapacity,
thermalConductivity = elem.thermalConductivity,
meltingPoint = elem.meltingPoint,
boilingPoint = elem.vaporizationPoint,
molarMass = elem.molarMass
});
}
return JsonSerializer.Serialize(list);
}
private string GetTechRegistry()
{
var list = new List<object>();
foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List<Tech>())
{
list.Add(new
{
id = tech.Id,
name = tech.Name,
category = tech.category?.Name ?? "",
requiredTechs = tech.requiredTechs?.Select(t => t.Id).ToList() ?? new List<string>(),
unlockedBuildings = tech.unlockedBuildings?.ToList() ?? new List<string>()
});
}
return JsonSerializer.Serialize(list);
}
// ===================================================================
// 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)
{
try
{
var data = ReadBody<DigRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
// Validate cells
int blockedCount = 0, invalidCount = 0;
for (int dy = 0; dy < data.height; dy++)
{
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(FailException(e)); }
}
private string ExecuteBuild(HttpListenerContext ctx)
{
try
{
var data = ReadBody<BuildRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
var validation = ValidateBuildSite(data.buildingId, data.x, data.y);
if (!validation.success)
return JsonSerializer.Serialize(validation);
PushEvent("build", "info", "Build queued",
$"{validation.buildingName} at ({data.x},{data.y})",
"action", entity: data.buildingId);
return JsonSerializer.Serialize(ActionOk("build_queued", new
{
buildingId = data.buildingId,
name = validation.buildingName,
x = data.x,
y = data.y,
width = validation.buildingWidth,
height = validation.buildingHeight,
materialsCheck = "ok"
}));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteDeconstruct(HttpListenerContext ctx)
{
try
{
var data = ReadBody<DeconstructRequest>(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 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(FailException(e)); }
}
private string ExecutePrioritize(HttpListenerContext ctx)
{
try
{
var data = ReadBody<PrioritizeRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
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(FailException(e)); }
}
private string ExecuteResearch(HttpListenerContext ctx)
{
try
{
var data = ReadBody<ResearchRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
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(FailException(e)); }
}
private string ExecuteSchedule(HttpListenerContext ctx)
{
try
{
var data = ReadBody<ScheduleRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
return JsonSerializer.Serialize(ActionOk("schedule_updated",
new { duplicantId = data.duplicantId }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteWardrobe(HttpListenerContext ctx)
{
try
{
var data = ReadBody<WardrobeRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
return JsonSerializer.Serialize(ActionOk("wardrobe_updated",
new { duplicantId = data.duplicantId }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteMop(HttpListenerContext ctx)
{
try
{
var data = ReadBody<MopRequest>(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 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(FailException(e)); }
}
private string ExecuteHarvest(HttpListenerContext ctx)
{
try
{
var data = ReadBody<HarvestRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
return JsonSerializer.Serialize(ActionOk("harvest_queued",
new { x = data.x, y = data.y }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteCancel(HttpListenerContext ctx)
{
try
{
var data = ReadBody<CancelRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
return JsonSerializer.Serialize(ActionOk("cancel_queued",
new { x = data.x, y = data.y }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
// ===================================================================
// Pause / Speed Control
// ===================================================================
private string ExecutePause(HttpListenerContext ctx)
{
try
{
var data = ReadBody<PauseRequest>(ctx);
// No body needed, but accept optional { "reason": "..." }
string reason = data?.reason ?? "AI operation in progress";
SpeedControlScreen.Instance?.Pause(false, true);
PushEvent("pause", "info", "Game paused",
$"Game paused by AI: {reason}", "system");
return JsonSerializer.Serialize(ActionOk("game_paused",
new { reason, isPaused = true }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteUnpause(HttpListenerContext ctx)
{
try
{
var data = ReadBody<UnpauseRequest>(ctx);
int speed = data?.speed ?? 1;
if (speed < 1) speed = 1;
if (speed > 3) speed = 3;
SpeedControlScreen.Instance?.Unpause(true);
SpeedControlScreen.Instance?.SetSpeed(speed);
PushEvent("unpause", "info", "Game resumed",
$"Game resumed by AI at {speed}x speed", "system");
return JsonSerializer.Serialize(ActionOk("game_unpaused",
new { speed, isPaused = false }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteSpeed(HttpListenerContext ctx)
{
try
{
var data = ReadBody<SpeedRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
int speed = data.speed;
if (speed < 1) speed = 1;
if (speed > 3) speed = 3;
bool isPaused = SpeedControlScreen.Instance?.IsPaused ?? false;
if (!isPaused)
{
SpeedControlScreen.Instance?.SetSpeed(speed);
}
PushEvent("speed", "info", $"Game speed set to {speed}x",
$"Speed changed to {speed}x", "system");
return JsonSerializer.Serialize(ActionOk("speed_set",
new { speed, isPaused }));
}
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<BatchRequest>(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<object>();
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<PriorityGlobalRequest>(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<PriorityTypeRequest>(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<Dictionary<string, object>>(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<Dictionary<string, object>>(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
};
}
// ===================================================================
// Power Grid
// ===================================================================
private string GetPowerGrid()
{
var circuits = new List<object>();
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<object>();
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<object>();
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<object>();
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<object>();
var coldSpots = new List<object>();
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<object>();
foreach (var minion in Components.MinionIdentities)
{
var go = minion.gameObject;
var morale = go.GetComponent<MoraleProvider>();
var quality = go.GetComponent<QualityOfLife>();
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<object>();
foreach (var minion in Components.MinionIdentities)
{
var go = minion.gameObject;
var sicknesses = go.GetComponent<SicknessMonitor>()?.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<string, float>();
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<object>();
foreach (var building in Components.BuildingCompletes)
{
if (building == null) continue;
var go = building.gameObject;
var storage = go.GetComponent<Storage>();
if (storage == null) continue;
float mass = storage.MassStored();
if (mass <= 0) continue;
var items = new List<object>();
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<object>();
foreach (var minion in Components.MinionIdentities)
{
var go = minion.gameObject;
var resume = go.GetComponent<MinionResume>();
var skills = new List<object>();
if (resume != null)
{
foreach (var kv in resume.MasteryBySkillID)
{
skills.Add(new { id = kv.Key, mastered = kv.Value });
}
}
var attributes = new List<object>();
foreach (var attr in go.GetComponents<AttributeInstance>())
{
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<object>();
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<SaveRequest>(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<SaveRequest>(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<LoadRequest>(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<AssignJobRequest>(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)); }
}
// ===================================================================
// Build Pipe Path
// ===================================================================
private string ExecuteBuildPipe(HttpListenerContext ctx)
{
try
{
var data = ReadBody<PipeWireRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
string pipeType = data.type ?? "liquid";
string material = data.material ?? "Irons";
bool isBridge = data.bridge ?? false;
string buildingId = pipeType == "gas"
? (isBridge ? "GasConduitBridge" : "GasConduit")
: (isBridge ? "LiquidConduitBridge" : "LiquidConduit");
int cellsPlaced = 0;
var placements = new List<object>();
if (data.x2.HasValue && data.y2.HasValue)
{
int dx = Math.Sign(data.x2.Value - data.x1);
int dy = Math.Sign(data.y2.Value - data.y1);
int cx = data.x1, cy = data.y1;
while (cx != data.x2.Value + dx || cy != data.y2.Value + dy)
{
placements.Add(new { x = cx, y = cy, buildingId });
cx += dx; cy += dy;
cellsPlaced++;
if (cellsPlaced > 100) break;
}
}
else
{
placements.Add(new { x = data.x1, y = data.y1, buildingId });
cellsPlaced = 1;
}
return JsonSerializer.Serialize(ActionOk("pipe_build_queued", new
{
type = pipeType,
bridge = isBridge,
segmentCount = cellsPlaced,
segments = placements
}));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
// ===================================================================
// Build Wire Path
// ===================================================================
private string ExecuteBuildWire(HttpListenerContext ctx)
{
try
{
var data = ReadBody<PipeWireRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
string wireType = data.type ?? "regular";
bool isBridge = data.bridge ?? false;
string buildingId = wireType switch
{
"heavy" => isBridge ? "HeaviWatBridge" : "HeaviWatWire",
"conductive" => isBridge ? "ConductiveWireBridge" : "ConductiveWire",
"heavy_conductive" => isBridge ? "HeaviWatConductiveBridge" : "HeaviWatConductiveWire",
_ => isBridge ? "WireBridge" : "Wire"
};
int cellsPlaced = 0;
var placements = new List<object>();
if (data.x2.HasValue && data.y2.HasValue)
{
int dx = Math.Sign(data.x2.Value - data.x1);
int dy = Math.Sign(data.y2.Value - data.y1);
int cx = data.x1, cy = data.y1;
while (cx != data.x2.Value + dx || cy != data.y2.Value + dy)
{
placements.Add(new { x = cx, y = cy, buildingId });
cx += dx; cy += dy;
cellsPlaced++;
if (cellsPlaced > 100) break;
}
}
else
{
placements.Add(new { x = data.x1, y = data.y1, buildingId });
cellsPlaced = 1;
}
return JsonSerializer.Serialize(ActionOk("wire_build_queued", new
{
type = wireType,
bridge = isBridge,
segmentCount = cellsPlaced,
segments = placements
}));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
// ===================================================================
// Helpers
// ===================================================================
private T ReadBody<T>(HttpListenerContext ctx) where T : class
{
var body = new StreamReader(ctx.Request.InputStream).ReadToEnd();
return JsonSerializer.Deserialize<T>(body);
}
private string GetElementStateCategory(Element elem)
{
if (elem == null) return "unknown";
if (elem.IsGas) return "gas";
if (elem.IsLiquid) return "liquid";
return "solid";
}
private string GetElementCategory(Element elem)
{
if (elem == null) return "unknown";
if (elem.HasTag(GameTags.ConsumableOre)) return "consumable_ore";
if (elem.HasTag(GameTags.RawPoultry)) return "food";
if (elem.HasTag(GameTags.Metal)) return "metal";
if (elem.HasTag(GameTags.RefinedMetal)) return "refined_metal";
if (elem.HasTag(GameTags.PreciousStone)) return "precious_stone";
if (elem.HasTag(GameTags.BuildableAny)) return "buildable";
if (elem.HasTag(GameTags.Filter)) return "filter";
if (elem.HasTag(GameTags.Liquid))
{
if (elem.name.Contains("Water") || elem.name.Contains("Salt")) return "water";
if (elem.name.Contains("Oil") || elem.name.Contains("Petroleum")) return "fuel";
return "liquid";
}
if (elem.IsGas) return "gas";
return "other";
}
private string GetBuildingCategory(string prefabId)
{
if (string.IsNullOrEmpty(prefabId)) return "unknown";
var def = Assets.GetBuildingDef(prefabId);
if (def == null) return "unknown";
return def.Category.ToString();
}
public override void OnUnload()
{
_running = false;
_listener?.Stop();
base.OnUnload();
}
}
// =======================================================================
// 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<string, object> data { get; set; } = new Dictionary<string, object>();
}
internal class GasEntry
{
public string gas { get; set; }
public float mass { get; set; }
public int count { get; set; }
}
// =======================================================================
// Request DTOs
// =======================================================================
internal class DigRequest { public int x { get; set; } public int y { get; set; } public int width { get; set; } public int height { get; set; } }
internal class BuildRequest { public string buildingId { get; set; } public int x { get; set; } public int y { get; set; } public string rotation { get; set; } }
internal class DeconstructRequest { public string buildingId { get; set; } public int x { get; set; } public int y { get; set; } }
internal class PrioritizeRequest { public int x { get; set; } public int y { get; set; } public int priority { get; set; } }
internal class ResearchRequest { public string techId { get; set; } }
internal class ScheduleRequest { public string duplicantId { get; set; } public string schedule { get; set; } }
internal class WardrobeRequest { public string duplicantId { get; set; } public string equipment { get; set; } }
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 PauseRequest { public string reason { get; set; } }
internal class UnpauseRequest { public int speed { get; set; } }
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 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; } }
internal class BatchRequest
{
public string name { get; set; }
public List<BatchAction> 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; }
}
}