namespace GeoSus.Server; using Microsoft.Extensions.Logging; using System.Threading.Channels; // Per-lobby actor - single-threaded zpracování všech mutací public class LobbyActor : IDisposable { private readonly string _lobbyId; private readonly ServerConfig _config; private readonly ILogger _logger; private readonly StatsDb _statsDb; private readonly Persistence _persistence; private readonly GameLogic _gameLogic; private readonly AntiCheat _antiCheat; private readonly OverpassService? _overpassService; // Actor channel - deterministic single-threaded processing private readonly Channel _actionChannel; private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; // State private readonly Dictionary _players = new(); private readonly Dictionary _connections = new(); private readonly List _bodies = new(); private readonly List _tasks = new(); private Meeting? _currentMeeting; private Sabotage? _currentSabotage; private DateTime? _lastSabotageTime; private GamePhase _phase = GamePhase.Lobby; private long _eventId; private long _serverSeq; private DateTime _lastActivity = DateTime.UtcNow; private int _snapshotEventCounter; private DateTime _lastSnapshotTime = DateTime.UtcNow; // Map data loading tracking private readonly HashSet _playersWithMapData = new(); public LobbySettings Settings { get; } public GamePhase Phase => _phase; public int PlayerCount => _players.Count; public DateTime LastActivity => _lastActivity; public DateTime CreatedAt { get; } = DateTime.UtcNow; // Admin panel properties public string LobbyId => _lobbyId; public string JoinCode => Settings.JoinCode; public DateTime? PhaseEndTime { get; private set; } public Sabotage? ActiveSabotage => _currentSabotage; public MapData? MapData => Settings.MapData; public LobbyActor( string lobbyId, LobbySettings settings, ServerConfig config, ILogger logger, StatsDb statsDb, Persistence persistence, OverpassService? overpassService = null) { _lobbyId = lobbyId; Settings = settings; _config = config; _logger = logger; _statsDb = statsDb; _persistence = persistence; _overpassService = overpassService; _gameLogic = new GameLogic(config, logger, overpassService); _antiCheat = new AntiCheat(config, logger); // Emergency meeting location = center of play area Settings.EmergencyMeetingLocation = Settings.PlayAreaCenter; _actionChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); _processingTask = Task.Run(ProcessActionsAsync); _logger.LogInformation("LobbyActor vytvořen: {LobbyId}", lobbyId); } #region Public API - thread-safe enqueue public async Task AddPlayerAsync(string clientUuid, string displayName, bool isOwner) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await EnqueueAsync(new AddPlayerAction { ClientUuid = clientUuid, DisplayName = displayName, IsOwner = isOwner, Completion = completion }); // Čekáme na dokončení zpracování akce await completion.Task; } public void RegisterConnection(string clientUuid, ClientConnection connection) { _connections[clientUuid] = connection; } public void UnregisterConnection(string clientUuid) { _connections.TryRemove(clientUuid, out _); } public async Task HandleMessageAsync(string clientUuid, Message message) { _lastActivity = DateTime.UtcNow; await EnqueueAsync(new HandleMessageAction { ClientUuid = clientUuid, Message = message }); } public async Task RemovePlayerAsync(string clientUuid, string? reason = null) { await EnqueueAsync(new RemovePlayerAction { ClientUuid = clientUuid, Reason = reason }); } public LobbyState GetLobbyState() { return new LobbyState { LobbyId = _lobbyId, JoinCode = Settings.JoinCode, OwnerId = _players.Values.FirstOrDefault(p => p.IsOwner)?.ClientUuid, Phase = _phase, Players = _players.Values.Select(p => new PlayerInfo { ClientUuid = p.ClientUuid, DisplayName = p.DisplayName, IsOwner = p.IsOwner, IsReady = p.IsReady, State = p.State }).ToList(), PlayAreaCenter = Settings.PlayAreaCenter, PlayAreaRadius = Settings.PlayAreaRadius, ImpostorCount = Settings.ImpostorCount, HasPassword = !string.IsNullOrEmpty(Settings.Password), CreatedAt = _lastActivity, MapData = Settings.MapDataPayload, MapDataReady = Settings.MapDataPayload != null || !Settings.OverpassEnabled }; } /// /// Get all players for admin panel spectate view /// public IEnumerable GetPlayers() { return _players.Values.Select(p => new AdminPlayerInfo { PlayerId = p.ClientUuid, DisplayName = p.DisplayName, State = p.State, Role = p.Role, Position = p.Position, IsHost = p.IsOwner, CheatScore = p.CheatScore, KillCooldownEnd = p.LastKillTime?.AddMilliseconds(_config.KillCooldownMs), VotedFor = _currentMeeting?.Votes.TryGetValue(p.ClientUuid, out var vote) == true ? vote : null }); } /// /// Get all tasks for admin panel spectate view /// public IEnumerable GetTasks() { // Build completed by sets from player data var completedByTask = new Dictionary>(); foreach (var player in _players.Values) { foreach (var taskId in player.CompletedTaskIds) { if (!completedByTask.ContainsKey(taskId)) completedByTask[taskId] = new HashSet(); completedByTask[taskId].Add(player.ClientUuid); } } return _tasks.Select(t => new AdminTaskInfo { TaskId = t.TaskId, Name = t.Name, Location = t.Location, CompletedBy = completedByTask.TryGetValue(t.TaskId, out var set) ? set : new HashSet() }); } /// /// Get all bodies for admin panel spectate view /// public IEnumerable GetBodies() { return _bodies.Select(b => new AdminBodyInfo { VictimId = b.VictimId, Position = b.Location, ReportedAt = b.Reported ? DateTime.UtcNow : null }); } /// /// Get current votes during voting phase /// public Dictionary? GetVotes() { return _currentMeeting?.Votes; } /// /// Broadcast system message to all players in lobby /// public void BroadcastSystemMessage(string message) { var evt = CreateEvent("SystemMessage", "server", new SystemMessagePayload { Message = message, Timestamp = DateTime.UtcNow }); PersistAndBroadcast(evt); _logger.LogInformation("System message broadcast to lobby {LobbyId}: {Message}", _lobbyId, message); } /// /// Kick player from lobby (admin action) /// public bool TryKickPlayer(string playerId, string reason) { if (!_players.ContainsKey(playerId)) return false; // Odstraníme hráče asynchronně _ = RemovePlayerAsync(playerId, reason); return true; } // ── P12: Admin lobby manipulation ──────────────────────────────────────── // These methods are the building blocks for the new admin-panel endpoints // (force-end, force-phase, edit-settings). Each enqueues an action via // the existing actor channel so mutation stays serialized with the rest // of the lobby's state machine - no race against tick handlers. /// /// Force-end the current game immediately. No winner is declared (the /// admin is interrupting outside normal win conditions); the lobby falls /// back to the lobby phase ready for a new round. Safe to call in any /// phase - the actor will no-op gracefully if there's nothing to end. /// public async Task TryForceEndGameAsync(string adminReason = "ended by admin") { var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await EnqueueAsync(new AdminForceEndGameAction { Reason = adminReason, Result = done }); return await done.Task; } /// /// Force the lobby into a specific phase. Used for testing / unsticking /// games where the natural phase-transition didn't fire. Accepts /// "Lobby", "Loading", "Playing", "Meeting", "Ended" (string for the /// JSON wire); "Voting" intentionally rejected because Voting is a /// sub-window of Meeting on the wire (see Phase 1 dead-code comment). /// public async Task TryAdminSetPhaseAsync(string targetPhase) { if (!System.Enum.TryParse(targetPhase, ignoreCase: true, out var p)) return false; if (p == GamePhase.Voting) return false; // dead value var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await EnqueueAsync(new AdminSetPhaseAction { TargetPhase = p, Result = done }); return await done.Task; } /// /// Update mutable lobby settings. Only takes effect during Lobby phase - /// changing radius/impostor count mid-game would be silly. Returns false /// if any required field is invalid OR the lobby is past the lobby phase. /// public async Task TryUpdateLobbySettingsAsync(double? radius, int? impostorCount, int? taskCount, string? tiePolicy) { var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await EnqueueAsync(new AdminUpdateSettingsAction { NewRadius = radius, NewImpostorCount = impostorCount, NewTaskCount = taskCount, NewTiePolicy = tiePolicy, Result = done }); return await done.Task; } public async Task ArchiveAndCloseAsync() { _cts.Cancel(); await _processingTask; // Uložíme finální snapshot SaveSnapshot(); // Archivujeme _persistence.ArchiveLobby(_lobbyId); } #endregion #region Actor processing private async Task EnqueueAsync(LobbyAction action) { await _actionChannel.Writer.WriteAsync(action, _cts.Token); } private async Task ProcessActionsAsync() { try { await foreach (var action in _actionChannel.Reader.ReadAllAsync(_cts.Token)) { try { await ProcessActionAsync(action); } catch (Exception ex) { _logger.LogError(ex, "Chyba při zpracování akce {Action}", action.GetType().Name); } } } catch (OperationCanceledException) { _logger.LogInformation("LobbyActor {LobbyId} ukončen", _lobbyId); } } private async Task ProcessActionAsync(LobbyAction action) { try { switch (action) { case AddPlayerAction a: ProcessAddPlayer(a); break; case RemovePlayerAction a: ProcessRemovePlayer(a); break; case HandleMessageAction a: await ProcessMessageAsync(a); break; case CloseMeetingAction a: if (_currentMeeting?.MeetingId == a.MeetingId) { CloseMeeting(); } break; case CheckMeetingArrivalAction a: if (_currentMeeting?.MeetingId == a.MeetingId) { CheckAllPlayersArrival(); } break; case RepairCompleteAction a: ProcessRepairComplete(a); break; case SabotageMeltdownAction a: ProcessSabotageMeltdown(a); break; case SabotageExpireAction a: ProcessSabotageExpire(a); break; case AdminForceEndGameAction a: ProcessAdminForceEndGame(a); break; case AdminSetPhaseAction a: ProcessAdminSetPhase(a); break; case AdminUpdateSettingsAction a: ProcessAdminUpdateSettings(a); break; } // Signalizace dokončení pro akce, které na to čekají action.Completion?.TrySetResult(); } catch (Exception ex) { action.Completion?.TrySetException(ex); throw; } // Periodické snapshoty CheckSnapshot(); } #endregion #region Player management private void ProcessAddPlayer(AddPlayerAction action) { if (_players.ContainsKey(action.ClientUuid)) { // Reconnect _players[action.ClientUuid].ConnectedAt = DateTime.UtcNow; _logger.LogInformation("Hráč {Uuid} reconnected", action.ClientUuid); return; } var player = new Player { ClientUuid = action.ClientUuid, DisplayName = action.DisplayName, IsOwner = action.IsOwner, Position = Settings.PlayAreaCenter }; _players[action.ClientUuid] = player; _statsDb.EnsurePlayerExists(action.ClientUuid, action.DisplayName); var evt = CreateEvent("PlayerJoined", action.ClientUuid, new PlayerJoinedPayload { ClientUuid = action.ClientUuid, DisplayName = action.DisplayName }); PersistAndBroadcast(evt); _logger.LogInformation("Hráč {Uuid} ({Name}) přidán do lobby", action.ClientUuid, action.DisplayName); } private void ProcessRemovePlayer(RemovePlayerAction action) { if (!_players.TryGetValue(action.ClientUuid, out var player)) return; var wasOwner = player.IsOwner; _players.Remove(action.ClientUuid); _connections.TryRemove(action.ClientUuid, out _); var evt = CreateEvent("PlayerLeft", action.ClientUuid, new PlayerLeftPayload { ClientUuid = action.ClientUuid, Reason = action.Reason }); PersistAndBroadcast(evt); // Host migration pokud odešel owner if (wasOwner && _players.Count > 0) { ElectNewOwner(); } _logger.LogInformation("Hráč {Uuid} opustil lobby: {Reason}", action.ClientUuid, action.Reason); } private void ElectNewOwner() { // Nejstarší hráč podle připojení var newOwner = _players.Values .OrderBy(p => p.ConnectedAt) .ThenBy(p => p.ClientUuid) .FirstOrDefault(); if (newOwner == null) return; var previousOwnerId = _players.Values.FirstOrDefault(p => p.IsOwner)?.ClientUuid ?? "none"; foreach (var p in _players.Values) p.IsOwner = false; newOwner.IsOwner = true; var evt = CreateEvent("HostChanged", newOwner.ClientUuid, new HostChangedPayload { NewHostId = newOwner.ClientUuid, PreviousHostId = previousOwnerId }); PersistAndBroadcast(evt); _logger.LogInformation("Nový owner: {Uuid}", newOwner.ClientUuid); } #endregion #region Message processing private async Task ProcessMessageAsync(HandleMessageAction action) { var clientUuid = action.ClientUuid; var message = action.Message; if (!_players.TryGetValue(clientUuid, out var player)) { _logger.LogWarning("Zpráva od neznámého hráče: {Uuid}", clientUuid); return; } // Ack pro všechny zprávy var ack = new Ack { AckedSeq = message.ClientSeq, Success = true }; switch (message) { case UpdatePosition m: ProcessUpdatePosition(player, m); break; case StartGame m: // StartGame is async - it handles its own ack await ProcessStartGame(player, m, ack); return; case KillAttempt m: ProcessKillAttempt(player, m, ref ack); break; case ReportBody m: ProcessReportBody(player, m, ref ack); break; case CallEmergencyMeeting m: ProcessEmergencyMeeting(player, m, ref ack); break; case CastVote m: ProcessCastVote(player, m, ref ack); break; case TaskStart m: ProcessTaskStart(player, m, ref ack); break; case TaskComplete m: ProcessTaskComplete(player, m, ref ack); break; case Ping m: var pong = new Pong { ClientTime = m.ClientTime, ServerTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; SendTo(clientUuid, pong); return; // Ping nepotřebuje ack case LeaveLobby: await RemovePlayerAsync(clientUuid, "Hráč opustil"); return; case KickPlayer m: ProcessKickPlayer(player, m, ref ack); break; case StartSabotage m: ProcessStartSabotage(player, m, ref ack); break; case ActivateRepairStation m: ProcessActivateRepairStation(player, m, ref ack); break; case DeactivateRepairStation m: ProcessDeactivateRepairStation(player, m, ref ack); break; case MapDataReceived m: ProcessMapDataReceived(player, m, ref ack); break; case ReturnToLobby: ProcessReturnToLobby(player, ref ack); break; } // Poslat ack SendTo(clientUuid, ack); } #endregion #region Game actions private void ProcessUpdatePosition(Player player, UpdatePosition message) { var violation = _antiCheat.ValidateMovement(player, message.Position); if (violation != null && player.CheatStatus == CheatStatus.Kicked) { // Kicknout hráče _statsDb.IncrementCheatIncidents(player.ClientUuid); _ = RemovePlayerAsync(player.ClientUuid, "Anti-cheat kick"); return; } player.Position = message.Position; player.LastPositionUpdate = DateTime.UtcNow; // Kontrola meeting arrival if (_currentMeeting != null && !_currentMeeting.ArrivedPlayers.Contains(player.ClientUuid)) { if (_gameLogic.CheckPlayerArrival(player, _currentMeeting)) { // Hráč právě dorazil na meeting - pošleme event var arrivalEvt = CreateEvent("PlayerArrivedAtMeeting", player.ClientUuid, new PlayerArrivedAtMeetingPayload { ClientUuid = player.ClientUuid, MeetingId = _currentMeeting.MeetingId }); PersistAndBroadcast(arrivalEvt); } } } private async Task ProcessStartGame(Player player, StartGame message, Ack ack) { if (!player.IsOwner) { ack.Success = false; ack.Error = "Pouze owner může spustit hru"; SendTo(player.ClientUuid, ack); return; } if (_phase != GamePhase.Lobby) { ack.Success = false; ack.Error = "Hra již probíhá"; SendTo(player.ClientUuid, ack); return; } if (_players.Count < 2) { ack.Success = false; ack.Error = "Minimum 2 hráči"; SendTo(player.ClientUuid, ack); return; } // Enter loading phase - notify clients to show loading screen _phase = GamePhase.Loading; _playersWithMapData.Clear(); var loadingEvt = CreateEvent("GameStarting", player.ClientUuid, new GameStartingPayload { Message = "Načítám mapová data..." }); PersistAndBroadcast(loadingEvt); // Send ack immediately ack.Success = true; SendTo(player.ClientUuid, ack); _logger.LogInformation("Lobby {LobbyId} entering loading phase, fetching map data", _lobbyId); // Fetch map data from Overpass API try { if (_overpassService != null) { var mapData = await _overpassService.FetchMapDataAsync( Settings.PlayAreaCenter, Settings.PlayAreaRadius); Settings.MapData = mapData; _logger.LogInformation("Lobby {LobbyId} map data loaded: {Buildings} buildings, {Pathways} pathways, {POIs} POIs", _lobbyId, mapData?.Buildings?.Count ?? 0, mapData?.Pathways?.Count ?? 0, mapData?.PointsOfInterest?.Count ?? 0); } // Broadcast map data to all clients var mapDataPayload = Settings.MapData != null ? MapDataPayload.FromMapData(Settings.MapData) : null; var mapDataEvt = CreateEvent("MapDataReady", player.ClientUuid, new MapDataReadyPayload { MapData = mapDataPayload, PlayAreaCenter = Settings.PlayAreaCenter, PlayAreaRadius = Settings.PlayAreaRadius }); PersistAndBroadcast(mapDataEvt); _logger.LogInformation("Lobby {LobbyId} map data distributed to {Count} players, waiting for confirmations", _lobbyId, _players.Count); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch map data for lobby {LobbyId}", _lobbyId); // Revert to lobby phase on error _phase = GamePhase.Lobby; var errorEvt = CreateEvent("MapDataError", player.ClientUuid, new { Error = "Nepodařilo se načíst mapová data" }); PersistAndBroadcast(errorEvt); } } private void ProcessMapDataReceived(Player player, MapDataReceived message, ref Ack ack) { if (_phase != GamePhase.Loading) { ack.Success = false; ack.Error = "Hra není ve fázi načítání"; return; } _playersWithMapData.Add(player.ClientUuid); _logger.LogDebug("Player {Player} confirmed map data receipt ({Count}/{Total})", player.DisplayName, _playersWithMapData.Count, _players.Count); // Broadcast progress to all clients var progressEvt = CreateEvent("PlayerMapDataReceived", player.ClientUuid, new PlayerMapDataReceivedPayload { ClientUuid = player.ClientUuid, DisplayName = player.DisplayName, PlayersReady = _playersWithMapData.Count, TotalPlayers = _players.Count }); PersistAndBroadcast(progressEvt); // Check if all players have received map data if (_playersWithMapData.Count >= _players.Count) { _logger.LogInformation("All players confirmed map data receipt, starting game in lobby {LobbyId}", _lobbyId); StartGameAfterMapDataLoaded(); } } private void StartGameAfterMapDataLoaded() { // Now actually start the game _phase = GamePhase.Playing; // Assign roles first var playerList = _players.Values.ToList(); _gameLogic.AssignRoles(playerList, Settings.ImpostorCount); // Generate unique tasks for each crew member _tasks.Clear(); int taskIndex = 0; foreach (var p in _players.Values) { if (p.Role == PlayerRole.Crew) { // Generate unique tasks for this player var playerTasks = _gameLogic.GenerateTasks( Settings.TaskCount, Settings.PlayAreaCenter, Settings.PlayAreaRadius, Settings.MapData, taskIndex); // Pass index for unique task IDs p.Tasks = playerTasks; _tasks.AddRange(playerTasks); taskIndex += Settings.TaskCount; } } // Find who triggered the start (owner) var owner = _players.Values.FirstOrDefault(p => p.IsOwner); var ownerUuid = owner?.ClientUuid ?? "server"; // Broadcast GameStarted var startEvt = CreateEvent("GameStarted", ownerUuid, new GameStartedPayload { ImpostorCount = Settings.ImpostorCount, TaskCount = Settings.TaskCount }); PersistAndBroadcast(startEvt); // Send each player their role and their own tasks foreach (var p in _players.Values) { var roleEvt = CreateEvent("RoleAssigned", p.ClientUuid, new RoleAssignedPayload { ClientUuid = p.ClientUuid, Role = p.Role, Tasks = p.Role == PlayerRole.Crew ? p.Tasks : null }); // Role is sent only to that player PersistEvent(roleEvt); SendTo(p.ClientUuid, roleEvt); } _logger.LogInformation("Game started in lobby {LobbyId}", _lobbyId); } private void ProcessKillAttempt(Player killer, KillAttempt message, ref Ack ack) { if (_phase != GamePhase.Playing) { ack.Success = false; ack.Error = "Hra neprobíhá"; return; } if (_currentMeeting != null) { ack.Success = false; ack.Error = "Probíhá meeting"; return; } if (!_antiCheat.CanPerformAction(killer, "Kill")) { ack.Success = false; ack.Error = "Akce blokována"; return; } if (!_players.TryGetValue(message.TargetClientUuid, out var victim)) { ack.Success = false; ack.Error = "Oběť neexistuje"; return; } var (success, error, body) = _gameLogic.TryKill(killer, victim, _players, _bodies); if (!success) { ack.Success = false; ack.Error = error; return; } // Update stats _statsDb.IncrementKills(killer.ClientUuid); _statsDb.IncrementDeaths(victim.ClientUuid); var evt = CreateEvent("PlayerKilled", killer.ClientUuid, new PlayerKilledPayload { VictimId = victim.ClientUuid, KillerId = killer.ClientUuid, BodyId = body!.BodyId, Location = body.Location }); PersistAndBroadcast(evt); // Check win conditions CheckWinConditions(); } private void ProcessReportBody(Player reporter, ReportBody message, ref Ack ack) { if (_phase != GamePhase.Playing) { ack.Success = false; ack.Error = "Hra neprobíhá"; return; } if (_currentMeeting != null) { ack.Success = false; ack.Error = "Probíhá meeting"; return; } // Check comms sabotage if (IsCommsBlocked()) { ack.Success = false; ack.Error = "Komunikace je rušena - nelze reportovat"; return; } // Check reactor meltdown - must repair first! if (IsMeltdownActive()) { ack.Success = false; ack.Error = "KRITICKÁ HAVARIE! Nelze reportovat - nejprve opravte reaktor!"; return; } var (success, error, body) = _gameLogic.TryReportBody(reporter, message.BodyId, _bodies); if (!success) { ack.Success = false; ack.Error = error; return; } _statsDb.IncrementBodiesReported(reporter.ClientUuid); var reportEvt = CreateEvent("BodyReported", reporter.ClientUuid, new BodyReportedPayload { ReporterId = reporter.ClientUuid, BodyId = body!.BodyId, VictimId = body.VictimId }); PersistAndBroadcast(reportEvt); // Cancel any active sabotage when meeting starts if (_currentSabotage != null && _currentSabotage.State == SabotageState.Active) { _currentSabotage.State = SabotageState.Repaired; _currentSabotage = null; } // Start meeting at body location StartMeeting(MeetingType.BodyReport, reporter.ClientUuid, body.Location, body.BodyId); } private void ProcessEmergencyMeeting(Player caller, CallEmergencyMeeting message, ref Ack ack) { if (_phase != GamePhase.Playing) { ack.Success = false; ack.Error = "Hra neprobíhá"; return; } if (_currentMeeting != null) { ack.Success = false; ack.Error = "Probíhá meeting"; return; } // Check comms sabotage if (IsCommsBlocked()) { ack.Success = false; ack.Error = "Komunikace je rušena - nelze svolat meeting"; return; } // Check reactor meltdown - must repair first! if (IsMeltdownActive()) { ack.Success = false; ack.Error = "KRITICKÁ HAVARIE! Nelze svolat meeting - nejprve opravte reaktor!"; return; } // Kontrola vzdálenosti od středu mapy (emergency button) var distanceToCenter = caller.Position.DistanceTo(Settings.EmergencyMeetingLocation); if (distanceToCenter > _config.EmergencyMeetingCallRadiusM) { ack.Success = false; ack.Error = $"Musíš být u emergency buttonu (střed mapy) - vzdálenost: {distanceToCenter:F0}m"; return; } var (success, error) = _gameLogic.TryCallEmergencyMeeting( caller, Settings.MaxEmergencyMeetingsPerPlayer, Settings.EmergencyMeetingCooldownMs); if (!success) { ack.Success = false; ack.Error = error; return; } _statsDb.IncrementEmergencyMeetings(caller.ClientUuid); var callEvt = CreateEvent("EmergencyMeetingCalled", caller.ClientUuid, new EmergencyMeetingCalledPayload { CallerId = caller.ClientUuid }); PersistAndBroadcast(callEvt); // Cancel any active sabotage when meeting starts if (_currentSabotage != null && _currentSabotage.State == SabotageState.Active) { _currentSabotage.State = SabotageState.Repaired; _currentSabotage = null; } // Start meeting at emergency location StartMeeting(MeetingType.Emergency, caller.ClientUuid, Settings.EmergencyMeetingLocation, null); } private void StartMeeting(MeetingType type, string callerId, Position location, string? bodyId) { _phase = GamePhase.Meeting; _currentMeeting = _gameLogic.StartMeeting( type, callerId, location, bodyId, _config.ArrivalBaseMs, _config.VotingPhaseMs, _config.DiscussionPhaseMs); var evt = CreateEvent("MeetingStarted", callerId, new MeetingStartedPayload { MeetingId = _currentMeeting.MeetingId, Type = type, MeetingLocation = location, ArrivalDeadline = _currentMeeting.ArrivalDeadline, DiscussionEndTime = _currentMeeting.DiscussionEndTime, VotingEndTime = _currentMeeting.VotingEndTime }); PersistAndBroadcast(evt); // Zkontroluj všechny hráče, kteří už jsou na místě meetingu CheckAllPlayersArrival(); // Spustíme periodickou kontrolu během arrival fáze var meetingId = _currentMeeting.MeetingId; _ = Task.Run(async () => { // Kontrolujeme každou sekundu během arrival fáze while (_currentMeeting != null && _currentMeeting.MeetingId == meetingId && DateTime.UtcNow < _currentMeeting.ArrivalDeadline) { await Task.Delay(1000); await EnqueueAsync(new CheckMeetingArrivalAction { MeetingId = meetingId }); } }); // Spustíme timer pro voting _ = Task.Run(async () => { await Task.Delay(_currentMeeting.VotingEndTime - DateTime.UtcNow); await EnqueueAsync(new CloseMeetingAction { MeetingId = _currentMeeting.MeetingId }); }); } /// /// Zkontroluje všechny hráče zda jsou na meeting location /// private void CheckAllPlayersArrival() { if (_currentMeeting == null) return; foreach (var player in _players.Values.Where(p => p.State == PlayerState.Alive)) { if (!_currentMeeting.ArrivedPlayers.Contains(player.ClientUuid) && _gameLogic.CheckPlayerArrival(player, _currentMeeting)) { var arrivalEvt = CreateEvent("PlayerArrivedAtMeeting", player.ClientUuid, new PlayerArrivedAtMeetingPayload { ClientUuid = player.ClientUuid, MeetingId = _currentMeeting.MeetingId }); PersistAndBroadcast(arrivalEvt); } } } private void ProcessCastVote(Player voter, CastVote message, ref Ack ack) { if (_currentMeeting == null) { ack.Success = false; ack.Error = "Žádný aktivní meeting"; return; } var (success, error) = _gameLogic.TryCastVote(voter, message.TargetClientUuid, _currentMeeting, _players); if (!success) { ack.Success = false; ack.Error = error; return; } var evt = CreateEvent("PlayerVoted", voter.ClientUuid, new PlayerVotedPayload { VoterId = voter.ClientUuid, TargetId = message.TargetClientUuid }); PersistAndBroadcast(evt); } private void CloseMeeting() { if (_currentMeeting == null) return; var (ejectedId, wasTie, voteCounts) = _gameLogic.ResolveVoting(_currentMeeting, _players, Settings.TiePolicy); // Broadcast voting results var closeEvt = CreateEvent("VotingClosed", null, new VotingClosedPayload { VoteCounts = voteCounts, EjectedPlayerId = ejectedId, WasTie = wasTie }); PersistAndBroadcast(closeEvt); // Eject player if any if (ejectedId != null && _players.TryGetValue(ejectedId, out var ejected)) { _gameLogic.EjectPlayer(ejected); _statsDb.IncrementTimesVotedOut(ejectedId); // Track successful votes (voted for impostor) if (ejected.Role == PlayerRole.Impostor) { foreach (var (voterId, targetId) in _currentMeeting.Votes) { if (targetId == ejectedId) { _statsDb.IncrementSuccessfulVotes(voterId); } } } var ejectEvt = CreateEvent("PlayerEjected", null, new PlayerEjectedPayload { ClientUuid = ejectedId, Role = ejected.Role }); PersistAndBroadcast(ejectEvt); } // Clear bodies and meeting _bodies.Clear(); _currentMeeting = null; _phase = GamePhase.Playing; // Reset cooldowns for next round foreach (var player in _players.Values.Where(p => p.State == PlayerState.Alive)) { if (player.Role == PlayerRole.Impostor) { player.LastKillTime = DateTime.UtcNow; // Reset kill cooldown } } // Check win conditions after ejection CheckWinConditions(); } private void ProcessTaskStart(Player player, TaskStart message, ref Ack ack) { // Same gating as TaskComplete: must be Playing, no meeting, must be crew. if (_phase != GamePhase.Playing || _currentMeeting != null) { ack.Success = false; ack.Error = "Nelze začít task"; return; } if (player.State != PlayerState.Alive) { ack.Success = false; ack.Error = "Mrtví hráči nemohou provádět tasky"; return; } if (player.Role == PlayerRole.Impostor) { ack.Success = false; ack.Error = "Impostoři nemohou dělat tasky"; return; } // Validate the task belongs to this player and isn't already done. var task = player.Tasks.FirstOrDefault(t => t.TaskId == message.TaskId); if (task == null || player.CompletedTaskIds.Contains(message.TaskId)) { ack.Success = false; ack.Error = "Neznámý nebo již dokončený task"; return; } // Distance check (same threshold as TryCompleteTask uses). var distance = player.Position.DistanceTo(task.Location); if (distance > _config.TaskStartDistanceM) { ack.Success = false; ack.Error = $"Příliš daleko od tasku ({distance:F1}m)"; return; } player.CurrentTaskId = message.TaskId; var evt = CreateEvent("TaskStarted", player.ClientUuid, new TaskStartedPayload { ClientUuid = player.ClientUuid, TaskId = message.TaskId }); PersistAndBroadcast(evt); } private void ProcessTaskComplete(Player player, TaskComplete message, ref Ack ack) { if (_phase != GamePhase.Playing || _currentMeeting != null) { ack.Success = false; ack.Error = "Nelze provádět tasky"; return; } if (!_antiCheat.CanPerformAction(player, "TaskComplete")) { ack.Success = false; ack.Error = "Akce blokována"; return; } var (success, error) = _gameLogic.TryCompleteTask(player, message.TaskId); if (!success) { ack.Success = false; ack.Error = error; return; } // Task dokončen úspěšně _statsDb.IncrementTasksCompleted(player.ClientUuid); 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); var evt = CreateEvent("TaskCompleted", player.ClientUuid, new TaskCompletedPayload { ClientUuid = player.ClientUuid, TaskId = message.TaskId, TotalCompleted = completedTasks, TotalTasks = totalTasks }); PersistAndBroadcast(evt); _logger.LogInformation("Task {TaskId} completed by {Player}. Progress: {Completed}/{Total}", message.TaskId, player.DisplayName, completedTasks, totalTasks); CheckWinConditions(); } private void ProcessKickPlayer(Player kicker, KickPlayer message, ref Ack ack) { if (!kicker.IsOwner) { ack.Success = false; ack.Error = "Pouze owner může kicknout"; return; } if (message.TargetClientUuid == kicker.ClientUuid) { ack.Success = false; ack.Error = "Nemůžeš kicknout sám sebe"; return; } _ = RemovePlayerAsync(message.TargetClientUuid, "Kicked by owner"); } private void CheckWinConditions() { var (gameOver, winningFaction, reason) = _gameLogic.CheckWinConditions(_players, _tasks); if (gameOver) { EndGame(winningFaction!, reason!); } } private void EndGame(string winningFaction, string reason) { _phase = GamePhase.Ended; var winners = _players.Values .Where(p => (winningFaction == "Crew" && p.Role == PlayerRole.Crew) || (winningFaction == "Impostor" && p.Role == PlayerRole.Impostor)) .Select(p => p.ClientUuid) .ToList(); var evt = CreateEvent("GameEnded", null, new GameEndedPayload { WinningFaction = winningFaction, Reason = reason, Winners = winners }); PersistAndBroadcast(evt); // Uložíme statistiky var gameStats = new GameEndStats { CrewWon = winningFaction == "Crew", Players = _players.Values.Select(p => new PlayerGameStats { ClientUuid = p.ClientUuid, WasCrew = p.Role == PlayerRole.Crew, WasImpostor = p.Role == PlayerRole.Impostor, PlaytimeSeconds = (int)(DateTime.UtcNow - p.ConnectedAt).TotalSeconds }).ToList() }; _statsDb.RecordGameEnd(gameStats); _logger.LogInformation("Hra ukončena: {Winner} vyhráli - {Reason}", winningFaction, reason); } private void ProcessReturnToLobby(Player player, ref Ack ack) { // Pouze owner může restartovat hru if (!player.IsOwner) { ack.Success = false; ack.Error = "Pouze owner může restartovat hru"; return; } // Pouze pokud je hra ve fázi Ended if (_phase != GamePhase.Ended) { ack.Success = false; ack.Error = "Hra ještě neskončila"; return; } // Resetujeme stav hry _phase = GamePhase.Lobby; _bodies.Clear(); _tasks.Clear(); _currentMeeting = null; _currentSabotage = null; _playersWithMapData.Clear(); // Resetujeme hráče foreach (var p in _players.Values) { p.Role = PlayerRole.Crew; p.Position = Settings.PlayAreaCenter; p.State = PlayerState.Alive; p.Tasks.Clear(); p.CompletedTaskIds.Clear(); p.CurrentTaskId = null; } // Broadcastujeme událost var evt = CreateEvent("ReturnedToLobby", player.ClientUuid, new ReturnedToLobbyPayload { Message = "Hra byla restartována" }); PersistAndBroadcast(evt); _logger.LogInformation("Lobby {LobbyId} vráceno do lobby fáze hráčem {Player}", _lobbyId, player.DisplayName); } #endregion #region Events & Broadcasting private GameEvent CreateEvent(string eventType, string? actor, object? payload) { return new GameEvent { EventId = Interlocked.Increment(ref _eventId), ServerSeq = Interlocked.Increment(ref _serverSeq), Timestamp = DateTime.UtcNow, EventType = eventType, Actor = actor, Payload = payload }; } private void PersistEvent(GameEvent evt) { _persistence.AppendToWal(_lobbyId, evt); _snapshotEventCounter++; } private void PersistAndBroadcast(GameEvent evt) { PersistEvent(evt); BroadcastEvent(evt); } private void BroadcastEvent(GameEvent evt) { var message = new GameEvent { EventId = evt.EventId, ServerSeq = evt.ServerSeq, Timestamp = evt.Timestamp, EventType = evt.EventType, Actor = evt.Actor, Payload = evt.Payload }; foreach (var (clientUuid, connection) in _connections) { try { connection.SendAsync(message).Wait(); } catch (Exception ex) { _logger.LogWarning(ex, "Chyba při broadcastu do {ClientUuid}", clientUuid); } } } private void SendTo(string clientUuid, Message message) { if (_connections.TryGetValue(clientUuid, out var connection)) { try { connection.SendAsync(message).Wait(); } catch (Exception ex) { _logger.LogWarning(ex, "Chyba při odesílání do {ClientUuid}", clientUuid); } } } // Position broadcast - volat periodicky z externího timeru public void BroadcastPositions() { if (_phase != GamePhase.Playing && _phase != GamePhase.Meeting) return; var positions = _players.Values.Select(p => new PlayerPositionInfo { ClientUuid = p.ClientUuid, Position = p.Position, State = p.State }).ToList(); var broadcast = new PositionBroadcast { Players = positions }; foreach (var connection in _connections.Values) { try { connection.SendAsync(broadcast).Wait(); } catch { } } } #endregion #region Sabotage private void ProcessStartSabotage(Player player, StartSabotage message, ref Ack ack) { // Only impostors can sabotage if (player.Role != PlayerRole.Impostor) { ack.Success = false; ack.Error = "Pouze impostor může sabotovat"; return; } // Must be in playing phase (not meeting) if (_phase != GamePhase.Playing) { ack.Success = false; ack.Error = "Nelze sabotovat během meetingu"; return; } // Check cooldown if (_lastSabotageTime.HasValue && (DateTime.UtcNow - _lastSabotageTime.Value).TotalMilliseconds < _config.SabotageCooldownMs) { var remaining = _config.SabotageCooldownMs - (DateTime.UtcNow - _lastSabotageTime.Value).TotalMilliseconds; ack.Success = false; ack.Error = $"Sabotáž na cooldownu ({remaining:F0}ms)"; return; } // Check if sabotage already active if (_currentSabotage != null && _currentSabotage.State == SabotageState.Active) { ack.Success = false; ack.Error = "Již probíhá jiná sabotáž"; return; } // Create sabotage _currentSabotage = new Sabotage { SabotageId = Guid.NewGuid().ToString("N")[..8], Type = message.SabotageType, InitiatorId = player.ClientUuid, StartTime = DateTime.UtcNow }; // Setup based on type switch (message.SabotageType) { case SabotageType.CommsBlackout: SetupCommsBlackout(_currentSabotage); break; case SabotageType.CriticalMeltdown: SetupCriticalMeltdown(_currentSabotage); break; } _lastSabotageTime = DateTime.UtcNow; // Broadcast sabotage started var evt = CreateEvent("SabotageStarted", player.ClientUuid, new SabotageStartedPayload { SabotageId = _currentSabotage.SabotageId, Type = _currentSabotage.Type, InitiatorId = _currentSabotage.InitiatorId, Deadline = _currentSabotage.Deadline, RepairStations = _currentSabotage.RepairStations.Select(s => new RepairStationInfo { StationId = s.StationId, Name = s.Name, Location = s.Location, RepairDurationMs = s.RepairDurationMs }).ToList(), RequiredSimultaneousRepairs = _currentSabotage.RequiredSimultaneousRepairs }); PersistAndBroadcast(evt); // Start timer for auto-expire or meltdown _ = Task.Run(async () => { if (_currentSabotage.Deadline.HasValue) { // Critical meltdown - wait until deadline await Task.Delay(_currentSabotage.Deadline.Value - DateTime.UtcNow); await EnqueueAsync(new SabotageMeltdownAction { SabotageId = _currentSabotage.SabotageId }); } else { // Comms blackout - auto-expire await Task.Delay(_config.CommsBlackoutDurationMs); await EnqueueAsync(new SabotageExpireAction { SabotageId = _currentSabotage.SabotageId }); } }); _logger.LogInformation("Sabotáž spuštěna: {Type} by {Player}", message.SabotageType, player.DisplayName); } private void SetupCommsBlackout(Sabotage sabotage) { sabotage.RequiredSimultaneousRepairs = 1; // Get a reachable position using map data if available var positions = _gameLogic.GenerateRepairStationPositions( 1, Settings.PlayAreaCenter, Settings.PlayAreaRadius, Settings.MapData); var station = new RepairStation { StationId = "comms_station", Name = "Komunikační věž", Location = positions.FirstOrDefault(), RepairDurationMs = _config.RepairStationHoldMs }; sabotage.RepairStations.Add(station); } private void SetupCriticalMeltdown(Sabotage sabotage) { sabotage.Deadline = DateTime.UtcNow.AddMilliseconds(_config.CriticalMeltdownDeadlineMs); sabotage.RequiredSimultaneousRepairs = 2; // Two repair stations - use map data for reachable positions if available var positions = _gameLogic.GenerateRepairStationPositions( 2, Settings.PlayAreaCenter, Settings.PlayAreaRadius, Settings.MapData); var station1 = new RepairStation { StationId = "reactor_alpha", Name = "Reaktor Alpha", Location = positions.Count > 0 ? positions[0] : Settings.PlayAreaCenter, RepairDurationMs = _config.RepairStationHoldMs }; var station2 = new RepairStation { StationId = "reactor_beta", Name = "Reaktor Beta", Location = positions.Count > 1 ? positions[1] : Settings.PlayAreaCenter, RepairDurationMs = _config.RepairStationHoldMs }; sabotage.RepairStations.Add(station1); sabotage.RepairStations.Add(station2); } private Position GenerateRandomLocation() { // Try to use map data for reachable position if (Settings.MapData != null && _overpassService != null) { var pos = _overpassService.GetRandomReachablePosition(Settings.MapData, new Random()); if (pos.HasValue) { return pos.Value; } } // Fallback to random position var center = Settings.PlayAreaCenter; var radius = Settings.PlayAreaRadius * 0.6; // 60% of play area var random = new Random(); var angle = random.NextDouble() * 2 * Math.PI; var dist = random.NextDouble() * radius; var latOffset = (dist * Math.Cos(angle)) / 111000.0; var lonOffset = (dist * Math.Sin(angle)) / (111000.0 * Math.Cos(center.Lat * Math.PI / 180)); return new Position(center.Lat + latOffset, center.Lon + lonOffset); } private void ProcessActivateRepairStation(Player player, ActivateRepairStation message, ref Ack ack) { if (_currentSabotage == null || _currentSabotage.State != SabotageState.Active) { ack.Success = false; ack.Error = "Žádná aktivní sabotáž"; return; } // Dead players can't repair if (player.State != PlayerState.Alive) { ack.Success = false; ack.Error = "Mrtví hráči nemohou opravovat"; return; } var station = _currentSabotage.RepairStations.FirstOrDefault(s => s.StationId == message.StationId); if (station == null) { ack.Success = false; ack.Error = "Neznámá stanice"; return; } // Check distance var distance = player.Position.DistanceTo(station.Location); if (distance > _config.RepairStationDistanceM) { ack.Success = false; ack.Error = $"Příliš daleko od stanice ({distance:F1}m)"; return; } // INSTANT oprava - klient může simulovat progress bar lokálně station.IsRepaired = true; station.RepairingPlayerId = player.ClientUuid; station.RepairStartTime = DateTime.UtcNow; _logger.LogInformation("Stanice opravena: {Station} by {Player}", station.Name, player.DisplayName); // Broadcast that this station's repair started so other clients can // surface "1/2 stations active" coaching during multi-station // (meltdown) sabotage. Without this, only the meltdown countdown // is visible - players don't know whether anyone else is helping. var startedEvt = CreateEvent("RepairStarted", player.ClientUuid, new RepairStartedPayload { SabotageId = _currentSabotage.SabotageId, StationId = station.StationId, PlayerId = player.ClientUuid }); PersistAndBroadcast(startedEvt); // Zkontroluj zda je sabotáž kompletně opravena CheckSabotageRepairComplete(player.ClientUuid, station.StationId); } private void ProcessDeactivateRepairStation(Player player, DeactivateRepairStation message, ref Ack ack) { if (_currentSabotage == null) { ack.Success = false; ack.Error = "Žádná aktivní sabotáž"; return; } var station = _currentSabotage.RepairStations.FirstOrDefault(s => s.StationId == message.StationId); if (station == null) { ack.Success = false; ack.Error = "Neznámá stanice"; return; } if (station.RepairingPlayerId != player.ClientUuid) { ack.Success = false; ack.Error = "Tuto stanici neopravujete"; return; } // Stop repairing station.IsBeingRepaired = false; station.RepairingPlayerId = null; station.RepairStartTime = null; var evt = CreateEvent("RepairStopped", player.ClientUuid, new RepairStoppedPayload { SabotageId = _currentSabotage.SabotageId, StationId = station.StationId, PlayerId = player.ClientUuid }); PersistAndBroadcast(evt); _logger.LogInformation("Oprava přerušena: {Station} by {Player}", station.Name, player.DisplayName); } private void ProcessRepairComplete(RepairCompleteAction action) { if (_currentSabotage == null || _currentSabotage.SabotageId != action.SabotageId) return; if (_currentSabotage.State != SabotageState.Active) return; var station = _currentSabotage.RepairStations.FirstOrDefault(s => s.StationId == action.StationId); if (station == null || station.RepairingPlayerId != action.PlayerId) return; // Check if player is still near the station if (_players.TryGetValue(action.PlayerId, out var player)) { var distance = player.Position.DistanceTo(station.Location); if (distance > _config.RepairStationDistanceM) { // Player moved away, cancel repair station.IsBeingRepaired = false; station.RepairingPlayerId = null; station.RepairStartTime = null; return; } } // Mark station as repair complete (keep IsBeingRepaired true for multi-station check) station.IsRepaired = true; // Check repair completion based on sabotage type if (_currentSabotage.RequiredSimultaneousRepairs == 1) { // Simple single-station repair station.IsBeingRepaired = false; CompleteSabotageRepair(new List { action.PlayerId }); } else { // Multi-station simultaneous repair var repairedStations = _currentSabotage.RepairStations .Where(s => s.IsRepaired && s.RepairStartTime.HasValue) .ToList(); if (repairedStations.Count >= _currentSabotage.RequiredSimultaneousRepairs) { // Check if repairs completed within the time window var repairCompleteTimes = repairedStations .Select(s => s.RepairStartTime!.Value.AddMilliseconds(s.RepairDurationMs)) .OrderBy(t => t) .ToList(); var timeDiff = (repairCompleteTimes.Last() - repairCompleteTimes.First()).TotalMilliseconds; if (timeDiff <= _config.SimultaneousRepairWindowMs) { // Simultaneous repair successful var repairerIds = repairedStations .Where(s => s.RepairingPlayerId != null) .Select(s => s.RepairingPlayerId!) .ToList(); // Clear repair states foreach (var s in _currentSabotage.RepairStations) { s.IsBeingRepaired = false; } CompleteSabotageRepair(repairerIds); } } } } private void CheckSabotageRepairComplete(string playerId, string stationId) { if (_currentSabotage == null) return; // Check repair completion based on sabotage type if (_currentSabotage.RequiredSimultaneousRepairs == 1) { // Simple single-station repair - done! CompleteSabotageRepair(new List { playerId }); } else { // Multi-station repair - check if all stations are repaired var repairedStations = _currentSabotage.RepairStations.Where(s => s.IsRepaired).ToList(); if (repairedStations.Count >= _currentSabotage.RequiredSimultaneousRepairs) { // Check if repairs completed within the time window var repairTimes = repairedStations .Where(s => s.RepairStartTime.HasValue) .Select(s => s.RepairStartTime!.Value) .OrderBy(t => t) .ToList(); if (repairTimes.Count >= 2) { var timeDiff = (repairTimes.Last() - repairTimes.First()).TotalMilliseconds; if (timeDiff <= _config.SimultaneousRepairWindowMs) { // Simultaneous repair successful! var repairerIds = repairedStations .Where(s => s.RepairingPlayerId != null) .Select(s => s.RepairingPlayerId!) .ToList(); CompleteSabotageRepair(repairerIds); } else { // Too slow - reset the older repairs foreach (var s in _currentSabotage.RepairStations) { if (s.StationId != stationId) { s.IsRepaired = false; s.RepairingPlayerId = null; s.RepairStartTime = null; } } _logger.LogInformation("Opravy nebyly simultánní - reset starších oprav"); } } } } } private void CompleteSabotageRepair(List repairerIds) { if (_currentSabotage == null) return; _currentSabotage.State = SabotageState.Repaired; _currentSabotage.RepairedAt = DateTime.UtcNow; var evt = CreateEvent("SabotageRepaired", repairerIds.First(), new SabotageRepairedPayload { SabotageId = _currentSabotage.SabotageId, Type = _currentSabotage.Type, RepairerIds = repairerIds }); PersistAndBroadcast(evt); _logger.LogInformation("Sabotáž opravena: {Type} by {Players}", _currentSabotage.Type, string.Join(", ", repairerIds)); _currentSabotage = null; } private void ProcessSabotageMeltdown(SabotageMeltdownAction action) { if (_currentSabotage == null || _currentSabotage.SabotageId != action.SabotageId) return; if (_currentSabotage.State != SabotageState.Active) return; // Meltdown - impostors win! var meltdownEvt = CreateEvent("SabotageMeltdown", _currentSabotage.InitiatorId, new SabotageMeltdownPayload { SabotageId = _currentSabotage.SabotageId }); PersistAndBroadcast(meltdownEvt); // End game - impostor win EndGame("Impostor", "Kritická sabotáž - meltdown"); } private void ProcessSabotageExpire(SabotageExpireAction action) { if (_currentSabotage == null || _currentSabotage.SabotageId != action.SabotageId) return; if (_currentSabotage.State != SabotageState.Active) return; // Comms blackout expired naturally _currentSabotage.State = SabotageState.Repaired; _currentSabotage = null; var evt = CreateEvent("SabotageExpired", "system", new SabotageRepairedPayload { SabotageId = action.SabotageId, Type = SabotageType.CommsBlackout, RepairerIds = new List() }); PersistAndBroadcast(evt); _logger.LogInformation("Sabotáž vypršela: CommsBlackout"); } /// /// Check if comms sabotage blocks reporting/meetings /// public bool IsCommsBlocked() { return _currentSabotage != null && _currentSabotage.Type == SabotageType.CommsBlackout && _currentSabotage.State == SabotageState.Active; } /// /// Check if reactor meltdown blocks reporting/meetings /// public bool IsMeltdownActive() { return _currentSabotage != null && _currentSabotage.Type == SabotageType.CriticalMeltdown && _currentSabotage.State == SabotageState.Active; } /// /// Get current sabotage info (for state sync) /// public Sabotage? GetCurrentSabotage() => _currentSabotage; #endregion #region P12 Admin actions /// /// Admin force-end-game implementation. Tears down active meeting/sabotage /// state, sets phase to Ended, and broadcasts a GameEnded event with /// "admin" as the winning faction so spectators see the interruption /// reason. The lobby actor stays alive for a return-to-lobby loop. /// private void ProcessAdminForceEndGame(AdminForceEndGameAction action) { try { if (_phase == GamePhase.Lobby || _phase == GamePhase.Ended) { action.Result.TrySetResult(false); return; } _currentMeeting = null; _currentSabotage = null; _bodies.Clear(); _phase = GamePhase.Ended; var evt = CreateEvent("GameEnded", "admin", new GameEndedPayload { WinningFaction = "Admin", Reason = action.Reason }); PersistAndBroadcast(evt); _logger.LogWarning("[Admin] Force-ended game in lobby {Id}: {Reason}", _lobbyId, action.Reason); action.Result.TrySetResult(true); } catch (Exception ex) { _logger.LogError(ex, "[Admin] ForceEndGame failed"); action.Result.TrySetResult(false); } } /// /// Admin set-phase implementation. Skips most of the natural phase- /// transition side effects (role assignment, map fetch etc.) - it's an /// override, not a full transition. Used to unstick games or jump to /// Ended for cleanup. The phase change is broadcast so spectators see /// the new state on their next push. /// private void ProcessAdminSetPhase(AdminSetPhaseAction action) { try { var prev = _phase; _phase = action.TargetPhase; var evt = CreateEvent("PhaseChanged", "admin", new PhaseChangedPayload { Phase = action.TargetPhase }); PersistAndBroadcast(evt); _logger.LogWarning("[Admin] Force-set phase {Prev} -> {Next} in lobby {Id}", prev, action.TargetPhase, _lobbyId); action.Result.TrySetResult(true); } catch (Exception ex) { _logger.LogError(ex, "[Admin] SetPhase failed"); action.Result.TrySetResult(false); } } /// /// Admin update-settings implementation. Only takes effect during the /// Lobby phase (changing impostor count mid-game would be incoherent; /// changing radius requires a re-fetch of map data which we don't try /// to coordinate from here). Each field is individually optional - only /// non-null values are applied. /// private void ProcessAdminUpdateSettings(AdminUpdateSettingsAction action) { try { if (_phase != GamePhase.Lobby) { action.Result.TrySetResult(false); return; } if (action.NewRadius.HasValue && action.NewRadius.Value > 0) Settings.PlayAreaRadius = action.NewRadius.Value; if (action.NewImpostorCount.HasValue && action.NewImpostorCount.Value >= 1 && action.NewImpostorCount.Value <= 8) Settings.ImpostorCount = action.NewImpostorCount.Value; if (action.NewTaskCount.HasValue && action.NewTaskCount.Value >= 1 && action.NewTaskCount.Value <= 30) Settings.TaskCount = action.NewTaskCount.Value; if (!string.IsNullOrEmpty(action.NewTiePolicy) && System.Enum.TryParse(action.NewTiePolicy, ignoreCase: true, out var tp)) Settings.TiePolicy = tp; // Broadcast a settings-changed event so all clients refresh their // lobby UI with the new values. Reuses the existing PhaseChanged // payload shape with current phase as a no-op-style notification; // a dedicated SettingsChanged payload would be cleaner but // requires touching all three Protocol.cs files. var evt = CreateEvent("LobbySettingsChanged", "admin", new LobbySettingsChangedPayload { Radius = Settings.PlayAreaRadius, ImpostorCount = Settings.ImpostorCount, TaskCount = Settings.TaskCount, TiePolicy = Settings.TiePolicy.ToString() }); PersistAndBroadcast(evt); _logger.LogInformation("[Admin] Updated settings in lobby {Id}: radius={R} impostors={I} tasks={T} tie={Tp}", _lobbyId, Settings.PlayAreaRadius, Settings.ImpostorCount, Settings.TaskCount, Settings.TiePolicy); action.Result.TrySetResult(true); } catch (Exception ex) { _logger.LogError(ex, "[Admin] UpdateSettings failed"); action.Result.TrySetResult(false); } } #endregion #region Snapshots private void CheckSnapshot() { var shouldSnapshot = _snapshotEventCounter >= _config.SnapshotEvents || (DateTime.UtcNow - _lastSnapshotTime).TotalMilliseconds >= _config.SnapshotIntervalMs; if (shouldSnapshot) { SaveSnapshot(); } } private void SaveSnapshot() { var snapshot = new LobbySnapshot { LobbyId = _lobbyId, LastEventId = _eventId, Phase = _phase, Players = _players.Values.ToList(), Bodies = _bodies.ToList(), Tasks = _tasks.ToList(), CurrentMeeting = _currentMeeting, PlayAreaCenter = Settings.PlayAreaCenter, PlayAreaRadius = Settings.PlayAreaRadius, ImpostorCount = Settings.ImpostorCount, TiePolicy = Settings.TiePolicy, Checksum = "" // Bude vyplněno v Persistence }; _persistence.SaveSnapshot(_lobbyId, snapshot); _snapshotEventCounter = 0; _lastSnapshotTime = DateTime.UtcNow; } #endregion public void Dispose() { _cts.Cancel(); _cts.Dispose(); } } #region Action types abstract class LobbyAction { public TaskCompletionSource? Completion { get; set; } } class AddPlayerAction : LobbyAction { public required string ClientUuid { get; set; } public required string DisplayName { get; set; } public bool IsOwner { get; set; } } class RemovePlayerAction : LobbyAction { public required string ClientUuid { get; set; } public string? Reason { get; set; } } class HandleMessageAction : LobbyAction { public required string ClientUuid { get; set; } public required Message Message { get; set; } } class CloseMeetingAction : LobbyAction { public required string MeetingId { get; set; } } class CheckMeetingArrivalAction : LobbyAction { public required string MeetingId { get; set; } } class RepairCompleteAction : LobbyAction { public required string SabotageId { get; set; } public required string StationId { get; set; } public required string PlayerId { get; set; } } class SabotageMeltdownAction : LobbyAction { public required string SabotageId { get; set; } } class SabotageExpireAction : LobbyAction { public required string SabotageId { get; set; } } // ── P12: Admin lobby manipulation actions ──────────────────────────────────── // Each carries a TaskCompletionSource so the caller can await the // outcome (true = applied, false = rejected by validation / phase gate). // The completion is signaled inside the per-action Process* implementation, // not by the generic ProcessActionAsync trySetResult hook. class AdminForceEndGameAction : LobbyAction { public required string Reason { get; set; } public required TaskCompletionSource Result { get; set; } } class AdminSetPhaseAction : LobbyAction { public required GamePhase TargetPhase { get; set; } public required TaskCompletionSource Result { get; set; } } class AdminUpdateSettingsAction : LobbyAction { public double? NewRadius { get; set; } public int? NewImpostorCount { get; set; } public int? NewTaskCount { get; set; } public string? NewTiePolicy { get; set; } public required TaskCompletionSource Result { get; set; } } #endregion // Stub pro ClientConnection - bude implementováno v Program.cs public class ClientConnection { public required string ClientUuid { get; set; } public required Func SendAsync { get; set; } } // Extension metoda pro Dictionary public static class DictionaryExtensions { public static bool TryRemove(this Dictionary dict, TKey key, out TValue? value) where TKey : notnull { if (dict.TryGetValue(key, out value)) { dict.Remove(key); return true; } value = default; return false; } } // Admin panel DTOs public class AdminPlayerInfo { public required string PlayerId { get; set; } public required string DisplayName { get; set; } public required PlayerState State { get; set; } public required PlayerRole Role { get; set; } public required Position Position { get; set; } public bool IsHost { get; set; } public int CheatScore { get; set; } public DateTime? KillCooldownEnd { get; set; } public string? VotedFor { get; set; } } public class AdminTaskInfo { public required string TaskId { get; set; } public required string Name { get; set; } public required Position Location { get; set; } public HashSet CompletedBy { get; set; } = new(); } public class AdminBodyInfo { public required string VictimId { get; set; } public required Position Position { get; set; } public DateTime? ReportedAt { get; set; } } public class SystemMessagePayload { public required string Message { get; set; } public DateTime Timestamp { get; set; } }