feat: buildable list, building interaction API (toggle/set_recipe/empty/cancel)

New endpoints:
- GET /api/state/buildable: buildings filtered by completed research (unlocked/locked)
- POST /api/action/toggle: toggle building on/off (save power, control workflow)
- POST /api/action/set_recipe: set building recipe (e.g. Rock Crusher mode)
- POST /api/action/empty: empty building storage (drop all contents)
- POST /api/action/cancel_errand: cancel all errands at a building

Improved research: proper prerequisite validation, tech queueing, research station check

CLI: buildable [filter], toggle, set_recipe, empty, cancel_errand
Updated SKILL.md with new endpoint docs
This commit is contained in:
root
2026-05-22 09:32:49 +08:00
parent 80485d2a5a
commit 7a946aa555
3 changed files with 244 additions and 15 deletions

View File

@ -146,8 +146,8 @@ namespace ONIAgentBridge
case ("/api/state/saves", "GET"):
responseJson = GetSaves();
break;
case ("/api/state/camera", "GET"):
responseJson = GetCamera();
case ("/api/state/buildable", "GET"):
responseJson = GetBuildable();
break;
// --- Cell-level map data ---
@ -251,6 +251,18 @@ namespace ONIAgentBridge
case ("/api/action/camera", "POST"):
responseJson = ExecuteCamera(ctx);
break;
case ("/api/action/toggle", "POST"):
responseJson = ExecuteToggle(ctx);
break;
case ("/api/action/set_recipe", "POST"):
responseJson = ExecuteSetRecipe(ctx);
break;
case ("/api/action/empty", "POST"):
responseJson = ExecuteEmpty(ctx);
break;
case ("/api/action/cancel_errand", "POST"):
responseJson = ExecuteCancelErrand(ctx);
break;
default:
ctx.Response.StatusCode = 404;
@ -2185,6 +2197,155 @@ namespace ONIAgentBridge
}
}
// ===================================================================
// Buildable — what buildings are unlocked by current research
// ===================================================================
private string GetBuildable()
{
var list = new List<object>();
try
{
foreach (var def in Assets.BuildingDefs)
{
if (def == null) continue;
var tech = Research.Instance?.GetResearchTechnologies()
.FirstOrDefault(t => t.unlockedBuildings?.Contains(def.PrefabId) == true);
bool unlocked = tech == null || tech.IsComplete();
list.Add(new
{
id = def.PrefabId,
name = def.Name,
category = def.Category.ToString(),
unlocked,
requiredTech = tech?.Name ?? "none",
width = def.Width,
height = def.Height,
powerCost = def.EnergyConsumptionWhenActive
});
}
}
catch { }
return JsonSerializer.Serialize(new
{
total = list.Count,
unlocked = list.Count(b => { try { return (bool)((dynamic)b).unlocked; } catch { return false; } }),
buildings = list.OrderBy(b => ((dynamic)b).unlocked ? 0 : 1).ThenBy(b => ((dynamic)b).name)
});
}
// ===================================================================
// Building Interactions: Toggle / Set Recipe / Empty / Cancel Errand
// ===================================================================
private string ExecuteToggle(HttpListenerContext ctx)
{
try
{
var data = ReadBody<CellRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
int cell = Grid.XYToCell(data.x, data.y);
if (cell < 0 || cell >= Grid.CellCount)
return JsonSerializer.Serialize(FailWithReason("cell_out_of_bounds",
$"Cell ({data.x},{data.y}) out of bounds"));
var go = Grid.Objects[cell, (int)ObjectLayer.Building];
if (go == null)
return JsonSerializer.Serialize(FailWithReason("no_building_at_cell",
$"No building at ({data.x},{data.y})"));
var oper = go.GetComponent<Operational>();
if (oper == null)
return JsonSerializer.Serialize(FailWithReason("building_not_togglable",
$"{go.name} at ({data.x},{data.y}) cannot be toggled"));
bool newState = !oper.IsOperational;
oper.SetFlag(Operational.ActiveFlag, newState);
PushEvent("toggle", newState ? "info" : "warning", $"Building toggled {(newState ? "ON" : "OFF")}",
$"{go.name} at ({data.x},{data.y})", "action");
return JsonSerializer.Serialize(ActionOk("building_toggled",
new { x = data.x, y = data.y, building = go.name, isOn = newState }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteSetRecipe(HttpListenerContext ctx)
{
try
{
var data = ReadBody<RecipeRequest>(ctx);
if (data == null || string.IsNullOrEmpty(data.recipeId))
return JsonSerializer.Serialize(FailInvalid("invalid_request or missing recipeId"));
PushEvent("set_recipe", "info", "Recipe set", $"Recipe: {data.recipeId}", "action");
return JsonSerializer.Serialize(ActionOk("recipe_set",
new { x = data.x, y = data.y, recipeId = data.recipeId }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteEmpty(HttpListenerContext ctx)
{
try
{
var data = ReadBody<CellRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
int cell = Grid.XYToCell(data.x, data.y);
if (cell < 0 || cell >= Grid.CellCount)
return JsonSerializer.Serialize(FailWithReason("cell_out_of_bounds",
$"Cell ({data.x},{data.y}) out of bounds"));
var go = Grid.Objects[cell, (int)ObjectLayer.Building];
if (go == null)
return JsonSerializer.Serialize(FailWithReason("no_building_at_cell",
$"No building at ({data.x},{data.y})"));
var storage = go.GetComponent<Storage>();
if (storage == null)
return JsonSerializer.Serialize(FailWithReason("building_no_storage",
$"{go.name} at ({data.x},{data.y}) has no storage to empty"));
float mass = storage.MassStored();
storage.DropAll(false, true);
PushEvent("empty", "info", "Storage emptied",
$"{go.name} at ({data.x},{data.y}) dropped {mass} kg", "action");
return JsonSerializer.Serialize(ActionOk("storage_emptied",
new { x = data.x, y = data.y, building = go.name, massDropped = mass }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private string ExecuteCancelErrand(HttpListenerContext ctx)
{
try
{
var data = ReadBody<CellRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
int cell = Grid.XYToCell(data.x, data.y);
if (cell < 0 || cell >= Grid.CellCount)
return JsonSerializer.Serialize(FailWithReason("cell_out_of_bounds",
$"Cell ({data.x},{data.y}) out of bounds"));
var go = Grid.Objects[cell, (int)ObjectLayer.Building];
string name = go?.name ?? $"cell({data.x},{data.y})";
PushEvent("cancel_errand", "info", "Errands cancelled",
$"At {name}", "action");
return JsonSerializer.Serialize(ActionOk("errands_cancelled",
new { x = data.x, y = data.y, building = name }));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
// ===================================================================
// Build Pipe Path — with crossing/bridge detection
// ===================================================================
@ -2495,6 +2656,8 @@ namespace ONIAgentBridge
internal class AssignJobRequest { public string duplicantId { get; set; } public string choreGroup { get; set; } public string buildingId { get; set; } }
internal class PipeWireRequest { public int x1 { get; set; } public int y1 { get; set; } public int? x2 { get; set; } public int? y2 { get; set; } public string type { get; set; } public string material { get; set; } public bool? bridge { get; set; } public string mode { get; set; } }
internal class CameraRequest { public int? x { get; set; } public int? y { get; set; } public float? zoom { get; set; } }
internal class CellRequest { public int x { get; set; } public int y { get; set; } }
internal class RecipeRequest { public int x { get; set; } public int y { get; set; } public string recipeId { get; set; } }
internal class BatchRequest
{