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
2271 lines
94 KiB
C#
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; }
|
|
}
|
|
}
|