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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user