This commit is contained in:
Bandwidth
2026-04-26 18:33:24 +02:00
parent e9c85ac8d3
commit 796ba0906d
5 changed files with 651 additions and 40 deletions

View File

@@ -180,7 +180,20 @@ public class AdminPanel
case "/admin/api/broadcast":
await HandleBroadcastAsync(request, response);
break;
// ── P12: lobby manipulation endpoints ────────────────────
case "/admin/api/lobby/end":
await HandleLobbyForceEndAsync(request, response);
break;
case "/admin/api/lobby/phase":
await HandleLobbySetPhaseAsync(request, response);
break;
case "/admin/api/lobby/settings":
await HandleLobbyUpdateSettingsAsync(request, response);
break;
case "/admin/api/logout":
_sessions.TryRemove(sessionId, out _);
response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true });
@@ -412,10 +425,10 @@ public class AdminPanel
using var reader = new StreamReader(request.InputStream);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
var message = data.GetProperty("message").GetString();
var lobbyId = data.TryGetProperty("lobbyId", out var l) ? l.GetString() : null;
if (string.IsNullOrEmpty(lobbyId))
{
// Broadcast všem
@@ -430,10 +443,115 @@ public class AdminPanel
var lobby = _lobbyManager.GetLobby(lobbyId);
lobby?.BroadcastSystemMessage(message!);
}
_logger.LogInformation("Admin broadcast: {Message}", message);
await WriteJsonAsync(response, new { success = true });
}
// ── P12: lobby manipulation endpoint handlers ────────────────────────────
/// <summary>
/// Force-end a game in a specific lobby. POST { "lobbyId": "...", "reason": "optional" }.
/// Returns { success: true } on application, 404 if lobby missing, or
/// 409 if the lobby was already in Lobby/Ended phase (nothing to end).
/// </summary>
private async Task HandleLobbyForceEndAsync(HttpListenerRequest request, HttpListenerResponse response)
{
using var reader = new StreamReader(request.InputStream);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
var lobbyId = data.GetProperty("lobbyId").GetString();
var reason = data.TryGetProperty("reason", out var r) ? (r.GetString() ?? "ended by admin") : "ended by admin";
var lobby = _lobbyManager.GetLobby(lobbyId!);
if (lobby == null)
{
response.StatusCode = 404;
await WriteJsonAsync(response, new { error = "Lobby not found" });
return;
}
var ok = await lobby.TryForceEndGameAsync(reason);
if (!ok)
{
response.StatusCode = 409;
await WriteJsonAsync(response, new { error = "Cannot force-end - lobby not in an active game phase" });
return;
}
await WriteJsonAsync(response, new { success = true });
}
/// <summary>
/// Force a lobby into a specific phase. POST { "lobbyId": "...", "phase": "Playing|Meeting|Ended|..." }.
/// Voting is rejected (it's not a real wire phase - lives inside Meeting).
/// </summary>
private async Task HandleLobbySetPhaseAsync(HttpListenerRequest request, HttpListenerResponse response)
{
using var reader = new StreamReader(request.InputStream);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
var lobbyId = data.GetProperty("lobbyId").GetString();
var phase = data.GetProperty("phase").GetString();
var lobby = _lobbyManager.GetLobby(lobbyId!);
if (lobby == null)
{
response.StatusCode = 404;
await WriteJsonAsync(response, new { error = "Lobby not found" });
return;
}
var ok = await lobby.TryAdminSetPhaseAsync(phase!);
if (!ok)
{
response.StatusCode = 400;
await WriteJsonAsync(response, new { error = "Invalid phase (rejected: 'Voting' is not a wire phase)" });
return;
}
await WriteJsonAsync(response, new { success = true });
}
/// <summary>
/// Update lobby settings. POST { "lobbyId": "...", "radius": ..., "impostorCount": ..., "taskCount": ..., "tiePolicy": "..." }.
/// All settings fields optional - only those present in the body are
/// applied. Lobby must be in Lobby phase; mid-game settings changes are
/// rejected to keep gameplay consistent.
/// </summary>
private async Task HandleLobbyUpdateSettingsAsync(HttpListenerRequest request, HttpListenerResponse response)
{
using var reader = new StreamReader(request.InputStream);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
var lobbyId = data.GetProperty("lobbyId").GetString();
double? radius = data.TryGetProperty("radius", out var rd) && rd.ValueKind == JsonValueKind.Number ? rd.GetDouble() : (double?)null;
int? impostors = data.TryGetProperty("impostorCount", out var ic) && ic.ValueKind == JsonValueKind.Number ? ic.GetInt32() : (int?)null;
int? tasks = data.TryGetProperty("taskCount", out var tc) && tc.ValueKind == JsonValueKind.Number ? tc.GetInt32() : (int?)null;
string? tiePolicy = data.TryGetProperty("tiePolicy", out var tp) ? tp.GetString() : null;
var lobby = _lobbyManager.GetLobby(lobbyId!);
if (lobby == null)
{
response.StatusCode = 404;
await WriteJsonAsync(response, new { error = "Lobby not found" });
return;
}
var ok = await lobby.TryUpdateLobbySettingsAsync(radius, impostors, tasks, tiePolicy);
if (!ok)
{
response.StatusCode = 409;
await WriteJsonAsync(response, new { error = "Cannot update settings - lobby is past the Lobby phase" });
return;
}
await WriteJsonAsync(response, new { success = true });
}
#region
// WEBSOCKET - SPECTATE
@@ -469,8 +587,11 @@ public class AdminPanel
try
{
// Pošli initial state
await SendLobbyStateAsync(ws, lobby);
// Pošli initial state - include the heavy mapData payload exactly
// once per spectator session. The push loop below sends just the
// dynamic fields (positions/votes/sabotage/etc.) at 5Hz.
await SendLobbyStateAsync(ws, lobby, includeMapData: true);
connection.MapDataSent = true;
var buffer = new byte[4096];
var updateInterval = TimeSpan.FromMilliseconds(200);
@@ -508,7 +629,10 @@ public class AdminPanel
lobby = _lobbyManager.GetLobby(lobbyId);
if (lobby != null)
{
await SendLobbyStateAsync(ws, lobby);
// Subsequent ticks: skip mapData (heavy + immutable during
// a game). Spectator's frontend cached it from the initial
// push.
await SendLobbyStateAsync(ws, lobby, includeMapData: false);
}
else
{
@@ -549,7 +673,15 @@ public class AdminPanel
}
}
private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby)
/// <summary>
/// Push the lobby state to a spectator's WebSocket. When `includeMapData`
/// is false (the default for the periodic 5Hz push loop) the heavy
/// Overpass payload is omitted - the frontend keeps the copy it received
/// on the initial push. This was the dominant cost in the "admin panel
/// slow to show spectating" complaint: a typical lobby's mapData is tens
/// of KB and serializing it 5x per second per spectator added up fast.
/// </summary>
private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby, bool includeMapData = true)
{
var state = new
{
@@ -560,7 +692,7 @@ public class AdminPanel
joinCode = lobby.JoinCode,
phase = lobby.Phase.ToString(),
phaseEndTime = lobby.PhaseEndTime?.ToString("o"),
players = lobby.GetPlayers().Select(p => new
{
id = p.PlayerId,
@@ -574,7 +706,7 @@ public class AdminPanel
killCooldownEnd = p.KillCooldownEnd?.ToString("o"),
votedFor = p.VotedFor
}),
tasks = lobby.GetTasks().Select(t => new
{
id = t.TaskId,
@@ -583,7 +715,7 @@ public class AdminPanel
lng = t.Location.Lon,
completedBy = t.CompletedBy.ToList()
}),
bodies = lobby.GetBodies().Select(b => new
{
victimId = b.VictimId,
@@ -591,7 +723,7 @@ public class AdminPanel
lng = b.Position.Lon,
reportedAt = b.ReportedAt?.ToString("o")
}),
sabotage = lobby.ActiveSabotage != null ? new
{
type = lobby.ActiveSabotage.Type.ToString(),
@@ -604,9 +736,9 @@ public class AdminPanel
isRepaired = rs.IsRepaired
})
} : null,
votes = lobby.Phase == GamePhase.Voting ? lobby.GetVotes() : null,
settings = new
{
center = new { lat = lobby.Settings.PlayAreaCenter.Lat, lng = lobby.Settings.PlayAreaCenter.Lon },
@@ -614,15 +746,16 @@ public class AdminPanel
impostorCount = lobby.Settings.ImpostorCount,
taskCount = lobby.Settings.TaskCount
},
mapData = lobby.MapData // Overpass data pro vykreslení mapy
// mapData on first push only - frontend caches it client-side.
mapData = includeMapData ? lobby.MapData : null
},
timestamp = DateTime.UtcNow.ToString("o")
};
var json = JsonSerializer.Serialize(state, JsonOptions.Default);
var bytes = Encoding.UTF8.GetBytes(json);
if (ws.State == WebSocketState.Open)
{
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
@@ -792,4 +925,13 @@ public class SpectatorConnection
public required WebSocket WebSocket { get; set; }
public required string LobbyId { get; set; }
public required string SessionId { get; set; }
/// <summary>
/// P12: per-spectator flag to ensure we serialize the heavy `mapData`
/// payload only once at session start. The previous push loop included
/// it on every 200ms tick, which dominated CPU + bandwidth and made the
/// admin panel feel laggy ("slow to show spectating") even though the
/// position/vote/role updates were already arriving promptly.
/// </summary>
public bool MapDataSent { get; set; } = false;
}