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(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= — 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 "" ` 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 ` 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( 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 "" ` // 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(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( 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(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(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(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(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(CmdId.Request_GlobalStructure_Update, CmdId.Event_GlobalStructure_List, new PString(""), timeout); return Results.Json(new { playfields = resp?.globalStructures?.Keys ?? Enumerable.Empty(), count = resp?.globalStructures?.Sum(kv => kv.Value?.Count ?? 0) ?? 0, structures = resp?.globalStructures?.SelectMany(kv => (kv.Value ?? new List()).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(), }); }); 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(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(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(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(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(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(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(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(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 ReadAsync(HttpRequest r) where T : class { try { return await r.ReadFromJsonAsync(); } catch { return null; } } }