1306 lines
55 KiB
C#
1306 lines
55 KiB
C#
namespace GeoSus.Server;
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Net.WebSockets;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
/// <summary>
|
|
/// Admin Panel - Webová administrace serveru
|
|
///
|
|
/// Funkce:
|
|
/// - Přehled stavu serveru
|
|
/// - Real-time spectate mode pro lobby
|
|
/// - Konfigurace serveru za běhu
|
|
/// - Informace o hráčích
|
|
/// - WebSocket pro live updates
|
|
/// </summary>
|
|
public class AdminPanel
|
|
{
|
|
private readonly ILogger<AdminPanel> _logger;
|
|
private readonly ServerConfig _config;
|
|
private readonly LobbyManager _lobbyManager;
|
|
private readonly StatsDb _statsDb;
|
|
|
|
// Bezpečnost
|
|
private readonly string _adminPasswordHash;
|
|
private readonly ConcurrentDictionary<string, AdminSession> _sessions = new();
|
|
private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(8);
|
|
|
|
// WebSocket connections pro spectate
|
|
private readonly ConcurrentDictionary<string, SpectatorConnection> _spectators = new();
|
|
|
|
public AdminPanel(
|
|
ILogger<AdminPanel> logger,
|
|
ServerConfig config,
|
|
LobbyManager lobbyManager,
|
|
StatsDb statsDb)
|
|
{
|
|
_logger = logger;
|
|
_config = config;
|
|
_lobbyManager = lobbyManager;
|
|
_statsDb = statsDb;
|
|
|
|
// Hash admin hesla (z env nebo default)
|
|
var adminPassword = Environment.GetEnvironmentVariable("GEOSUS_ADMIN_PASSWORD") ?? "admin123";
|
|
_adminPasswordHash = HashPassword(adminPassword);
|
|
|
|
_logger.LogInformation("Admin Panel inicializován");
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// AUTENTIZACE
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
private static string HashPassword(string password)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "GeoSus_Salt_2026"));
|
|
return Convert.ToBase64String(bytes);
|
|
}
|
|
|
|
private string? ValidateSession(HttpListenerRequest request)
|
|
{
|
|
var cookie = request.Cookies["geosus_admin_session"];
|
|
if (cookie == null) return null;
|
|
|
|
if (_sessions.TryGetValue(cookie.Value, out var session))
|
|
{
|
|
if (DateTime.UtcNow - session.LastActivity < _sessionTimeout)
|
|
{
|
|
session.LastActivity = DateTime.UtcNow;
|
|
return cookie.Value;
|
|
}
|
|
_sessions.TryRemove(cookie.Value, out _);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private string CreateSession()
|
|
{
|
|
var sessionId = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
|
_sessions[sessionId] = new AdminSession { LastActivity = DateTime.UtcNow };
|
|
return sessionId;
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// HTTP HANDLING
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
public async Task HandleRequestAsync(HttpListenerContext context)
|
|
{
|
|
var request = context.Request;
|
|
var response = context.Response;
|
|
var path = request.Url?.AbsolutePath ?? "/";
|
|
|
|
try
|
|
{
|
|
// Login endpoint - vždy přístupný
|
|
if (path == "/admin/login" && request.HttpMethod == "POST")
|
|
{
|
|
await HandleLoginAsync(request, response);
|
|
return;
|
|
}
|
|
|
|
// Statické soubory pro login stránku - bez autentizace
|
|
if (path == "/admin" || path == "/admin/")
|
|
{
|
|
await ServeStaticFileAsync(response, "admin.html", "text/html");
|
|
return;
|
|
}
|
|
|
|
// CSS a JS musí být dostupné bez autentizace (pro login stránku)
|
|
if (path == "/admin/app.js")
|
|
{
|
|
await ServeStaticFileAsync(response, "admin.js", "application/javascript");
|
|
return;
|
|
}
|
|
|
|
if (path == "/admin/style.css")
|
|
{
|
|
await ServeStaticFileAsync(response, "admin.css", "text/css");
|
|
return;
|
|
}
|
|
|
|
// Všechno ostatní vyžaduje autentizaci
|
|
var sessionId = ValidateSession(request);
|
|
if (sessionId == null)
|
|
{
|
|
response.StatusCode = 401;
|
|
await WriteJsonAsync(response, new { error = "Unauthorized", redirect = "/admin" });
|
|
return;
|
|
}
|
|
|
|
// WebSocket pro spectate
|
|
if (path.StartsWith("/admin/ws/spectate/") && request.IsWebSocketRequest)
|
|
{
|
|
var lobbyId = path["/admin/ws/spectate/".Length..];
|
|
await HandleSpectateWebSocketAsync(context, lobbyId, sessionId);
|
|
return;
|
|
}
|
|
|
|
// WebSocket pro server stats
|
|
if (path == "/admin/ws/stats" && request.IsWebSocketRequest)
|
|
{
|
|
await HandleStatsWebSocketAsync(context, sessionId);
|
|
return;
|
|
}
|
|
|
|
// P13d: archive endpoints with archive id in the URL path
|
|
// Layout: /admin/api/archive/<id>/events and /admin/api/archive/<id>/positions
|
|
if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/events"))
|
|
{
|
|
var archiveId = path["/admin/api/archive/".Length..^"/events".Length];
|
|
await HandleArchiveEventsAsync(archiveId, response);
|
|
return;
|
|
}
|
|
if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/positions"))
|
|
{
|
|
var archiveId = path["/admin/api/archive/".Length..^"/positions".Length];
|
|
await HandleArchivePositionsAsync(archiveId, response);
|
|
return;
|
|
}
|
|
|
|
// REST API endpointy
|
|
switch (path)
|
|
{
|
|
case "/admin/api/status":
|
|
await HandleStatusAsync(response);
|
|
break;
|
|
|
|
case "/admin/api/lobbies":
|
|
await HandleLobbiesAsync(response);
|
|
break;
|
|
|
|
case "/admin/api/config":
|
|
if (request.HttpMethod == "GET")
|
|
await HandleGetConfigAsync(response);
|
|
else if (request.HttpMethod == "POST")
|
|
await HandleSetConfigAsync(request, response);
|
|
break;
|
|
|
|
case "/admin/api/players":
|
|
await HandlePlayersAsync(request, response);
|
|
break;
|
|
|
|
case "/admin/api/kick":
|
|
await HandleKickAsync(request, response);
|
|
break;
|
|
|
|
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;
|
|
|
|
// ── P13d: archive listing endpoint ───────────────────────
|
|
case "/admin/api/archive":
|
|
await HandleListArchivesAsync(response);
|
|
break;
|
|
|
|
case "/admin/api/logout":
|
|
_sessions.TryRemove(sessionId, out _);
|
|
response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true });
|
|
await WriteJsonAsync(response, new { success = true });
|
|
break;
|
|
|
|
default:
|
|
response.StatusCode = 404;
|
|
await WriteJsonAsync(response, new { error = "Not found" });
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Admin panel error: {Path}", path);
|
|
|
|
// Don't try to write response for WebSocket requests (already submitted)
|
|
if (!request.IsWebSocketRequest)
|
|
{
|
|
try
|
|
{
|
|
response.StatusCode = 500;
|
|
await WriteJsonAsync(response, new { error = ex.Message });
|
|
}
|
|
catch { /* Response may already be sent */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// LOGIN
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
private async Task HandleLoginAsync(HttpListenerRequest request, HttpListenerResponse response)
|
|
{
|
|
using var reader = new StreamReader(request.InputStream);
|
|
var body = await reader.ReadToEndAsync();
|
|
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
|
|
|
var password = data.GetProperty("password").GetString() ?? "";
|
|
var hash = HashPassword(password);
|
|
|
|
if (hash == _adminPasswordHash)
|
|
{
|
|
var sessionId = CreateSession();
|
|
response.SetCookie(new Cookie("geosus_admin_session", sessionId, "/admin")
|
|
{
|
|
HttpOnly = true,
|
|
Expires = DateTime.Now.Add(_sessionTimeout)
|
|
});
|
|
|
|
_logger.LogInformation("Admin login successful from {IP}", request.RemoteEndPoint);
|
|
await WriteJsonAsync(response, new { success = true });
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Admin login failed from {IP}", request.RemoteEndPoint);
|
|
response.StatusCode = 401;
|
|
await WriteJsonAsync(response, new { error = "Invalid password" });
|
|
}
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// API ENDPOINTS
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
private async Task HandleStatusAsync(HttpListenerResponse response)
|
|
{
|
|
var uptime = DateTime.UtcNow - Program.StartTime;
|
|
var status = new
|
|
{
|
|
status = "online",
|
|
version = "1.0.0",
|
|
uptime = new
|
|
{
|
|
days = uptime.Days,
|
|
hours = uptime.Hours,
|
|
minutes = uptime.Minutes,
|
|
seconds = uptime.Seconds,
|
|
totalSeconds = (long)uptime.TotalSeconds
|
|
},
|
|
lobbies = new
|
|
{
|
|
active = _lobbyManager.LobbyCount,
|
|
players = _lobbyManager.TotalPlayerCount
|
|
},
|
|
memory = new
|
|
{
|
|
usedMb = GC.GetTotalMemory(false) / 1024 / 1024,
|
|
gcCollections = new
|
|
{
|
|
gen0 = GC.CollectionCount(0),
|
|
gen1 = GC.CollectionCount(1),
|
|
gen2 = GC.CollectionCount(2)
|
|
}
|
|
},
|
|
sessions = _sessions.Count,
|
|
spectators = _spectators.Count
|
|
};
|
|
|
|
await WriteJsonAsync(response, status);
|
|
}
|
|
|
|
private async Task HandleLobbiesAsync(HttpListenerResponse response)
|
|
{
|
|
var lobbies = _lobbyManager.GetAllLobbies().Select(l => new
|
|
{
|
|
id = l.LobbyId,
|
|
joinCode = l.JoinCode,
|
|
phase = l.Phase.ToString(),
|
|
players = l.GetPlayers().Select(p => new
|
|
{
|
|
id = p.PlayerId,
|
|
name = p.DisplayName,
|
|
state = p.State.ToString(),
|
|
role = p.Role.ToString(),
|
|
position = new { lat = p.Position.Lat, lng = p.Position.Lon },
|
|
isHost = p.IsHost
|
|
}),
|
|
playerCount = l.PlayerCount,
|
|
settings = new
|
|
{
|
|
impostorCount = l.Settings.ImpostorCount,
|
|
taskCount = l.Settings.TaskCount,
|
|
center = new { lat = l.Settings.PlayAreaCenter.Lat, lng = l.Settings.PlayAreaCenter.Lon },
|
|
radius = l.Settings.PlayAreaRadius
|
|
},
|
|
createdAt = l.CreatedAt,
|
|
activeSabotage = l.ActiveSabotage?.Type.ToString()
|
|
});
|
|
|
|
await WriteJsonAsync(response, new { lobbies });
|
|
}
|
|
|
|
/// <summary>
|
|
/// P13b: full ServerConfig round-trip. The previous hand-rolled selective
|
|
/// serialization only exposed ~10 fields; the admin panel now needs the
|
|
/// entire appsettings.json shape (~50 fields). We serialize through the
|
|
/// same JsonOptions the file loader uses so camelCase / enum-as-string
|
|
/// stays consistent. The StatsApiKey is masked in the GET response so
|
|
/// the secret doesn't leak into a browser dump.
|
|
/// </summary>
|
|
private async Task HandleGetConfigAsync(HttpListenerResponse response)
|
|
{
|
|
// Round-trip through JSON to get a clean copy + apply the server's
|
|
// own naming policy / enum converters. Mask StatsApiKey on the way out.
|
|
var json = JsonSerializer.Serialize(_config, JsonOptions.Indented);
|
|
var node = JsonDocument.Parse(json).RootElement;
|
|
|
|
// Re-emit with the api-key masked.
|
|
using var ms = new MemoryStream();
|
|
using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true }))
|
|
{
|
|
writer.WriteStartObject();
|
|
foreach (var prop in node.EnumerateObject())
|
|
{
|
|
if (prop.NameEquals("statsApiKey"))
|
|
{
|
|
var raw = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : null;
|
|
if (string.IsNullOrEmpty(raw)) writer.WriteNull("statsApiKey");
|
|
else writer.WriteString("statsApiKey", "********");
|
|
continue;
|
|
}
|
|
prop.WriteTo(writer);
|
|
}
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
response.ContentType = "application/json; charset=utf-8";
|
|
var bytes = ms.ToArray();
|
|
response.ContentLength64 = bytes.Length;
|
|
await response.OutputStream.WriteAsync(bytes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// P13b: accept arbitrary subset of ServerConfig fields. Only updates
|
|
/// fields the request actually carries; ignores unknown fields. Persists
|
|
/// the file at appsettings.json on every successful update so changes
|
|
/// survive restart. Apply rule: changes affect ONLY new lobbies created
|
|
/// after this point - existing lobbies keep their snapshot of the values
|
|
/// taken at their creation time. (See LobbySettings.FromDefaults.)
|
|
/// </summary>
|
|
private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response)
|
|
{
|
|
using var reader = new StreamReader(request.InputStream);
|
|
var body = await reader.ReadToEndAsync();
|
|
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
|
|
|
// Reflection-based update: walk every public settable property on
|
|
// ServerConfig, look for a matching camelCased field in the JSON,
|
|
// try to coerce. Skip the masked key sentinel so the user can leave
|
|
// ******** in the form without nuking their real key.
|
|
var props = typeof(ServerConfig).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
|
int applied = 0;
|
|
foreach (var p in props)
|
|
{
|
|
if (!p.CanWrite) continue;
|
|
var camel = char.ToLowerInvariant(p.Name[0]) + p.Name[1..];
|
|
if (!data.TryGetProperty(camel, out var el)) continue;
|
|
|
|
try
|
|
{
|
|
if (p.Name == nameof(ServerConfig.StatsApiKey) &&
|
|
el.ValueKind == JsonValueKind.String && el.GetString() == "********")
|
|
{
|
|
continue; // masked sentinel - leave existing
|
|
}
|
|
|
|
object? val = (Type.GetTypeCode(p.PropertyType)) switch
|
|
{
|
|
TypeCode.Int32 => el.ValueKind == JsonValueKind.Number ? el.GetInt32() : int.TryParse(el.GetString(), out var i) ? i : (object?)null,
|
|
TypeCode.Int64 => el.ValueKind == JsonValueKind.Number ? el.GetInt64() : long.TryParse(el.GetString(), out var l) ? l : (object?)null,
|
|
TypeCode.Double => el.ValueKind == JsonValueKind.Number ? el.GetDouble() : double.TryParse(el.GetString(), out var d) ? d : (object?)null,
|
|
TypeCode.Boolean => el.ValueKind == JsonValueKind.True ? true : el.ValueKind == JsonValueKind.False ? false : (object?)null,
|
|
TypeCode.String => el.ValueKind == JsonValueKind.String ? el.GetString() : el.ValueKind == JsonValueKind.Null ? null : (object?)null,
|
|
_ => p.PropertyType.IsEnum && el.ValueKind == JsonValueKind.String && Enum.TryParse(p.PropertyType, el.GetString(), true, out var ev) ? ev : null
|
|
};
|
|
if (val != null || p.PropertyType == typeof(string))
|
|
{
|
|
p.SetValue(_config, val);
|
|
applied++;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Admin SetConfig: failed to set {Property}", p.Name);
|
|
}
|
|
}
|
|
|
|
// Persist to disk so the change survives restart.
|
|
try
|
|
{
|
|
_config.Save("appsettings.json");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to persist admin config update to appsettings.json");
|
|
}
|
|
|
|
_logger.LogInformation("Config updated via admin panel: {Count} field(s)", applied);
|
|
|
|
await WriteJsonAsync(response, new { success = true, fieldsApplied = applied });
|
|
}
|
|
|
|
private async Task HandlePlayersAsync(HttpListenerRequest request, HttpListenerResponse response)
|
|
{
|
|
var query = request.QueryString;
|
|
var search = query["search"] ?? "";
|
|
|
|
var allPlayers = _lobbyManager.GetAllLobbies()
|
|
.SelectMany(l => l.GetPlayers().Select(p => new
|
|
{
|
|
playerId = p.PlayerId,
|
|
displayName = p.DisplayName,
|
|
lobbyId = l.LobbyId,
|
|
joinCode = l.JoinCode,
|
|
state = p.State.ToString(),
|
|
role = p.Role.ToString(),
|
|
position = new { lat = p.Position.Lat, lng = p.Position.Lon },
|
|
isHost = p.IsHost,
|
|
cheatScore = p.CheatScore
|
|
}))
|
|
.Where(p => string.IsNullOrEmpty(search) ||
|
|
p.displayName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
|
p.playerId.Contains(search, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
await WriteJsonAsync(response, new { players = allPlayers, total = allPlayers.Count });
|
|
}
|
|
|
|
private async Task HandleKickAsync(HttpListenerRequest request, HttpListenerResponse response)
|
|
{
|
|
using var reader = new StreamReader(request.InputStream);
|
|
var body = await reader.ReadToEndAsync();
|
|
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
|
|
|
var playerId = data.GetProperty("playerId").GetString();
|
|
var reason = data.TryGetProperty("reason", out var r) ? r.GetString() : "Kicked by admin";
|
|
|
|
// Najdi hráče a kickni ho
|
|
foreach (var lobby in _lobbyManager.GetAllLobbies())
|
|
{
|
|
if (lobby.TryKickPlayer(playerId!, reason!))
|
|
{
|
|
_logger.LogInformation("Player {PlayerId} kicked by admin: {Reason}", playerId, reason);
|
|
await WriteJsonAsync(response, new { success = true });
|
|
return;
|
|
}
|
|
}
|
|
|
|
response.StatusCode = 404;
|
|
await WriteJsonAsync(response, new { error = "Player not found" });
|
|
}
|
|
|
|
private async Task HandleBroadcastAsync(HttpListenerRequest request, HttpListenerResponse response)
|
|
{
|
|
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
|
|
foreach (var lobby in _lobbyManager.GetAllLobbies())
|
|
{
|
|
lobby.BroadcastSystemMessage(message!);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Broadcast do konkrétní lobby
|
|
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 ═══════════════════════════════════════════════════════════════════
|
|
// P13d: ARCHIVE
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// P13d: list every archived lobby on disk. Persistence.ArchiveLobby
|
|
/// drops them at <DataPath>/archive/<lobbyId>_<yyyyMMddHHmmss>/. We parse
|
|
/// the directory name back into (lobbyId, timestamp) for the UI, count
|
|
/// the WAL events, and report on-disk size so the operator can spot
|
|
/// abnormally-large or empty runs at a glance.
|
|
/// </summary>
|
|
private async Task HandleListArchivesAsync(HttpListenerResponse response)
|
|
{
|
|
var archiveDir = Path.Combine(_config.DataPath, "archive");
|
|
if (!Directory.Exists(archiveDir))
|
|
{
|
|
await WriteJsonAsync(response, new { archives = Array.Empty<object>() });
|
|
return;
|
|
}
|
|
|
|
var archives = Directory.GetDirectories(archiveDir)
|
|
.Select(d => new DirectoryInfo(d))
|
|
.OrderByDescending(d => d.CreationTimeUtc)
|
|
.Select(d =>
|
|
{
|
|
var name = d.Name;
|
|
var lastUnderscore = name.LastIndexOf('_');
|
|
string lobbyId = name;
|
|
string timestampRaw = "";
|
|
if (lastUnderscore > 0)
|
|
{
|
|
lobbyId = name[..lastUnderscore];
|
|
timestampRaw = name[(lastUnderscore + 1)..];
|
|
}
|
|
|
|
string isoTimestamp = "";
|
|
if (DateTime.TryParseExact(timestampRaw, "yyyyMMddHHmmss",
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
|
out var parsedTs))
|
|
{
|
|
isoTimestamp = parsedTs.ToString("o");
|
|
}
|
|
else
|
|
{
|
|
// Fallback: directory creation time.
|
|
isoTimestamp = d.CreationTimeUtc.ToString("o");
|
|
}
|
|
|
|
int eventCount = 0;
|
|
long sizeBytes = 0;
|
|
try
|
|
{
|
|
foreach (var w in Directory.GetFiles(d.FullName, "wal_*.ndjson"))
|
|
{
|
|
var fi = new FileInfo(w);
|
|
sizeBytes += fi.Length;
|
|
eventCount += File.ReadLines(w).Count(l => !string.IsNullOrWhiteSpace(l));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to stat archive {Dir}", d.FullName);
|
|
}
|
|
|
|
return new
|
|
{
|
|
id = name,
|
|
lobbyId,
|
|
timestamp = isoTimestamp,
|
|
eventCount,
|
|
sizeBytes
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
await WriteJsonAsync(response, new { archives });
|
|
}
|
|
|
|
/// <summary>
|
|
/// P13d: stream a single archive's WAL events back to the admin panel as
|
|
/// a JSON array. The on-disk format is NDJSON (one already-serialized
|
|
/// GameEvent per line); we glue the lines with commas inside [] rather
|
|
/// than re-parsing each one - the admin panel doesn't need the parsed
|
|
/// tree on the server side, and zero-copy keeps memory usage bounded
|
|
/// even for hour-long lobbies.
|
|
/// </summary>
|
|
private async Task HandleArchiveEventsAsync(string archiveId, HttpListenerResponse response)
|
|
{
|
|
if (!IsSafeArchiveId(archiveId))
|
|
{
|
|
response.StatusCode = 400;
|
|
await WriteJsonAsync(response, new { error = "Invalid archive id" });
|
|
return;
|
|
}
|
|
|
|
var archivePath = Path.Combine(_config.DataPath, "archive", archiveId);
|
|
if (!Directory.Exists(archivePath))
|
|
{
|
|
response.StatusCode = 404;
|
|
await WriteJsonAsync(response, new { error = "Archive not found" });
|
|
return;
|
|
}
|
|
|
|
var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson")
|
|
.OrderBy(f => f)
|
|
.ToList();
|
|
|
|
// Buffer the whole array in memory so we can set Content-Length and
|
|
// avoid chunked transfer headaches. Typical lobby archives are well
|
|
// under 10MB; if we ever need to stream, swap in chunked-encoding here.
|
|
var sb = new StringBuilder();
|
|
sb.Append('[');
|
|
bool first = true;
|
|
foreach (var w in walFiles)
|
|
{
|
|
string[] lines;
|
|
try
|
|
{
|
|
lines = await File.ReadAllLinesAsync(w);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to read archive WAL {Path}", w);
|
|
continue;
|
|
}
|
|
foreach (var line in lines)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
if (!first) sb.Append(',');
|
|
sb.Append(line);
|
|
first = false;
|
|
}
|
|
}
|
|
sb.Append(']');
|
|
|
|
response.ContentType = "application/json; charset=utf-8";
|
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
|
response.ContentLength64 = bytes.Length;
|
|
await response.OutputStream.WriteAsync(bytes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// P13d: extract per-player polylines from an archive's WAL. We don't
|
|
/// persist UpdatePosition (high-rate client telemetry), so we reconstruct
|
|
/// movement traces from the events that DO carry coordinates: kill/body/
|
|
/// task-start/meeting-call/etc. Each event with a `location` or `position`
|
|
/// payload is attributed to the actor (preferred) or the victim/clientUuid
|
|
/// inside the payload, then stitched into a per-player time-ordered list
|
|
/// the frontend renders as a Leaflet polyline.
|
|
/// </summary>
|
|
private async Task HandleArchivePositionsAsync(string archiveId, HttpListenerResponse response)
|
|
{
|
|
if (!IsSafeArchiveId(archiveId))
|
|
{
|
|
response.StatusCode = 400;
|
|
await WriteJsonAsync(response, new { error = "Invalid archive id" });
|
|
return;
|
|
}
|
|
|
|
var archivePath = Path.Combine(_config.DataPath, "archive", archiveId);
|
|
if (!Directory.Exists(archivePath))
|
|
{
|
|
response.StatusCode = 404;
|
|
await WriteJsonAsync(response, new { error = "Archive not found" });
|
|
return;
|
|
}
|
|
|
|
var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson")
|
|
.OrderBy(f => f)
|
|
.ToList();
|
|
|
|
var polylines = new Dictionary<string, List<object>>();
|
|
var playerNames = new Dictionary<string, string>();
|
|
|
|
foreach (var w in walFiles)
|
|
{
|
|
string[] lines;
|
|
try
|
|
{
|
|
lines = await File.ReadAllLinesAsync(w);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to read archive WAL {Path}", w);
|
|
continue;
|
|
}
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
|
|
JsonDocument? doc = null;
|
|
try { doc = JsonDocument.Parse(line); }
|
|
catch { continue; }
|
|
using (doc)
|
|
{
|
|
var root = doc.RootElement;
|
|
string eventType = root.TryGetProperty("eventType", out var et) ? (et.GetString() ?? "") : "";
|
|
string actor = root.TryGetProperty("actor", out var a) ? (a.GetString() ?? "") : "";
|
|
string ts = root.TryGetProperty("timestamp", out var t) ? (t.GetString() ?? "") : "";
|
|
|
|
if (!root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object)
|
|
continue;
|
|
|
|
// Capture display names from PlayerJoined for legend labels.
|
|
if (eventType == "PlayerJoined")
|
|
{
|
|
string cid = payload.TryGetProperty("clientUuid", out var c) ? (c.GetString() ?? "") : "";
|
|
string dn = payload.TryGetProperty("displayName", out var d) ? (d.GetString() ?? "") : "";
|
|
if (!string.IsNullOrEmpty(cid) && !string.IsNullOrEmpty(dn))
|
|
playerNames[cid] = dn;
|
|
continue;
|
|
}
|
|
|
|
// Find a (lat,lon) pair in either `location` or `position`.
|
|
double? lat = null, lon = null;
|
|
if (payload.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (loc.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble();
|
|
if (loc.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble();
|
|
}
|
|
else if (payload.TryGetProperty("position", out var pos) && pos.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (pos.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble();
|
|
if (pos.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble();
|
|
}
|
|
|
|
if (!lat.HasValue || !lon.HasValue) continue;
|
|
|
|
// Attribute the position to a specific player. PlayerKilled
|
|
// gives us the victim's last position (more useful than the
|
|
// killer's for trace reconstruction); everything else falls
|
|
// through to actor / payload.clientUuid.
|
|
string? pid = null;
|
|
if (eventType == "PlayerKilled")
|
|
pid = payload.TryGetProperty("victimId", out var v) ? v.GetString() : null;
|
|
else if (!string.IsNullOrEmpty(actor))
|
|
pid = actor;
|
|
else if (payload.TryGetProperty("clientUuid", out var c2))
|
|
pid = c2.GetString();
|
|
|
|
if (string.IsNullOrEmpty(pid)) continue;
|
|
|
|
if (!polylines.TryGetValue(pid, out var list))
|
|
{
|
|
list = new List<object>();
|
|
polylines[pid] = list;
|
|
}
|
|
list.Add(new { lat = lat.Value, lon = lon.Value, ts, evt = eventType });
|
|
}
|
|
}
|
|
}
|
|
|
|
var result = polylines.Select(kvp => new
|
|
{
|
|
playerId = kvp.Key,
|
|
displayName = playerNames.TryGetValue(kvp.Key, out var n) ? n : kvp.Key,
|
|
points = kvp.Value
|
|
}).ToList();
|
|
|
|
await WriteJsonAsync(response, new { polylines = result });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reject any archive id containing a path-traversal token. We also bar
|
|
/// raw separators because the on-disk layout is one flat directory per
|
|
/// archive - no nesting expected.
|
|
/// </summary>
|
|
private static bool IsSafeArchiveId(string archiveId)
|
|
{
|
|
if (string.IsNullOrEmpty(archiveId)) return false;
|
|
if (archiveId.Contains("..")) return false;
|
|
if (archiveId.Contains('/') || archiveId.Contains('\\')) return false;
|
|
return true;
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// WEBSOCKET - SPECTATE
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
private async Task HandleSpectateWebSocketAsync(HttpListenerContext context, string lobbyId, string sessionId)
|
|
{
|
|
var wsContext = await context.AcceptWebSocketAsync(null);
|
|
var ws = wsContext.WebSocket;
|
|
var spectatorId = Guid.NewGuid().ToString("N")[..8];
|
|
|
|
var lobby = _lobbyManager.GetLobby(lobbyId);
|
|
if (lobby == null)
|
|
{
|
|
try
|
|
{
|
|
await ws.CloseAsync(WebSocketCloseStatus.InvalidPayloadData, "Lobby not found", CancellationToken.None);
|
|
}
|
|
catch (WebSocketException) { /* Client already disconnected */ }
|
|
return;
|
|
}
|
|
|
|
var connection = new SpectatorConnection
|
|
{
|
|
WebSocket = ws,
|
|
LobbyId = lobbyId,
|
|
SessionId = sessionId
|
|
};
|
|
|
|
_spectators[spectatorId] = connection;
|
|
_logger.LogInformation("Spectator {Id} connected to lobby {LobbyId}", spectatorId, lobbyId);
|
|
|
|
try
|
|
{
|
|
// 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);
|
|
var cts = new CancellationTokenSource();
|
|
var receivedMessage = new TaskCompletionSource<(bool closed, string? message)>();
|
|
|
|
// Spustíme receive task separátně
|
|
var receiveTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
while (ws.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
|
|
{
|
|
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
return true; // closed
|
|
}
|
|
|
|
if (result.Count > 0)
|
|
{
|
|
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
|
await HandleSpectatorMessageAsync(ws, lobby!, message);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (WebSocketException) { }
|
|
return ws.State != WebSocketState.Open;
|
|
});
|
|
|
|
// Hlavní smyčka posílá state
|
|
while (ws.State == WebSocketState.Open && !receiveTask.IsCompleted)
|
|
{
|
|
lobby = _lobbyManager.GetLobby(lobbyId);
|
|
if (lobby != null)
|
|
{
|
|
// 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
|
|
{
|
|
break;
|
|
}
|
|
|
|
try
|
|
{
|
|
await Task.WhenAny(receiveTask, Task.Delay(updateInterval, cts.Token));
|
|
}
|
|
catch (OperationCanceledException) { break; }
|
|
}
|
|
|
|
cts.Cancel();
|
|
|
|
// Gracefully close if still open
|
|
if (ws.State == WebSocketState.Open)
|
|
{
|
|
try
|
|
{
|
|
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, lobby == null ? "Lobby closed" : "Closing", CancellationToken.None);
|
|
}
|
|
catch (WebSocketException) { /* Already closed by client */ }
|
|
}
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// Client disconnected - this is normal
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Spectate WebSocket error");
|
|
}
|
|
finally
|
|
{
|
|
_spectators.TryRemove(spectatorId, out _);
|
|
_logger.LogInformation("Spectator {Id} disconnected", spectatorId);
|
|
}
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
type = "state",
|
|
lobby = new
|
|
{
|
|
id = lobby.LobbyId,
|
|
joinCode = lobby.JoinCode,
|
|
phase = lobby.Phase.ToString(),
|
|
phaseEndTime = lobby.PhaseEndTime?.ToString("o"),
|
|
|
|
players = lobby.GetPlayers().Select(p => new
|
|
{
|
|
id = p.PlayerId,
|
|
name = p.DisplayName,
|
|
state = p.State.ToString(),
|
|
role = p.Role.ToString(),
|
|
lat = p.Position.Lat,
|
|
lng = p.Position.Lon,
|
|
isHost = p.IsHost,
|
|
cheatScore = p.CheatScore,
|
|
killCooldownEnd = p.KillCooldownEnd?.ToString("o"),
|
|
votedFor = p.VotedFor
|
|
}),
|
|
|
|
tasks = lobby.GetTasks().Select(t => new
|
|
{
|
|
id = t.TaskId,
|
|
name = t.Name,
|
|
lat = t.Location.Lat,
|
|
lng = t.Location.Lon,
|
|
completedBy = t.CompletedBy.ToList()
|
|
}),
|
|
|
|
bodies = lobby.GetBodies().Select(b => new
|
|
{
|
|
victimId = b.VictimId,
|
|
lat = b.Position.Lat,
|
|
lng = b.Position.Lon,
|
|
reportedAt = b.ReportedAt?.ToString("o")
|
|
}),
|
|
|
|
sabotage = lobby.ActiveSabotage != null ? new
|
|
{
|
|
type = lobby.ActiveSabotage.Type.ToString(),
|
|
deadline = lobby.ActiveSabotage.Deadline?.ToString("o"),
|
|
repairStations = lobby.ActiveSabotage.RepairStations.Select(rs => new
|
|
{
|
|
id = rs.StationId,
|
|
lat = rs.Location.Lat,
|
|
lng = rs.Location.Lon,
|
|
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 },
|
|
radius = lobby.Settings.PlayAreaRadius,
|
|
impostorCount = lobby.Settings.ImpostorCount,
|
|
taskCount = lobby.Settings.TaskCount
|
|
},
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
private async Task HandleSpectatorMessageAsync(WebSocket ws, LobbyActor lobby, string message)
|
|
{
|
|
try
|
|
{
|
|
var data = JsonSerializer.Deserialize<JsonElement>(message);
|
|
var type = data.GetProperty("type").GetString();
|
|
|
|
switch (type)
|
|
{
|
|
case "ping":
|
|
await SendJsonAsync(ws, new { type = "pong" });
|
|
break;
|
|
|
|
case "getPlayerHistory":
|
|
var playerId = data.GetProperty("playerId").GetString();
|
|
var stats = _statsDb.GetPlayerStats(playerId!);
|
|
await SendJsonAsync(ws, new { type = "playerHistory", stats });
|
|
break;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// WEBSOCKET - SERVER STATS
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
private async Task HandleStatsWebSocketAsync(HttpListenerContext context, string sessionId)
|
|
{
|
|
var wsContext = await context.AcceptWebSocketAsync(null);
|
|
var ws = wsContext.WebSocket;
|
|
|
|
try
|
|
{
|
|
var buffer = new byte[1024];
|
|
var updateInterval = TimeSpan.FromSeconds(1);
|
|
var cts = new CancellationTokenSource();
|
|
|
|
// Spustíme receive task separátně - bude běžet celou dobu
|
|
var receiveTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
while (ws.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
|
|
{
|
|
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (WebSocketException) { }
|
|
});
|
|
|
|
// Hlavní smyčka posílá stats
|
|
while (ws.State == WebSocketState.Open && !receiveTask.IsCompleted)
|
|
{
|
|
var uptime = DateTime.UtcNow - Program.StartTime;
|
|
var stats = new
|
|
{
|
|
type = "stats",
|
|
uptime = (long)uptime.TotalSeconds,
|
|
lobbies = _lobbyManager.LobbyCount,
|
|
players = _lobbyManager.TotalPlayerCount,
|
|
memoryMb = GC.GetTotalMemory(false) / 1024 / 1024,
|
|
spectators = _spectators.Count,
|
|
timestamp = DateTime.UtcNow.ToString("o")
|
|
};
|
|
|
|
await SendJsonAsync(ws, stats);
|
|
|
|
// Čekáme na interval nebo ukončení receive tasku
|
|
var delayTask = Task.Delay(updateInterval, cts.Token);
|
|
try
|
|
{
|
|
await Task.WhenAny(receiveTask, delayTask);
|
|
}
|
|
catch (OperationCanceledException) { break; }
|
|
}
|
|
|
|
cts.Cancel();
|
|
|
|
if (ws.State == WebSocketState.Open)
|
|
{
|
|
try
|
|
{
|
|
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
|
|
}
|
|
catch (WebSocketException) { /* Already closed */ }
|
|
}
|
|
}
|
|
catch (WebSocketException) { }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Stats WebSocket error");
|
|
}
|
|
}
|
|
|
|
#region ═══════════════════════════════════════════════════════════════════
|
|
// HELPERS
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
#endregion
|
|
|
|
private static async Task WriteJsonAsync(HttpListenerResponse response, object data)
|
|
{
|
|
response.ContentType = "application/json";
|
|
var json = JsonSerializer.Serialize(data, JsonOptions.Default);
|
|
var bytes = Encoding.UTF8.GetBytes(json);
|
|
response.ContentLength64 = bytes.Length;
|
|
await response.OutputStream.WriteAsync(bytes);
|
|
}
|
|
|
|
private static async Task SendJsonAsync(WebSocket ws, object data)
|
|
{
|
|
var json = JsonSerializer.Serialize(data, 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);
|
|
}
|
|
}
|
|
|
|
private async Task ServeStaticFileAsync(HttpListenerResponse response, string filename, string contentType)
|
|
{
|
|
var content = GetEmbeddedResource(filename);
|
|
if (content == null)
|
|
{
|
|
response.StatusCode = 404;
|
|
return;
|
|
}
|
|
|
|
response.ContentType = contentType + "; charset=utf-8";
|
|
var bytes = Encoding.UTF8.GetBytes(content);
|
|
response.ContentLength64 = bytes.Length;
|
|
await response.OutputStream.WriteAsync(bytes);
|
|
}
|
|
|
|
private string? GetEmbeddedResource(string filename)
|
|
{
|
|
// Embedded resources z AdminResources třídy
|
|
return filename switch
|
|
{
|
|
"admin.html" => AdminResources.Html,
|
|
"admin.css" => AdminResources.Css,
|
|
"admin.js" => AdminResources.JavaScript,
|
|
_ => null
|
|
};
|
|
}
|
|
}
|
|
|
|
public class AdminSession
|
|
{
|
|
public DateTime LastActivity { get; set; }
|
|
}
|
|
|
|
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;
|
|
}
|