Files
oniagent/mod/ONIAgentBridge.cs
root 48a70e4928 feat: game pause/speed control API with AI pause protocol
- POST /api/action/pause - pause game (AI must call before operations)
- POST /api/action/unpause - resume game at desired speed
- POST /api/action/speed - set game speed (1x/2x/3x)
- Game state now includes isPaused and gameSpeed
- AI pause protocol documented in SKILL.md: pause before every operation
- Introduces cell.isDiggable flag that filters neutronium properly
- pause/unpause/speed CLI commands added to oni_api.py
2026-05-22 09:04:04 +08:00

1689 lines
69 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;
// --- 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;
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
};
}
// ===================================================================
// 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 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; }
}
}