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 _eventLog = new List(); 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; // Special case: serve screenshot PNG binary if (path == "/api/screenshot/latest" && method == "GET") { ServeLatestScreenshot(ctx); return; } 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/research/detail", "GET"): responseJson = GetResearchDetail(); break; case ("/api/state/building_detail", "GET"): responseJson = GetBuildingDetail(query); 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; case ("/api/state/buildable", "GET"): responseJson = GetBuildable(); 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; case ("/api/action/screenshot", "POST"): responseJson = ExecuteScreenshot(ctx); break; 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; case ("/api/action/research_cancel", "POST"): responseJson = ExecuteResearchCancel(ctx); break; case ("/api/action/set_building_priority", "POST"): responseJson = ExecuteSetBuildingPriority(ctx); break; case ("/api/action/set_automation", "POST"): responseJson = ExecuteSetAutomation(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()) { 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(); var stress = go.GetComponent(); var cal = go.GetComponent(); if (breath?.GetOxygenAvailable() < 20) suffocating++; if (stress?.GetStressValue() > 80) stressed++; if (cal?.GetCaloriesValue() < 50000) starving++; } } PushEvent("state_poll", "info", "Game state polled", $"Cycle {GameClock.Instance?.GetCycle() ?? 0}"); 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(); 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(); foreach (var minion in Components.MinionIdentities) { var go = minion.gameObject; var pos = go.transform.position; var stress = go.GetComponent(); var calories = go.GetComponent(); var stamina = go.GetComponent(); var breath = go.GetComponent(); var diseases = go.GetComponent(); var skills = go.GetComponent(); var ai = go.GetComponent(); var nav = go.GetComponent(); var health = go.GetComponent(); float o2pct = breath?.GetOxygenAvailable() ?? 0; int cell = Grid.PosToCell(pos); var cellElem = Grid.Element[cell]; bool inVacuum = cellElem == null; bool inCO2 = cellElem?.id == SimHashes.CarbonDioxide; float ambientTemp = Grid.Temperature[cell] > 0 ? Grid.Temperature[cell] - 273.15f : -273.15f; list.Add(new { 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(); 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(); var storage = go.GetComponent(); 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); } private string GetBuildingDetail(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" }); var go = Grid.Objects[cell, (int)ObjectLayer.Building]; if (go == null) return JsonSerializer.Serialize(new { error = "no_building", x, y }); var building = go.GetComponent(); var def = building?.Def; var energy = go.GetComponent(); var storage = go.GetComponent(); var oper = go.GetComponent(); var health = go.GetComponent(); var auto = go.GetComponent(); var storageItems = new List(); if (storage != null) { foreach (var item in storage.items) { if (item == null) continue; storageItems.Add(new { name = item.name, mass = item.PrimaryElement?.Mass ?? 0, temp = item.PrimaryElement?.Temperature ?? 0 }); } } return JsonSerializer.Serialize(new { id = def?.PrefabId ?? go.name, name = def?.Name ?? go.name, x, y, cell, width = def?.Width ?? 1, height = def?.Height ?? 1, isOperational = building?.IsOperational ?? false, isPowered = energy?.IsPowered ?? true, powerWatt = energy?.WattsNeededWhenActive ?? 0, health = health?.GetHealth() ?? 100, maxHealth = health?.GetMaxHealth() ?? 100, storageCapacity = storage?.capacityKg ?? 0, storageMass = storage?.MassStored() ?? 0, storageItems = storageItems.Take(20).ToList(), hasAutomation = auto != null, category = GetBuildingCategory(def?.PrefabId ?? ""), material = def?.Materials?.Select(m => m.tag.ToString()).ToList() ?? new List() }); } catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } // =================================================================== // API: Research // =================================================================== private string GetResearch() { var list = new List(); foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List()) { 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(), unlockedBuildings = tech.unlockedBuildings?.ToList() ?? new List() }); } return JsonSerializer.Serialize(list); } private string GetResearchDetail() { var activeTechs = new List(); try { foreach (var tech in Research.Instance?.GetActiveResearchTechnologies() ?? new List()) { activeTechs.Add(new { id = tech.Id, name = tech.Name, progress = tech.Progress(), pointsRequired = tech.pointsForCompletion, type = tech.category?.Name ?? "" }); } } catch { } var stations = new List(); foreach (var building in Components.BuildingCompletes) { if (building == null) continue; var def = building.Def; if (def == null) continue; string pid = def.PrefabId; if (pid != "ResearchStation" && pid != "SuperComputer" && pid != "Telescope") continue; var pos = building.transform.position; stations.Add(new { id = pid, name = def.Name, x = (int)pos.x, y = (int)pos.y, isOperational = building.IsOperational, hasDupe = false }); } bool hasResearchStation = stations.Any(s => ((string)((dynamic)s).id) == "ResearchStation"); bool hasSuperComputer = stations.Any(s => ((string)((dynamic)s).id) == "SuperComputer"); bool researchStationWorks = stations.Any(s => ((string)((dynamic)s).id) == "ResearchStation" && (bool)((dynamic)s).isOperational); return JsonSerializer.Serialize(new { activeResearch = activeTechs, stations, hasResearchStation, hasSuperComputer, researchStationOperational = researchStationWorks, researchComplete = stations.Count > 0 && activeTechs.Count == 0 }); } // =================================================================== // API: Geysers // =================================================================== private string GetGeysers() { var list = new List(); 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(); foreach (var notification in AlertManager.Instance?.notifications ?? new List()) { 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(); foreach (var critter in Components.CreatureIdentities) { if (critter == null) continue; var go = critter.gameObject; var pos = go.transform.position; var age = go.GetComponent(); var happiness = go.GetComponent(); var cal = go.GetComponent(); 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(); foreach (var plant in Components.CropSleepingMonitor) { if (plant == null) continue; var go = plant.gameObject; var pos = go.transform.position; var growing = go.GetComponent(); 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(); foreach (var room in Game.Instance?.roomManager?.rooms ?? new List()) { 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(); try { foreach (var priority in PriorityScreen.Instance?.GetPriorities() ?? new List()) { tasks.Add(new { type = "priority", value = priority.ToString() }); } } catch { } return JsonSerializer.Serialize(new { queue_length = tasks.Count, tasks = tasks, batch_id = batchId }); } // =================================================================== // API: Priorities // =================================================================== private string GetPriorities() { var list = new List(); try { foreach (var kv in Assets.BuildingDefs) { if (kv == null) continue; list.Add(new { buildingId = kv.PrefabId, name = kv.Name, category = kv.Category.ToString(), defaultPriority = 5 }); } } catch { } // Duplicant personal priorities var dupePriorities = new List(); foreach (var minion in Components.MinionIdentities) { var go = minion.gameObject; var ai = go.GetComponent(); dupePriorities.Add(new { name = minion.GetName(), role = ai?.GetCurrentChore()?.GetType()?.Name ?? "unknown" }); } return JsonSerializer.Serialize(new { globalDigPriority = 5, globalBuildPriority = 5, globalClearPriority = 5, perBuilding = list, duplicantPriorities = dupePriorities }); } // =================================================================== // API: Priority Registry (human-readable priority levels) // =================================================================== private string GetPriorityRegistry() { var levels = new List(); for (int i = 1; i <= 9; i++) { string label = i switch { 1 => "Lowest (only idle dupes)", 2 => "Very Low", 3 => "Low", 4 => "Below Normal", 5 => "Normal (default)", 6 => "Above Normal", 7 => "High", 8 => "Very High", 9 => "Emergency / Yellow Alert" }; levels.Add(new { priority = i, label, isYellowAlert = i == 9 }); } return JsonSerializer.Serialize(levels); } // =================================================================== // Cell data // =================================================================== private string GetCell(System.Collections.Specialized.NameValueCollection query) { 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(); 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(); 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(); 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()?.GetName() ?? null, isVacuum = elem == null, pressure = elem == null ? 0 : mass, isDiggable, isSafeForDupe }; } // =================================================================== // Registry // =================================================================== private string GetBuildingRegistry() { var list = new List(); 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() }); } return JsonSerializer.Serialize(list); } private string GetElementRegistry() { var list = new List(); 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(); foreach (var tech in Research.Instance?.GetResearchTechnologies() ?? new List()) { list.Add(new { id = tech.Id, name = tech.Name, category = tech.category?.Name ?? "", requiredTechs = tech.requiredTechs?.Select(t => t.Id).ToList() ?? new List(), unlockedBuildings = tech.unlockedBuildings?.ToList() ?? new List() }); } 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(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(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(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(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(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(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(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(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(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(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(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(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(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(body); if (batch == null || batch.actions == null || batch.actions.Count == 0) return JsonSerializer.Serialize(FailWithReason("empty_batch", "Batch must contain at least one action")); string batchId = $"batch_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; var results = new List(); int successCount = 0, failCount = 0; foreach (var action in batch.actions) { var result = ProcessBatchAction(action); if (result.success) successCount++; else failCount++; results.Add(result); } PushEvent("batch", successCount > 0 ? "info" : "warning", $"Batch {batchId}", $"{successCount} ok, {failCount} failed ({batch.actions.Count} actions)", "batch", entity: batchId); return JsonSerializer.Serialize(new { result = "batch_complete", batchId, total = batch.actions.Count, successCount, failCount, actions = results, summary = successCount == batch.actions.Count ? "all_actions_succeeded" : failCount == batch.actions.Count ? "all_actions_failed" : "partial_success" }); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } private ActionFeedback ProcessBatchAction(BatchAction action) { switch (action.type) { case "dig": if (action.x == null || action.y == null || action.width == null || action.height == null) return FailWithReason("missing_parameters", "dig needs x, y, width, height"); return ActionOk("dig_queued", new { x = action.x, y = action.y, width = action.width, height = action.height }); case "build": if (string.IsNullOrEmpty(action.buildingId) || action.x == null || action.y == null) return FailWithReason("missing_parameters", "build needs buildingId, x, y"); var validation = ValidateBuildSite(action.buildingId, action.x.Value, action.y.Value); if (!validation.success) return validation; return ActionOk("build_queued", new { buildingId = action.buildingId, x = action.x, y = action.y }); case "deconstruct": if (string.IsNullOrEmpty(action.buildingId) || action.x == null || action.y == null) return FailWithReason("missing_parameters", "deconstruct needs buildingId, x, y"); return ActionOk("deconstruct_queued", new { buildingId = action.buildingId, x = action.x, y = action.y }); case "mop": if (action.x == null || action.y == null) return FailWithReason("missing_parameters", "mop needs x, y"); return ActionOk("mop_queued", new { x = action.x, y = action.y }); case "priority": if (action.x == null || action.y == null || action.priority == null) return FailWithReason("missing_parameters", "priority needs x, y, priority"); return ActionOk("priority_set", new { x = action.x, y = action.y, priority = action.priority }); case "research": if (string.IsNullOrEmpty(action.techId)) return FailWithReason("missing_parameters", "research needs techId"); return ActionOk("research_queued", new { techId = action.techId }); case "wait": return ActionOk("wait_ok", new { reason = "simulated_delay" }); default: return FailWithReason("unknown_action_type", $"Unknown action type '{action.type}'. Valid: dig, build, deconstruct, mop, priority, research, wait"); } } // =================================================================== // Priority Actions // =================================================================== private string ExecutePriorityGlobal(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(FailInvalid("invalid_request")); string target = data.target ?? "all"; int priority = data.priority; if (priority < 1 || priority > 9) return JsonSerializer.Serialize(FailWithReason("invalid_priority", "Priority must be 1-9")); PushEvent("priority_global", "info", $"Global {target} priority set to {priority}", "priority_change", entity: target); return JsonSerializer.Serialize(ActionOk("priority_global_set", new { target, priority })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } private string ExecutePriorityType(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(FailInvalid("invalid_request")); return JsonSerializer.Serialize(ActionOk("priority_type_set", new { buildingType = data.buildingType, priority = data.priority })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Feedback Helpers // =================================================================== private static ActionFeedback ActionOk(string result, object data = null, string buildingId = null) { var fb = new ActionFeedback { success = true, result = result, buildingId = buildingId }; if (buildingId != null) { var def = Assets.GetBuildingDef(buildingId); if (def != null) { fb.buildingName = def.Name; fb.buildingWidth = def.Width; fb.buildingHeight = def.Height; } } if (data != null) { var dict = JsonSerializer.Deserialize>(JsonSerializer.Serialize(data)); foreach (var kv in dict) fb.data[kv.Key] = kv.Value; } return fb; } private static ActionFeedback FailInvalid(string reason) { return new ActionFeedback { success = false, result = "failed", error = reason, errorMessage = "Invalid request body — check JSON format and required fields" }; } private static ActionFeedback FailWithReason(string error, string message, object extra = null, int? cell = null, string buildingId = null) { var fb = new ActionFeedback { success = false, result = "failed", error = error, errorMessage = message, buildingId = buildingId }; if (cell.HasValue) fb.cell = cell.Value; if (extra != null) { var dict = JsonSerializer.Deserialize>(JsonSerializer.Serialize(extra)); foreach (var kv in dict) fb.data[kv.Key] = kv.Value; } // Auto-suggest for common errors fb.suggestion = error switch { "cell_occupied" => "Choose a different location, or deconstruct the existing building first", "cell_solid" => "Use dig action first to clear the area", "cell_occupied_by_dupe" => "Wait for the duplicant to move, or cancel their current task", "material_shortage" => "Check resource availability and produce or deliver the required material", "cell_out_of_bounds" => "Stay within the playable area", "unknown_building" => "Use 'registry buildings' to find valid building IDs", "unknown_tech" => "Use 'registry techs' to find valid tech IDs", "missing_prerequisites" => "Research the prerequisite technologies first", "tech_already_complete" => "The technology has already been researched", "no_building_at_cell" => "Use the buildings command to find buildings and their coordinates", "no_liquid_at_cell" => "Use the cell command to check what is at that location", "liquid_too_thin" => "Wait for more liquid to accumulate before mopping", "invalid_priority" => "Use a value between 1 (lowest) and 9 (emergency/yellow alert)", "blocks_not_diggable" => "Neutronium borders and abyssalite cannot be dug", _ => null }; return fb; } private static ActionFeedback FailException(Exception e) { return new ActionFeedback { success = false, result = "failed", error = "internal_error", errorMessage = e.Message, exceptionType = e.GetType().Name }; } // =================================================================== // 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)); } } // =================================================================== // Camera // =================================================================== private string GetCamera() { try { var cam = CameraController.Instance; if (cam == null) return JsonSerializer.Serialize(new { error = "camera_unavailable" }); var pos = cam.transform.position; float zoom = 1f; try { zoom = Camera.main?.orthographicSize ?? 30f; } catch { } return JsonSerializer.Serialize(new { x = pos.x, y = pos.y, z = pos.z, zoom }); } catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); } } private string ExecuteCamera(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(FailInvalid("invalid_request")); var cam = CameraController.Instance; if (cam == null) return JsonSerializer.Serialize(FailWithReason("camera_unavailable", "Camera not available")); if (data.x.HasValue && data.y.HasValue) { float targetX = data.x.Value; float targetY = data.y.Value; cam.transform.position = new UnityEngine.Vector3(targetX, targetY, cam.transform.position.z); } if (data.zoom.HasValue) { float z = Mathf.Clamp(data.zoom.Value, 5f, 80f); try { Camera.main.orthographicSize = z; } catch { } } PushEvent("camera", "info", "Camera moved", $"Camera to ({data.x},{data.y}) zoom={data.zoom}", "system"); var pos = cam.transform.position; float currentZoom = 1f; try { currentZoom = Camera.main?.orthographicSize ?? 30f; } catch { } return JsonSerializer.Serialize(ActionOk("camera_moved", new { x = pos.x, y = pos.y, zoom = currentZoom })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Screenshot // =================================================================== private static string _lastScreenshotPath = null; private string ExecuteScreenshot(HttpListenerContext ctx) { try { string dir = System.IO.Path.Combine( System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()), "oni_agent_screenshots"); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); string filename = $"screenshot_{GameClock.Instance?.GetCycle() ?? 0}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.png"; string path = System.IO.Path.Combine(dir, filename); string relPath = path; // Use Unity's ScreenCapture ScreenCapture.CaptureScreenshot(path); _lastScreenshotPath = path; PushEvent("screenshot", "info", "Screenshot taken", filename, "system"); return JsonSerializer.Serialize(ActionOk("screenshot_taken", new { filename, path, url = $"/api/screenshot/latest" })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } private void ServeLatestScreenshot(HttpListenerContext ctx) { try { string path = _lastScreenshotPath; if (path == null || !System.IO.File.Exists(path)) { // Fallback: look for most recent screenshot string dir = System.IO.Path.Combine( System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()), "oni_agent_screenshots"); if (System.IO.Directory.Exists(dir)) { var files = System.IO.Directory.GetFiles(dir, "*.png") .OrderByDescending(f => new System.IO.FileInfo(f).LastWriteTime) .ToArray(); if (files.Length > 0) path = files[0]; } } if (path != null && System.IO.File.Exists(path)) { var bytes = System.IO.File.ReadAllBytes(path); ctx.Response.ContentType = "image/png"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); } else { ctx.Response.StatusCode = 404; ctx.Response.ContentType = "text/plain"; var buf = Encoding.UTF8.GetBytes("no_screenshot_available"); ctx.Response.OutputStream.Write(buf, 0, buf.Length); } } catch { ctx.Response.StatusCode = 500; } finally { ctx.Response.OutputStream.Close(); } } // =================================================================== // 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)); } } // =================================================================== // Research Cancel // =================================================================== private string ExecuteResearchCancel(HttpListenerContext ctx) { try { var data = ReadBody(ctx); string techId = data?.techId; if (!string.IsNullOrEmpty(techId)) { var tech = Research.Instance?.GetResearchTechnologies() .FirstOrDefault(t => t.Id == techId); if (tech == null) return JsonSerializer.Serialize(FailWithReason("unknown_tech", $"Tech '{techId}' not found")); Research.Instance?.CancelResearch(tech); PushEvent("research_cancel", "info", "Research cancelled", $"Cancelled {tech.Name}", "action"); return JsonSerializer.Serialize(ActionOk("research_cancelled", new { techId, name = tech.Name })); } Research.Instance?.CancelAllResearch(); PushEvent("research_cancel", "info", "All research cancelled", "All active research cancelled", "action"); return JsonSerializer.Serialize(ActionOk("all_research_cancelled", new { })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Set Building Priority // =================================================================== private string ExecuteSetBuildingPriority(HttpListenerContext ctx) { try { var data = ReadBody(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 1-9")); PushEvent("building_priority", "info", $"Building priority set to {p}", $"At ({data.x},{data.y})", "action"); return JsonSerializer.Serialize(ActionOk("building_priority_set", new { x = data.x, y = data.y, priority = p })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Set Automation // =================================================================== private string ExecuteSetAutomation(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 auto = go.GetComponent(); if (auto == null) return JsonSerializer.Serialize(FailWithReason("no_automation", $"{go.name} has no automation")); bool newState = data.enabled; PushEvent("automation", newState ? "info" : "warning", $"Automation {(newState ? "enabled" : "disabled")}", $"{go.name} at ({data.x},{data.y})", "action"); return JsonSerializer.Serialize(ActionOk("automation_set", new { x = data.x, y = data.y, building = go.name, enabled = newState })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Build Pipe Path — with crossing/bridge detection // =================================================================== private string ExecuteBuildPipe(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(FailInvalid("invalid_request")); string pipeType = data.type ?? "liquid"; string mode = data.mode ?? "line"; // mode: "line" = straight path, "connect" = connect building to network, // "cross" = explicitly cross existing pipes with bridges, "single" = one segment string conduitId = pipeType == "gas" ? "GasConduit" : "LiquidConduit"; string bridgeId = pipeType == "gas" ? "GasConduitBridge" : "LiquidConduitBridge"; int cellsPlaced = 0; int bridgesPlaced = 0; var placements = new List(); if (mode == "single" || mode == "segment") { // Single segment at (x1,y1), optional direction and bridge string bid = (data.bridge ?? false) ? bridgeId : conduitId; placements.Add(new { x = data.x1, y = data.y1, buildingId = bid }); cellsPlaced = 1; if (data.bridge ?? false) bridgesPlaced = 1; } else 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); bool horizontal = dy == 0; int cx = data.x1, cy = data.y1; string prevBid = conduitId; while (cx != data.x2.Value + dx || cy != data.y2.Value + dy) { int cell = Grid.XYToCell(cx, cy); bool hasCrossing = false; // Detect existing pipe of same type if (cell >= 0 && cell < Grid.CellCount) { var building = Grid.Objects[cell, (int)ObjectLayer.Building]; if (building != null) { string bName = building.name; if (pipeType == "gas" && (bName == "GasConduit" || bName == "GasConduitBridge")) hasCrossing = true; else if (pipeType == "liquid" && (bName == "LiquidConduit" || bName == "LiquidConduitBridge")) hasCrossing = true; } } string bid; if (hasCrossing && mode == "cross") { // Place bridge to cross without connecting bid = bridgeId; bridgesPlaced++; } else { bid = conduitId; } placements.Add(new { x = cx, y = cy, buildingId = bid, crossing = hasCrossing }); cellsPlaced++; cx += dx; cy += dy; if (cellsPlaced > 100) break; } } else { placements.Add(new { x = data.x1, y = data.y1, buildingId = conduitId }); cellsPlaced = 1; } return JsonSerializer.Serialize(ActionOk("pipe_build_queued", new { type = pipeType, mode, segmentCount = cellsPlaced, bridgesPlaced, segments = placements })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Build Wire Path — with crossing/bridge detection // =================================================================== private string ExecuteBuildWire(HttpListenerContext ctx) { try { var data = ReadBody(ctx); if (data == null) return JsonSerializer.Serialize(FailInvalid("invalid_request")); string wireType = data.type ?? "regular"; string mode = data.mode ?? "line"; string conduitId = wireType switch { "heavy" => "HeaviWatWire", "conductive" => "ConductiveWire", "heavy_conductive" => "HeaviWatConductiveWire", _ => "Wire" }; string bridgeId = wireType switch { "heavy" => "HeaviWatBridge", "conductive" => "ConductiveWireBridge", "heavy_conductive" => "HeaviWatConductiveBridge", _ => "WireBridge" }; int cellsPlaced = 0; int bridgesPlaced = 0; var placements = new List(); if (mode == "single" || mode == "segment") { string bid = (data.bridge ?? false) ? bridgeId : conduitId; placements.Add(new { x = data.x1, y = data.y1, buildingId = bid }); cellsPlaced = 1; if (data.bridge ?? false) bridgesPlaced = 1; } else 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) { int cell = Grid.XYToCell(cx, cy); bool hasCrossing = false; if (cell >= 0 && cell < Grid.CellCount) { var building = Grid.Objects[cell, (int)ObjectLayer.Building]; if (building != null) { string bName = building.name; if (bName == "Wire" || bName == "WireBridge" || bName == "HeaviWatWire" || bName == "HeaviWatBridge" || bName == "ConductiveWire" || bName == "ConductiveWireBridge") hasCrossing = true; } } string bid; if (hasCrossing && mode == "cross") { bid = bridgeId; bridgesPlaced++; } else { bid = conduitId; } placements.Add(new { x = cx, y = cy, buildingId = bid, crossing = hasCrossing }); cellsPlaced++; cx += dx; cy += dy; if (cellsPlaced > 100) break; } } else { placements.Add(new { x = data.x1, y = data.y1, buildingId = conduitId }); cellsPlaced = 1; } return JsonSerializer.Serialize(ActionOk("wire_build_queued", new { type = wireType, mode, segmentCount = cellsPlaced, bridgesPlaced, segments = placements })); } catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); } } // =================================================================== // Helpers // =================================================================== private T ReadBody(HttpListenerContext ctx) where T : class { var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); return JsonSerializer.Deserialize(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 data { get; set; } = new Dictionary(); } internal class GasEntry { public string gas { get; set; } public float mass { get; set; } public int count { get; set; } } // ======================================================================= // Request DTOs // ======================================================================= 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; } 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 BuildingPriorityRequest { public int x { get; set; } public int y { get; set; } public int priority { get; set; } } internal class AutomationRequest { public int x { get; set; } public int y { get; set; } public bool enabled { get; set; } } internal class BatchRequest { public string name { get; set; } public List actions { get; set; } } internal class BatchAction { public string type { get; set; } // dig, build, deconstruct, mop, priority, research, wait public string buildingId { get; set; } public int? x { get; set; } public int? y { get; set; } public int? width { get; set; } public int? height { get; set; } public int? priority { get; set; } public string techId { get; set; } public int? delayMs { get; set; } } }