Server
This commit is contained in:
795
AdminPanel.cs
Normal file
795
AdminPanel.cs
Normal file
@@ -0,0 +1,795 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
private async Task HandleGetConfigAsync(HttpListenerResponse response)
|
||||
{
|
||||
// Vrátíme pouze bezpečné konfigurace (ne hesla apod.)
|
||||
var config = new
|
||||
{
|
||||
tcpPort = _config.TcpPort,
|
||||
httpPort = _config.HttpPort,
|
||||
maxPlayersPerLobby = _config.MaxPlayersPerLobby,
|
||||
killDistanceM = _config.KillDistanceM,
|
||||
killCooldownMs = _config.KillCooldownMs,
|
||||
discussionPhaseMs = _config.DiscussionPhaseMs,
|
||||
votingPhaseMs = _config.VotingPhaseMs,
|
||||
taskStartDistanceM = _config.TaskStartDistanceM,
|
||||
sabotageCooldownMs = _config.SabotageCooldownMs,
|
||||
criticalMeltdownDeadlineMs = _config.CriticalMeltdownDeadlineMs,
|
||||
maxSpeedMps = _config.MaxSpeedMps,
|
||||
teleportThresholdMeters = _config.TeleportThresholdMeters
|
||||
};
|
||||
|
||||
await WriteJsonAsync(response, config);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Aplikuj změny
|
||||
if (data.TryGetProperty("killDistanceM", out var kd)) _config.KillDistanceM = kd.GetDouble();
|
||||
if (data.TryGetProperty("killCooldownMs", out var kc)) _config.KillCooldownMs = kc.GetInt32();
|
||||
if (data.TryGetProperty("discussionPhaseMs", out var dp)) _config.DiscussionPhaseMs = dp.GetInt32();
|
||||
if (data.TryGetProperty("votingPhaseMs", out var vp)) _config.VotingPhaseMs = vp.GetInt32();
|
||||
if (data.TryGetProperty("maxSpeedMps", out var ms)) _config.MaxSpeedMps = ms.GetDouble();
|
||||
|
||||
_logger.LogInformation("Config updated via admin panel");
|
||||
|
||||
await WriteJsonAsync(response, new { success = true });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
#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
|
||||
await SendLobbyStateAsync(ws, lobby);
|
||||
|
||||
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)
|
||||
{
|
||||
await SendLobbyStateAsync(ws, lobby);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby)
|
||||
{
|
||||
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 = lobby.MapData // Overpass data pro vykreslení mapy
|
||||
},
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user