This commit is contained in:
root
2026-04-26 12:44:06 +02:00
commit 9590629795
398 changed files with 26617 additions and 0 deletions

795
AdminPanel.cs Normal file
View 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; }
}

2374
AdminResources.cs Normal file

File diff suppressed because it is too large Load Diff

192
AntiCheat.cs Normal file
View File

@@ -0,0 +1,192 @@
namespace GeoSus.Server;
using Microsoft.Extensions.Logging;
// Anti-cheat validace pohybu
public class AntiCheat
{
private readonly ServerConfig _config;
private readonly ILogger _logger;
private const int PositionHistorySize = 20;
public AntiCheat(ServerConfig config, ILogger logger)
{
_config = config;
_logger = logger;
}
// Validuje pohyb hráče a vrací případné cheat violation
public CheatViolation? ValidateMovement(Player player, Position newPosition)
{
var now = DateTime.UtcNow;
var timeSinceLastUpdate = (now - player.LastPositionUpdate).TotalSeconds;
// Příliš krátký interval ignorujeme
if (timeSinceLastUpdate < 0.1)
return null;
var distance = player.Position.DistanceTo(newPosition);
// Teleport detekce
if (distance > _config.TeleportThresholdMeters)
{
var violation = new CheatViolation
{
Type = CheatViolationType.Teleport,
Description = $"Teleport detekován: {distance:F1}m za {timeSinceLastUpdate:F2}s",
Severity = 10
};
ApplyViolation(player, violation);
return violation;
}
// Speed check
var speed = distance / timeSinceLastUpdate;
var maxAllowedSpeed = _config.MaxSpeedMps * 1.5; // 50% tolerance
if (speed > maxAllowedSpeed)
{
var violation = new CheatViolation
{
Type = CheatViolationType.SpeedHack,
Description = $"Podezřelá rychlost: {speed:F1}m/s (max {_config.MaxSpeedMps}m/s)",
Severity = 3
};
ApplyViolation(player, violation);
return violation;
}
// Historie pro detekci pattern
UpdatePositionHistory(player, newPosition, now);
// Validace historie - průměrná rychlost za delší období
var historyViolation = ValidatePositionHistory(player);
if (historyViolation != null)
{
ApplyViolation(player, historyViolation);
return historyViolation;
}
// Pokud je OK, pomalu snižujeme cheat score
if (player.CheatScore > 0 && timeSinceLastUpdate > 1)
{
player.CheatScore = Math.Max(0, player.CheatScore - 1);
UpdateCheatStatus(player);
}
return null;
}
private void ApplyViolation(Player player, CheatViolation violation)
{
player.CheatScore += violation.Severity;
var previousStatus = player.CheatStatus;
UpdateCheatStatus(player);
if (player.CheatStatus != previousStatus)
{
_logger.LogWarning("Cheat status změněn: {Player} -> {Status} (score: {Score})",
player.ClientUuid, player.CheatStatus, player.CheatScore);
}
_logger.LogWarning("Cheat violation: {Player} - {Type}: {Description}",
player.ClientUuid, violation.Type, violation.Description);
}
private void UpdateCheatStatus(Player player)
{
player.CheatStatus = player.CheatScore switch
{
>= 50 => CheatStatus.Kicked,
>= 25 => CheatStatus.Restrict,
>= 10 => CheatStatus.Warn,
_ => CheatStatus.Ok
};
}
private void UpdatePositionHistory(Player player, Position position, DateTime time)
{
player.PositionHistory.Enqueue((position, time));
while (player.PositionHistory.Count > PositionHistorySize)
{
player.PositionHistory.Dequeue();
}
}
private CheatViolation? ValidatePositionHistory(Player player)
{
if (player.PositionHistory.Count < 5)
return null;
var history = player.PositionHistory.ToArray();
var first = history[0];
var last = history[^1];
var totalTime = (last.Time - first.Time).TotalSeconds;
if (totalTime < _config.MovementValidationWindowSec)
return null;
// Spočítáme celkovou ujetou vzdálenost
double totalDistance = 0;
for (int i = 1; i < history.Length; i++)
{
totalDistance += history[i - 1].Pos.DistanceTo(history[i].Pos);
}
var avgSpeed = totalDistance / totalTime;
// Pokud průměrná rychlost za celé období překračuje limit
if (avgSpeed > _config.MaxSpeedMps * 1.2)
{
return new CheatViolation
{
Type = CheatViolationType.SustainedSpeedHack,
Description = $"Dlouhodobě vysoká rychlost: {avgSpeed:F1}m/s za {totalTime:F0}s",
Severity = 5
};
}
return null;
}
// Validace akce - zda hráč může provést danou akci
public bool CanPerformAction(Player player, string actionType)
{
if (player.CheatStatus == CheatStatus.Kicked)
return false;
if (player.CheatStatus == CheatStatus.Restrict)
{
// Omezené akce pro podezřelé hráče
var restrictedActions = new[] { "TaskComplete", "Kill", "EmergencyMeeting" };
if (restrictedActions.Contains(actionType))
{
_logger.LogWarning("Akce {Action} blokována pro hráče {Player} (Restrict status)",
actionType, player.ClientUuid);
return false;
}
}
return true;
}
}
public class CheatViolation
{
public CheatViolationType Type { get; set; }
public required string Description { get; set; }
public int Severity { get; set; }
}
public enum CheatViolationType
{
SpeedHack,
Teleport,
SustainedSpeedHack,
ActionSpam,
InvalidAction
}

171
Config.cs Normal file
View File

@@ -0,0 +1,171 @@
namespace GeoSus.Server;
using System.Text.Json;
using System.Text.Json.Serialization;
// Konfigurace serveru - všechny herní konstanty
public class ServerConfig
{
// Síťové
public int TcpPort { get; set; } = 7777;
public int HttpPort { get; set; } = 8088;
public int MaxPacketSizeBytes { get; set; } = 1048576; // 1MB
// Timing
public int TickMs { get; set; } = 200;
public int PositionBroadcastRateMs { get; set; } = 1000;
// Pohyb & Anti-Cheat
public double MaxSpeedMps { get; set; } = 12.0;
public double MovementValidationWindowSec { get; set; } = 5.0;
public double TeleportThresholdMeters { get; set; } = 50.0;
public int CheatScoreWarnThreshold { get; set; } = 10;
public int CheatScoreRestrictThreshold { get; set; } = 25;
public int CheatScoreKickThreshold { get; set; } = 50;
// Zabíjení
public double KillDistanceM { get; set; } = 10.0;
public int KillCooldownMs { get; set; } = 30000;
// Meetingy
public double MeetingArrivalRadiusM { get; set; } = 15.0;
public int ArrivalBaseMs { get; set; } = 30000;
public int ArrivalSafetyMarginMs { get; set; } = 500;
public int AllowedLateMs { get; set; } = 2000;
public int DiscussionPhaseMs { get; set; } = 30000;
public int VotingPhaseMs { get; set; } = 45000;
// Emergency meeting
public int EmergencyMeetingCooldownMs { get; set; } = 60000;
public int MaxEmergencyMeetingsPerPlayer { get; set; } = 1;
public double EmergencyMeetingCallRadiusM { get; set; } = 15.0; // Vzdálenost od středu mapy pro svolání emergency meeting
public double ReportDistanceM { get; set; } = 5.0;
// Tasky
public double TaskStartDistanceM { get; set; } = 3.0;
public int TaskLeaveDebounceMs { get; set; } = 2000;
public int TaskProgressKeepaliveMs { get; set; } = 5000;
// Persistence
public int SnapshotEvents { get; set; } = 200;
public int SnapshotIntervalMs { get; set; } = 300000;
public int WalMaxSizeMb { get; set; } = 10;
public string DataPath { get; set; } = "data";
// Host Migration
public int HostTimeoutMs { get; set; } = 15000;
public int ReconnectWindowMs { get; set; } = 60000;
// Lobby
public int IdleLobbyTtlMs { get; set; } = 3600000;
public int JoinCodeTtlMs { get; set; } = 86400000; // 24h
public int MaxPlayersPerLobby { get; set; } = 15;
public int JoinRateLimitPerMinute { get; set; } = 10;
// Security
public int SessionKeySizeBytes { get; set; } = 32; // AES-256
public int RsaKeySizeBits { get; set; } = 2048;
// Stats API
public int StatsApiRateLimit { get; set; } = 100; // per minute per IP
public string? StatsApiKey { get; set; } = null;
// Hra - výchozí hodnoty pro nové lobby
public int DefaultImpostorCount { get; set; } = 1;
public int DefaultTaskCount { get; set; } = 5;
public TiePolicy DefaultTiePolicy { get; set; } = TiePolicy.NoEject;
// Sabotage system
public int SabotageCooldownMs { get; set; } = 30000; // 30s between sabotages
public int CommsBlackoutDurationMs { get; set; } = 30000; // 30s max duration (or until repaired)
public int CriticalMeltdownDeadlineMs { get; set; } = 45000; // 45s to fix or impostor wins
public double RepairStationDistanceM { get; set; } = 5.0; // Must be within 5m to repair
public int RepairStationHoldMs { get; set; } = 3000; // Must hold for 3s to complete repair
public int SimultaneousRepairWindowMs { get; set; } = 5000; // 5s window for both stations to be repaired
// Overpass API (Map Data)
public string OverpassApiUrl { get; set; } = "https://overpass-api.de/api/interpreter";
public int OverpassTimeoutSec { get; set; } = 30;
public int OverpassCacheMaxEntries { get; set; } = 100;
public bool OverpassEnabled { get; set; } = true;
public double MinTaskSpacingMeters { get; set; } = 20.0; // Minimum spacing between tasks
public static ServerConfig Load(string path)
{
ServerConfig config;
if (File.Exists(path))
{
var json = File.ReadAllText(path);
config = JsonSerializer.Deserialize<ServerConfig>(json, JsonOptions.Default) ?? new ServerConfig();
}
else
{
config = new ServerConfig();
}
// Override z environment variables (pro Docker)
config.ApplyEnvironmentOverrides();
return config;
}
/// <summary>
/// Aplikuje override hodnot z environment variables.
/// Prefix: GEOSUS_
/// Příklady: GEOSUS_TCP_PORT=7777, GEOSUS_HTTP_PORT=8088
/// </summary>
private void ApplyEnvironmentOverrides()
{
// Síťové
if (int.TryParse(Environment.GetEnvironmentVariable("GEOSUS_TCP_PORT"), out var tcpPort))
TcpPort = tcpPort;
if (int.TryParse(Environment.GetEnvironmentVariable("GEOSUS_HTTP_PORT"), out var httpPort))
HttpPort = httpPort;
// Data path
var dataPath = Environment.GetEnvironmentVariable("GEOSUS_DATA_PATH");
if (!string.IsNullOrEmpty(dataPath))
DataPath = dataPath;
// Overpass API
var overpassUrl = Environment.GetEnvironmentVariable("GEOSUS_OVERPASS_URL");
if (!string.IsNullOrEmpty(overpassUrl))
OverpassApiUrl = overpassUrl;
// Stats API key
var apiKey = Environment.GetEnvironmentVariable("GEOSUS_API_KEY");
if (!string.IsNullOrEmpty(apiKey))
StatsApiKey = apiKey;
}
public void Save(string path)
{
var json = JsonSerializer.Serialize(this, JsonOptions.Indented);
File.WriteAllText(path, json);
}
}
public enum TiePolicy
{
NoEject,
Random,
EjectLowestId
}
public static class JsonOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
WriteIndented = false
};
public static readonly JsonSerializerOptions Indented = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true
};
}

180
DOCKER.md Normal file
View File

@@ -0,0 +1,180 @@
# GeoSus Server - Docker Deployment
## Rychlý start
```bash
# Build image
docker build -t geosus-server .
# Spusť kontejner
docker run -d \
--name geosus \
-p 7777:7777 \
-p 8088:8088 \
-v geosus-data:/app/data \
geosus-server
```
## Docker Compose (doporučeno)
```bash
# Spuštění
docker-compose up -d
# Sledování logů
docker-compose logs -f
# Zastavení
docker-compose down
# Zastavení včetně smazání dat
docker-compose down -v
```
## Porty
| Port | Protokol | Popis |
|------|----------|-------|
| 7777 | TCP | Herní komunikace (binární protokol s šifrováním) |
| 8088 | HTTP | Stats REST API |
## Environment Variables
| Proměnná | Výchozí | Popis |
|----------|---------|-------|
| `GEOSUS_TCP_PORT` | 7777 | TCP port pro herní komunikaci |
| `GEOSUS_HTTP_PORT` | 8088 | HTTP port pro Stats API |
| `GEOSUS_DATA_PATH` | data | Cesta k datové složce |
| `GEOSUS_OVERPASS_URL` | https://mapz.honzuvkod.dev/api/interpreter | URL Overpass API pro mapová data |
| `GEOSUS_API_KEY` | (none) | API klíč pro Stats API (volitelné) |
| `DOTNET_ENVIRONMENT` | Production | Prostředí (.NET) |
## Volumes
- `/app/data` - Herní data (lobby, statistiky SQLite)
## Health Check
Server poskytuje health endpoint:
```bash
curl http://localhost:8088/health
```
Odpověď:
```json
{
"status": "ok",
"version": "1.0.0",
"uptimeSeconds": 3600,
"activeLobbies": 2,
"connectedPlayers": 15
}
```
## Vlastní konfigurace
Můžeš připojit vlastní `appsettings.json`:
```bash
docker run -d \
--name geosus \
-p 7777:7777 \
-p 8088:8088 \
-v geosus-data:/app/data \
-v ./my-settings.json:/app/appsettings.json:ro \
geosus-server
```
## Logování
```bash
# Živé logy
docker logs -f geosus
# Posledních 100 řádků
docker logs --tail 100 geosus
```
## Firewall
Nezapomeň otevřít porty na firewallu:
```bash
# UFW (Ubuntu)
sudo ufw allow 7777/tcp
sudo ufw allow 8088/tcp
# firewalld (CentOS/RHEL)
sudo firewall-cmd --permanent --add-port=7777/tcp
sudo firewall-cmd --permanent --add-port=8088/tcp
sudo firewall-cmd --reload
```
## Reverse Proxy (volitelné)
Pro HTTPS můžeš použít nginx jako reverse proxy:
```nginx
server {
listen 443 ssl;
server_name geosus.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Stats API
location /api/ {
proxy_pass http://localhost:8088/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# TCP proxy pro herní komunikaci (stream modul)
stream {
server {
listen 7777;
proxy_pass localhost:7777;
}
}
```
## Aktualizace
```bash
# Stáhni nový kód
git pull
# Rebuild a restart
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
## Troubleshooting
### Port je obsazený
```bash
# Zjisti co používá port
sudo netstat -tlnp | grep 7777
sudo lsof -i :7777
```
### Kontejner padá
```bash
# Zkontroluj logy
docker logs geosus
# Interaktivní shell
docker run -it --rm geosus-server /bin/bash
```
### Problémy s daty
```bash
# Zkontroluj volume
docker volume inspect geosus-data
# Backup dat
docker cp geosus:/app/data ./backup
```

58
Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# ═══════════════════════════════════════════════════════════════════════════
# GeoSus Server Dockerfile
# ═══════════════════════════════════════════════════════════════════════════
# Multi-stage build pro optimální velikost image
#
# Build: docker build -t geosus-server .
# Run: docker run -d -p 7777:7777 -p 8088:8088 --name geosus geosus-server
# ═══════════════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────────────────────
# STAGE 1: Build
# ─────────────────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Kopíruj pouze csproj pro využití cache vrstev
COPY Server.csproj ./
RUN dotnet restore
# Kopíruj zbytek zdrojáků a builduj
COPY . ./
RUN dotnet publish -c Release -o /app
# ─────────────────────────────────────────────────────────────────────────────
# STAGE 2: Runtime
# ─────────────────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# Vytvoř uživatele bez root práv
RUN groupadd -r geosus && useradd -r -g geosus geosus
# Kopíruj build output
COPY --from=build /app ./
# Vytvoř složku pro data s právy
RUN mkdir -p /app/data && chown -R geosus:geosus /app
# Přepni na non-root uživatele
USER geosus
# Exponuj porty
# TCP 7777 - Herní komunikace (binární protokol)
# HTTP 8088 - Stats REST API
EXPOSE 7777
EXPOSE 8088
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8088/health || exit 1
# Environment variables pro konfiguraci
ENV DOTNET_ENVIRONMENT=Production
ENV GEOSUS_TCP_PORT=7777
ENV GEOSUS_HTTP_PORT=8088
# Spuštění
ENTRYPOINT ["dotnet", "Server.dll"]

181
Encryption.cs Normal file
View File

@@ -0,0 +1,181 @@
namespace GeoSus.Server;
using System.Security.Cryptography;
using System.Text;
// Šifrování komunikace - RSA handshake + AES-256-CBC session (kompatibilní s Unity)
public class ServerEncryption : IDisposable
{
private readonly RSA _rsa;
private readonly string _publicKeyPem;
public ServerEncryption(int keySizeBits = 2048)
{
_rsa = RSA.Create(keySizeBits);
_publicKeyPem = ExportPublicKeyPem();
}
public string PublicKeyPem => _publicKeyPem;
// Dešifruje session key od klienta
public (byte[] Key, byte[] IV) DecryptSessionKey(string encryptedKeyBase64, string encryptedIvBase64)
{
var encryptedKey = Convert.FromBase64String(encryptedKeyBase64);
var encryptedIv = Convert.FromBase64String(encryptedIvBase64);
// Používáme OaepSHA1 pro Unity kompatibilitu
var key = _rsa.Decrypt(encryptedKey, RSAEncryptionPadding.OaepSHA1);
var iv = _rsa.Decrypt(encryptedIv, RSAEncryptionPadding.OaepSHA1);
return (key, iv);
}
private string ExportPublicKeyPem()
{
var publicKey = _rsa.ExportSubjectPublicKeyInfo();
var base64 = Convert.ToBase64String(publicKey);
var sb = new StringBuilder();
sb.AppendLine("-----BEGIN PUBLIC KEY-----");
for (int i = 0; i < base64.Length; i += 64)
{
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
sb.AppendLine("-----END PUBLIC KEY-----");
return sb.ToString();
}
public void Dispose()
{
_rsa.Dispose();
}
}
// Session šifrování pro konkrétní klientské spojení - AES-256-CBC + HMAC-SHA256
public class SessionCrypto : IDisposable
{
private readonly byte[] _key;
private readonly object _lock = new();
public SessionCrypto(byte[] key, byte[] iv)
{
if (key.Length != 32)
throw new ArgumentException("Key musí být 32 bajtů pro AES-256");
if (iv.Length != 16)
throw new ArgumentException("IV musí být 16 bajtů pro AES-CBC");
_key = key;
// IV se nepoužívá přímo - každá zpráva má svůj unikátní IV
}
// Šifruje zprávu s AES-CBC a HMAC
public byte[] Encrypt(byte[] plaintext)
{
lock (_lock)
{
using var aes = Aes.Create();
aes.Key = _key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateIV(); // Generujeme nový IV pro každou zprávu
byte[] ciphertext;
using (var encryptor = aes.CreateEncryptor())
{
ciphertext = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);
}
// Počítáme HMAC přes IV + ciphertext (používáme AES klíč pro HMAC)
byte[] hmac;
using (var hmacSha = new HMACSHA256(_key))
{
var dataToSign = new byte[aes.IV.Length + ciphertext.Length];
Buffer.BlockCopy(aes.IV, 0, dataToSign, 0, aes.IV.Length);
Buffer.BlockCopy(ciphertext, 0, dataToSign, aes.IV.Length, ciphertext.Length);
hmac = hmacSha.ComputeHash(dataToSign);
}
// Výstup: [16 bytes IV][32 bytes HMAC][ciphertext]
var result = new byte[16 + 32 + ciphertext.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, 16);
Buffer.BlockCopy(hmac, 0, result, 16, 32);
Buffer.BlockCopy(ciphertext, 0, result, 48, ciphertext.Length);
return result;
}
}
// Dešifruje zprávu a validuje HMAC
public byte[]? Decrypt(byte[] encrypted)
{
if (encrypted.Length < 48) return null; // 16 IV + 32 HMAC + min data
try
{
var iv = new byte[16];
var receivedHmac = new byte[32];
var ciphertext = new byte[encrypted.Length - 48];
Buffer.BlockCopy(encrypted, 0, iv, 0, 16);
Buffer.BlockCopy(encrypted, 16, receivedHmac, 0, 32);
Buffer.BlockCopy(encrypted, 48, ciphertext, 0, ciphertext.Length);
// Ověříme HMAC (používáme AES klíč pro HMAC)
byte[] computedHmac;
using (var hmacSha = new HMACSHA256(_key))
{
var dataToVerify = new byte[16 + ciphertext.Length];
Buffer.BlockCopy(iv, 0, dataToVerify, 0, 16);
Buffer.BlockCopy(ciphertext, 0, dataToVerify, 16, ciphertext.Length);
computedHmac = hmacSha.ComputeHash(dataToVerify);
}
// Constant-time porovnání
if (!CryptographicOperations.FixedTimeEquals(receivedHmac, computedHmac))
{
return null; // HMAC mismatch - zpráva byla změněna
}
// Dešifrujeme
using var aes = Aes.Create();
aes.Key = _key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length);
}
catch (CryptographicException)
{
return null;
}
}
public void Dispose()
{
Array.Clear(_key, 0, _key.Length);
}
}
// Klientská strana - generuje session key a šifruje ho RSA public key
public static class ClientEncryptionHelper
{
public static (byte[] Key, byte[] IV) GenerateSessionKey()
{
var key = RandomNumberGenerator.GetBytes(32); // AES-256
var iv = RandomNumberGenerator.GetBytes(16); // AES-CBC IV (16 bytes)
return (key, iv);
}
public static (string EncryptedKey, string EncryptedIV) EncryptSessionKey(
string rsaPublicKeyPem, byte[] key, byte[] iv)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(rsaPublicKeyPem);
var encryptedKey = rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256);
var encryptedIv = rsa.Encrypt(iv, RSAEncryptionPadding.OaepSHA256);
return (Convert.ToBase64String(encryptedKey), Convert.ToBase64String(encryptedIv));
}
}

508
GameLogic.cs Normal file
View File

@@ -0,0 +1,508 @@
namespace GeoSus.Server;
using Microsoft.Extensions.Logging;
using System.Threading.Channels;
using System.Security.Cryptography;
// Herní logika - kill, meeting, voting, tasks
public class GameLogic
{
private readonly ServerConfig _config;
private readonly ILogger _logger;
private readonly OverpassService? _overpassService;
private readonly Random _random = new Random(); // Sdílená instance pro lepší náhodnost
public GameLogic(ServerConfig config, ILogger logger, OverpassService? overpassService = null)
{
_config = config;
_logger = logger;
_overpassService = overpassService;
}
#region Zabíjení
public (bool Success, string? Error, Body? Body) TryKill(
Player killer, Player victim, Dictionary<string, Player> players, List<Body> bodies)
{
// Validace - killer musí být impostor a alive
if (killer.Role != PlayerRole.Impostor)
return (false, "Nejsi impostor", null);
if (killer.State != PlayerState.Alive)
return (false, "Jsi mrtvý", null);
if (victim.State != PlayerState.Alive)
return (false, "Oběť je již mrtvá", null);
// Cooldown kontrola
if (killer.LastKillTime.HasValue)
{
var elapsed = (DateTime.UtcNow - killer.LastKillTime.Value).TotalMilliseconds;
if (elapsed < _config.KillCooldownMs)
{
var remaining = (_config.KillCooldownMs - elapsed) / 1000;
return (false, $"Cooldown: {remaining:F1}s", null);
}
}
// Vzdálenost
var distance = killer.Position.DistanceTo(victim.Position);
if (distance > _config.KillDistanceM)
return (false, $"Příliš daleko ({distance:F1}m)", null);
// Kill úspěšný
victim.State = PlayerState.Dead;
killer.LastKillTime = DateTime.UtcNow;
var body = new Body
{
BodyId = Guid.NewGuid().ToString("N")[..8],
VictimId = victim.ClientUuid,
KillerId = killer.ClientUuid,
Location = victim.Position,
KilledAt = DateTime.UtcNow
};
bodies.Add(body);
_logger.LogInformation("Kill: {Killer} zabil {Victim}, body {BodyId}",
killer.ClientUuid, victim.ClientUuid, body.BodyId);
return (true, null, body);
}
#endregion
#region Reporty a meetingy
public (bool Success, string? Error, Body? Body) TryReportBody(
Player reporter, string bodyId, List<Body> bodies)
{
if (reporter.State != PlayerState.Alive)
return (false, "Jsi mrtvý", null);
var body = bodies.FirstOrDefault(b => b.BodyId == bodyId);
if (body == null)
return (false, "Tělo neexistuje", null);
if (body.Reported)
return (false, "Tělo již bylo reportnuto", null);
// Vzdálenost
var distance = reporter.Position.DistanceTo(body.Location);
if (distance > _config.ReportDistanceM)
return (false, $"Příliš daleko ({distance:F1}m)", null);
body.Reported = true;
body.ReportedBy = reporter.ClientUuid;
_logger.LogInformation("Report: {Reporter} reportnul tělo {BodyId}",
reporter.ClientUuid, bodyId);
return (true, null, body);
}
public (bool Success, string? Error) TryCallEmergencyMeeting(
Player caller, int maxMeetings, int cooldownMs)
{
if (caller.State != PlayerState.Alive)
return (false, "Jsi mrtvý");
// Limit na počet emergency meetings
if (caller.EmergencyMeetingsUsed >= maxMeetings)
return (false, $"Vyčerpal jsi emergency meetingy ({maxMeetings})");
// Cooldown
if (caller.LastEmergencyMeetingTime.HasValue)
{
var elapsed = (DateTime.UtcNow - caller.LastEmergencyMeetingTime.Value).TotalMilliseconds;
if (elapsed < cooldownMs)
{
var remaining = (cooldownMs - elapsed) / 1000;
return (false, $"Cooldown: {remaining:F1}s");
}
}
caller.EmergencyMeetingsUsed++;
caller.LastEmergencyMeetingTime = DateTime.UtcNow;
_logger.LogInformation("Emergency meeting: {Caller}", caller.ClientUuid);
return (true, null);
}
public Meeting StartMeeting(
MeetingType type, string callerId, Position meetingLocation,
string? reportedBodyId, int arrivalBaseMs, int votingPhaseMs, int discussionPhaseMs)
{
var now = DateTime.UtcNow;
return new Meeting
{
MeetingId = Guid.NewGuid().ToString("N")[..8],
Type = type,
CallerId = callerId,
ReportedBodyId = reportedBodyId,
MeetingLocation = meetingLocation,
StartTime = now,
ArrivalDeadline = now.AddMilliseconds(arrivalBaseMs + _config.ArrivalSafetyMarginMs),
DiscussionEndTime = discussionPhaseMs > 0 ? now.AddMilliseconds(arrivalBaseMs + discussionPhaseMs) : null,
VotingEndTime = now.AddMilliseconds(arrivalBaseMs + discussionPhaseMs + votingPhaseMs)
};
}
public bool CheckPlayerArrival(Player player, Meeting meeting)
{
if (player.State != PlayerState.Alive)
return false;
var distance = player.Position.DistanceTo(meeting.MeetingLocation);
var now = DateTime.UtcNow;
// Grace period
var deadline = meeting.ArrivalDeadline.AddMilliseconds(_config.AllowedLateMs);
if (now <= deadline && distance <= _config.MeetingArrivalRadiusM)
{
meeting.ArrivedPlayers.Add(player.ClientUuid);
return true;
}
return false;
}
#endregion
#region Hlasování
public (bool Success, string? Error) TryCastVote(
Player voter, string? targetId, Meeting meeting, Dictionary<string, Player> players)
{
if (voter.State != PlayerState.Alive)
return (false, "Jsi mrtvý");
if (!meeting.ArrivedPlayers.Contains(voter.ClientUuid))
return (false, "Nedorazil jsi včas na meeting");
var now = DateTime.UtcNow;
// Kontrola fáze - musíme být ve voting fázi
if (meeting.DiscussionEndTime.HasValue && now < meeting.DiscussionEndTime.Value)
return (false, "Ještě probíhá diskuze");
if (now > meeting.VotingEndTime)
return (false, "Hlasování skončilo");
// Rate limit na změny hlasu
if (meeting.Votes.ContainsKey(voter.ClientUuid) && meeting.LastVoteChangeTime.HasValue)
{
var sinceLastChange = (now - meeting.LastVoteChangeTime.Value).TotalMilliseconds;
if (sinceLastChange < 2000)
return (false, "Příliš rychlá změna hlasu");
}
// Validace targetu
if (targetId != null)
{
if (!players.TryGetValue(targetId, out var target))
return (false, "Neplatný cíl hlasu");
if (target.State != PlayerState.Alive)
return (false, "Cíl je mrtvý");
}
meeting.Votes[voter.ClientUuid] = targetId;
meeting.LastVoteChangeTime = now;
return (true, null);
}
public (string? EjectedId, bool WasTie, Dictionary<string, int> VoteCounts) ResolveVoting(
Meeting meeting, Dictionary<string, Player> players, TiePolicy tiePolicy)
{
var voteCounts = new Dictionary<string, int>();
var skipCount = 0;
foreach (var (voterId, targetId) in meeting.Votes)
{
if (targetId == null)
{
skipCount++;
}
else
{
voteCounts.TryAdd(targetId, 0);
voteCounts[targetId]++;
}
}
voteCounts["__SKIP__"] = skipCount;
// Najdeme maximum
var maxVotes = voteCounts.Values.Max();
var topVoted = voteCounts.Where(kv => kv.Value == maxVotes).Select(kv => kv.Key).ToList();
// Kontrola remízy
if (topVoted.Count > 1 || topVoted.Contains("__SKIP__"))
{
// Remíza nebo skip vyhrál
switch (tiePolicy)
{
case TiePolicy.NoEject:
return (null, topVoted.Count > 1, voteCounts);
case TiePolicy.Random:
if (topVoted.Contains("__SKIP__"))
topVoted.Remove("__SKIP__");
if (topVoted.Count > 0)
{
var random = new Random();
var ejected = topVoted[random.Next(topVoted.Count)];
return (ejected, true, voteCounts);
}
return (null, true, voteCounts);
case TiePolicy.EjectLowestId:
if (topVoted.Contains("__SKIP__"))
topVoted.Remove("__SKIP__");
if (topVoted.Count > 0)
{
var ejected = topVoted.OrderBy(id => id).First();
return (ejected, true, voteCounts);
}
return (null, true, voteCounts);
default:
return (null, topVoted.Count > 1, voteCounts);
}
}
var winner = topVoted[0];
if (winner == "__SKIP__")
return (null, false, voteCounts);
return (winner, false, voteCounts);
}
public void EjectPlayer(Player player)
{
player.State = PlayerState.Dead;
_logger.LogInformation("Ejected: {Player}", player.ClientUuid);
}
#endregion
#region Tasky
/// <summary>
/// Pokusí se dokončit task. Kontroluje:
/// - Hráč je naživu
/// - Není impostor
/// - Task existuje a patří hráči
/// - Task ještě není dokončen
/// - Hráč je dostatečně blízko
/// Poznámka: Duchové (mrtví hráči) MOHOU plnit úkoly - to je důležité pro crew!
/// </summary>
public (bool Success, string? Error) TryCompleteTask(Player player, string taskId)
{
// Duchové mohou plnit úkoly - neblokujeme mrtvé hráče
// Ale musí být crew role
if (player.Role == PlayerRole.Impostor)
return (false, "Impostoři nemohou dělat tasky");
var task = player.Tasks.FirstOrDefault(t => t.TaskId == taskId);
if (task == null)
return (false, "Tento task ti nepatří");
if (player.CompletedTaskIds.Contains(taskId))
return (false, "Task již dokončen");
// Kontrola vzdálenosti
var distance = player.Position.DistanceTo(task.Location);
if (distance > _config.TaskStartDistanceM)
return (false, $"Příliš daleko ({distance:F1}m, max {_config.TaskStartDistanceM}m)");
// Dokončit task
player.CompletedTaskIds.Add(taskId);
_logger.LogInformation("Task completed: {Player} dokončil {Task} na pozici {Pos} (vzdálenost {Dist:F1}m)",
player.ClientUuid, task.Name, task.Location, distance);
return (true, null);
}
/// <summary>
/// Najde nejbližší nedokončený task hráče
/// </summary>
public GameTask? FindNearestTask(Player player, double maxDistance)
{
return player.Tasks
.Where(t => !player.CompletedTaskIds.Contains(t.TaskId))
.Where(t => player.Position.DistanceTo(t.Location) <= maxDistance)
.OrderBy(t => player.Position.DistanceTo(t.Location))
.FirstOrDefault();
}
#endregion
#region Win conditions
public (bool GameOver, string? WinningFaction, string? Reason) CheckWinConditions(
Dictionary<string, Player> players, List<GameTask> tasks)
{
var aliveCrew = players.Values.Count(p => p.State == PlayerState.Alive && p.Role == PlayerRole.Crew);
var aliveImpostors = players.Values.Count(p => p.State == PlayerState.Alive && p.Role == PlayerRole.Impostor);
// Impostoři vyhráli - mají většinu nebo rovnost
if (aliveImpostors >= aliveCrew && aliveCrew > 0)
{
return (true, "Impostor", "Impostoři mají převahu");
}
// Všichni impostoři mrtví
if (aliveImpostors == 0)
{
return (true, "Crew", "Všichni impostoři eliminováni");
}
// Všechny tasky hotové (počítáme pouze crew tasky)
var crewPlayers = players.Values.Where(p => p.Role == PlayerRole.Crew).ToList();
var totalTasks = crewPlayers.Sum(p => p.Tasks.Count);
var completedTasks = crewPlayers.Sum(p => p.CompletedTaskIds.Count);
if (totalTasks > 0 && completedTasks >= totalTasks)
{
return (true, "Crew", "Všechny tasky dokončeny");
}
return (false, null, null);
}
#endregion
#region Role assignment
public void AssignRoles(List<Player> players, int impostorCount)
{
if (players.Count < 2)
{
// Minimálně 2 hráči
foreach (var p in players)
p.Role = PlayerRole.Crew;
return;
}
// Omezení počtu impostorů
var maxImpostors = Math.Max(1, players.Count / 3);
impostorCount = Math.Min(impostorCount, maxImpostors);
impostorCount = Math.Min(impostorCount, players.Count - 1);
// Náhodně vybereme impostory
var shuffled = players.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).ToList();
for (int i = 0; i < players.Count; i++)
{
shuffled[i].Role = i < impostorCount ? PlayerRole.Impostor : PlayerRole.Crew;
shuffled[i].State = PlayerState.Alive;
shuffled[i].EmergencyMeetingsUsed = 0;
shuffled[i].LastEmergencyMeetingTime = null;
shuffled[i].LastKillTime = null;
shuffled[i].CompletedTaskIds.Clear();
shuffled[i].CurrentTaskId = null;
}
_logger.LogInformation("Roles assigned: {ImpostorCount} impostorů z {TotalPlayers} hráčů",
impostorCount, players.Count);
}
public List<GameTask> GenerateTasks(int count, Position center, double radius, MapData? mapData = null, int startIndex = 0)
{
var tasks = new List<GameTask>();
var taskNames = new[] {
"Opravit kabel", "Zkalibrovat senzor", "Stáhnout data",
"Nabít baterii", "Vyčistit filtr", "Nastavit kompas",
"Opravit antenu", "Zkontrolovat zásoby", "Otestovat reaktor"
};
// Get task positions - use map data if available
List<Position> positions;
if (mapData != null && _overpassService != null && mapData.ReachablePositions.Count >= count)
{
positions = _overpassService.GetPOIBasedPositions(mapData, count, _random);
_logger.LogInformation("Generated {Count} positions for tasks using map data", positions.Count);
}
else
{
positions = GenerateRandomPositions(count, center, radius, _random);
_logger.LogDebug("Generated {Count} positions for tasks using random fallback", positions.Count);
}
for (int i = 0; i < count && i < positions.Count; i++)
{
string taskId = $"task_{startIndex + i}";
string taskName = taskNames[(startIndex + i) % taskNames.Length];
tasks.Add(new GameTask
{
TaskId = taskId,
Name = taskName,
Location = positions[i],
Type = TaskType.Instant
});
}
return tasks;
}
private List<Position> GenerateRandomPositions(int count, Position center, double radius, Random random)
{
var positions = new List<Position>();
for (int i = 0; i < count; i++)
{
// Náhodná pozice v kruhu
var angle = random.NextDouble() * 2 * Math.PI;
var distance = random.NextDouble() * radius * 0.8; // 80% radius aby nebyly na kraji
var lat = center.Lat + (distance / 111000) * Math.Cos(angle);
var lon = center.Lon + (distance / (111000 * Math.Cos(center.Lat * Math.PI / 180))) * Math.Sin(angle);
positions.Add(new Position(lat, lon));
}
return positions;
}
/// <summary>
/// Generate repair station positions using map data for reachability
/// </summary>
public List<Position> GenerateRepairStationPositions(int count, Position center, double radius, MapData? mapData = null)
{
if (mapData != null && _overpassService != null && mapData.ReachablePositions.Count >= count)
{
// Get well-distributed reachable positions
var positions = _overpassService.GetDistributedReachablePositions(
mapData, count, _random, _config.MinTaskSpacingMeters * 2);
if (positions.Count >= count)
{
_logger.LogInformation("Generated {Count} repair station positions using map data", positions.Count);
return positions;
}
}
// Fallback: opposite ends of play area
var result = new List<Position>();
for (int i = 0; i < count; i++)
{
var angle = (2 * Math.PI * i) / count;
var dist = radius * 0.7;
var lat = center.Lat + (dist * Math.Cos(angle)) / 111000.0;
var lon = center.Lon + (dist * Math.Sin(angle)) / (111000.0 * Math.Cos(center.Lat * Math.PI / 180));
result.Add(new Position(lat, lon));
}
return result;
}
#endregion
}

1981
LobbyActor.cs Normal file

File diff suppressed because it is too large Load Diff

300
LobbyManager.cs Normal file
View File

@@ -0,0 +1,300 @@
namespace GeoSus.Server;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
// Správa lobby - vytváření, join codes, vyhledávání
public class LobbyManager
{
private readonly ServerConfig _config;
private readonly ILogger<LobbyManager> _logger;
private readonly StatsDb _statsDb;
private readonly Persistence _persistence;
private readonly OverpassService _overpassService;
private readonly ConcurrentDictionary<string, LobbyActor> _lobbies = new();
private readonly ConcurrentDictionary<string, string> _joinCodes = new(); // code -> lobbyId
private readonly ConcurrentDictionary<string, DateTime> _codeExpiry = new();
private readonly ConcurrentDictionary<string, RateLimiter> _joinRateLimiters = new();
// Znaky pro join code - bez podobných (0/O, 1/I/L)
private const string JoinCodeChars = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
public LobbyManager(ServerConfig config, ILogger<LobbyManager> logger, StatsDb statsDb, Persistence persistence, OverpassService overpassService)
{
_config = config;
_logger = logger;
_statsDb = statsDb;
_persistence = persistence;
_overpassService = overpassService;
}
public async Task<(LobbyActor? Lobby, string? JoinCode, string? Error)> CreateLobbyAsync(
string ownerId, string ownerName, CreateLobby request)
{
var lobbyId = Guid.NewGuid().ToString("N")[..16];
var joinCode = GenerateJoinCode();
var playAreaCenter = request.PlayAreaCenter ?? new Position(50.0, 14.0); // Default Praha
var playAreaRadius = request.PlayAreaRadius > 0 ? request.PlayAreaRadius : 500;
// NOTE: Map data is now fetched when game starts, not on lobby creation
// This allows all clients to see a loading screen while data is fetched
var settings = new LobbySettings
{
LobbyId = lobbyId,
JoinCode = joinCode,
Password = request.Password,
PlayAreaCenter = playAreaCenter,
PlayAreaRadius = playAreaRadius,
ImpostorCount = request.ImpostorCount > 0 ? request.ImpostorCount : _config.DefaultImpostorCount,
TaskCount = request.TaskCount > 0 ? request.TaskCount : _config.DefaultTaskCount,
TiePolicy = _config.DefaultTiePolicy,
EmergencyMeetingCooldownMs = _config.EmergencyMeetingCooldownMs,
MaxEmergencyMeetingsPerPlayer = _config.MaxEmergencyMeetingsPerPlayer,
AllowJoinInProgress = false,
MapData = null,
MapDataPayload = null,
OverpassEnabled = _config.OverpassEnabled
};
var lobbyLogger = _logger.CreateLogger<LobbyActor>();
var lobby = new LobbyActor(lobbyId, settings, _config, lobbyLogger, _statsDb, _persistence, _overpassService);
if (!_lobbies.TryAdd(lobbyId, lobby))
{
return (null, null, "Nepodařilo se vytvořit lobby");
}
_joinCodes[joinCode] = lobbyId;
_codeExpiry[joinCode] = DateTime.UtcNow.AddMilliseconds(_config.JoinCodeTtlMs);
// Přidáme vlastníka
await lobby.AddPlayerAsync(ownerId, ownerName, isOwner: true);
_logger.LogInformation("Lobby {LobbyId} vytvořeno, join code: {JoinCode}", lobbyId, joinCode);
return (lobby, joinCode, null);
}
public async Task<(LobbyActor? Lobby, string? Error)> JoinLobbyAsync(
string clientIp, string clientUuid, string displayName, string joinCode, string? password)
{
// Rate limiting per IP
var limiter = _joinRateLimiters.GetOrAdd(clientIp, _ => new RateLimiter(_config.JoinRateLimitPerMinute, TimeSpan.FromMinutes(1)));
if (!limiter.TryAcquire())
{
return (null, "Příliš mnoho pokusů o připojení. Počkej chvíli.");
}
// Najdeme lobby podle join code
if (!_joinCodes.TryGetValue(joinCode.ToUpperInvariant(), out var lobbyId))
{
return (null, "Neplatný join code");
}
// Kontrola expirace
if (_codeExpiry.TryGetValue(joinCode.ToUpperInvariant(), out var expiry) && expiry < DateTime.UtcNow)
{
_joinCodes.TryRemove(joinCode.ToUpperInvariant(), out _);
_codeExpiry.TryRemove(joinCode.ToUpperInvariant(), out _);
return (null, "Join code vypršel");
}
if (!_lobbies.TryGetValue(lobbyId, out var lobby))
{
return (null, "Lobby již neexistuje");
}
// Kontrola hesla
if (!string.IsNullOrEmpty(lobby.Settings.Password) && lobby.Settings.Password != password)
{
return (null, "Špatné heslo");
}
// Kontrola fáze
if (lobby.Phase != GamePhase.Lobby && !lobby.Settings.AllowJoinInProgress)
{
return (null, "Hra již probíhá");
}
// Kontrola počtu hráčů
if (lobby.PlayerCount >= _config.MaxPlayersPerLobby)
{
return (null, "Lobby je plné");
}
await lobby.AddPlayerAsync(clientUuid, displayName, isOwner: false);
_logger.LogInformation("Hráč {ClientUuid} se připojil do lobby {LobbyId}", clientUuid, lobbyId);
return (lobby, null);
}
public LobbyActor? GetLobby(string lobbyId)
{
_lobbies.TryGetValue(lobbyId, out var lobby);
return lobby;
}
public LobbyActor? GetLobbyByJoinCode(string joinCode)
{
if (_joinCodes.TryGetValue(joinCode.ToUpperInvariant(), out var lobbyId))
{
return GetLobby(lobbyId);
}
return null;
}
public IEnumerable<LobbyActor> GetAllLobbies()
{
return _lobbies.Values;
}
public void RemoveLobby(string lobbyId)
{
if (_lobbies.TryRemove(lobbyId, out var lobby))
{
// Odstraníme join code
var joinCode = lobby.Settings.JoinCode;
_joinCodes.TryRemove(joinCode, out _);
_codeExpiry.TryRemove(joinCode, out _);
lobby.Dispose();
_logger.LogInformation("Lobby {LobbyId} odstraněno", lobbyId);
}
}
// Čištění idle lobby
public async Task CleanupIdleLobbiesAsync()
{
var now = DateTime.UtcNow;
var idleThreshold = TimeSpan.FromMilliseconds(_config.IdleLobbyTtlMs);
foreach (var (lobbyId, lobby) in _lobbies)
{
if (now - lobby.LastActivity > idleThreshold)
{
_logger.LogInformation("Ruším idle lobby {LobbyId}", lobbyId);
await lobby.ArchiveAndCloseAsync();
RemoveLobby(lobbyId);
}
}
// Čištění expirovaných join codes
foreach (var (code, expiry) in _codeExpiry)
{
if (expiry < now)
{
_joinCodes.TryRemove(code, out _);
_codeExpiry.TryRemove(code, out _);
}
}
}
private string GenerateJoinCode()
{
Span<byte> randomBytes = stackalloc byte[6];
RandomNumberGenerator.Fill(randomBytes);
var sb = new StringBuilder(6);
foreach (var b in randomBytes)
{
sb.Append(JoinCodeChars[b % JoinCodeChars.Length]);
}
var code = sb.ToString();
// Pokud kód existuje, generujeme znovu
if (_joinCodes.ContainsKey(code))
{
return GenerateJoinCode();
}
return code;
}
public int LobbyCount => _lobbies.Count;
public int TotalPlayerCount => _lobbies.Values.Sum(l => l.PlayerCount);
}
public class LobbySettings
{
public required string LobbyId { get; set; }
public required string JoinCode { get; set; }
public string? Password { get; set; }
public Position PlayAreaCenter { get; set; }
public double PlayAreaRadius { get; set; }
public int ImpostorCount { get; set; }
public int TaskCount { get; set; }
public TiePolicy TiePolicy { get; set; }
public int EmergencyMeetingCooldownMs { get; set; }
public int MaxEmergencyMeetingsPerPlayer { get; set; }
public bool AllowJoinInProgress { get; set; }
public Position EmergencyMeetingLocation { get; set; } // Střed play area jako default
/// <summary>Full map data from Overpass (server-side only, for task placement)</summary>
public MapData? MapData { get; set; }
/// <summary>Compact map data payload for clients</summary>
public MapDataPayload? MapDataPayload { get; set; }
/// <summary>Whether Overpass API was enabled for this lobby</summary>
public bool OverpassEnabled { get; set; }
}
// Jednoduchý rate limiter
public class RateLimiter
{
private readonly int _maxRequests;
private readonly TimeSpan _window;
private readonly Queue<DateTime> _requests = new();
private readonly object _lock = new();
public RateLimiter(int maxRequests, TimeSpan window)
{
_maxRequests = maxRequests;
_window = window;
}
public bool TryAcquire()
{
lock (_lock)
{
var now = DateTime.UtcNow;
var cutoff = now - _window;
// Odstraníme staré požadavky
while (_requests.Count > 0 && _requests.Peek() < cutoff)
{
_requests.Dequeue();
}
if (_requests.Count >= _maxRequests)
{
return false;
}
_requests.Enqueue(now);
return true;
}
}
}
// Extension pro vytvoření sub-loggeru
public static class LoggerExtensions
{
public static ILogger<T> CreateLogger<T>(this ILogger logger)
{
if (logger is ILoggerFactory factory)
{
return factory.CreateLogger<T>();
}
// Fallback - vrátíme prázdný logger
return new LoggerFactory().CreateLogger<T>();
}
}

586
OverpassService.cs Normal file
View File

@@ -0,0 +1,586 @@
namespace GeoSus.Server;
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
/// <summary>
/// Service for fetching and processing OpenStreetMap data via Overpass API
/// </summary>
public class OverpassService
{
private readonly ServerConfig _config;
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly ConcurrentDictionary<string, MapData> _cache = new();
public OverpassService(ServerConfig config, ILogger logger)
{
_config = config;
_logger = logger;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(config.OverpassTimeoutSec)
};
}
/// <summary>
/// Fetch map data for a circular area, with caching
/// </summary>
public async Task<MapData?> GetMapDataAsync(Position center, double radiusMeters)
{
var cacheKey = $"{center.Lat:F5}_{center.Lon:F5}_{radiusMeters:F0}";
if (_cache.TryGetValue(cacheKey, out var cached))
{
_logger.LogDebug("Map data cache hit for {CacheKey}", cacheKey);
return cached;
}
try
{
var mapData = await FetchMapDataAsync(center, radiusMeters);
if (mapData != null)
{
_cache[cacheKey] = mapData;
// Clean old cache entries if too many
if (_cache.Count > _config.OverpassCacheMaxEntries)
{
var oldest = _cache.Keys.First();
_cache.TryRemove(oldest, out _);
}
}
return mapData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch Overpass data for {Center}, radius {Radius}m", center, radiusMeters);
return null;
}
}
public async Task<MapData?> FetchMapDataAsync(Position center, double radiusMeters)
{
// Build Overpass QL query
var query = BuildOverpassQuery(center, radiusMeters);
_logger.LogInformation("Fetching Overpass data for center {Center}, radius {Radius}m", center, radiusMeters);
var response = await _httpClient.PostAsync(
_config.OverpassApiUrl,
new FormUrlEncodedContent(new[] { new KeyValuePair<string, string>("data", query) })
);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Overpass API returned {StatusCode}: {Content}",
response.StatusCode, await response.Content.ReadAsStringAsync());
return null;
}
var json = await response.Content.ReadAsStringAsync();
var overpassResult = JsonSerializer.Deserialize<OverpassResult>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
if (overpassResult == null)
{
_logger.LogError("Failed to parse Overpass response");
return null;
}
return ProcessOverpassResult(overpassResult, center, radiusMeters);
}
private string BuildOverpassQuery(Position center, double radiusMeters)
{
// Query for: roads, paths, buildings, amenities
// We use "around" to get data within radius of center point
return $@"
[out:json][timeout:{_config.OverpassTimeoutSec}];
(
// Walkable ways (roads, paths, sidewalks)
way[""highway""~""footway|path|pedestrian|steps|track|residential|tertiary|secondary|primary|service|living_street|cycleway|bridleway""]
(around:{radiusMeters},{center.Lat},{center.Lon});
// Buildings
way[""building""]
(around:{radiusMeters},{center.Lat},{center.Lon});
relation[""building""]
(around:{radiusMeters},{center.Lat},{center.Lon});
// Interesting POIs for task placement
node[""amenity""]
(around:{radiusMeters},{center.Lat},{center.Lon});
way[""amenity""]
(around:{radiusMeters},{center.Lat},{center.Lon});
// Parks and green areas
way[""leisure""~""park|playground|garden""]
(around:{radiusMeters},{center.Lat},{center.Lon});
relation[""leisure""~""park|playground|garden""]
(around:{radiusMeters},{center.Lat},{center.Lon});
);
out body;
>;
out skel qt;
";
}
private MapData ProcessOverpassResult(OverpassResult result, Position center, double radiusMeters)
{
var mapData = new MapData
{
Center = center,
RadiusMeters = radiusMeters,
FetchedAt = DateTime.UtcNow
};
// Index nodes by ID for quick lookup
var nodesById = new Dictionary<long, OverpassNode>();
foreach (var element in result.Elements)
{
if (element.Type == "node" && element.Lat.HasValue && element.Lon.HasValue)
{
nodesById[element.Id] = new OverpassNode
{
Id = element.Id,
Lat = element.Lat.Value,
Lon = element.Lon.Value,
Tags = element.Tags
};
}
}
// Process ways
foreach (var element in result.Elements.Where(e => e.Type == "way"))
{
if (element.Nodes == null || element.Nodes.Count < 2) continue;
var wayNodes = new List<Position>();
foreach (var nodeId in element.Nodes)
{
if (nodesById.TryGetValue(nodeId, out var node))
{
wayNodes.Add(new Position(node.Lat, node.Lon));
}
}
if (wayNodes.Count < 2) continue;
var tags = element.Tags ?? new Dictionary<string, string>();
// Classify way type
if (tags.ContainsKey("building"))
{
mapData.Buildings.Add(new MapBuilding
{
Id = $"b_{element.Id}",
Outline = wayNodes,
BuildingType = tags.GetValueOrDefault("building", "yes"),
Name = tags.GetValueOrDefault("name"),
Levels = int.TryParse(tags.GetValueOrDefault("building:levels", "1"), out var l) ? l : 1
});
}
else if (tags.ContainsKey("highway"))
{
var highwayType = tags["highway"];
mapData.Pathways.Add(new MapPathway
{
Id = $"p_{element.Id}",
Points = wayNodes,
PathType = ClassifyPathType(highwayType),
Name = tags.GetValueOrDefault("name"),
IsWalkable = IsWalkableHighway(highwayType),
Width = EstimatePathWidth(highwayType)
});
}
else if (tags.ContainsKey("leisure"))
{
mapData.Areas.Add(new MapArea
{
Id = $"a_{element.Id}",
Outline = wayNodes,
AreaType = MapAreaType.Park,
Name = tags.GetValueOrDefault("name")
});
}
else if (tags.ContainsKey("amenity"))
{
// Calculate centroid for POI
var centroid = CalculateCentroid(wayNodes);
mapData.PointsOfInterest.Add(new MapPOI
{
Id = $"poi_{element.Id}",
Position = centroid,
PoiType = ClassifyAmenity(tags["amenity"]),
Name = tags.GetValueOrDefault("name")
});
}
}
// Process standalone POI nodes
foreach (var element in result.Elements.Where(e => e.Type == "node" && e.Tags != null))
{
var tags = element.Tags!;
if (tags.ContainsKey("amenity") && element.Lat.HasValue && element.Lon.HasValue)
{
mapData.PointsOfInterest.Add(new MapPOI
{
Id = $"poi_{element.Id}",
Position = new Position(element.Lat.Value, element.Lon.Value),
PoiType = ClassifyAmenity(tags["amenity"]),
Name = tags.GetValueOrDefault("name")
});
}
}
// Build reachability graph and compute reachable positions
BuildReachabilityData(mapData, center, radiusMeters);
_logger.LogInformation(
"Processed map data: {Buildings} buildings, {Pathways} pathways, {POIs} POIs, {ReachablePoints} reachable points",
mapData.Buildings.Count, mapData.Pathways.Count, mapData.PointsOfInterest.Count, mapData.ReachablePositions.Count);
return mapData;
}
private void BuildReachabilityData(MapData mapData, Position center, double radiusMeters)
{
// Build a graph of walkable paths and find connected components from center
var graph = new PathGraph();
// Add all walkable pathway segments to graph
foreach (var pathway in mapData.Pathways.Where(p => p.IsWalkable))
{
for (int i = 0; i < pathway.Points.Count - 1; i++)
{
graph.AddEdge(pathway.Points[i], pathway.Points[i + 1]);
}
}
// Find all positions reachable from center (or nearest walkable point to center)
var startNode = graph.FindNearestNode(center);
if (startNode == null)
{
_logger.LogWarning("No walkable paths found near center, using all pathway points as reachable");
// Fallback: all pathway points are considered reachable
foreach (var pathway in mapData.Pathways.Where(p => p.IsWalkable))
{
mapData.ReachablePositions.AddRange(pathway.Points);
}
return;
}
// BFS from start node to find all reachable nodes
var reachable = graph.GetReachableNodes(startNode.Value, radiusMeters);
// Filter to only include points within the play area
foreach (var pos in reachable)
{
if (pos.DistanceTo(center) <= radiusMeters)
{
mapData.ReachablePositions.Add(pos);
}
}
// Also mark pathways as reachable or not
foreach (var pathway in mapData.Pathways)
{
var reachablePoints = pathway.Points.Count(p =>
mapData.ReachablePositions.Any(rp => rp.DistanceTo(p) < 5));
pathway.IsFullyReachable = reachablePoints == pathway.Points.Count;
pathway.IsPartiallyReachable = reachablePoints > 0;
}
_logger.LogDebug("Reachability analysis: {Reachable} of {Total} positions are reachable from center",
mapData.ReachablePositions.Count,
mapData.Pathways.Sum(p => p.Points.Count));
}
private PathType ClassifyPathType(string highway)
{
return highway switch
{
"footway" or "pedestrian" or "path" => PathType.Footway,
"steps" => PathType.Steps,
"cycleway" => PathType.Cycleway,
"residential" or "living_street" => PathType.Residential,
"service" => PathType.Service,
"tertiary" or "secondary" or "primary" => PathType.Road,
"track" or "bridleway" => PathType.Track,
_ => PathType.Other
};
}
private bool IsWalkableHighway(string highway)
{
// Everything except motorways is considered walkable
return highway switch
{
"motorway" or "motorway_link" or "trunk" or "trunk_link" => false,
_ => true
};
}
private double EstimatePathWidth(string highway)
{
return highway switch
{
"footway" or "path" => 1.5,
"pedestrian" => 5.0,
"steps" => 2.0,
"cycleway" => 2.0,
"residential" => 6.0,
"living_street" => 5.0,
"service" => 4.0,
"tertiary" => 7.0,
"secondary" => 9.0,
"primary" => 11.0,
_ => 3.0
};
}
private MapPOIType ClassifyAmenity(string amenity)
{
return amenity switch
{
"cafe" or "restaurant" or "fast_food" or "bar" or "pub" => MapPOIType.FoodDrink,
"bank" or "atm" => MapPOIType.Finance,
"pharmacy" or "hospital" or "clinic" or "doctors" => MapPOIType.Health,
"school" or "university" or "library" => MapPOIType.Education,
"fuel" or "parking" => MapPOIType.Transport,
"shop" or "supermarket" or "convenience" => MapPOIType.Shop,
"toilets" or "bench" or "drinking_water" => MapPOIType.Amenity,
_ => MapPOIType.Other
};
}
private Position CalculateCentroid(List<Position> points)
{
if (points.Count == 0) return new Position(0, 0);
var lat = points.Average(p => p.Lat);
var lon = points.Average(p => p.Lon);
return new Position(lat, lon);
}
/// <summary>
/// Get a random reachable position suitable for placing a task or repair station
/// </summary>
public Position? GetRandomReachablePosition(MapData mapData, Random random, double minDistFromCenter = 0)
{
var candidates = mapData.ReachablePositions
.Where(p => p.DistanceTo(mapData.Center) >= minDistFromCenter)
.ToList();
if (candidates.Count == 0)
{
// Fallback to any pathway point
candidates = mapData.Pathways
.Where(p => p.IsWalkable)
.SelectMany(p => p.Points)
.Where(p => p.DistanceTo(mapData.Center) <= mapData.RadiusMeters)
.ToList();
}
if (candidates.Count == 0)
return null;
return candidates[random.Next(candidates.Count)];
}
/// <summary>
/// Get multiple well-distributed reachable positions (e.g., for placing multiple tasks)
/// </summary>
public List<Position> GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20)
{
var result = new List<Position>();
var available = mapData.ReachablePositions.ToList();
// Shuffle available positions
for (int i = available.Count - 1; i > 0; i--)
{
int j = random.Next(i + 1);
(available[i], available[j]) = (available[j], available[i]);
}
foreach (var pos in available)
{
if (result.Count >= count) break;
// Check minimum spacing from already selected positions
bool tooClose = result.Any(r => r.DistanceTo(pos) < minSpacing);
if (!tooClose)
{
result.Add(pos);
}
}
// If we couldn't find enough spaced positions, fill with any available
if (result.Count < count)
{
foreach (var pos in available)
{
if (result.Count >= count) break;
if (!result.Contains(pos))
{
result.Add(pos);
}
}
}
return result;
}
/// <summary>
/// Get positions near POIs (better for task placement as they have semantic meaning)
/// </summary>
public List<Position> GetPOIBasedPositions(MapData mapData, int count, Random random)
{
var result = new List<Position>();
// Prefer POIs that are reachable
var reachablePOIs = mapData.PointsOfInterest
.Where(poi => mapData.ReachablePositions.Any(rp => rp.DistanceTo(poi.Position) < 10))
.ToList();
// Shuffle
for (int i = reachablePOIs.Count - 1; i > 0; i--)
{
int j = random.Next(i + 1);
(reachablePOIs[i], reachablePOIs[j]) = (reachablePOIs[j], reachablePOIs[i]);
}
foreach (var poi in reachablePOIs.Take(count))
{
result.Add(poi.Position);
}
// Fill remaining with distributed positions
if (result.Count < count)
{
var additional = GetDistributedReachablePositions(mapData, count - result.Count, random);
result.AddRange(additional);
}
return result;
}
}
#region Overpass API Response Types
internal class OverpassResult
{
[JsonPropertyName("elements")]
public List<OverpassElement> Elements { get; set; } = new();
}
internal class OverpassElement
{
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("lat")]
public double? Lat { get; set; }
[JsonPropertyName("lon")]
public double? Lon { get; set; }
[JsonPropertyName("nodes")]
public List<long>? Nodes { get; set; }
[JsonPropertyName("tags")]
public Dictionary<string, string>? Tags { get; set; }
}
internal class OverpassNode
{
public long Id { get; set; }
public double Lat { get; set; }
public double Lon { get; set; }
public Dictionary<string, string>? Tags { get; set; }
}
#endregion
#region Path Graph for Reachability
internal class PathGraph
{
private readonly Dictionary<Position, HashSet<Position>> _adjacency = new();
private readonly List<Position> _allNodes = new();
public void AddEdge(Position a, Position b)
{
if (!_adjacency.ContainsKey(a))
{
_adjacency[a] = new HashSet<Position>();
_allNodes.Add(a);
}
if (!_adjacency.ContainsKey(b))
{
_adjacency[b] = new HashSet<Position>();
_allNodes.Add(b);
}
_adjacency[a].Add(b);
_adjacency[b].Add(a);
}
public Position? FindNearestNode(Position target)
{
if (_allNodes.Count == 0) return null;
Position? nearest = null;
double minDist = double.MaxValue;
foreach (var node in _allNodes)
{
var dist = node.DistanceTo(target);
if (dist < minDist)
{
minDist = dist;
nearest = node;
}
}
return nearest;
}
public HashSet<Position> GetReachableNodes(Position start, double maxDistance)
{
var visited = new HashSet<Position>();
var queue = new Queue<Position>();
queue.Enqueue(start);
visited.Add(start);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!_adjacency.TryGetValue(current, out var neighbors))
continue;
foreach (var neighbor in neighbors)
{
if (!visited.Contains(neighbor) && neighbor.DistanceTo(start) <= maxDistance)
{
visited.Add(neighbor);
queue.Enqueue(neighbor);
}
}
}
return visited;
}
}
#endregion

245
Persistence.cs Normal file
View File

@@ -0,0 +1,245 @@
namespace GeoSus.Server;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
// Write-Ahead Log a Snapshoty pro persistenci
public class Persistence
{
private readonly ServerConfig _config;
private readonly ILogger<Persistence> _logger;
private readonly object _walLock = new();
public Persistence(ServerConfig config, ILogger<Persistence> logger)
{
_config = config;
_logger = logger;
// Vytvoříme adresář pro data
Directory.CreateDirectory(_config.DataPath);
}
#region WAL
public void AppendToWal(string lobbyId, GameEvent evt)
{
var lobbyDir = GetLobbyDir(lobbyId);
Directory.CreateDirectory(lobbyDir);
var walPath = GetCurrentWalPath(lobbyDir);
var json = JsonSerializer.Serialize(evt, JsonOptions.Default);
lock (_walLock)
{
// Kontrola velikosti WAL
if (File.Exists(walPath))
{
var fileInfo = new FileInfo(walPath);
if (fileInfo.Length > _config.WalMaxSizeMb * 1024 * 1024)
{
// Rotace - nový soubor
var newWalPath = Path.Combine(lobbyDir, $"wal_{DateTime.UtcNow:yyyyMMddHHmmss}.ndjson");
walPath = newWalPath;
}
}
using var fs = new FileStream(walPath, FileMode.Append, FileAccess.Write, FileShare.Read);
using var writer = new StreamWriter(fs, Encoding.UTF8);
writer.WriteLine(json);
writer.Flush();
fs.Flush(true); // fsync
}
}
public List<GameEvent> ReadWal(string lobbyId, long fromEventId = 0)
{
var lobbyDir = GetLobbyDir(lobbyId);
var events = new List<GameEvent>();
if (!Directory.Exists(lobbyDir))
return events;
// Načteme všechny WAL soubory seřazené podle času
var walFiles = Directory.GetFiles(lobbyDir, "wal_*.ndjson")
.OrderBy(f => f)
.ToList();
foreach (var walPath in walFiles)
{
try
{
var lines = File.ReadAllLines(walPath, Encoding.UTF8);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var evt = JsonSerializer.Deserialize<GameEvent>(line, JsonOptions.Default);
if (evt != null && evt.EventId > fromEventId)
{
events.Add(evt);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Chyba při čtení WAL {Path}", walPath);
}
}
return events.OrderBy(e => e.EventId).ToList();
}
private string GetCurrentWalPath(string lobbyDir)
{
// Najdeme nejnovější WAL nebo vytvoříme nový
var walFiles = Directory.GetFiles(lobbyDir, "wal_*.ndjson");
if (walFiles.Length == 0)
{
return Path.Combine(lobbyDir, $"wal_{DateTime.UtcNow:yyyyMMddHHmmss}.ndjson");
}
return walFiles.OrderByDescending(f => f).First();
}
#endregion
#region Snapshots
public void SaveSnapshot(string lobbyId, LobbySnapshot snapshot)
{
var lobbyDir = GetLobbyDir(lobbyId);
Directory.CreateDirectory(lobbyDir);
// Serializujeme bez checksumu
snapshot.Timestamp = DateTime.UtcNow;
var json = JsonSerializer.Serialize(snapshot, JsonOptions.Indented);
// Spočítáme checksum
var checksum = ComputeChecksum(json);
snapshot.Checksum = checksum;
// Serializujeme znovu s checksumem
json = JsonSerializer.Serialize(snapshot, JsonOptions.Indented);
var snapshotPath = Path.Combine(lobbyDir, $"snapshot_{snapshot.LastEventId}.json");
var tempPath = snapshotPath + ".tmp";
// Atomic write - temp + rename
File.WriteAllText(tempPath, json, Encoding.UTF8);
File.Move(tempPath, snapshotPath, overwrite: true);
_logger.LogInformation("Snapshot uložen: {Path}, eventId: {EventId}", snapshotPath, snapshot.LastEventId);
// Čištění starých snapshotů
CleanupOldSnapshots(lobbyDir);
}
public LobbySnapshot? LoadLatestSnapshot(string lobbyId)
{
var lobbyDir = GetLobbyDir(lobbyId);
if (!Directory.Exists(lobbyDir))
return null;
var snapshotFiles = Directory.GetFiles(lobbyDir, "snapshot_*.json")
.OrderByDescending(f => f)
.ToList();
foreach (var snapshotPath in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotPath, Encoding.UTF8);
var snapshot = JsonSerializer.Deserialize<LobbySnapshot>(json, JsonOptions.Default);
if (snapshot == null) continue;
// Validace checksumu
var storedChecksum = snapshot.Checksum;
snapshot.Checksum = "";
var jsonWithoutChecksum = JsonSerializer.Serialize(snapshot, JsonOptions.Indented);
var computedChecksum = ComputeChecksum(jsonWithoutChecksum);
if (storedChecksum != computedChecksum)
{
_logger.LogWarning("Checksum mismatch pro snapshot {Path}", snapshotPath);
continue;
}
snapshot.Checksum = storedChecksum;
return snapshot;
}
catch (Exception ex)
{
_logger.LogError(ex, "Chyba při čtení snapshot {Path}", snapshotPath);
}
}
return null;
}
private void CleanupOldSnapshots(string lobbyDir, int keepCount = 5)
{
var snapshotFiles = Directory.GetFiles(lobbyDir, "snapshot_*.json")
.OrderByDescending(f => f)
.Skip(keepCount)
.ToList();
foreach (var oldSnapshot in snapshotFiles)
{
try
{
File.Delete(oldSnapshot);
_logger.LogDebug("Smazán starý snapshot: {Path}", oldSnapshot);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nelze smazat snapshot {Path}", oldSnapshot);
}
}
}
private static string ComputeChecksum(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
#region Archive
public void ArchiveLobby(string lobbyId)
{
var lobbyDir = GetLobbyDir(lobbyId);
if (!Directory.Exists(lobbyDir))
return;
var archiveDir = Path.Combine(_config.DataPath, "archive");
Directory.CreateDirectory(archiveDir);
var archivePath = Path.Combine(archiveDir, $"{lobbyId}_{DateTime.UtcNow:yyyyMMddHHmmss}");
Directory.Move(lobbyDir, archivePath);
_logger.LogInformation("Lobby archivováno: {LobbyId} -> {ArchivePath}", lobbyId, archivePath);
}
public void DeleteLobbyData(string lobbyId)
{
var lobbyDir = GetLobbyDir(lobbyId);
if (Directory.Exists(lobbyDir))
{
Directory.Delete(lobbyDir, recursive: true);
_logger.LogInformation("Data lobby smazána: {LobbyId}", lobbyId);
}
}
#endregion
private string GetLobbyDir(string lobbyId)
{
return Path.Combine(_config.DataPath, "lobbies", lobbyId);
}
}

769
Program.cs Normal file
View File

@@ -0,0 +1,769 @@
namespace GeoSus.Server;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
// Hlavní vstupní bod serveru
public class Program
{
static readonly ConcurrentDictionary<string, ConnectedClient> _clients = new();
static readonly DateTime _startTime = DateTime.UtcNow;
static ServerConfig _config = null!;
static ServerEncryption _encryption = null!;
static LobbyManager _lobbyManager = null!;
static StatsDb _statsDb = null!;
static Persistence _persistence = null!;
static OverpassService _overpassService = null!;
static AdminPanel _adminPanel = null!;
static ILoggerFactory _loggerFactory = null!;
static ILogger<Program> _logger = null!;
static CancellationTokenSource _cts = new();
// Public accessor for admin panel
public static DateTime StartTime => _startTime;
public static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
// Inicializace
_loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
_logger = _loggerFactory.CreateLogger<Program>();
// Načtení konfigurace
_config = ServerConfig.Load("appsettings.json");
// Uložíme výchozí config pokud neexistuje
if (!File.Exists("appsettings.json"))
{
_config.Save("appsettings.json");
_logger.LogInformation("Vytvořen výchozí appsettings.json");
}
// Inicializace komponent
_encryption = new ServerEncryption(_config.RsaKeySizeBits);
_statsDb = new StatsDb(Path.Combine(_config.DataPath, "stats.db"), _loggerFactory.CreateLogger<StatsDb>());
_persistence = new Persistence(_config, _loggerFactory.CreateLogger<Persistence>());
_overpassService = new OverpassService(_config, _loggerFactory.CreateLogger<OverpassService>());
_lobbyManager = new LobbyManager(_config, _loggerFactory.CreateLogger<LobbyManager>(), _statsDb, _persistence, _overpassService);
_adminPanel = new AdminPanel(_loggerFactory.CreateLogger<AdminPanel>(), _config, _lobbyManager, _statsDb);
_logger.LogInformation("GeoSus Server spouštím...");
_logger.LogInformation("TCP port: {TcpPort}, HTTP port: {HttpPort}", _config.TcpPort, _config.HttpPort);
// Handle shutdown signals - but only on explicit request
var shutdownRequested = false;
Console.CancelKeyPress += (s, e) =>
{
if (!shutdownRequested)
{
shutdownRequested = true;
e.Cancel = true;
_logger.LogInformation("Shutdown requested (Ctrl+C)... press again to force");
}
else
{
_logger.LogInformation("Force shutdown...");
_cts.Cancel();
}
};
// Spustíme TCP listener a HTTP API paralelně
var tcpTask = RunTcpServerAsync();
var httpTask = RunHttpServerAsync();
var cleanupTask = RunCleanupLoopAsync();
var broadcastTask = RunBroadcastLoopAsync();
_logger.LogInformation("Server běží. Stiskni Ctrl+C dvakrát pro ukončení.");
// Just wait forever until cancellation - don't monitor individual tasks
try
{
await Task.Delay(Timeout.Infinite, _cts.Token);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Shutting down...");
}
_statsDb.Dispose();
_encryption.Dispose();
_logger.LogInformation("Server ukončen");
}
#region TCP Server
static async Task RunTcpServerAsync()
{
var listener = new TcpListener(IPAddress.Any, _config.TcpPort);
listener.Start();
_logger.LogInformation("TCP server naslouchá na portu {Port}", _config.TcpPort);
try
{
while (!_cts.IsCancellationRequested)
{
try
{
var tcpClient = await listener.AcceptTcpClientAsync(_cts.Token);
_ = HandleClientAsync(tcpClient);
}
catch (SocketException ex)
{
_logger.LogWarning(ex, "Socket error while accepting client");
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("TCP server ukončen (cancelled)");
}
catch (Exception ex)
{
_logger.LogError(ex, "TCP server fatal error");
}
finally
{
listener.Stop();
}
}
static async Task HandleClientAsync(TcpClient tcpClient)
{
var endpoint = tcpClient.Client.RemoteEndPoint?.ToString() ?? "unknown";
_logger.LogDebug("Nové spojení z {Endpoint}", endpoint);
using var stream = tcpClient.GetStream();
ConnectedClient? client = null;
try
{
// Handshake - plain JSON
client = await PerformHandshakeAsync(stream, endpoint);
if (client == null)
{
_logger.LogWarning("Handshake selhal pro {Endpoint}", endpoint);
return;
}
_clients[client.ClientUuid] = client;
_logger.LogInformation("Klient {Uuid} připojen z {Endpoint}", client.ClientUuid, endpoint);
// Hlavní smyčka zpráv - šifrované
await ProcessMessagesAsync(stream, client);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Chyba při zpracování klienta {Endpoint}", endpoint);
}
finally
{
if (client != null)
{
_clients.TryRemove(client.ClientUuid, out _);
// Odebrat z lobby pokud byl přihlášen
if (client.Lobby != null)
{
client.Lobby.UnregisterConnection(client.ClientUuid);
await client.Lobby.RemovePlayerAsync(client.ClientUuid, "Disconnected");
}
client.Session?.Dispose();
_logger.LogInformation("Klient {Uuid} odpojen", client.ClientUuid);
}
tcpClient.Close();
}
}
static async Task<ConnectedClient?> PerformHandshakeAsync(NetworkStream stream, string endpoint)
{
// 1. Čekáme na ClientHello (plain)
var helloData = await ReadMessageAsync(stream);
if (helloData == null) return null;
// Debug: log co přichází
_logger.LogInformation("Handshake from {Endpoint}: {Length} bytes, first: 0x{First:X2}, content: {Content}",
endpoint, helloData.Length, helloData[0],
System.Text.Encoding.UTF8.GetString(helloData.AsSpan(0, Math.Min(200, helloData.Length))));
var hello = MessageSerializer.Deserialize(helloData) as ClientHello;
if (hello == null)
{
_logger.LogWarning("Neplatný ClientHello z {Endpoint}", endpoint);
return null;
}
// 2. Posíláme ServerHello s RSA public key (plain)
var serverHello = new ServerHello
{
RsaPublicKeyPem = _encryption.PublicKeyPem,
ServerId = Environment.MachineName
};
await SendMessagePlainAsync(stream, serverHello);
// 3. Čekáme na KeyExchange (plain - obsahuje RSA šifrovaný session key)
var keyExData = await ReadMessageAsync(stream);
if (keyExData == null) return null;
// Debug: hex dump prvních 20 bajtů
var hexDump = BitConverter.ToString(keyExData, 0, Math.Min(20, keyExData.Length));
_logger.LogInformation("KeyExchange from {Endpoint}: {Length} bytes, hex: {Hex}",
endpoint, keyExData.Length, hexDump);
var keyEx = MessageSerializer.Deserialize(keyExData) as KeyExchange;
if (keyEx == null)
{
_logger.LogWarning("Neplatný KeyExchange z {Endpoint}", endpoint);
return null;
}
// 4. Dešifrujeme session key
byte[] sessionKey, sessionIv;
try
{
(sessionKey, sessionIv) = _encryption.DecryptSessionKey(keyEx.EncryptedSessionKey, keyEx.EncryptedIV);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Chyba při dešifrování session key z {Endpoint}", endpoint);
return null;
}
var session = new SessionCrypto(sessionKey, sessionIv);
// 5. Posíláme KeyExchangeAck (šifrovaně)
var ack = new KeyExchangeAck { Status = "success" };
await SendMessageEncryptedAsync(stream, ack, session);
return new ConnectedClient
{
ClientUuid = hello.ClientUuid,
DisplayName = hello.DisplayName ?? $"Player_{hello.ClientUuid[..4]}",
Endpoint = endpoint,
Session = session,
Stream = stream
};
}
static async Task ProcessMessagesAsync(NetworkStream stream, ConnectedClient client)
{
int decryptFailures = 0;
while (!_cts.IsCancellationRequested)
{
var encrypted = await ReadMessageAsync(stream);
if (encrypted == null) break;
// Dešifrovat
var decrypted = client.Session!.Decrypt(encrypted);
if (decrypted == null)
{
decryptFailures++;
_logger.LogWarning("Dešifrování selhalo pro {Uuid} ({Count}x)", client.ClientUuid, decryptFailures);
if (decryptFailures >= 3)
{
_logger.LogWarning("Příliš mnoho chyb dešifrování, odpojuji {Uuid}", client.ClientUuid);
break;
}
continue;
}
decryptFailures = 0;
var message = MessageSerializer.Deserialize(decrypted);
if (message == null)
{
_logger.LogWarning("Neplatná zpráva od {Uuid}", client.ClientUuid);
continue;
}
await HandleMessageAsync(client, message);
}
}
static async Task HandleMessageAsync(ConnectedClient client, Message message)
{
switch (message)
{
case CreateLobby m:
await HandleCreateLobbyAsync(client, m);
break;
case JoinLobby m:
await HandleJoinLobbyAsync(client, m);
break;
case Reconnect m:
await HandleReconnectAsync(client, m);
break;
default:
// Předat do lobby pokud je přihlášen
if (client.Lobby != null)
{
await client.Lobby.HandleMessageAsync(client.ClientUuid, message);
}
else
{
var error = new ErrorMessage
{
ErrorCode = "NOT_IN_LOBBY",
ErrorText = "Nejsi v žádném lobby"
};
await SendToClientAsync(client, error);
}
break;
}
}
static async Task HandleCreateLobbyAsync(ConnectedClient client, CreateLobby message)
{
var (lobby, joinCode, error) = await _lobbyManager.CreateLobbyAsync(
client.ClientUuid, client.DisplayName, message);
var response = new CreateLobbyResponse
{
ClientSeq = message.ClientSeq,
Success = lobby != null,
JoinCode = joinCode,
LobbyId = lobby?.Settings.LobbyId,
Error = error,
LobbyState = lobby?.GetLobbyState()
};
if (lobby != null)
{
client.Lobby = lobby;
lobby.RegisterConnection(client.ClientUuid, new ClientConnection
{
ClientUuid = client.ClientUuid,
SendAsync = msg => SendToClientAsync(client, msg)
});
}
await SendToClientAsync(client, response);
}
static async Task HandleJoinLobbyAsync(ConnectedClient client, JoinLobby message)
{
var clientIp = client.Endpoint.Split(':')[0];
var (lobby, error) = await _lobbyManager.JoinLobbyAsync(
clientIp, client.ClientUuid, client.DisplayName, message.JoinCode, message.Password);
var response = new JoinLobbyResponse
{
ClientSeq = message.ClientSeq,
Success = lobby != null,
LobbyId = lobby?.Settings.LobbyId,
Error = error,
LobbyState = lobby?.GetLobbyState()
};
if (lobby != null)
{
client.Lobby = lobby;
lobby.RegisterConnection(client.ClientUuid, new ClientConnection
{
ClientUuid = client.ClientUuid,
SendAsync = msg => SendToClientAsync(client, msg)
});
}
await SendToClientAsync(client, response);
}
static async Task HandleReconnectAsync(ConnectedClient client, Reconnect message)
{
var lobby = _lobbyManager.GetLobby(message.LobbyId);
if (lobby == null)
{
await SendToClientAsync(client, new ReconnectResponse
{
Success = false,
Error = "Lobby neexistuje"
});
return;
}
// Načteme snapshot a eventy
var snapshot = _persistence.LoadLatestSnapshot(message.LobbyId);
var events = _persistence.ReadWal(message.LobbyId, message.LastEventId);
client.Lobby = lobby;
lobby.RegisterConnection(client.ClientUuid, new ClientConnection
{
ClientUuid = client.ClientUuid,
SendAsync = msg => SendToClientAsync(client, msg)
});
await lobby.AddPlayerAsync(client.ClientUuid, client.DisplayName, isOwner: false);
await SendToClientAsync(client, new ReconnectResponse
{
Success = true,
Snapshot = snapshot,
MissedEvents = events
});
}
#endregion
#region HTTP Stats API
static async Task RunHttpServerAsync()
{
HttpListener? listener = null;
bool isDocker = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"
|| Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") == "Production";
// V Dockeru/produkci bindujeme na 0.0.0.0 (všechny interfaces)
// Lokálně bindujeme na localhost (nevyžaduje admin práva na Windows)
var prefixes = isDocker
? new[] { $"http://+:{_config.HttpPort}/", $"http://*:{_config.HttpPort}/" }
: new[] { $"http://localhost:{_config.HttpPort}/", $"http://127.0.0.1:{_config.HttpPort}/" };
foreach (var prefixSet in new[] { prefixes })
{
try
{
listener = new HttpListener();
foreach (var prefix in prefixSet)
listener.Prefixes.Add(prefix);
listener.Start();
_logger.LogInformation("HTTP Stats API naslouchá na portu {Port} (Docker: {IsDocker})", _config.HttpPort, isDocker);
break;
}
catch (HttpListenerException ex)
{
_logger.LogWarning("Nelze spustit HTTP Stats API: {Error}", ex.Message);
listener?.Close();
listener = null;
}
}
if (listener == null)
{
_logger.LogWarning("HTTP Stats API není dostupné - nelze otevřít port {Port}. Server běží bez Stats API.", _config.HttpPort);
// Just wait for cancellation instead of crashing
try { await Task.Delay(Timeout.Infinite, _cts.Token); } catch (OperationCanceledException) { }
return;
}
try
{
while (!_cts.IsCancellationRequested)
{
var context = await listener.GetContextAsync();
_ = HandleHttpRequestAsync(context);
}
}
catch (HttpListenerException) when (_cts.IsCancellationRequested)
{
// Normal shutdown
}
catch (ObjectDisposedException) when (_cts.IsCancellationRequested)
{
// Normal shutdown
}
catch (Exception ex)
{
_logger.LogError(ex, "HTTP listener error");
}
finally
{
listener?.Stop();
}
}
static async Task HandleHttpRequestAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
// CORS headers
response.Headers.Add("Access-Control-Allow-Origin", "*");
response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (request.HttpMethod == "OPTIONS")
{
response.StatusCode = 200;
response.Close();
return;
}
try
{
var path = request.Url?.AbsolutePath ?? "/";
// Admin panel routes
if (path.StartsWith("/admin"))
{
await _adminPanel.HandleRequestAsync(context);
return;
}
if (path.StartsWith("/stats/leaderboard"))
{
await HandleLeaderboardAsync(request, response);
}
else if (path.StartsWith("/stats/"))
{
var clientUuid = path[7..]; // Remove "/stats/"
await HandlePlayerStatsAsync(clientUuid, response);
}
else if (path == "/health")
{
var uptime = DateTime.UtcNow - _startTime;
await WriteJsonResponseAsync(response, new
{
status = "ok",
version = "1.0.0",
uptimeSeconds = (long)uptime.TotalSeconds,
activeLobbies = _lobbyManager.LobbyCount,
connectedPlayers = _lobbyManager.TotalPlayerCount
});
}
else
{
response.StatusCode = 404;
await WriteJsonResponseAsync(response, new { error = "Not found" });
}
response.Close();
}
catch (Exception ex)
{
_logger.LogError(ex, "HTTP error");
try
{
response.StatusCode = 500;
await WriteJsonResponseAsync(response, new { error = "Internal server error" });
response.Close();
}
catch { /* Already closed */ }
}
}
static async Task HandlePlayerStatsAsync(string clientUuid, HttpListenerResponse response)
{
var stats = _statsDb.GetPlayerStats(clientUuid);
if (stats == null)
{
response.StatusCode = 404;
await WriteJsonResponseAsync(response, new { error = "Player not found" });
return;
}
var result = new
{
clientUuid = stats.ClientUuid,
displayName = stats.DisplayName,
totalGames = stats.TotalGames,
gamesAsCrew = stats.GamesAsCrew,
gamesAsImpostor = stats.GamesAsImpostor,
crewWins = stats.CrewWins,
impostorWins = stats.ImpostorWins,
crewWinRate = Math.Round(stats.CrewWinRate, 2),
impostorWinRate = Math.Round(stats.ImpostorWinRate, 2),
totalKills = stats.TotalKills,
totalDeaths = stats.TotalDeaths,
killDeathRatio = Math.Round(stats.KillDeathRatio, 2),
tasksCompleted = stats.TasksCompleted,
averageTasksPerGame = Math.Round(stats.AverageTasksPerGame, 2),
bodiesReported = stats.BodiesReported,
emergencyMeetingsCalled = stats.EmergencyMeetingsCalled,
timesVotedOut = stats.TimesVotedOut,
successfulVotes = stats.SuccessfulVotes,
totalPlaytimeSeconds = stats.TotalPlaytimeSeconds,
cheatIncidents = stats.CheatIncidents,
lastSeen = stats.LastSeenUtc
};
await WriteJsonResponseAsync(response, result);
}
static async Task HandleLeaderboardAsync(HttpListenerRequest request, HttpListenerResponse response)
{
var sortBy = request.QueryString["sortBy"] ?? "total_games";
var limitStr = request.QueryString["limit"] ?? "100";
var limit = int.TryParse(limitStr, out var l) ? l : 100;
limit = Math.Min(limit, 1000);
var leaderboard = _statsDb.GetLeaderboard(sortBy, limit);
var result = leaderboard.Select(s => new
{
clientUuid = s.ClientUuid,
displayName = s.DisplayName,
totalGames = s.TotalGames,
crewWins = s.CrewWins,
impostorWins = s.ImpostorWins,
totalKills = s.TotalKills,
tasksCompleted = s.TasksCompleted
});
await WriteJsonResponseAsync(response, result);
}
static async Task WriteJsonResponseAsync(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);
}
#endregion
#region Background tasks
static async Task RunCleanupLoopAsync()
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(_cts.Token))
{
try
{
await _lobbyManager.CleanupIdleLobbiesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Chyba při cleanup");
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
}
static async Task RunBroadcastLoopAsync()
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_config.PositionBroadcastRateMs));
while (await timer.WaitForNextTickAsync(_cts.Token))
{
foreach (var client in _clients.Values)
{
client.Lobby?.BroadcastPositions();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
}
#endregion
#region Network helpers
static async Task<byte[]?> ReadMessageAsync(NetworkStream stream)
{
// 4 bytes length prefix (big-endian)
var lengthBuffer = new byte[4];
var read = await stream.ReadAsync(lengthBuffer, 0, 4);
if (read < 4) return null;
if (BitConverter.IsLittleEndian)
Array.Reverse(lengthBuffer);
var length = BitConverter.ToInt32(lengthBuffer, 0);
if (length <= 0 || length > _config.MaxPacketSizeBytes)
return null;
var buffer = new byte[length];
var totalRead = 0;
while (totalRead < length)
{
read = await stream.ReadAsync(buffer, totalRead, length - totalRead);
if (read == 0) return null;
totalRead += read;
}
return buffer;
}
static async Task SendMessagePlainAsync(NetworkStream stream, Message message)
{
var data = MessageSerializer.Serialize(message);
await SendDataAsync(stream, data);
}
static async Task SendMessageEncryptedAsync(NetworkStream stream, Message message, SessionCrypto session)
{
var plain = MessageSerializer.Serialize(message);
var encrypted = session.Encrypt(plain);
await SendDataAsync(stream, encrypted);
}
static async Task SendDataAsync(NetworkStream stream, byte[] data)
{
var lengthBuffer = BitConverter.GetBytes(data.Length);
if (BitConverter.IsLittleEndian)
Array.Reverse(lengthBuffer);
await stream.WriteAsync(lengthBuffer);
await stream.WriteAsync(data);
await stream.FlushAsync();
}
static async Task SendToClientAsync(ConnectedClient client, Message message)
{
if (client.Session == null || client.Stream == null) return;
var plain = MessageSerializer.Serialize(message);
var encrypted = client.Session.Encrypt(plain);
lock (client.SendLock)
{
try
{
SendDataAsync(client.Stream, encrypted).Wait();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Chyba při odesílání do {Uuid}", client.ClientUuid);
}
}
}
#endregion
}
// Connected client state
class ConnectedClient
{
public required string ClientUuid { get; set; }
public required string DisplayName { get; set; }
public required string Endpoint { get; set; }
public SessionCrypto? Session { get; set; }
public NetworkStream? Stream { get; set; }
public LobbyActor? Lobby { get; set; }
public object SendLock { get; } = new();
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"Server": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:9091;http://localhost:9092"
}
}
}

948
Protocol.cs Normal file
View File

@@ -0,0 +1,948 @@
namespace GeoSus.Server;
using System.Text.Json;
using System.Text.Json.Serialization;
#region Základní typy
public record struct Position(double Lat, double Lon)
{
// Haversine vzdálenost v metrech
public double DistanceTo(Position other)
{
const double R = 6371000; // Poloměr Země v metrech
var lat1 = Lat * Math.PI / 180;
var lat2 = other.Lat * Math.PI / 180;
var dLat = (other.Lat - Lat) * Math.PI / 180;
var dLon = (other.Lon - Lon) * Math.PI / 180;
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(lat1) * Math.Cos(lat2) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return R * c;
}
}
public enum PlayerRole { Crew, Impostor }
public enum PlayerState { Alive, Dead }
public enum CheatStatus { Ok, Warn, Restrict, Kicked }
public enum GamePhase { Lobby, Loading, Playing, Meeting, Voting, Ended }
public enum TaskType { Instant }
public enum MeetingType { BodyReport, Emergency }
// Sabotage system
public enum SabotageType
{
/// <summary>Comms Blackout - Cannot report bodies or call emergency meetings</summary>
CommsBlackout,
/// <summary>Critical Meltdown - Must be repaired in time or impostors win (requires 2 simultaneous repairs)</summary>
CriticalMeltdown
}
public enum SabotageState
{
Inactive,
Active,
Repaired
}
// Map data types
public enum PathType
{
Footway,
Steps,
Cycleway,
Residential,
Service,
Road,
Track,
Other
}
public enum MapAreaType
{
Park,
Playground,
Garden,
Water,
Other
}
public enum MapPOIType
{
FoodDrink,
Finance,
Health,
Education,
Transport,
Shop,
Amenity,
Other
}
#endregion
#region Herní entity
public class Player
{
public required string ClientUuid { get; set; }
public required string DisplayName { get; set; }
public Position Position { get; set; }
public PlayerRole Role { get; set; } = PlayerRole.Crew;
public PlayerState State { get; set; } = PlayerState.Alive;
public CheatStatus CheatStatus { get; set; } = CheatStatus.Ok;
public int CheatScore { get; set; }
public DateTime ConnectedAt { get; set; } = DateTime.UtcNow;
public DateTime LastPositionUpdate { get; set; } = DateTime.UtcNow;
public DateTime? LastKillTime { get; set; }
public DateTime? LastEmergencyMeetingTime { get; set; }
public int EmergencyMeetingsUsed { get; set; }
public List<string> CompletedTaskIds { get; set; } = new();
public List<GameTask> Tasks { get; set; } = new(); // Per-player tasks
public string? CurrentTaskId { get; set; }
public DateTime? TaskStartTime { get; set; }
public bool IsOwner { get; set; }
public bool IsReady { get; set; }
public long LastEventId { get; set; }
public int Ping { get; set; }
// Historie pozic pro anti-cheat
public Queue<(Position Pos, DateTime Time)> PositionHistory { get; set; } = new();
}
public class Body
{
public required string BodyId { get; set; }
public required string VictimId { get; set; }
public required string KillerId { get; set; }
public Position Location { get; set; }
public DateTime KilledAt { get; set; } = DateTime.UtcNow;
public bool Reported { get; set; }
public string? ReportedBy { get; set; }
}
public class GameTask
{
public required string TaskId { get; set; }
public required string Name { get; set; }
public Position Location { get; set; }
public TaskType Type { get; set; } = TaskType.Instant;
}
public class Meeting
{
public required string MeetingId { get; set; }
public MeetingType Type { get; set; }
public string? ReportedBodyId { get; set; }
public required string CallerId { get; set; }
public Position MeetingLocation { get; set; }
public DateTime StartTime { get; set; } = DateTime.UtcNow;
public DateTime ArrivalDeadline { get; set; }
public DateTime? DiscussionEndTime { get; set; }
public DateTime VotingEndTime { get; set; }
public HashSet<string> ArrivedPlayers { get; set; } = new();
public Dictionary<string, string?> Votes { get; set; } = new(); // Voter -> Target (null = skip)
public DateTime? LastVoteChangeTime { get; set; }
}
/// <summary>
/// Active sabotage instance
/// </summary>
public class Sabotage
{
public required string SabotageId { get; set; }
public SabotageType Type { get; set; }
public SabotageState State { get; set; } = SabotageState.Active;
public required string InitiatorId { get; set; }
public DateTime StartTime { get; set; } = DateTime.UtcNow;
public DateTime? Deadline { get; set; } // For critical meltdown
public DateTime? RepairedAt { get; set; }
public string? RepairedBy { get; set; }
/// <summary>
/// Repair stations for this sabotage.
/// CommsBlackout: 1 station
/// CriticalMeltdown: 2 stations that need simultaneous activation
/// </summary>
public List<RepairStation> RepairStations { get; set; } = new();
/// <summary>
/// How many stations need to be simultaneously active to repair.
/// CommsBlackout: 1, CriticalMeltdown: 2
/// </summary>
public int RequiredSimultaneousRepairs { get; set; } = 1;
}
/// <summary>
/// Repair station for fixing sabotages
/// </summary>
public class RepairStation
{
public required string StationId { get; set; }
public required string Name { get; set; }
public Position Location { get; set; }
public bool IsBeingRepaired { get; set; }
public string? RepairingPlayerId { get; set; }
public DateTime? RepairStartTime { get; set; }
/// <summary>
/// Has this station been successfully repaired
/// </summary>
public bool IsRepaired { get; set; }
/// <summary>
/// How long player must hold to complete repair at this station
/// </summary>
public int RepairDurationMs { get; set; } = 3000;
}
#endregion
#region Map Data
/// <summary>
/// Complete map data for a play area - sent to clients for rendering
/// </summary>
public class MapData
{
public Position Center { get; set; }
public double RadiusMeters { get; set; }
public DateTime FetchedAt { get; set; }
/// <summary>Buildings to render (polygons)</summary>
public List<MapBuilding> Buildings { get; set; } = new();
/// <summary>Walkable pathways (lines)</summary>
public List<MapPathway> Pathways { get; set; } = new();
/// <summary>Parks, green areas (polygons)</summary>
public List<MapArea> Areas { get; set; } = new();
/// <summary>Points of interest (single points)</summary>
public List<MapPOI> PointsOfInterest { get; set; } = new();
/// <summary>All positions reachable from center via walkable paths (within play area)</summary>
[JsonIgnore] // Too large for network, computed server-side only
public List<Position> ReachablePositions { get; set; } = new();
}
/// <summary>Building outline for rendering</summary>
public class MapBuilding
{
public required string Id { get; set; }
public List<Position> Outline { get; set; } = new();
public string BuildingType { get; set; } = "yes";
public string? Name { get; set; }
public int Levels { get; set; } = 1;
}
/// <summary>Walkable pathway for rendering and reachability</summary>
public class MapPathway
{
public required string Id { get; set; }
public List<Position> Points { get; set; } = new();
public PathType PathType { get; set; }
public string? Name { get; set; }
public bool IsWalkable { get; set; } = true;
public double Width { get; set; } = 2.0;
[JsonIgnore]
public bool IsFullyReachable { get; set; }
[JsonIgnore]
public bool IsPartiallyReachable { get; set; }
}
/// <summary>Area like park or garden</summary>
public class MapArea
{
public required string Id { get; set; }
public List<Position> Outline { get; set; } = new();
public MapAreaType AreaType { get; set; }
public string? Name { get; set; }
}
/// <summary>Point of interest for task placement hints</summary>
public class MapPOI
{
public required string Id { get; set; }
public Position Position { get; set; }
public MapPOIType PoiType { get; set; }
public string? Name { get; set; }
}
/// <summary>Simplified map data sent to clients (smaller payload)</summary>
public class MapDataPayload
{
public Position Center { get; set; }
public double RadiusMeters { get; set; }
/// <summary>Buildings: [[lat,lon,lat,lon,...], ...]</summary>
public List<double[]> Buildings { get; set; } = new();
/// <summary>Building types: ["residential", "commercial", ...]</summary>
public List<string> BuildingTypes { get; set; } = new();
/// <summary>Pathways: [[lat,lon,lat,lon,...], ...]</summary>
public List<double[]> Pathways { get; set; } = new();
/// <summary>Pathway types: [0=footway, 1=steps, ...]</summary>
public List<int> PathwayTypes { get; set; } = new();
/// <summary>Areas (parks): [[lat,lon,lat,lon,...], ...]</summary>
public List<double[]> Areas { get; set; } = new();
/// <summary>Area types</summary>
public List<int> AreaTypes { get; set; } = new();
/// <summary>POIs: [lat, lon, type, lat, lon, type, ...]</summary>
public List<double> POIs { get; set; } = new();
/// <summary>Convert from full MapData to compact payload</summary>
public static MapDataPayload FromMapData(MapData data)
{
var payload = new MapDataPayload
{
Center = data.Center,
RadiusMeters = data.RadiusMeters
};
foreach (var b in data.Buildings)
{
var coords = new double[b.Outline.Count * 2];
for (int i = 0; i < b.Outline.Count; i++)
{
coords[i * 2] = b.Outline[i].Lat;
coords[i * 2 + 1] = b.Outline[i].Lon;
}
payload.Buildings.Add(coords);
payload.BuildingTypes.Add(b.BuildingType);
}
foreach (var p in data.Pathways)
{
var coords = new double[p.Points.Count * 2];
for (int i = 0; i < p.Points.Count; i++)
{
coords[i * 2] = p.Points[i].Lat;
coords[i * 2 + 1] = p.Points[i].Lon;
}
payload.Pathways.Add(coords);
payload.PathwayTypes.Add((int)p.PathType);
}
foreach (var a in data.Areas)
{
var coords = new double[a.Outline.Count * 2];
for (int i = 0; i < a.Outline.Count; i++)
{
coords[i * 2] = a.Outline[i].Lat;
coords[i * 2 + 1] = a.Outline[i].Lon;
}
payload.Areas.Add(coords);
payload.AreaTypes.Add((int)a.AreaType);
}
foreach (var poi in data.PointsOfInterest)
{
payload.POIs.Add(poi.Position.Lat);
payload.POIs.Add(poi.Position.Lon);
payload.POIs.Add((int)poi.PoiType);
}
return payload;
}
}
#endregion
#region Protokol - Zprávy
public abstract class Message
{
[JsonPropertyName("type")]
public abstract string Type { get; }
[JsonPropertyName("clientSeq")]
public int ClientSeq { get; set; }
[JsonPropertyName("actionId")]
public string? ActionId { get; set; }
}
// Handshake
public class ClientHello : Message
{
public override string Type => "ClientHello";
public string ProtocolVersion { get; set; } = "1.0";
public required string ClientUuid { get; set; }
public string? DisplayName { get; set; }
}
public class ServerHello : Message
{
public override string Type => "ServerHello";
public required string RsaPublicKeyPem { get; set; }
public required string ServerId { get; set; }
}
public class KeyExchange : Message
{
public override string Type => "KeyExchange";
public required string EncryptedSessionKey { get; set; }
public required string EncryptedIV { get; set; }
}
public class KeyExchangeAck : Message
{
public override string Type => "KeyExchangeAck";
public string Status { get; set; } = "success";
}
// Lobby
public class CreateLobby : Message
{
public override string Type => "CreateLobby";
public string? Password { get; set; }
public Position? PlayAreaCenter { get; set; }
public double PlayAreaRadius { get; set; } = 500; // metry
public int ImpostorCount { get; set; } = 1;
public int TaskCount { get; set; } = 5;
}
public class CreateLobbyResponse : Message
{
public override string Type => "CreateLobbyResponse";
public bool Success { get; set; }
public string? JoinCode { get; set; }
public string? LobbyId { get; set; }
public string? Error { get; set; }
public LobbyState? LobbyState { get; set; }
}
public class JoinLobby : Message
{
public override string Type => "JoinLobby";
public required string JoinCode { get; set; }
public string? Password { get; set; }
}
public class JoinLobbyResponse : Message
{
public override string Type => "JoinLobbyResponse";
public bool Success { get; set; }
public string? LobbyId { get; set; }
public string? Error { get; set; }
public LobbyState? LobbyState { get; set; }
}
public class LeaveLobby : Message
{
public override string Type => "LeaveLobby";
}
public class KickPlayer : Message
{
public override string Type => "KickPlayer";
public required string TargetClientUuid { get; set; }
}
public class StartGame : Message
{
public override string Type => "StartGame";
}
public class ReturnToLobby : Message
{
public override string Type => "ReturnToLobby";
}
/// <summary>
/// Client confirms it received map data and is ready to play
/// </summary>
public class MapDataReceived : Message
{
public override string Type => "MapDataReceived";
}
// Hra
public class UpdatePosition : Message
{
public override string Type => "UpdatePosition";
public Position Position { get; set; }
}
public class PositionBroadcast : Message
{
public override string Type => "PositionBroadcast";
public List<PlayerPositionInfo> Players { get; set; } = new();
}
public class PlayerPositionInfo
{
public required string ClientUuid { get; set; }
public Position Position { get; set; }
public PlayerState State { get; set; }
}
public class KillAttempt : Message
{
public override string Type => "KillAttempt";
public required string TargetClientUuid { get; set; }
}
public class ReportBody : Message
{
public override string Type => "ReportBody";
public required string BodyId { get; set; }
}
public class CallEmergencyMeeting : Message
{
public override string Type => "CallEmergencyMeeting";
}
public class CastVote : Message
{
public override string Type => "CastVote";
public string? TargetClientUuid { get; set; } // null = skip
}
public class TaskStart : Message
{
public override string Type => "TaskStart";
public required string TaskId { get; set; }
}
public class TaskProgress : Message
{
public override string Type => "TaskProgress";
public required string TaskId { get; set; }
public int Step { get; set; } = 1;
}
public class TaskComplete : Message
{
public override string Type => "TaskComplete";
public required string TaskId { get; set; }
}
// Ping/Heartbeat
public class Ping : Message
{
public override string Type => "Ping";
public long ClientTime { get; set; }
}
public class Pong : Message
{
public override string Type => "Pong";
public long ClientTime { get; set; }
public long ServerTime { get; set; }
}
// Reconnect
public class Reconnect : Message
{
public override string Type => "Reconnect";
public required string LobbyId { get; set; }
public long LastEventId { get; set; }
}
public class ReconnectResponse : Message
{
public override string Type => "ReconnectResponse";
public bool Success { get; set; }
public string? Error { get; set; }
public LobbySnapshot? Snapshot { get; set; }
public List<GameEvent>? MissedEvents { get; set; }
}
// Ack
public class Ack : Message
{
public override string Type => "Ack";
public int AckedSeq { get; set; }
public bool Success { get; set; }
public string? Error { get; set; }
}
// Error
public class ErrorMessage : Message
{
public override string Type => "Error";
public required string ErrorCode { get; set; }
public required string ErrorText { get; set; }
}
// Sabotage messages
public class StartSabotage : Message
{
public override string Type => "StartSabotage";
public SabotageType SabotageType { get; set; }
public Position? TargetLocation { get; set; } // For zone lockdown
}
public class ActivateRepairStation : Message
{
public override string Type => "ActivateRepairStation";
public required string StationId { get; set; }
}
public class DeactivateRepairStation : Message
{
public override string Type => "DeactivateRepairStation";
public required string StationId { get; set; }
}
#endregion
#region Eventy
public class GameEvent : Message
{
public override string Type => "GameEvent";
public long EventId { get; set; }
public long ServerSeq { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string? Actor { get; set; }
public required string EventType { get; set; }
public object? Payload { get; set; }
}
// Payload typy pro eventy
public class LobbyCreatedPayload
{
public required string LobbyId { get; set; }
public required string JoinCode { get; set; }
public required string OwnerId { get; set; }
}
public class PlayerJoinedPayload
{
public required string ClientUuid { get; set; }
public required string DisplayName { get; set; }
}
public class PlayerLeftPayload
{
public required string ClientUuid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Sent when owner clicks StartGame - clients should show loading screen
/// </summary>
public class GameStartingPayload
{
public string? InitiatorId { get; set; }
public string Message { get; set; } = "Loading map data...";
}
/// <summary>
/// Sent when map data is ready and distributed to all clients
/// </summary>
public class MapDataReadyPayload
{
public MapDataPayload? MapData { get; set; }
public Position PlayAreaCenter { get; set; }
public double PlayAreaRadius { get; set; }
}
/// <summary>
/// Sent when a client confirms they received map data
/// </summary>
public class PlayerMapDataReceivedPayload
{
public required string ClientUuid { get; set; }
public string DisplayName { get; set; } = "";
public int PlayersReady { get; set; }
public int TotalPlayers { get; set; }
}
/// <summary>
/// Sent when all clients have confirmed map data and game actually starts
/// </summary>
public class GameStartedPayload
{
public int ImpostorCount { get; set; }
public int TaskCount { get; set; }
}
public class RoleAssignedPayload
{
public required string ClientUuid { get; set; }
public PlayerRole Role { get; set; }
public List<GameTask>? Tasks { get; set; }
}
public class PlayerKilledPayload
{
public required string VictimId { get; set; }
public required string KillerId { get; set; }
public required string BodyId { get; set; }
public Position Location { get; set; }
}
public class BodyReportedPayload
{
public required string ReporterId { get; set; }
public required string BodyId { get; set; }
public required string VictimId { get; set; }
}
public class EmergencyMeetingCalledPayload
{
public required string CallerId { get; set; }
}
public class MeetingStartedPayload
{
public required string MeetingId { get; set; }
public MeetingType Type { get; set; }
public Position MeetingLocation { get; set; }
public DateTime ArrivalDeadline { get; set; }
public DateTime? DiscussionEndTime { get; set; }
public DateTime VotingEndTime { get; set; }
}
public class PlayerArrivedAtMeetingPayload
{
public required string ClientUuid { get; set; }
public required string MeetingId { get; set; }
}
public class PlayerVotedPayload
{
public required string VoterId { get; set; }
public string? TargetId { get; set; } // null = skip
}
public class VotingClosedPayload
{
public Dictionary<string, int> VoteCounts { get; set; } = new();
public string? EjectedPlayerId { get; set; }
public bool WasTie { get; set; }
}
public class PlayerEjectedPayload
{
public required string ClientUuid { get; set; }
public PlayerRole Role { get; set; }
}
public class TaskCompletedPayload
{
public required string ClientUuid { get; set; }
public required string TaskId { get; set; }
public int TotalCompleted { get; set; }
public int TotalTasks { get; set; }
}
public class GameEndedPayload
{
public required string WinningFaction { get; set; } // "Crew" nebo "Impostor"
public required string Reason { get; set; }
public List<string> Winners { get; set; } = new();
}
public class ReturnedToLobbyPayload
{
public string Message { get; set; } = "";
}
public class HostChangedPayload
{
public required string NewHostId { get; set; }
public required string PreviousHostId { get; set; }
}
public class CheatDetectedPayload
{
public required string ClientUuid { get; set; }
public required string Violation { get; set; }
public int NewCheatScore { get; set; }
public CheatStatus NewStatus { get; set; }
}
// Sabotage event payloads
public class SabotageStartedPayload
{
public required string SabotageId { get; set; }
public SabotageType Type { get; set; }
public required string InitiatorId { get; set; }
public DateTime? Deadline { get; set; } // For critical meltdown
public List<RepairStationInfo> RepairStations { get; set; } = new();
public int RequiredSimultaneousRepairs { get; set; }
}
public class RepairStationInfo
{
public required string StationId { get; set; }
public required string Name { get; set; }
public Position Location { get; set; }
public int RepairDurationMs { get; set; }
}
public class RepairStartedPayload
{
public required string SabotageId { get; set; }
public required string StationId { get; set; }
public required string PlayerId { get; set; }
}
public class RepairStoppedPayload
{
public required string SabotageId { get; set; }
public required string StationId { get; set; }
public required string PlayerId { get; set; }
}
public class RepairProgressPayload
{
public required string SabotageId { get; set; }
public required string StationId { get; set; }
public required string PlayerId { get; set; }
public int ProgressMs { get; set; }
public int RequiredMs { get; set; }
}
public class SabotageRepairedPayload
{
public required string SabotageId { get; set; }
public SabotageType Type { get; set; }
public List<string> RepairerIds { get; set; } = new(); // Players who helped repair
}
public class SabotageMeltdownPayload
{
public required string SabotageId { get; set; }
// Impostor wins - game ends
}
#endregion
#region State
public class LobbyState
{
public required string LobbyId { get; set; }
public required string JoinCode { get; set; }
public string? OwnerId { get; set; }
public GamePhase Phase { get; set; } = GamePhase.Lobby;
public List<PlayerInfo> Players { get; set; } = new();
public Position PlayAreaCenter { get; set; }
public double PlayAreaRadius { get; set; }
public int ImpostorCount { get; set; }
public bool HasPassword { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Map data for client rendering (null if Overpass disabled or failed)</summary>
public MapDataPayload? MapData { get; set; }
/// <summary>True if map data has been loaded (or Overpass is disabled)</summary>
public bool MapDataReady { get; set; } = true;
}
public class PlayerInfo
{
public required string ClientUuid { get; set; }
public required string DisplayName { get; set; }
public bool IsOwner { get; set; }
public bool IsReady { get; set; }
public PlayerState State { get; set; }
// Role je viditelná pouze pro daného hráče, ne v broadcast
}
public class LobbySnapshot
{
public required string LobbyId { get; set; }
public long LastEventId { get; set; }
public DateTime Timestamp { get; set; }
public required string Checksum { get; set; }
public GamePhase Phase { get; set; }
public List<Player> Players { get; set; } = new();
public List<Body> Bodies { get; set; } = new();
public List<GameTask> Tasks { get; set; } = new();
public Meeting? CurrentMeeting { get; set; }
public Position PlayAreaCenter { get; set; }
public double PlayAreaRadius { get; set; }
public int ImpostorCount { get; set; }
public TiePolicy TiePolicy { get; set; }
}
#endregion
#region Serializace
public static class MessageSerializer
{
private static readonly Dictionary<string, Type> MessageTypes = new()
{
["ClientHello"] = typeof(ClientHello),
["ServerHello"] = typeof(ServerHello),
["KeyExchange"] = typeof(KeyExchange),
["KeyExchangeAck"] = typeof(KeyExchangeAck),
["CreateLobby"] = typeof(CreateLobby),
["CreateLobbyResponse"] = typeof(CreateLobbyResponse),
["JoinLobby"] = typeof(JoinLobby),
["JoinLobbyResponse"] = typeof(JoinLobbyResponse),
["LeaveLobby"] = typeof(LeaveLobby),
["KickPlayer"] = typeof(KickPlayer),
["StartGame"] = typeof(StartGame),
["ReturnToLobby"] = typeof(ReturnToLobby),
["MapDataReceived"] = typeof(MapDataReceived),
["UpdatePosition"] = typeof(UpdatePosition),
["PositionBroadcast"] = typeof(PositionBroadcast),
["KillAttempt"] = typeof(KillAttempt),
["ReportBody"] = typeof(ReportBody),
["CallEmergencyMeeting"] = typeof(CallEmergencyMeeting),
["CastVote"] = typeof(CastVote),
["TaskStart"] = typeof(TaskStart),
["TaskProgress"] = typeof(TaskProgress),
["TaskComplete"] = typeof(TaskComplete),
["Ping"] = typeof(Ping),
["Pong"] = typeof(Pong),
["Reconnect"] = typeof(Reconnect),
["ReconnectResponse"] = typeof(ReconnectResponse),
["Ack"] = typeof(Ack),
["Error"] = typeof(ErrorMessage),
["GameEvent"] = typeof(GameEvent),
["StartSabotage"] = typeof(StartSabotage),
["ActivateRepairStation"] = typeof(ActivateRepairStation),
["DeactivateRepairStation"] = typeof(DeactivateRepairStation)
};
public static byte[] Serialize(Message msg)
{
return JsonSerializer.SerializeToUtf8Bytes(msg, msg.GetType(), JsonOptions.Default);
}
public static byte[] Serialize(GameEvent evt)
{
return JsonSerializer.SerializeToUtf8Bytes(evt, JsonOptions.Default);
}
public static Message? Deserialize(ReadOnlySpan<byte> data)
{
// Nejdřív zjistíme typ
using var doc = JsonDocument.Parse(data.ToArray());
if (!doc.RootElement.TryGetProperty("type", out var typeProp))
return null;
var typeName = typeProp.GetString();
if (typeName == null || !MessageTypes.TryGetValue(typeName, out var type))
return null;
return (Message?)JsonSerializer.Deserialize(data, type, JsonOptions.Default);
}
public static GameEvent? DeserializeEvent(ReadOnlySpan<byte> data)
{
return JsonSerializer.Deserialize<GameEvent>(data, JsonOptions.Default);
}
}
#endregion

17
Server.csproj Normal file
View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>GeoSus.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
</ItemGroup>
</Project>

327
StatsDb.cs Normal file
View File

@@ -0,0 +1,327 @@
namespace GeoSus.Server;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
// SQLite databáze pro statistiky hráčů
public class StatsDb : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ILogger<StatsDb> _logger;
private readonly object _lock = new();
public StatsDb(string dbPath, ILogger<StatsDb> logger)
{
_logger = logger;
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
_connection = new SqliteConnection($"Data Source={dbPath}");
_connection.Open();
InitializeSchema();
}
private void InitializeSchema()
{
var sql = @"
CREATE TABLE IF NOT EXISTS player_stats (
client_uuid TEXT PRIMARY KEY,
display_name TEXT,
total_games INTEGER DEFAULT 0,
games_as_crew INTEGER DEFAULT 0,
games_as_impostor INTEGER DEFAULT 0,
crew_wins INTEGER DEFAULT 0,
impostor_wins INTEGER DEFAULT 0,
total_kills INTEGER DEFAULT 0,
total_deaths INTEGER DEFAULT 0,
tasks_completed INTEGER DEFAULT 0,
bodies_reported INTEGER DEFAULT 0,
emergency_meetings_called INTEGER DEFAULT 0,
times_voted_out INTEGER DEFAULT 0,
successful_votes INTEGER DEFAULT 0,
total_playtime_seconds INTEGER DEFAULT 0,
cheat_incidents INTEGER DEFAULT 0,
last_seen_utc TEXT,
created_at_utc TEXT
);
CREATE INDEX IF NOT EXISTS idx_crew_wins ON player_stats(crew_wins DESC);
CREATE INDEX IF NOT EXISTS idx_impostor_wins ON player_stats(impostor_wins DESC);
CREATE INDEX IF NOT EXISTS idx_total_games ON player_stats(total_games DESC);
";
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
_logger.LogInformation("StatsDb inicializována");
}
#region CRUD operace
public void EnsurePlayerExists(string clientUuid, string displayName)
{
lock (_lock)
{
var sql = @"
INSERT INTO player_stats (client_uuid, display_name, created_at_utc, last_seen_utc)
VALUES (@uuid, @name, @now, @now)
ON CONFLICT(client_uuid) DO UPDATE SET
display_name = @name,
last_seen_utc = @now
";
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@uuid", clientUuid);
cmd.Parameters.AddWithValue("@name", displayName);
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.ExecuteNonQuery();
}
}
public PlayerStats? GetPlayerStats(string clientUuid)
{
lock (_lock)
{
var sql = "SELECT * FROM player_stats WHERE client_uuid = @uuid";
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@uuid", clientUuid);
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
return ReadPlayerStats(reader);
}
return null;
}
}
public List<PlayerStats> GetLeaderboard(string sortBy, int limit = 100)
{
lock (_lock)
{
var validColumns = new[] {
"crew_wins", "impostor_wins", "total_games",
"total_kills", "tasks_completed"
};
if (!validColumns.Contains(sortBy))
sortBy = "total_games";
var sql = $"SELECT * FROM player_stats ORDER BY {sortBy} DESC LIMIT @limit";
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@limit", limit);
var results = new List<PlayerStats>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
results.Add(ReadPlayerStats(reader));
}
return results;
}
}
#endregion
#region Inkrementální aktualizace
public void IncrementKills(string clientUuid)
{
IncrementColumn(clientUuid, "total_kills");
}
public void IncrementDeaths(string clientUuid)
{
IncrementColumn(clientUuid, "total_deaths");
}
public void IncrementTasksCompleted(string clientUuid)
{
IncrementColumn(clientUuid, "tasks_completed");
}
public void IncrementBodiesReported(string clientUuid)
{
IncrementColumn(clientUuid, "bodies_reported");
}
public void IncrementEmergencyMeetings(string clientUuid)
{
IncrementColumn(clientUuid, "emergency_meetings_called");
}
public void IncrementTimesVotedOut(string clientUuid)
{
IncrementColumn(clientUuid, "times_voted_out");
}
public void IncrementSuccessfulVotes(string clientUuid)
{
IncrementColumn(clientUuid, "successful_votes");
}
public void IncrementCheatIncidents(string clientUuid)
{
IncrementColumn(clientUuid, "cheat_incidents");
}
private void IncrementColumn(string clientUuid, string column)
{
lock (_lock)
{
var sql = $@"
UPDATE player_stats
SET {column} = {column} + 1, last_seen_utc = @now
WHERE client_uuid = @uuid
";
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@uuid", clientUuid);
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.ExecuteNonQuery();
}
}
#endregion
#region Game end aktualizace
public void RecordGameEnd(GameEndStats stats)
{
lock (_lock)
{
using var transaction = _connection.BeginTransaction();
try
{
foreach (var player in stats.Players)
{
var sql = @"
UPDATE player_stats SET
total_games = total_games + 1,
games_as_crew = games_as_crew + @asCrew,
games_as_impostor = games_as_impostor + @asImpostor,
crew_wins = crew_wins + @crewWin,
impostor_wins = impostor_wins + @impostorWin,
total_playtime_seconds = total_playtime_seconds + @playtime,
last_seen_utc = @now
WHERE client_uuid = @uuid
";
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@uuid", player.ClientUuid);
cmd.Parameters.AddWithValue("@asCrew", player.WasCrew ? 1 : 0);
cmd.Parameters.AddWithValue("@asImpostor", player.WasImpostor ? 1 : 0);
cmd.Parameters.AddWithValue("@crewWin", player.WasCrew && stats.CrewWon ? 1 : 0);
cmd.Parameters.AddWithValue("@impostorWin", player.WasImpostor && !stats.CrewWon ? 1 : 0);
cmd.Parameters.AddWithValue("@playtime", player.PlaytimeSeconds);
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.ExecuteNonQuery();
}
transaction.Commit();
_logger.LogInformation("Game stats uloženy pro {Count} hráčů", stats.Players.Count);
}
catch
{
transaction.Rollback();
throw;
}
}
}
#endregion
private PlayerStats ReadPlayerStats(SqliteDataReader reader)
{
return new PlayerStats
{
ClientUuid = reader.GetString(reader.GetOrdinal("client_uuid")),
DisplayName = reader.IsDBNull(reader.GetOrdinal("display_name"))
? null : reader.GetString(reader.GetOrdinal("display_name")),
TotalGames = reader.GetInt32(reader.GetOrdinal("total_games")),
GamesAsCrew = reader.GetInt32(reader.GetOrdinal("games_as_crew")),
GamesAsImpostor = reader.GetInt32(reader.GetOrdinal("games_as_impostor")),
CrewWins = reader.GetInt32(reader.GetOrdinal("crew_wins")),
ImpostorWins = reader.GetInt32(reader.GetOrdinal("impostor_wins")),
TotalKills = reader.GetInt32(reader.GetOrdinal("total_kills")),
TotalDeaths = reader.GetInt32(reader.GetOrdinal("total_deaths")),
TasksCompleted = reader.GetInt32(reader.GetOrdinal("tasks_completed")),
BodiesReported = reader.GetInt32(reader.GetOrdinal("bodies_reported")),
EmergencyMeetingsCalled = reader.GetInt32(reader.GetOrdinal("emergency_meetings_called")),
TimesVotedOut = reader.GetInt32(reader.GetOrdinal("times_voted_out")),
SuccessfulVotes = reader.GetInt32(reader.GetOrdinal("successful_votes")),
TotalPlaytimeSeconds = reader.GetInt32(reader.GetOrdinal("total_playtime_seconds")),
CheatIncidents = reader.GetInt32(reader.GetOrdinal("cheat_incidents")),
LastSeenUtc = reader.IsDBNull(reader.GetOrdinal("last_seen_utc"))
? null : reader.GetString(reader.GetOrdinal("last_seen_utc")),
CreatedAtUtc = reader.IsDBNull(reader.GetOrdinal("created_at_utc"))
? null : reader.GetString(reader.GetOrdinal("created_at_utc"))
};
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
}
}
#region DTO
public class PlayerStats
{
public required string ClientUuid { get; set; }
public string? DisplayName { get; set; }
public int TotalGames { get; set; }
public int GamesAsCrew { get; set; }
public int GamesAsImpostor { get; set; }
public int CrewWins { get; set; }
public int ImpostorWins { get; set; }
public int TotalKills { get; set; }
public int TotalDeaths { get; set; }
public int TasksCompleted { get; set; }
public int BodiesReported { get; set; }
public int EmergencyMeetingsCalled { get; set; }
public int TimesVotedOut { get; set; }
public int SuccessfulVotes { get; set; }
public int TotalPlaytimeSeconds { get; set; }
public int CheatIncidents { get; set; }
public string? LastSeenUtc { get; set; }
public string? CreatedAtUtc { get; set; }
// Computed properties pro API
public double CrewWinRate => GamesAsCrew > 0 ? (double)CrewWins / GamesAsCrew : 0;
public double ImpostorWinRate => GamesAsImpostor > 0 ? (double)ImpostorWins / GamesAsImpostor : 0;
public double KillDeathRatio => TotalDeaths > 0 ? (double)TotalKills / TotalDeaths : TotalKills;
public double AverageTasksPerGame => TotalGames > 0 ? (double)TasksCompleted / TotalGames : 0;
}
public class GameEndStats
{
public bool CrewWon { get; set; }
public List<PlayerGameStats> Players { get; set; } = new();
}
public class PlayerGameStats
{
public required string ClientUuid { get; set; }
public bool WasCrew { get; set; }
public bool WasImpostor { get; set; }
public int PlaytimeSeconds { get; set; }
}
#endregion

42
apache-vhost.conf Normal file
View File

@@ -0,0 +1,42 @@
# GeoSus Admin Panel - Apache Virtual Host
#
# Required modules:
# sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl
#
# Installation:
# 1. sudo cp apache-vhost.conf /etc/apache2/sites-available/geosus.conf
# 2. sudo a2ensite geosus && sudo systemctl reload apache2
# 3. sudo certbot --apache -d geosus.honzuvkod.dev
<VirtualHost *:80>
ServerName geosus.honzuvkod.dev
ErrorLog ${APACHE_LOG_DIR}/geosus_error.log
CustomLog ${APACHE_LOG_DIR}/geosus_access.log combined
ProxyPreserveHost On
ProxyRequests Off
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/admin/ws/(.*)$ ws://127.0.0.1:8088/admin/ws/$1 [P,L]
ProxyPass /admin http://127.0.0.1:8088/admin
ProxyPassReverse /admin http://127.0.0.1:8088/admin
ProxyPass /api http://127.0.0.1:8088/api
ProxyPassReverse /api http://127.0.0.1:8088/api
ProxyPass /health http://127.0.0.1:8088/health
ProxyPassReverse /health http://127.0.0.1:8088/health
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
ProxyTimeout 3600
SetEnv proxy-nokeepalive 0
SetEnv proxy-initial-not-pooled 1
</VirtualHost>

45
appsettings.json Normal file
View File

@@ -0,0 +1,45 @@
{
"tcpPort": 7777,
"httpPort": 8088,
"maxPacketSizeBytes": 1048576,
"tickMs": 200,
"positionBroadcastRateMs": 1000,
"maxSpeedMps": 12.0,
"movementValidationWindowSec": 5.0,
"teleportThresholdMeters": 50.0,
"cheatScoreWarnThreshold": 10,
"cheatScoreRestrictThreshold": 25,
"cheatScoreKickThreshold": 50,
"killDistanceM": 10.0,
"killCooldownMs": 5000,
"meetingArrivalRadiusM": 15.0,
"arrivalBaseMs": 20000,
"arrivalSafetyMarginMs": 3000,
"allowedLateMs": 5000,
"discussionPhaseMs": 15000,
"votingPhaseMs": 30000,
"emergencyMeetingCooldownMs": 30000,
"maxEmergencyMeetingsPerPlayer": 2,
"emergencyMeetingCallRadiusM": 15.0,
"reportDistanceM": 10.0,
"taskStartDistanceM": 5.0,
"taskLeaveDebounceMs": 2000,
"taskProgressKeepaliveMs": 5000,
"snapshotEvents": 200,
"snapshotIntervalMs": 300000,
"walMaxSizeMb": 10,
"dataPath": "data",
"hostTimeoutMs": 15000,
"reconnectWindowMs": 60000,
"idleLobbyTtlMs": 3600000,
"joinCodeTtlMs": 86400000,
"maxPlayersPerLobby": 15,
"joinRateLimitPerMinute": 10,
"sessionKeySizeBytes": 32,
"rsaKeySizeBits": 2048,
"statsApiRateLimit": 100,
"statsApiKey": null,
"defaultImpostorCount": 1,
"defaultTaskCount": 5,
"defaultTiePolicy": "NoEject"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,220 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"Server/1.0.0": {
"dependencies": {
"Microsoft.Data.Sqlite": "8.0.0"
},
"runtime": {
"Server.dll": {}
}
},
"Microsoft.Data.Sqlite/8.0.0": {
"dependencies": {
"Microsoft.Data.Sqlite.Core": "8.0.0",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.6"
}
},
"Microsoft.Data.Sqlite.Core/8.0.0": {
"dependencies": {
"SQLitePCLRaw.core": "2.1.6"
},
"runtime": {
"lib/net8.0/Microsoft.Data.Sqlite.dll": {
"assemblyVersion": "8.0.0.0",
"fileVersion": "8.0.23.53103"
}
}
},
"SQLitePCLRaw.bundle_e_sqlite3/2.1.6": {
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.6",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.6"
},
"runtime": {
"lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll": {
"assemblyVersion": "2.1.6.2060",
"fileVersion": "2.1.6.2060"
}
}
},
"SQLitePCLRaw.core/2.1.6": {
"runtime": {
"lib/netstandard2.0/SQLitePCLRaw.core.dll": {
"assemblyVersion": "2.1.6.2060",
"fileVersion": "2.1.6.2060"
}
}
},
"SQLitePCLRaw.lib.e_sqlite3/2.1.6": {
"runtimeTargets": {
"runtimes/browser-wasm/nativeassets/net8.0/e_sqlite3.a": {
"rid": "browser-wasm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-arm/native/libe_sqlite3.so": {
"rid": "linux-arm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-arm64/native/libe_sqlite3.so": {
"rid": "linux-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-armel/native/libe_sqlite3.so": {
"rid": "linux-armel",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-mips64/native/libe_sqlite3.so": {
"rid": "linux-mips64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-arm/native/libe_sqlite3.so": {
"rid": "linux-musl-arm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-arm64/native/libe_sqlite3.so": {
"rid": "linux-musl-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-x64/native/libe_sqlite3.so": {
"rid": "linux-musl-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-ppc64le/native/libe_sqlite3.so": {
"rid": "linux-ppc64le",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-s390x/native/libe_sqlite3.so": {
"rid": "linux-s390x",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-x64/native/libe_sqlite3.so": {
"rid": "linux-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-x86/native/libe_sqlite3.so": {
"rid": "linux-x86",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/maccatalyst-arm64/native/libe_sqlite3.dylib": {
"rid": "maccatalyst-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/maccatalyst-x64/native/libe_sqlite3.dylib": {
"rid": "maccatalyst-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/osx-arm64/native/libe_sqlite3.dylib": {
"rid": "osx-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/osx-x64/native/libe_sqlite3.dylib": {
"rid": "osx-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-arm/native/e_sqlite3.dll": {
"rid": "win-arm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-arm64/native/e_sqlite3.dll": {
"rid": "win-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-x64/native/e_sqlite3.dll": {
"rid": "win-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-x86/native/e_sqlite3.dll": {
"rid": "win-x86",
"assetType": "native",
"fileVersion": "0.0.0.0"
}
}
},
"SQLitePCLRaw.provider.e_sqlite3/2.1.6": {
"dependencies": {
"SQLitePCLRaw.core": "2.1.6"
},
"runtime": {
"lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll": {
"assemblyVersion": "2.1.6.2060",
"fileVersion": "2.1.6.2060"
}
}
}
}
},
"libraries": {
"Server/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Data.Sqlite/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-H+iC5IvkCCKSNHXzL3JARvDn7VpkvuJM91KVB89sKjeTF/KX/BocNNh93ZJtX5MCQKb/z4yVKgkU2sVIq+xKfg==",
"path": "microsoft.data.sqlite/8.0.0",
"hashPath": "microsoft.data.sqlite.8.0.0.nupkg.sha512"
},
"Microsoft.Data.Sqlite.Core/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-pujbzfszX7jAl7oTbHhqx7pxd9jibeyHHl8zy1gd55XMaKWjDtc5XhhNYwQnrwWYCInNdVoArbaaAvLgW7TwuA==",
"path": "microsoft.data.sqlite.core/8.0.0",
"hashPath": "microsoft.data.sqlite.core.8.0.0.nupkg.sha512"
},
"SQLitePCLRaw.bundle_e_sqlite3/2.1.6": {
"type": "package",
"serviceable": true,
"sha512": "sha512-BmAf6XWt4TqtowmiWe4/5rRot6GerAeklmOPfviOvwLoF5WwgxcJHAxZtySuyW9r9w+HLILnm8VfJFLCUJYW8A==",
"path": "sqlitepclraw.bundle_e_sqlite3/2.1.6",
"hashPath": "sqlitepclraw.bundle_e_sqlite3.2.1.6.nupkg.sha512"
},
"SQLitePCLRaw.core/2.1.6": {
"type": "package",
"serviceable": true,
"sha512": "sha512-wO6v9GeMx9CUngAet8hbO7xdm+M42p1XeJq47ogyRoYSvNSp0NGLI+MgC0bhrMk9C17MTVFlLiN6ylyExLCc5w==",
"path": "sqlitepclraw.core/2.1.6",
"hashPath": "sqlitepclraw.core.2.1.6.nupkg.sha512"
},
"SQLitePCLRaw.lib.e_sqlite3/2.1.6": {
"type": "package",
"serviceable": true,
"sha512": "sha512-2ObJJLkIUIxRpOUlZNGuD4rICpBnrBR5anjyfUFQep4hMOIeqW+XGQYzrNmHSVz5xSWZ3klSbh7sFR6UyDj68Q==",
"path": "sqlitepclraw.lib.e_sqlite3/2.1.6",
"hashPath": "sqlitepclraw.lib.e_sqlite3.2.1.6.nupkg.sha512"
},
"SQLitePCLRaw.provider.e_sqlite3/2.1.6": {
"type": "package",
"serviceable": true,
"sha512": "sha512-PQ2Oq3yepLY4P7ll145P3xtx2bX8xF4PzaKPRpw9jZlKvfe4LE/saAV82inND9usn1XRpmxXk7Lal3MTI+6CNg==",
"path": "sqlitepclraw.provider.e_sqlite3/2.1.6",
"hashPath": "sqlitepclraw.provider.e_sqlite3.2.1.6.nupkg.sha512"
}
}
}

BIN
bin/Debug/net8.0/Server.dll Normal file

Binary file not shown.

BIN
bin/Debug/net8.0/Server.exe Normal file

Binary file not shown.

BIN
bin/Debug/net8.0/Server.pdb Normal file

Binary file not shown.

View File

@@ -0,0 +1,19 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

View File

@@ -0,0 +1 @@
{"Version":1,"ManifestType":"Build","Endpoints":[]}

View File

@@ -0,0 +1,44 @@
{
"tcpPort": 7777,
"httpPort": 8080,
"maxPacketSizeBytes": 1048576,
"tickMs": 200,
"positionBroadcastRateMs": 1000,
"maxSpeedMps": 12.0,
"movementValidationWindowSec": 5.0,
"teleportThresholdMeters": 50.0,
"cheatScoreWarnThreshold": 10,
"cheatScoreRestrictThreshold": 25,
"cheatScoreKickThreshold": 50,
"killDistanceM": 10.0,
"killCooldownMs": 30000,
"meetingArrivalRadiusM": 15.0,
"arrivalBaseMs": 30000,
"arrivalSafetyMarginMs": 500,
"allowedLateMs": 2000,
"discussionPhaseMs": 30000,
"votingPhaseMs": 45000,
"emergencyMeetingCooldownMs": 60000,
"maxEmergencyMeetingsPerPlayer": 1,
"reportDistanceM": 5.0,
"taskStartDistanceM": 3.0,
"taskLeaveDebounceMs": 2000,
"taskProgressKeepaliveMs": 5000,
"snapshotEvents": 200,
"snapshotIntervalMs": 300000,
"walMaxSizeMb": 10,
"dataPath": "data",
"hostTimeoutMs": 15000,
"reconnectWindowMs": 60000,
"idleLobbyTtlMs": 3600000,
"joinCodeTtlMs": 86400000,
"maxPlayersPerLobby": 15,
"joinRateLimitPerMinute": 10,
"sessionKeySizeBytes": 32,
"rsaKeySizeBits": 2048,
"statsApiRateLimit": 100,
"statsApiKey": null,
"defaultImpostorCount": 1,
"defaultTaskCount": 5,
"defaultTiePolicy": "NoEject"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,226 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v9.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v9.0": {
"Server/1.0.0": {
"dependencies": {
"Microsoft.Data.Sqlite": "9.0.0"
},
"runtime": {
"Server.dll": {}
}
},
"Microsoft.Data.Sqlite/9.0.0": {
"dependencies": {
"Microsoft.Data.Sqlite.Core": "9.0.0",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.10",
"SQLitePCLRaw.core": "2.1.10"
}
},
"Microsoft.Data.Sqlite.Core/9.0.0": {
"dependencies": {
"SQLitePCLRaw.core": "2.1.10"
},
"runtime": {
"lib/net8.0/Microsoft.Data.Sqlite.dll": {
"assemblyVersion": "9.0.0.0",
"fileVersion": "9.0.24.52902"
}
}
},
"SQLitePCLRaw.bundle_e_sqlite3/2.1.10": {
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.10",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.10"
},
"runtime": {
"lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll": {
"assemblyVersion": "2.1.10.2445",
"fileVersion": "2.1.10.2445"
}
}
},
"SQLitePCLRaw.core/2.1.10": {
"runtime": {
"lib/netstandard2.0/SQLitePCLRaw.core.dll": {
"assemblyVersion": "2.1.10.2445",
"fileVersion": "2.1.10.2445"
}
}
},
"SQLitePCLRaw.lib.e_sqlite3/2.1.10": {
"runtimeTargets": {
"runtimes/browser-wasm/nativeassets/net9.0/e_sqlite3.a": {
"rid": "browser-wasm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-arm/native/libe_sqlite3.so": {
"rid": "linux-arm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-arm64/native/libe_sqlite3.so": {
"rid": "linux-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-armel/native/libe_sqlite3.so": {
"rid": "linux-armel",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-mips64/native/libe_sqlite3.so": {
"rid": "linux-mips64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-arm/native/libe_sqlite3.so": {
"rid": "linux-musl-arm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-arm64/native/libe_sqlite3.so": {
"rid": "linux-musl-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-s390x/native/libe_sqlite3.so": {
"rid": "linux-musl-s390x",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-musl-x64/native/libe_sqlite3.so": {
"rid": "linux-musl-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-ppc64le/native/libe_sqlite3.so": {
"rid": "linux-ppc64le",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-s390x/native/libe_sqlite3.so": {
"rid": "linux-s390x",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-x64/native/libe_sqlite3.so": {
"rid": "linux-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/linux-x86/native/libe_sqlite3.so": {
"rid": "linux-x86",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/maccatalyst-arm64/native/libe_sqlite3.dylib": {
"rid": "maccatalyst-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/maccatalyst-x64/native/libe_sqlite3.dylib": {
"rid": "maccatalyst-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/osx-arm64/native/libe_sqlite3.dylib": {
"rid": "osx-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/osx-x64/native/libe_sqlite3.dylib": {
"rid": "osx-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-arm/native/e_sqlite3.dll": {
"rid": "win-arm",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-arm64/native/e_sqlite3.dll": {
"rid": "win-arm64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-x64/native/e_sqlite3.dll": {
"rid": "win-x64",
"assetType": "native",
"fileVersion": "0.0.0.0"
},
"runtimes/win-x86/native/e_sqlite3.dll": {
"rid": "win-x86",
"assetType": "native",
"fileVersion": "0.0.0.0"
}
}
},
"SQLitePCLRaw.provider.e_sqlite3/2.1.10": {
"dependencies": {
"SQLitePCLRaw.core": "2.1.10"
},
"runtime": {
"lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll": {
"assemblyVersion": "2.1.10.2445",
"fileVersion": "2.1.10.2445"
}
}
}
}
},
"libraries": {
"Server/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Data.Sqlite/9.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-lw6wthgXGx3r/U775k1UkUAWIn0kAT0wj4ZRq0WlhPx4WAOiBsIjgDKgWkXcNTGT0KfHiClkM+tyPVFDvxeObw==",
"path": "microsoft.data.sqlite/9.0.0",
"hashPath": "microsoft.data.sqlite.9.0.0.nupkg.sha512"
},
"Microsoft.Data.Sqlite.Core/9.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-cFfZjFL+tqzGYw9lB31EkV1IWF5xRQNk2k+MQd+Cf86Gl6zTeAoiZIFw5sRB1Z8OxpEC7nu+nTDsLSjieBAPTw==",
"path": "microsoft.data.sqlite.core/9.0.0",
"hashPath": "microsoft.data.sqlite.core.9.0.0.nupkg.sha512"
},
"SQLitePCLRaw.bundle_e_sqlite3/2.1.10": {
"type": "package",
"serviceable": true,
"sha512": "sha512-UxWuisvZ3uVcVOLJQv7urM/JiQH+v3TmaJc1BLKl5Dxfm/nTzTUrqswCqg/INiYLi61AXnHo1M1JPmPqqLnAdg==",
"path": "sqlitepclraw.bundle_e_sqlite3/2.1.10",
"hashPath": "sqlitepclraw.bundle_e_sqlite3.2.1.10.nupkg.sha512"
},
"SQLitePCLRaw.core/2.1.10": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Ii8JCbC7oiVclaE/mbDEK000EFIJ+ShRPwAvvV89GOZhQ+ZLtlnSWl6ksCNMKu/VGXA4Nfi2B7LhN/QFN9oBcw==",
"path": "sqlitepclraw.core/2.1.10",
"hashPath": "sqlitepclraw.core.2.1.10.nupkg.sha512"
},
"SQLitePCLRaw.lib.e_sqlite3/2.1.10": {
"type": "package",
"serviceable": true,
"sha512": "sha512-mAr69tDbnf3QJpRy2nJz8Qdpebdil00fvycyByR58Cn9eARvR+UiG2Vzsp+4q1tV3ikwiYIjlXCQFc12GfebbA==",
"path": "sqlitepclraw.lib.e_sqlite3/2.1.10",
"hashPath": "sqlitepclraw.lib.e_sqlite3.2.1.10.nupkg.sha512"
},
"SQLitePCLRaw.provider.e_sqlite3/2.1.10": {
"type": "package",
"serviceable": true,
"sha512": "sha512-uZVTi02C1SxqzgT0HqTWatIbWGb40iIkfc3FpFCpE/r7g6K0PqzDUeefL6P6HPhDtc6BacN3yQysfzP7ks+wSQ==",
"path": "sqlitepclraw.provider.e_sqlite3/2.1.10",
"hashPath": "sqlitepclraw.provider.e_sqlite3.2.1.10.nupkg.sha512"
}
}
}

BIN
bin/Debug/net9.0/Server.dll Normal file

Binary file not shown.

BIN
bin/Debug/net9.0/Server.exe Normal file

Binary file not shown.

BIN
bin/Debug/net9.0/Server.pdb Normal file

Binary file not shown.

View File

@@ -0,0 +1,19 @@
{
"runtimeOptions": {
"tfm": "net9.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "9.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "9.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

View File

@@ -0,0 +1 @@
{"Version":1,"ManifestType":"Build","Endpoints":[]}

View File

@@ -0,0 +1,45 @@
{
"tcpPort": 7777,
"httpPort": 8088,
"maxPacketSizeBytes": 1048576,
"tickMs": 200,
"positionBroadcastRateMs": 1000,
"maxSpeedMps": 12.0,
"movementValidationWindowSec": 5.0,
"teleportThresholdMeters": 50.0,
"cheatScoreWarnThreshold": 10,
"cheatScoreRestrictThreshold": 25,
"cheatScoreKickThreshold": 50,
"killDistanceM": 10.0,
"killCooldownMs": 5000,
"meetingArrivalRadiusM": 15.0,
"arrivalBaseMs": 20000,
"arrivalSafetyMarginMs": 3000,
"allowedLateMs": 5000,
"discussionPhaseMs": 15000,
"votingPhaseMs": 30000,
"emergencyMeetingCooldownMs": 30000,
"maxEmergencyMeetingsPerPlayer": 2,
"emergencyMeetingCallRadiusM": 15.0,
"reportDistanceM": 10.0,
"taskStartDistanceM": 5.0,
"taskLeaveDebounceMs": 2000,
"taskProgressKeepaliveMs": 5000,
"snapshotEvents": 200,
"snapshotIntervalMs": 300000,
"walMaxSizeMb": 10,
"dataPath": "data",
"hostTimeoutMs": 15000,
"reconnectWindowMs": 60000,
"idleLobbyTtlMs": 3600000,
"joinCodeTtlMs": 86400000,
"maxPlayersPerLobby": 15,
"joinRateLimitPerMinute": 10,
"sessionKeySizeBytes": 32,
"rsaKeySizeBits": 2048,
"statsApiRateLimit": 100,
"statsApiKey": null,
"defaultImpostorCount": 1,
"defaultTaskCount": 5,
"defaultTiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "0ef46de0570a4be6",
"lastEventId": 12,
"timestamp": "2026-01-27T13:41:27.5170822Z",
"checksum": "eaeb904e566968a23b9fad8defde721f0a958b5173f671f96b67463cd102e5b8",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0868023,
"lon": 14.4203088
},
"type": "Progress",
"durationMs": 7851,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.0855253,
"lon": 14.4216649
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.087640820000004,
"lon": 14.42321536
},
"type": "Progress",
"durationMs": 9934,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.0862119,
"lon": 14.4239779
},
"type": "Progress",
"durationMs": 7287,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.08971575714286,
"lon": 14.420300657142858
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "27685b18526d4ce5",
"lastEventId": 7,
"timestamp": "2026-01-27T13:36:27.5323111Z",
"checksum": "cc270b3bd115ec20db477f4b58e987b0b4a0bede6b35d5fb8786f1a3ad5a89bf",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "34d8737b80434782",
"lastEventId": 12,
"timestamp": "2026-01-27T13:36:27.5253055Z",
"checksum": "ec7a8cc5ca949945cac59b72af28e810b5db1ed59683d8840aeb5293c21154e6",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0894098,
"lon": 14.4226833
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.08879728000001,
"lon": 14.41972866
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0879498,
"lon": 14.4233898
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.0865062,
"lon": 14.4206464
},
"type": "Progress",
"durationMs": 5593,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.0883246,
"lon": 14.4238715
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,52 @@
{
"lobbyId": "5d096533c3094eaa",
"lastEventId": 51,
"timestamp": "2026-01-27T13:31:27.5287743Z",
"checksum": "c3eb831a207d599f3fbf61d89359c0c36b3d787fdb339fb36766c4cbf1f52462",
"phase": "Ended",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 49.9999798,
"lon": 14.0026563
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 49.9996681,
"lon": 14.0003986
},
"type": "Progress",
"durationMs": 8093,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0031177,
"lon": 13.998514216666669
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50,
"lon": 14
},
"playAreaRadius": 500,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "6772d80dd8a44352",
"lastEventId": 12,
"timestamp": "2026-01-27T11:37:11.3093918Z",
"checksum": "2c421294fe51e01d08fe09e59f84c8a0c1e73120cb2764eb503b6c24cfb94d8e",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50,
"lon": 14
},
"playAreaRadius": 500,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,45 @@
{
"lobbyId": "6af233ecf68b4434",
"lastEventId": 4,
"timestamp": "2026-01-27T13:41:27.5355764Z",
"checksum": "a14cbc3f62b3a9d629f3852bc29e2d6e5460d7c7c5db39eae124537a56bc9fd5",
"phase": "Lobby",
"players": [
{
"clientUuid": "4cda16e3",
"displayName": "SabPlayer",
"position": {
"lat": 50.0875,
"lon": 14.4213
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T12:38:01.2482361Z",
"lastPositionUpdate": "2026-01-27T12:38:01.2482361Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": []
}
],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "6dba8c0edbe7490d",
"lastEventId": 12,
"timestamp": "2026-01-27T13:41:27.5240463Z",
"checksum": "e006a9e9b5a9473600b72662bb8ee30ed1d8f5896538ca22976917c77ecd88e2",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0877076,
"lon": 14.4241423
},
"type": "Progress",
"durationMs": 6709,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.089346660000004,
"lon": 14.42227354
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.087235,
"lon": 14.4177676
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.0872447,
"lon": 14.4252718
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.08679,
"lon": 14.4209933
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "74771a6153544040",
"lastEventId": 7,
"timestamp": "2026-01-27T13:41:27.5390519Z",
"checksum": "c4133709107e0aa5b7b36b09e2275c68306c8b0ba9f149dd2a7acc8af4841d72",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "7da1c15116fc4fbc",
"lastEventId": 7,
"timestamp": "2026-01-27T13:36:27.528593Z",
"checksum": "4536b2249e71f0a79258b5a3861a0e4868ad592170b159404031f315105966a5",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,52 @@
{
"lobbyId": "7f234630f0d3470c",
"lastEventId": 51,
"timestamp": "2026-01-27T13:41:27.5227014Z",
"checksum": "f52e66c8c122229b9ed6b67b4a965540971b421294a9adf54f2295ea6206da80",
"phase": "Ended",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0004706,
"lon": 14.0066172
},
"type": "Progress",
"durationMs": 5753,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.0030234,
"lon": 13.9993778
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0030134625,
"lon": 13.999101749999998
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50,
"lon": 14
},
"playAreaRadius": 500,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "8da0b3e582444daf",
"lastEventId": 7,
"timestamp": "2026-01-27T13:41:27.5200488Z",
"checksum": "926ef187cbeca5bbb412bbd70e86494ae9ae0bad25bb322cf2e8798fb46156ee",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "9f07a2d91db949ad",
"lastEventId": 7,
"timestamp": "2026-01-27T13:31:27.538973Z",
"checksum": "d2834a20c3fbf35631ac19aa4cc881dc4ea22da294f378c9ea175f79def2e5b1",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "a16bac8d99e74830",
"lastEventId": 12,
"timestamp": "2026-01-27T13:41:27.5257123Z",
"checksum": "ce1d6aec022b9dfcd40896dc2d137c997eb55b180fcb565d259dd9d3fba5efe2",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0858413,
"lon": 14.4205435
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.08849002857143,
"lon": 14.421553200000002
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0875007,
"lon": 14.4179042
},
"type": "Progress",
"durationMs": 5708,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.08828,
"lon": 14.4177757
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.0863048,
"lon": 14.4194325
},
"type": "Progress",
"durationMs": 9104,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "b9cffa33b9264559",
"lastEventId": 11,
"timestamp": "2026-01-27T13:36:27.5272024Z",
"checksum": "eb35b9829659a173f4ff3ff4bffae656b8f7fd54d7d0b77addc9e65dc7e95d47",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0880837,
"lon": 14.4241303
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.0868455,
"lon": 14.4251958
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0881001,
"lon": 14.4251481
},
"type": "Progress",
"durationMs": 5959,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.0868548,
"lon": 14.4252407
},
"type": "Progress",
"durationMs": 8586,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.0889995,
"lon": 14.4222151
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "cf3be038c5a3472e",
"lastEventId": 7,
"timestamp": "2026-01-27T13:41:27.521301Z",
"checksum": "b8c94dca9682f3f2308d847b765fd5d473149dcb87f84a9c6dff93f403e2d622",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,18 @@
{
"lobbyId": "d3e8a657109144ce",
"lastEventId": 7,
"timestamp": "2026-01-27T13:36:27.5310617Z",
"checksum": "0b23ea5bf52ae1200a729d7db310feb1c8a9307f014bbc58d2e532468d112a91",
"phase": "Lobby",
"players": [],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "dc41c9d596db4946",
"lastEventId": 12,
"timestamp": "2026-01-27T13:41:27.518834Z",
"checksum": "6cf9b25105fdf12ec96a8dedb18dd85aa5fef345c2c08a50045e7a53df078ffc",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.085561139999996,
"lon": 14.42309244
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.085837,
"lon": 14.4188593
},
"type": "Progress",
"durationMs": 7152,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0859669,
"lon": 14.4188964
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.0860902,
"lon": 14.4235956
},
"type": "Progress",
"durationMs": 5893,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.09006194,
"lon": 14.421070419999998
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "df5878e2914a4104",
"lastEventId": 12,
"timestamp": "2026-01-27T13:36:27.5297309Z",
"checksum": "33e3631ebc9785562d42cb7d2a9ac0778daa8fbccf56496c6938f1da4305fe0b",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0870176,
"lon": 14.4213745
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.08900948,
"lon": 14.418448039999998
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.0856234,
"lon": 14.4229967
},
"type": "Progress",
"durationMs": 5157,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.0876224,
"lon": 14.4200385
},
"type": "Progress",
"durationMs": 6963,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.0873335,
"lon": 14.4229653
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,74 @@
{
"lobbyId": "e52ea7e70f9c4475",
"lastEventId": 12,
"timestamp": "2026-01-27T13:31:27.5486405Z",
"checksum": "b974ca483616d916c79e6661a25e155a34d9905bf1e8bf044f958e3332ddecbe",
"phase": "Playing",
"players": [],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.0883326,
"lon": 14.4243654
},
"type": "Progress",
"durationMs": 9830,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.0888976,
"lon": 14.4235413
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.087982700000005,
"lon": 14.424510520000002
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.08682402000001,
"lon": 14.41780478
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.0879393,
"lon": 14.4195066
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.0875,
"lon": 14.4213
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,254 @@
{
"lobbyId": "2648919f382c41c5",
"lastEventId": 59,
"timestamp": "2026-01-27T18:59:03.2825318Z",
"checksum": "1a2ad5f4cbb6cce28242186be6f40b40eec4324c03c7f86a5240dd90a9dd2177",
"phase": "Lobby",
"players": [
{
"clientUuid": "8994ac32",
"displayName": "Hr\u00E1\u010D415",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:53:06.3002728Z",
"lastPositionUpdate": "2026-01-27T18:57:32.9413685Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": "2026-01-27T18:55:14.0260895Z",
"emergencyMeetingsUsed": 1,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "0edf1729",
"displayName": "Hr\u00E1\u010D807",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:53:14.049105Z",
"lastPositionUpdate": "2026-01-27T18:56:19.557754Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "71890d7c",
"displayName": "Hr\u00E1\u010D727",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:53:16.7573899Z",
"lastPositionUpdate": "2026-01-27T18:57:32.6978551Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "c9700c3e",
"displayName": "Hr\u00E1\u010D811",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:53:18.3315357Z",
"lastPositionUpdate": "2026-01-27T18:57:32.6887401Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "8d4ca20b",
"displayName": "Hr\u00E1\u010D45",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:53:20.2071786Z",
"lastPositionUpdate": "2026-01-27T18:57:32.6947537Z",
"lastKillTime": "2026-01-27T18:56:19.060694Z",
"lastEmergencyMeetingTime": "2026-01-27T18:53:50.6568196Z",
"emergencyMeetingsUsed": 1,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 104,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,87 @@
{
"lobbyId": "2c50d55476084b56",
"lastEventId": 12,
"timestamp": "2026-01-27T16:28:29.5775449Z",
"checksum": "f350e9f787de7222da92560a4faf764440917922ee2f6bc477a61b0b2597c047",
"phase": "Playing",
"players": [],
"bodies": [
{
"bodyId": "93c535cb",
"victimId": "3a7b7d51",
"killerId": "4776b4fd",
"location": {
"lat": 50.773528230993556,
"lon": 15.072186665335956
},
"killedAt": "2026-01-27T16:07:05.4091568Z",
"reported": false,
"reportedBy": null
}
],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Progress",
"durationMs": 8268,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7748682,
"lon": 15.0725437
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Progress",
"durationMs": 6077,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7746238,
"lon": 15.0716847
},
"type": "Progress",
"durationMs": 5791,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 175,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,545 @@
{
"lobbyId": "2e22bb042f2f4bbe",
"lastEventId": 22,
"timestamp": "2026-01-27T15:51:57.8920069Z",
"checksum": "9d1b249ae84e5d6285b305b86c43d02c035c58d7639d26b444131c7d6e548953",
"phase": "Playing",
"players": [
{
"clientUuid": "f950d5da",
"displayName": "Hr\u00E1\u010D807",
"position": {
"lat": 50.77313331657089,
"lon": 15.072188416646162
},
"role": "Crew",
"state": "Dead",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:41:57.4124996Z",
"lastPositionUpdate": "2026-01-27T15:43:03.3779377Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 7149,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7727517,
"lon": 15.0713078
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7743857,
"lon": 15.0723828
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738718,
"lon": 15.0726246
},
"type": "Progress",
"durationMs": 6496,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7730823,
"lon": 15.071947
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "c5654f74",
"displayName": "Hr\u00E1\u010D437",
"position": {
"lat": 50.77278259768372,
"lon": 15.07222070338361
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:42:07.6931815Z",
"lastPositionUpdate": "2026-01-27T15:51:57.5706389Z",
"lastKillTime": "2026-01-27T15:45:05.8370242Z",
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "03a78181",
"displayName": "Hr\u00E1\u010D551",
"position": {
"lat": 50.77398539640443,
"lon": 15.072124569466894
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:42:10.6142359Z",
"lastPositionUpdate": "2026-01-27T15:51:57.7340338Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": "2026-01-27T15:44:49.798083Z",
"emergencyMeetingsUsed": 1,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7731933,
"lon": 15.0726196
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.772732,
"lon": 15.0728076
},
"type": "Progress",
"durationMs": 9946,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7743766,
"lon": 15.0722922
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.7738298,
"lon": 15.0716222
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "ecd89d1a",
"displayName": "Hr\u00E1\u010D18",
"position": {
"lat": 50.77368486134701,
"lon": 15.071483856973888
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:42:12.192649Z",
"lastPositionUpdate": "2026-01-27T15:51:57.8914614Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7730142,
"lon": 15.0715442
},
"type": "Progress",
"durationMs": 8933,
"steps": 1
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738298,
"lon": 15.0716222
},
"type": "Progress",
"durationMs": 8475,
"steps": 1
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.772732,
"lon": 15.0728076
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7732768,
"lon": 15.0725856
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 7149,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7727517,
"lon": 15.0713078
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7743857,
"lon": 15.0723828
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738718,
"lon": 15.0726246
},
"type": "Progress",
"durationMs": 6496,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7730823,
"lon": 15.071947
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7731933,
"lon": 15.0726196
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.772732,
"lon": 15.0728076
},
"type": "Progress",
"durationMs": 9946,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7743766,
"lon": 15.0722922
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.7738298,
"lon": 15.0716222
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7730142,
"lon": 15.0715442
},
"type": "Progress",
"durationMs": 8933,
"steps": 1
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738298,
"lon": 15.0716222
},
"type": "Progress",
"durationMs": 8475,
"steps": 1
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.772732,
"lon": 15.0728076
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7732768,
"lon": 15.0725856
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 112,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,163 @@
{
"lobbyId": "525fa8b4d76a42ed",
"lastEventId": 18,
"timestamp": "2026-01-27T16:41:21.5644571Z",
"checksum": "f00d06dc1b92bc8cfdebcf3fc4ea33925e0fb24ca9ffc130f838f042c7d0fec7",
"phase": "Lobby",
"players": [
{
"clientUuid": "a4b084ae",
"displayName": "Hr\u00E1\u010D49",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T16:35:21.4117891Z",
"lastPositionUpdate": "2026-01-27T16:40:04.8976091Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "e7432841",
"displayName": "Hr\u00E1\u010D952",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T16:35:34.1694493Z",
"lastPositionUpdate": "2026-01-27T16:40:04.8708739Z",
"lastKillTime": "2026-01-27T16:40:04.944272Z",
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "a529c9bf",
"displayName": "Hr\u00E1\u010D887",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T16:35:34.6701344Z",
"lastPositionUpdate": "2026-01-27T16:40:04.8646587Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": "2026-01-27T16:38:42.7706296Z",
"emergencyMeetingsUsed": 1,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 100,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,71 @@
{
"lobbyId": "578a2e6234924b7e",
"lastEventId": 8,
"timestamp": "2026-01-27T13:58:30.9653651Z",
"checksum": "903a6c6bed851b55cb00fdf3578463e6541556145374f6211fd60adbbdbfad13",
"phase": "Lobby",
"players": [
{
"clientUuid": "39cc9c4a",
"displayName": "Hr\u00E1\u010D880",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T13:53:36.8705417Z",
"lastPositionUpdate": "2026-01-27T13:53:36.8705418Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": []
},
{
"clientUuid": "e76cb534",
"displayName": "Hr\u00E1\u010D839",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T13:53:38.3550591Z",
"lastPositionUpdate": "2026-01-27T13:53:38.3550591Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": []
}
],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,207 @@
{
"lobbyId": "5e6dc2f6267b4a23",
"lastEventId": 49,
"timestamp": "2026-01-27T17:23:51.9496141Z",
"checksum": "a076881d1a0cea6a2b39fdfeb1fb2041927e6b0a4351c1871422900729eac9f6",
"phase": "Lobby",
"players": [
{
"clientUuid": "ac74a0a6",
"displayName": "Hr\u00E1\u010D229",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:18:51.5675805Z",
"lastPositionUpdate": "2026-01-27T17:23:44.7477804Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "beb07c52",
"displayName": "Hr\u00E1\u010D623",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:19:28.7165473Z",
"lastPositionUpdate": "2026-01-27T17:23:49.0892842Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "8505d1ed",
"displayName": "Hr\u00E1\u010D976",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:19:32.3214433Z",
"lastPositionUpdate": "2026-01-27T17:23:49.0984457Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "55d92428",
"displayName": "Hr\u00E1\u010D172",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:19:35.2884502Z",
"lastPositionUpdate": "2026-01-27T17:23:49.2130304Z",
"lastKillTime": "2026-01-27T17:23:49.4098068Z",
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 116,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,518 @@
{
"lobbyId": "5e6dc2f6267b4a23",
"lastEventId": 97,
"timestamp": "2026-01-27T17:28:52.5626512Z",
"checksum": "f67d29a0cbd24da5e340bdb8d725233366ff81b97e13488d09de13477f5291ce",
"phase": "Meeting",
"players": [
{
"clientUuid": "ac74a0a6",
"displayName": "Hr\u00E1\u010D229",
"position": {
"lat": 50.77361640940255,
"lon": 15.072599356266377
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:18:51.5675805Z",
"lastPositionUpdate": "2026-01-27T17:28:17.854293Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7735493,
"lon": 15.0730608
},
"type": "Instant"
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7738298,
"lon": 15.0716222
},
"type": "Instant"
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738661,
"lon": 15.0722328
},
"type": "Instant"
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"type": "Instant"
}
],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "beb07c52",
"displayName": "Hr\u00E1\u010D623",
"position": {
"lat": 50.77365433872294,
"lon": 15.07261560623589
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:19:28.7165473Z",
"lastPositionUpdate": "2026-01-27T17:28:17.8661244Z",
"lastKillTime": "2026-01-27T17:27:45.615597Z",
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "8505d1ed",
"displayName": "Hr\u00E1\u010D976",
"position": {
"lat": 50.77364810754672,
"lon": 15.072627464770637
},
"role": "Crew",
"state": "Dead",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:19:32.3214433Z",
"lastPositionUpdate": "2026-01-27T17:27:45.7426596Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7739109,
"lon": 15.0728539
},
"type": "Instant"
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7734516,
"lon": 15.0723155
},
"type": "Instant"
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7729336,
"lon": 15.0713619
},
"type": "Instant"
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.773807,
"lon": 15.071876
},
"type": "Instant"
}
],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "55d92428",
"displayName": "Hr\u00E1\u010D172",
"position": {
"lat": 50.77365944629486,
"lon": 15.07263119340305
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:19:35.2884502Z",
"lastPositionUpdate": "2026-01-27T17:28:17.7087384Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7732768,
"lon": 15.0725856
},
"type": "Instant"
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.772732,
"lon": 15.0728076
},
"type": "Instant"
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"type": "Instant"
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7728733,
"lon": 15.0713562
},
"type": "Instant"
}
],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [
{
"bodyId": "a0d004c6",
"victimId": "8505d1ed",
"killerId": "beb07c52",
"location": {
"lat": 50.77364810754672,
"lon": 15.072627464770637
},
"killedAt": "2026-01-27T17:27:45.6156674Z",
"reported": true,
"reportedBy": "beb07c52"
}
],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7735493,
"lon": 15.0730608
},
"type": "Instant"
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7738298,
"lon": 15.0716222
},
"type": "Instant"
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7738661,
"lon": 15.0722328
},
"type": "Instant"
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"type": "Instant"
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7739109,
"lon": 15.0728539
},
"type": "Instant"
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7734516,
"lon": 15.0723155
},
"type": "Instant"
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7729336,
"lon": 15.0713619
},
"type": "Instant"
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.773807,
"lon": 15.071876
},
"type": "Instant"
},
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7732768,
"lon": 15.0725856
},
"type": "Instant"
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.772732,
"lon": 15.0728076
},
"type": "Instant"
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"type": "Instant"
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7728733,
"lon": 15.0713562
},
"type": "Instant"
}
],
"currentMeeting": {
"meetingId": "0b48d4b8",
"type": "BodyReport",
"reportedBodyId": "a0d004c6",
"callerId": "beb07c52",
"meetingLocation": {
"lat": 50.77364810754672,
"lon": 15.072627464770637
},
"startTime": "2026-01-27T17:27:54.9995201Z",
"arrivalDeadline": "2026-01-27T17:28:17.9995201Z",
"discussionEndTime": "2026-01-27T17:28:29.9995201Z",
"votingEndTime": "2026-01-27T17:28:59.9995201Z",
"arrivedPlayers": [
"beb07c52",
"55d92428",
"ac74a0a6"
],
"votes": {
"55d92428": "beb07c52",
"ac74a0a6": "beb07c52",
"beb07c52": "55d92428"
},
"lastVoteChangeTime": "2026-01-27T17:28:52.5435833Z"
},
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 116,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,843 @@
{
"lobbyId": "6dc0701887224f6c",
"lastEventId": 29,
"timestamp": "2026-01-27T17:08:04.9544682Z",
"checksum": "35e22951c680f0a9dc2e4dcf31e203e75e9bc3601ff3d2654d925857ca3d9e11",
"phase": "Playing",
"players": [
{
"clientUuid": "0cabd196",
"displayName": "Hr\u00E1\u010D308",
"position": {
"lat": 50.77360619739959,
"lon": 15.072054196830372
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:03:04.9405525Z",
"lastPositionUpdate": "2026-01-27T17:08:04.8139149Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"currentWaypointIndex": 1,
"taskStartTime": "2026-01-27T17:06:22.6362862Z",
"lastTaskProgressTime": "2026-01-27T17:06:22.636287Z",
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "70d9764e",
"displayName": "Hr\u00E1\u010D467",
"position": {
"lat": 50.773608029754094,
"lon": 15.072135796821454
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:03:12.3441902Z",
"lastPositionUpdate": "2026-01-27T17:08:04.9533762Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"waypoints": [
{
"waypointId": "task_0_wp0",
"name": "Opravit kabel - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"order": 0
},
{
"waypointId": "task_0_wp1",
"name": "Opravit kabel - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7738727,
"lon": 15.0724211
},
"order": 1
},
{
"waypointId": "task_0_wp2",
"name": "Opravit kabel - Potvrdit",
"location": {
"lat": 50.7735894,
"lon": 15.0717216
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"waypoints": [
{
"waypointId": "task_1_wp0",
"name": "Zkalibrovat senzor - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"order": 0
},
{
"waypointId": "task_1_wp1",
"name": "Zkalibrovat senzor - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7729336,
"lon": 15.0713619
},
"order": 1
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7735493,
"lon": 15.0730608
},
"waypoints": [
{
"waypointId": "task_2_wp0",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7735493,
"lon": 15.0730608
},
"order": 0
}
],
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7736218,
"lon": 15.0734419
},
"waypoints": [
{
"waypointId": "task_3_wp0",
"name": "Nab\u00EDt baterii - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7736218,
"lon": 15.0734419
},
"order": 0
},
{
"waypointId": "task_3_wp1",
"name": "Nab\u00EDt baterii - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7730823,
"lon": 15.071947
},
"order": 1
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"waypoints": [
{
"waypointId": "task_4_wp0",
"name": "Vy\u010Distit filtr - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"order": 0
},
{
"waypointId": "task_4_wp1",
"name": "Vy\u010Distit filtr - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7735484,
"lon": 15.0720508
},
"order": 1
},
{
"waypointId": "task_4_wp2",
"name": "Vy\u010Distit filtr - Potvrdit",
"location": {
"lat": 50.7741669,
"lon": 15.0718846
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
}
],
"currentTaskId": null,
"currentWaypointIndex": 0,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "47c5062e",
"displayName": "Hr\u00E1\u010D283",
"position": {
"lat": 50.77362533873519,
"lon": 15.072083787211199
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T17:03:20.2284988Z",
"lastPositionUpdate": "2026-01-27T17:08:04.7240844Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"waypoints": [
{
"waypointId": "task_5_wp0",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"order": 0
}
],
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7738661,
"lon": 15.0722328
},
"waypoints": [
{
"waypointId": "task_6_wp0",
"name": "Opravit antenu - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7738661,
"lon": 15.0722328
},
"order": 0
},
{
"waypointId": "task_6_wp1",
"name": "Opravit antenu - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7730823,
"lon": 15.071947
},
"order": 1
},
{
"waypointId": "task_6_wp2",
"name": "Opravit antenu - Potvrdit",
"location": {
"lat": 50.773902,
"lon": 15.0727669
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7739852,
"lon": 15.072001
},
"waypoints": [
{
"waypointId": "task_7_wp0",
"name": "Zkontrolovat z\u00E1soby - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7739852,
"lon": 15.072001
},
"order": 0
},
{
"waypointId": "task_7_wp1",
"name": "Zkontrolovat z\u00E1soby - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7735939,
"lon": 15.0732437
},
"order": 1
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7735004,
"lon": 15.0728728
},
"waypoints": [
{
"waypointId": "task_8_wp0",
"name": "Otestovat reaktor - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7735004,
"lon": 15.0728728
},
"order": 0
},
{
"waypointId": "task_8_wp1",
"name": "Otestovat reaktor - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7729336,
"lon": 15.0713619
},
"order": 1
},
{
"waypointId": "task_8_wp2",
"name": "Otestovat reaktor - Potvrdit",
"location": {
"lat": 50.7735894,
"lon": 15.0717216
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.773807,
"lon": 15.071876
},
"waypoints": [
{
"waypointId": "task_9_wp0",
"name": "Opravit kabel - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.773807,
"lon": 15.071876
},
"order": 0
},
{
"waypointId": "task_9_wp1",
"name": "Opravit kabel - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"order": 1
},
{
"waypointId": "task_9_wp2",
"name": "Opravit kabel - Potvrdit",
"location": {
"lat": 50.7734588,
"lon": 15.0719916
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
}
],
"currentTaskId": null,
"currentWaypointIndex": 1,
"taskStartTime": null,
"lastTaskProgressTime": "2026-01-27T17:06:50.8371719Z",
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"waypoints": [
{
"waypointId": "task_0_wp0",
"name": "Opravit kabel - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"order": 0
},
{
"waypointId": "task_0_wp1",
"name": "Opravit kabel - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7738727,
"lon": 15.0724211
},
"order": 1
},
{
"waypointId": "task_0_wp2",
"name": "Opravit kabel - Potvrdit",
"location": {
"lat": 50.7735894,
"lon": 15.0717216
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"waypoints": [
{
"waypointId": "task_1_wp0",
"name": "Zkalibrovat senzor - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"order": 0
},
{
"waypointId": "task_1_wp1",
"name": "Zkalibrovat senzor - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7729336,
"lon": 15.0713619
},
"order": 1
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7735493,
"lon": 15.0730608
},
"waypoints": [
{
"waypointId": "task_2_wp0",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7735493,
"lon": 15.0730608
},
"order": 0
}
],
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7736218,
"lon": 15.0734419
},
"waypoints": [
{
"waypointId": "task_3_wp0",
"name": "Nab\u00EDt baterii - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7736218,
"lon": 15.0734419
},
"order": 0
},
{
"waypointId": "task_3_wp1",
"name": "Nab\u00EDt baterii - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7730823,
"lon": 15.071947
},
"order": 1
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"waypoints": [
{
"waypointId": "task_4_wp0",
"name": "Vy\u010Distit filtr - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"order": 0
},
{
"waypointId": "task_4_wp1",
"name": "Vy\u010Distit filtr - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7735484,
"lon": 15.0720508
},
"order": 1
},
{
"waypointId": "task_4_wp2",
"name": "Vy\u010Distit filtr - Potvrdit",
"location": {
"lat": 50.7741669,
"lon": 15.0718846
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"waypoints": [
{
"waypointId": "task_5_wp0",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"order": 0
}
],
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7738661,
"lon": 15.0722328
},
"waypoints": [
{
"waypointId": "task_6_wp0",
"name": "Opravit antenu - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7738661,
"lon": 15.0722328
},
"order": 0
},
{
"waypointId": "task_6_wp1",
"name": "Opravit antenu - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7730823,
"lon": 15.071947
},
"order": 1
},
{
"waypointId": "task_6_wp2",
"name": "Opravit antenu - Potvrdit",
"location": {
"lat": 50.773902,
"lon": 15.0727669
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7739852,
"lon": 15.072001
},
"waypoints": [
{
"waypointId": "task_7_wp0",
"name": "Zkontrolovat z\u00E1soby - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7739852,
"lon": 15.072001
},
"order": 0
},
{
"waypointId": "task_7_wp1",
"name": "Zkontrolovat z\u00E1soby - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7735939,
"lon": 15.0732437
},
"order": 1
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7735004,
"lon": 15.0728728
},
"waypoints": [
{
"waypointId": "task_8_wp0",
"name": "Otestovat reaktor - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.7735004,
"lon": 15.0728728
},
"order": 0
},
{
"waypointId": "task_8_wp1",
"name": "Otestovat reaktor - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7729336,
"lon": 15.0713619
},
"order": 1
},
{
"waypointId": "task_8_wp2",
"name": "Otestovat reaktor - Potvrdit",
"location": {
"lat": 50.7735894,
"lon": 15.0717216
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.773807,
"lon": 15.071876
},
"waypoints": [
{
"waypointId": "task_9_wp0",
"name": "Opravit kabel - P\u0159ij\u00EDt sem",
"location": {
"lat": 50.773807,
"lon": 15.071876
},
"order": 0
},
{
"waypointId": "task_9_wp1",
"name": "Opravit kabel - Aktivovat termin\u00E1l",
"location": {
"lat": 50.7733695,
"lon": 15.072243
},
"order": 1
},
{
"waypointId": "task_9_wp2",
"name": "Opravit kabel - Potvrdit",
"location": {
"lat": 50.7734588,
"lon": 15.0719916
},
"order": 2
}
],
"type": "MultiStep",
"durationMs": 0,
"steps": 3
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 100,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,263 @@
{
"lobbyId": "79b0a3695df748e9",
"lastEventId": 15,
"timestamp": "2026-01-27T14:09:32.6436672Z",
"checksum": "c5ed7252657435f2394fb45ea57a0a9a55580f67ce1fa47c8d3d57133b8b4ec3",
"phase": "Playing",
"players": [
{
"clientUuid": "0a2becd5",
"displayName": "Hr\u00E1\u010D663",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:04:32.409501Z",
"lastPositionUpdate": "2026-01-27T14:09:32.251747Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "7ddc8c89",
"displayName": "Hr\u00E1\u010D774",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:04:49.0930502Z",
"lastPositionUpdate": "2026-01-27T14:09:32.1675785Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "7f1e49f6",
"displayName": "Hr\u00E1\u010D409",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:04:50.9443623Z",
"lastPositionUpdate": "2026-01-27T14:09:32.6433769Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "1c0c235a",
"displayName": "Hr\u00E1\u010D643",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:04:52.5855743Z",
"lastPositionUpdate": "2026-01-27T14:09:32.3222919Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.773738,
"lon": 15.0696539
},
"type": "Progress",
"durationMs": 7376,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7742687,
"lon": 15.0684062
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7739087,
"lon": 15.0680313
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,390 @@
{
"lobbyId": "833e71c1c6c64572",
"lastEventId": 41,
"timestamp": "2026-01-27T19:49:33.8228142Z",
"checksum": "947203265976dbb0c3abb28163f1ad9f4a95832ae6af7af2db9b6cb1a7d35a1f",
"phase": "Playing",
"players": [
{
"clientUuid": "42abae11",
"displayName": "Hr\u00E1\u010D71",
"position": {
"lat": 50.77368411905394,
"lon": 15.072021509115855
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T19:44:33.6038667Z",
"lastPositionUpdate": "2026-01-27T19:49:33.5853267Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "e43245ed",
"displayName": "Hr\u00E1\u010D656",
"position": {
"lat": 50.773616604900646,
"lon": 15.07330046468691
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T19:46:15.7160727Z",
"lastPositionUpdate": "2026-01-27T19:49:33.5497451Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [
"task_9"
],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.7738714,
"lon": 15.0698715
},
"type": "Instant"
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.774702,
"lon": 15.0718182
},
"type": "Instant"
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant"
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Instant"
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
}
],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "ac9c6e85",
"displayName": "Hr\u00E1\u010D454",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T19:46:21.0904587Z",
"lastPositionUpdate": "2026-01-27T19:49:33.4002447Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.774702,
"lon": 15.0718182
},
"type": "Instant"
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant"
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "Instant"
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7739942,
"lon": 15.0706985
},
"type": "Instant"
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7740297,
"lon": 15.0744936
},
"type": "Instant"
}
],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant"
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7739942,
"lon": 15.0706985
},
"type": "Instant"
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "Instant"
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7746238,
"lon": 15.0716847
},
"type": "Instant"
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Instant"
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.7738714,
"lon": 15.0698715
},
"type": "Instant"
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.774702,
"lon": 15.0718182
},
"type": "Instant"
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant"
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Instant"
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant"
},
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.774702,
"lon": 15.0718182
},
"type": "Instant"
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant"
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "Instant"
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7739942,
"lon": 15.0706985
},
"type": "Instant"
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7740297,
"lon": 15.0744936
},
"type": "Instant"
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 207,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,442 @@
{
"lobbyId": "8d4e9da599f54808",
"lastEventId": 24,
"timestamp": "2026-01-27T15:18:37.7070312Z",
"checksum": "f58ec73bd590e27a43011ac40020f25a033ec754c77741ad3520b6a846a25d0f",
"phase": "Playing",
"players": [
{
"clientUuid": "3cf2ce1c",
"displayName": "Hr\u00E1\u010D136",
"position": {
"lat": 50.77379320364965,
"lon": 15.070223610918289
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:13:37.5682167Z",
"lastPositionUpdate": "2026-01-27T15:18:37.4568323Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Progress",
"durationMs": 9550,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 6835,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7748682,
"lon": 15.0725437
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "fc0eec4c",
"displayName": "Hr\u00E1\u010D652",
"position": {
"lat": 50.7745196638094,
"lon": 15.071680304081935
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:13:59.4594629Z",
"lastPositionUpdate": "2026-01-27T15:18:37.3824595Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "Progress",
"durationMs": 9304,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7741494,
"lon": 15.0703058
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Progress",
"durationMs": 9090,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "4485888a",
"displayName": "Hr\u00E1\u010D445",
"position": {
"lat": 50.77362353479194,
"lon": 15.0721653
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:14:09.4702052Z",
"lastPositionUpdate": "2026-01-27T15:18:37.7046693Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.774702,
"lon": 15.0718182
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Progress",
"durationMs": 7553,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Progress",
"durationMs": 9550,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 6835,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7738016,
"lon": 15.0702315
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7748682,
"lon": 15.0725437
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "Progress",
"durationMs": 9304,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7741494,
"lon": 15.0703058
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Progress",
"durationMs": 9090,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 155,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,442 @@
{
"lobbyId": "8d4e9da599f54808",
"lastEventId": 38,
"timestamp": "2026-01-27T15:33:38.0063974Z",
"checksum": "961ac66fd07f021b7e58dfbb84defa1a985c143994a1d4de652d5e2060c510e3",
"phase": "Playing",
"players": [
{
"clientUuid": "3cf2ce1c",
"displayName": "Hr\u00E1\u010D136",
"position": {
"lat": 50.773291841983664,
"lon": 15.07256331093216
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:13:37.5682167Z",
"lastPositionUpdate": "2026-01-27T15:33:37.716566Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7741494,
"lon": 15.0703058
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "fc0eec4c",
"displayName": "Hr\u00E1\u010D652",
"position": {
"lat": 50.77317868182666,
"lon": 15.072231622732726
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:13:59.4594629Z",
"lastPositionUpdate": "2026-01-27T15:33:38.0059926Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "Progress",
"durationMs": 9304,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7741494,
"lon": 15.0703058
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Progress",
"durationMs": 9090,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "Instant",
"durationMs": 0,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "4485888a",
"displayName": "Hr\u00E1\u010D445",
"position": {
"lat": 50.77364163912988,
"lon": 15.072117688520137
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:14:09.4702052Z",
"lastPositionUpdate": "2026-01-27T15:33:37.8006425Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7748514,
"lon": 15.0724958
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7746238,
"lon": 15.0716847
},
"type": "Progress",
"durationMs": 5107,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "Progress",
"durationMs": 9759,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "MultiStep",
"durationMs": 0,
"steps": 3
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7741494,
"lon": 15.0703058
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77303,
"lon": 15.07395
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.7748514,
"lon": 15.0724958
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7741443,
"lon": 15.0702437
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7746238,
"lon": 15.0716847
},
"type": "Progress",
"durationMs": 5107,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.774201,
"lon": 15.0712028
},
"type": "Progress",
"durationMs": 9759,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 155,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,276 @@
{
"lobbyId": "92a59e40e47f46a4",
"lastEventId": 24,
"timestamp": "2026-01-27T14:19:25.6511339Z",
"checksum": "34a5e98d571d2b1417dcd8170b95ea7a4e54c25cdcbb43b0c6442527a0619d58",
"phase": "Playing",
"players": [
{
"clientUuid": "d5b6a42a",
"displayName": "Hr\u00E1\u010D329",
"position": {
"lat": 50.77360212891568,
"lon": 15.072066545144711
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:14:25.4748393Z",
"lastPositionUpdate": "2026-01-27T14:19:25.4482291Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": "2026-01-27T14:17:23.8607905Z",
"emergencyMeetingsUsed": 2,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "cb379618",
"displayName": "Hr\u00E1\u010D723",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Dead",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:14:45.3520713Z",
"lastPositionUpdate": "2026-01-27T14:18:48.3375132Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "1545672a",
"displayName": "Hr\u00E1\u010D473",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:14:47.2696299Z",
"lastPositionUpdate": "2026-01-27T14:19:25.3302047Z",
"lastKillTime": "2026-01-27T14:18:47.9069569Z",
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "69e62b18",
"displayName": "Hr\u00E1\u010D276",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T14:14:49.3924189Z",
"lastPositionUpdate": "2026-01-27T14:19:25.6462515Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [
{
"bodyId": "096a1f19",
"victimId": "cb379618",
"killerId": "1545672a",
"location": {
"lat": 50.7735892,
"lon": 15.0721653
},
"killedAt": "2026-01-27T14:18:47.9069664Z",
"reported": false,
"reportedBy": null
}
],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.7744712,
"lon": 15.0716784
},
"type": "Progress",
"durationMs": 5468,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7741888,
"lon": 15.0705734
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7743404,
"lon": 15.0686409
},
"type": "Progress",
"durationMs": 6822,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7740297,
"lon": 15.0744936
},
"type": "Progress",
"durationMs": 8038,
"steps": 1
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7739087,
"lon": 15.0680313
},
"type": "Progress",
"durationMs": 8266,
"steps": 1
}
],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 300,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,212 @@
{
"lobbyId": "c20295d1ca0241b3",
"lastEventId": 39,
"timestamp": "2026-01-27T18:46:44.0153464Z",
"checksum": "c869a0099601f13a5f3e7ef86940bb965849277cd84a2f7f3e88ecbdcb03f320",
"phase": "Lobby",
"players": [
{
"clientUuid": "e8888b3e",
"displayName": "Hr\u00E1\u010D229",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:41:34.084531Z",
"lastPositionUpdate": "2026-01-27T18:45:44.9225573Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "7614bb77",
"displayName": "Hr\u00E1\u010D423",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:41:47.9066426Z",
"lastPositionUpdate": "2026-01-27T18:45:44.9999768Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "a482eaf8",
"displayName": "Hr\u00E1\u010D755",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:41:49.9957639Z",
"lastPositionUpdate": "2026-01-27T18:45:45.0762014Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "b0172c9f",
"displayName": "Hr\u00E1\u010D382",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:46:12.1783916Z",
"lastPositionUpdate": "2026-01-27T18:46:12.1783917Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": []
},
{
"clientUuid": "0d634cf6",
"displayName": "Hr\u00E1\u010D27",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T18:46:43.9875318Z",
"lastPositionUpdate": "2026-01-27T18:46:43.987532Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": []
}
],
"bodies": [],
"tasks": [],
"currentMeeting": null,
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 248,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

View File

@@ -0,0 +1,565 @@
{
"lobbyId": "ce407ba2a282475a",
"lastEventId": 23,
"timestamp": "2026-01-27T16:01:32.7927329Z",
"checksum": "094b8a24f68546bf3d88658d7bb01dc7621ae1087f5aa2b0f865e08008e2fdde",
"phase": "Meeting",
"players": [
{
"clientUuid": "481bcb45",
"displayName": "Hr\u00E1\u010D402",
"position": {
"lat": 50.77312730016415,
"lon": 15.072042156480837
},
"role": "Crew",
"state": "Dead",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:56:20.9156815Z",
"lastPositionUpdate": "2026-01-27T15:58:53.9908881Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 7125,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7738727,
"lon": 15.0724211
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"type": "Progress",
"durationMs": 5835,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7732806,
"lon": 15.0725075
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7731719,
"lon": 15.0719627
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": true,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "154a8e55",
"displayName": "Hr\u00E1\u010D949",
"position": {
"lat": 50.773134818907785,
"lon": 15.07198899309649
},
"role": "Impostor",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:56:32.367088Z",
"lastPositionUpdate": "2026-01-27T16:00:53.790974Z",
"lastKillTime": "2026-01-27T16:00:02.4620235Z",
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "b8c1c6dc",
"displayName": "Hr\u00E1\u010D590",
"position": {
"lat": 50.77358763220549,
"lon": 15.072158011186659
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:56:44.2222077Z",
"lastPositionUpdate": "2026-01-27T16:00:53.7269354Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": "2026-01-27T16:00:53.7404921Z",
"emergencyMeetingsUsed": 1,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 5791,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.773851,
"lon": 15.073075
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7731933,
"lon": 15.0726196
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.7737495,
"lon": 15.0715106
},
"type": "Progress",
"durationMs": 7350,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
},
{
"clientUuid": "104d74ed",
"displayName": "Hr\u00E1\u010D381",
"position": {
"lat": 50.7735892,
"lon": 15.0721653
},
"role": "Crew",
"state": "Alive",
"cheatStatus": "Ok",
"cheatScore": 0,
"connectedAt": "2026-01-27T15:56:56.1455962Z",
"lastPositionUpdate": "2026-01-27T16:00:53.8024205Z",
"lastKillTime": null,
"lastEmergencyMeetingTime": null,
"emergencyMeetingsUsed": 0,
"completedTaskIds": [],
"tasks": [
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 6764,
"steps": 1
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7734516,
"lon": 15.0723155
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7741669,
"lon": 15.0718846
},
"type": "Progress",
"durationMs": 7958,
"steps": 1
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.773646,
"lon": 15.0719148
},
"type": "Progress",
"durationMs": 8269,
"steps": 1
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"type": "Progress",
"durationMs": 7902,
"steps": 1
}
],
"currentTaskId": null,
"taskStartTime": null,
"lastTaskProgressTime": null,
"isOwner": false,
"isReady": false,
"lastEventId": 0,
"ping": 0,
"positionHistory": [
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{},
{}
]
}
],
"bodies": [],
"tasks": [
{
"taskId": "task_0",
"name": "Opravit kabel",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 7125,
"steps": 1
},
{
"taskId": "task_1",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.7738727,
"lon": 15.0724211
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_2",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"type": "Progress",
"durationMs": 5835,
"steps": 1
},
{
"taskId": "task_3",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7732806,
"lon": 15.0725075
},
"type": "MultiStep",
"durationMs": 0,
"steps": 2
},
{
"taskId": "task_4",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.7731719,
"lon": 15.0719627
},
"type": "MultiStep",
"durationMs": 0,
"steps": 4
},
{
"taskId": "task_5",
"name": "Nastavit kompas",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 5791,
"steps": 1
},
{
"taskId": "task_6",
"name": "Opravit antenu",
"location": {
"lat": 50.773851,
"lon": 15.073075
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_7",
"name": "Zkontrolovat z\u00E1soby",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_8",
"name": "Otestovat reaktor",
"location": {
"lat": 50.7731933,
"lon": 15.0726196
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_9",
"name": "Opravit kabel",
"location": {
"lat": 50.7737495,
"lon": 15.0715106
},
"type": "Progress",
"durationMs": 7350,
"steps": 1
},
{
"taskId": "task_10",
"name": "Zkalibrovat senzor",
"location": {
"lat": 50.77358,
"lon": 15.07339
},
"type": "Progress",
"durationMs": 6764,
"steps": 1
},
{
"taskId": "task_11",
"name": "St\u00E1hnout data",
"location": {
"lat": 50.7734516,
"lon": 15.0723155
},
"type": "Instant",
"durationMs": 0,
"steps": 1
},
{
"taskId": "task_12",
"name": "Nab\u00EDt baterii",
"location": {
"lat": 50.7741669,
"lon": 15.0718846
},
"type": "Progress",
"durationMs": 7958,
"steps": 1
},
{
"taskId": "task_13",
"name": "Vy\u010Distit filtr",
"location": {
"lat": 50.773646,
"lon": 15.0719148
},
"type": "Progress",
"durationMs": 8269,
"steps": 1
},
{
"taskId": "task_14",
"name": "Nastavit kompas",
"location": {
"lat": 50.7739981,
"lon": 15.0714832
},
"type": "Progress",
"durationMs": 7902,
"steps": 1
}
],
"currentMeeting": {
"meetingId": "8897a8fb",
"type": "Emergency",
"reportedBodyId": null,
"callerId": "b8c1c6dc",
"meetingLocation": {
"lat": 50.7735892,
"lon": 15.0721653
},
"startTime": "2026-01-27T16:00:53.7788042Z",
"arrivalDeadline": "2026-01-27T16:01:16.7788042Z",
"discussionEndTime": "2026-01-27T16:01:28.7788042Z",
"votingEndTime": "2026-01-27T16:01:58.7788042Z",
"arrivedPlayers": [
"104d74ed"
],
"votes": {
"104d74ed": "154a8e55"
},
"lastVoteChangeTime": "2026-01-27T16:01:32.7852692Z"
},
"playAreaCenter": {
"lat": 50.7735892,
"lon": 15.0721653
},
"playAreaRadius": 100,
"impostorCount": 1,
"tiePolicy": "NoEject"
}

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More