panel v0.9.2 — public release

Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+618
View File
@@ -0,0 +1,618 @@
using System.Reflection;
using Eleon.Modding;
namespace EmpyrionBridge;
// EAH-parity verbs grouped by the Empyrion Admin Helper tab they map to.
//
// Categories:
// - Players : kick, ban/unban, give-item, give-credits, teleport, info, inventory
// - Factions : list, alliances, message
// - Chat : broadcast (server-wide), faction chat, single-player whisper
// - Structures : list global, set-name, transfer-faction (via console)
// - Playfields : list, load, wipe (via console), entity list
// - Console : raw console line, save, restart-warning
//
// Most actions either go through a typed CmdId (Request_Player_AddItem) or
// fall back to Request_ConsoleCommand with a templated string. Console
// commands are documented at https://empyrion.fandom.com/wiki/Server_commands
// — the wiki is mostly accurate as of Empyrion 1.16.x.
public static class EndpointMaps
{
public static void MapAll(WebApplication app, BridgePool pool, TimeSpan timeout)
{
MapPlayers(app, pool, timeout);
MapFactions(app, pool, timeout);
MapChat(app, pool, timeout);
MapStructures(app, pool, timeout);
MapStructureOps(app, pool, timeout);
MapPlayfields(app, pool, timeout);
MapConsole(app, pool, timeout);
MapBans(app, pool, timeout);
MapEventStream(app, pool, timeout);
}
// ---------- SSE event stream ----------
// GET /api/v1/events?host=&port= — text/event-stream of unsolicited
// Event_* packages (chat, player join/leave, structure changes, etc).
// Each event line: data: { "cmd": "...", "seq": N, "data": {...} }\n\n
static void MapEventStream(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapGet("/api/v1/events", async (HttpContext ctx) =>
{
var ep = Endpoint(ctx.Request);
if (ep is null) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("missing host or port"); return; }
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
ctx.Response.Headers["Content-Type"] = "text/event-stream";
ctx.Response.Headers["Cache-Control"] = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
var queue = new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false };
var ch = System.Threading.Channels.Channel.CreateUnbounded<string>(queue);
using var sub = conn.Subscribe(p =>
{
try
{
var payload = System.Text.Json.JsonSerializer.Serialize(new
{
cmd = p.cmd.ToString(),
cmdId = (int)p.cmd,
seq = (int)p.seqNr,
clientId = p.clientId,
data = p.data,
type = p.data?.GetType()?.Name,
});
ch.Writer.TryWrite(payload);
}
catch { /* don't let serialization errors take down the connection */ }
});
// Initial heartbeat so client knows the stream is live.
await ctx.Response.WriteAsync("event: ready\ndata: {}\n\n");
await ctx.Response.Body.FlushAsync();
var heartbeat = TimeSpan.FromSeconds(15);
using var hbCts = new CancellationTokenSource();
var hbTask = Task.Run(async () =>
{
try
{
while (!hbCts.IsCancellationRequested)
{
await Task.Delay(heartbeat, hbCts.Token);
ch.Writer.TryWrite(":heartbeat");
}
}
catch (TaskCanceledException) { }
});
try
{
await foreach (var msg in ch.Reader.ReadAllAsync(ctx.RequestAborted))
{
if (msg.StartsWith(":")) await ctx.Response.WriteAsync(msg + "\n\n");
else await ctx.Response.WriteAsync("data: " + msg + "\n\n");
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
}
}
catch (OperationCanceledException) { /* client went away */ }
finally
{
hbCts.Cancel();
ch.Writer.TryComplete();
}
});
}
static (string host, int port)? Endpoint(HttpRequest r)
{
var h = r.Query["host"].FirstOrDefault();
var p = r.Query["port"].FirstOrDefault();
if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null;
return (h, pi);
}
static async Task<(BridgeConnection conn, IResult? err)> Connect(BridgePool pool, HttpRequest r)
{
var ep = Endpoint(r);
if (ep is null) return (null!, Results.BadRequest(new { error = "missing host or port" }));
return (await pool.GetAsync(ep.Value.host, ep.Value.port), null);
}
// ---------- Players ----------
static void MapPlayers(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/players, /api/v1/players/{id}
app.MapPost("/api/v1/players/{id:int}/kick", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var reason = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(reason)) reason = "kicked by admin";
// Use console verb — there is no typed Request_Kick.
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"kick {id} '{reason.Replace("'", "")}'"));
return Results.Json(new { ok = true, sent = $"kick {id} '{reason}'" });
});
app.MapPost("/api/v1/players/{id:int}/ban", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Two duration shapes supported:
// ?dur=<string> — passed verbatim to Empyrion (e.g. "30s", "5m", "7h", "30d", "1y", "0" for permanent)
// ?days=N — legacy integer days, kept for back-compat
// dur takes precedence when both are set.
var rawDur = r.Query["dur"].FirstOrDefault();
string dur;
if (!string.IsNullOrWhiteSpace(rawDur))
{
// Allow only [0-9smhdy] to keep injection out of the console verb.
var clean = new string(rawDur.Where(c => char.IsDigit(c) || c is 's' or 'm' or 'h' or 'd' or 'y').ToArray());
if (string.IsNullOrEmpty(clean)) return Results.BadRequest(new { error = "dur must be like 30s / 5m / 7h / 30d / 1y / 0" });
dur = clean;
}
else
{
var days = int.TryParse(r.Query["days"].FirstOrDefault(), out var d) ? d : 1;
dur = $"{days}d";
}
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban add {id} {dur}"));
return Results.Json(new { ok = true, sent = $"ban add {id} {dur}" });
});
app.MapPost("/api/v1/players/{id:int}/unban", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban remove {id}"));
return Results.Json(new { ok = true, sent = $"ban remove {id}" });
});
// Set Empyrion's per-player role. Wraps the
// `setrole "<playerName>" <role>` console verb — Empyrion's setrole
// looks up by name (or steam id), NOT by entity id, so we resolve
// the entity id → name via a quick PlayerInfo round-trip first.
// Sending `setrole <entityId> <role>` silently fails (Empyrion
// returns no error but the permission level never changes).
app.MapPost("/api/v1/players/{id:int}/set-role", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var role = (r.Query["role"].FirstOrDefault() ?? "").Trim().ToLowerInvariant();
if (role is not ("admin" or "gamemaster" or "moderator" or "player"))
return Results.BadRequest(new { error = "role must be one of: admin, gamemaster, moderator, player" });
var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
var name = (info?.playerName ?? "").Replace("\"", "");
if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
var cmd = $"setrole \"{name}\" {role}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, sent = cmd, id, playerName = name, role });
});
app.MapPost("/api/v1/players/{id:int}/give-credits", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var body = (await sr.ReadToEndAsync()).Trim();
if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (credits to add; negative subtracts)" });
conn.SendFireAndForget(CmdId.Request_Player_AddCredits, new IdCredits { id = id, credits = amount });
return Results.Json(new { ok = true, id, credits = amount });
});
app.MapPost("/api/v1/players/{id:int}/set-credits", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var body = (await sr.ReadToEndAsync()).Trim();
if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (absolute credits)" });
conn.SendFireAndForget(CmdId.Request_Player_SetCredits, new IdCredits { id = id, credits = amount });
return Results.Json(new { ok = true, id, credits = amount });
});
// Give an item to a player. We use Empyrion's `give "<name>" <itemId> <count>`
// console command rather than Request_Player_AddItem because the typed
// RPC requires a slotIdx — without one the API defaults to slot 0 in the
// toolbar, which is normally occupied (starter pickaxe etc), and Empyrion
// silently refuses the overwrite. The console command auto-places into
// the first free bag slot, the natural "give-me-this-item" behavior.
// Caller can still pass slotIdx explicitly to use the typed path.
app.MapPost("/api/v1/players/{id:int}/give-item", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Body: { "itemId": 4307, "count": 1, "slotIdx": 0|null, "ammo": 0, "decay": 0 }
var body = await JsonContent.ReadAsync<GiveItemReq>(r);
if (body is null || body.itemId <= 0) return Results.BadRequest(new { error = "itemId required" });
var count = body.count <= 0 ? 1 : body.count;
// Path A: caller pinned a specific slot — go through typed RPC.
if (body.slotIdx.HasValue)
{
var stack = new ItemStack
{
id = body.itemId,
count = count,
slotIdx = body.slotIdx.Value,
ammo = body.ammo,
decay = body.decay,
};
conn.SendFireAndForget(CmdId.Request_Player_AddItem, new IdItemStack { id = id, itemStack = stack });
return Results.Json(new { ok = true, id, item = stack, via = "typed" });
}
// Path B (default): auto-place via console — needs the player NAME.
var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
var name = (info?.playerName ?? "").Replace("\"", "");
if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
var cmd = $"give \"{name}\" {body.itemId} {count}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, id, playerName = name, itemId = body.itemId, count, sent = cmd, via = "console" });
});
app.MapGet("/api/v1/players/{id:int}/inventory", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<Inventory>(CmdId.Request_Player_GetInventory, CmdId.Event_Player_Inventory, new Id(id), timeout);
if (resp is null) return Results.NotFound(new { error = "no inventory for player", id });
return Results.Json(resp);
});
app.MapPost("/api/v1/players/{id:int}/teleport", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Body: { "x":0, "y":0, "z":0, "playfield":"optional" }
var body = await JsonContent.ReadAsync<TeleportReq>(r);
if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
if (!string.IsNullOrEmpty(body.playfield))
{
var pkt = new IdPlayfieldPositionRotation
{
id = id,
playfield = body.playfield,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Player_ChangePlayerfield, pkt);
return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
}
else
{
var pkt = new IdPositionRotation
{
id = id,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
}
});
}
// ---------- Factions ----------
static void MapFactions(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/factions
app.MapGet("/api/v1/factions/alliances", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<AlliancesTable>(CmdId.Request_AlliancesAll, CmdId.Event_AlliancesAll, null, timeout);
return Results.Json(resp ?? new AlliancesTable());
});
// POST /api/v1/factions/alliance-state — set alliance state between two factions
// state mapping: ally=0, neutral=1, hostile=2 (Empyrion console verb values)
app.MapPost("/api/v1/factions/alliance-state", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<AllianceStateReq>(r);
if (body is null) return Results.BadRequest(new { error = "factionA, factionB, and state required" });
int stateCode;
switch (body.state?.Trim().ToLowerInvariant())
{
case "ally": stateCode = 0; break;
case "neutral": stateCode = 1; break;
case "hostile": stateCode = 2; break;
default: return Results.BadRequest(new { error = "state must be one of: ally, neutral, hostile" });
}
var cmd = $"setalliancestate {body.factionA} {body.factionB} {stateCode}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Chat ----------
static void MapChat(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapPost("/api/v1/chat/broadcast", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
// prio: 0=info, 1=warning, 2=alert. Default to alert for visibility.
var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)2;
var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
conn.SendFireAndForget(CmdId.Request_InGameMessage_AllPlayers, new IdMsgPrio { id = 0, msg = msg, prio = prio, time = time });
return Results.Json(new { ok = true, msg, prio, time });
});
app.MapPost("/api/v1/chat/whisper/{playerId:int}", async (int playerId, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)0;
var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
conn.SendFireAndForget(CmdId.Request_InGameMessage_SinglePlayer, new IdMsgPrio { id = playerId, msg = msg, prio = prio, time = time });
return Results.Json(new { ok = true, playerId, msg });
});
app.MapPost("/api/v1/chat/faction/{factionId:int}", async (int factionId, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
conn.SendFireAndForget(CmdId.Request_InGameMessage_Faction, new IdMsgPrio { id = factionId, msg = msg, prio = 2, time = 8.0f });
return Results.Json(new { ok = true, factionId, msg });
});
}
// ---------- Structures ----------
static void MapStructures(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Empyrion's pattern: send Request_GlobalStructure_Update, the server
// pushes Event_GlobalStructure_List back. We correlate by expected cmd.
app.MapGet("/api/v1/structures", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<GlobalStructureList>(CmdId.Request_GlobalStructure_Update, CmdId.Event_GlobalStructure_List, new PString(""), timeout);
return Results.Json(new
{
playfields = resp?.globalStructures?.Keys ?? Enumerable.Empty<string>(),
count = resp?.globalStructures?.Sum(kv => kv.Value?.Count ?? 0) ?? 0,
structures = resp?.globalStructures?.SelectMany(kv => (kv.Value ?? new List<GlobalStructureInfo>()).Select(gs => new
{
playfield = kv.Key,
id = gs.id,
name = gs.name,
type = gs.type,
pos = new[] { gs.pos.x, gs.pos.y, gs.pos.z },
factionId = gs.factionId,
factionGroup = gs.factionGroup,
coreType = gs.coreType,
classNr = gs.classNr,
blocks = gs.cntBlocks,
devices = gs.cntDevices,
triangles = gs.cntTriangles,
lights = gs.cntLights,
fuel = gs.fuel,
pilotId = gs.pilotId,
powered = gs.powered,
})) ?? Enumerable.Empty<object>(),
});
});
app.MapPost("/api/v1/structures/{id:int}/rename", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var newName = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(newName)) return Results.BadRequest(new { error = "empty name" });
// The Mod API has Request_Entity_SetName but it requires playfield+name, not just id.
// Easier path: console "rename ID NEW_NAME"
var safe = newName.Replace("\"", "");
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"rename '{id}' '{safe}'"));
return Results.Json(new { ok = true, id, name = newName });
});
app.MapPost("/api/v1/structures/{id:int}/destroy", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
conn.SendFireAndForget(CmdId.Request_Entity_Destroy, new Id(id));
return Results.Json(new { ok = true, id });
});
}
// ---------- Structure Operations ----------
static void MapStructureOps(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// POST /api/v1/structures/{id:int}/faction — transfer structure to a faction
app.MapPost("/api/v1/structures/{id:int}/faction", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<FactionReq>(r);
if (body is null) return Results.BadRequest(new { error = "factionId required" });
var cmd = $"faction structure {id} {body.factionId}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/heal — heal structure to 100%
app.MapPost("/api/v1/structures/{id:int}/heal", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"heal {id} 100";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/refuel — refuel structure
app.MapPost("/api/v1/structures/{id:int}/refuel", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"refuel {id}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/set-core — set core type
app.MapPost("/api/v1/structures/{id:int}/set-core", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<SetCoreReq>(r);
if (body is null || body.mode is null) return Results.BadRequest(new { error = "mode required (admin|player|hardcore)" });
var mode = body.mode.Trim().ToLowerInvariant();
if (mode is not ("admin" or "player" or "hardcore"))
return Results.BadRequest(new { error = "mode must be one of: admin, player, hardcore" });
var cmd = $"setadmincore {id} {mode}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/move — move structure (same playfield or cross-playfield)
app.MapPost("/api/v1/structures/{id:int}/move", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<MoveStructureReq>(r);
if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
if (!string.IsNullOrEmpty(body.playfield))
{
var pkt = new IdPlayfieldPositionRotation
{
id = id,
playfield = body.playfield,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_ChangePlayfield, pkt);
return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
}
else
{
var pkt = new IdPositionRotation
{
id = id,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
}
});
// POST /api/v1/structures/{id:int}/save-as-blueprint — save structure as blueprint
app.MapPost("/api/v1/structures/{id:int}/save-as-blueprint", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<SaveBlueprintReq>(r);
var cmd = string.IsNullOrEmpty(body?.name) ? $"bp {id}" : $"bp {id} \"{body.name.Replace("\"", "")}\"";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/regenerate — regenerate a structure (optionally with faction override)
app.MapPost("/api/v1/structures/{id:int}/regenerate", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<RegenerateReq>(r);
var cmd = string.IsNullOrEmpty(body?.faction) ? $"regenerate {id}" : $"regenerate {id} {body.faction}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Playfields ----------
static void MapPlayfields(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/playfields, /api/v1/playfields/{name}/entities
app.MapGet("/api/v1/playfields/{name}/stats", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<PlayfieldStats>(CmdId.Request_Playfield_Stats, CmdId.Event_Playfield_Stats, new PString(name), timeout);
return Results.Json(resp ?? new PlayfieldStats { playfield = name });
});
app.MapPost("/api/v1/playfields/{name}/wipe", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// wipe playfield POI/all/terrain/deposit
var what = r.Query["what"].FirstOrDefault() ?? "poi";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"wipe '{name}' {what}"));
return Results.Json(new { ok = true, playfield = name, scope = what });
});
// POST /api/v1/playfields/{name}/load — load a playfield
app.MapPost("/api/v1/playfields/{name}/load", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"loadpf '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/playfields/{name}/unload — unload a playfield
app.MapPost("/api/v1/playfields/{name}/unload", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"unloadpf '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/playfields/{name}/reset — full playfield reset (aggressive)
app.MapPost("/api/v1/playfields/{name}/reset", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"resetplayfield '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Console ----------
static void MapConsole(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: POST /api/v1/console (raw command)
app.MapPost("/api/v1/save", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Empyrion's "saveandexit" command saves and shuts down — we don't want that.
// The "save" alias triggers an immediate save without exit; exists in 1.16+.
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString("save"));
return Results.Json(new { ok = true, sent = "save" });
});
app.MapPost("/api/v1/dedi/stats", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<DediStats>(CmdId.Request_Dedi_Stats, CmdId.Event_Dedi_Stats, null, timeout);
return Results.Json(resp ?? new DediStats());
});
}
// ---------- Bans ----------
static void MapBans(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapGet("/api/v1/banned", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<BannedPlayerData>(CmdId.Request_GetBannedPlayers, CmdId.Event_BannedPlayers, null, timeout);
return Results.Json(resp ?? new BannedPlayerData());
});
}
}
public class GiveItemReq { public int itemId { get; set; } public int count { get; set; } public byte? slotIdx { get; set; } public int ammo { get; set; } public int decay { get; set; } }
public class TeleportReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class FactionReq { public int factionId { get; set; } }
public class SetCoreReq { public string? mode { get; set; } }
public class MoveStructureReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class SaveBlueprintReq { public string? name { get; set; } }
public class AllianceStateReq { public int factionA { get; set; } public int factionB { get; set; } public string? state { get; set; } }
public class RegenerateReq { public string? faction { get; set; } }
internal static class JsonContent
{
public static async Task<T?> ReadAsync<T>(HttpRequest r) where T : class
{
try { return await r.ReadFromJsonAsync<T>(); }
catch { return null; }
}
}