Files
Server/LobbyActor.cs
2026-04-26 12:44:06 +02:00

1982 lines
66 KiB
C#

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<LobbyActor> _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<LobbyAction> _actionChannel;
private readonly CancellationTokenSource _cts = new();
private readonly Task _processingTask;
// State
private readonly Dictionary<string, Player> _players = new();
private readonly Dictionary<string, ClientConnection> _connections = new();
private readonly List<Body> _bodies = new();
private readonly List<GameTask> _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<string> _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<LobbyActor> 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<LobbyAction>(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
};
}
/// <summary>
/// Get all players for admin panel spectate view
/// </summary>
public IEnumerable<AdminPlayerInfo> 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
});
}
/// <summary>
/// Get all tasks for admin panel spectate view
/// </summary>
public IEnumerable<AdminTaskInfo> GetTasks()
{
// Build completed by sets from player data
var completedByTask = new Dictionary<string, HashSet<string>>();
foreach (var player in _players.Values)
{
foreach (var taskId in player.CompletedTaskIds)
{
if (!completedByTask.ContainsKey(taskId))
completedByTask[taskId] = new HashSet<string>();
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<string>()
});
}
/// <summary>
/// Get all bodies for admin panel spectate view
/// </summary>
public IEnumerable<AdminBodyInfo> GetBodies()
{
return _bodies.Select(b => new AdminBodyInfo
{
VictimId = b.VictimId,
Position = b.Location,
ReportedAt = b.Reported ? DateTime.UtcNow : null
});
}
/// <summary>
/// Get current votes during voting phase
/// </summary>
public Dictionary<string, string?>? GetVotes()
{
return _currentMeeting?.Votes;
}
/// <summary>
/// Broadcast system message to all players in lobby
/// </summary>
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);
}
/// <summary>
/// Kick player from lobby (admin action)
/// </summary>
public bool TryKickPlayer(string playerId, string reason)
{
if (!_players.ContainsKey(playerId))
return false;
// Odstraníme hráče asynchronně
_ = RemovePlayerAsync(playerId, reason);
return true;
}
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;
}
// 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 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 });
});
}
/// <summary>
/// Zkontroluje všechny hráče zda jsou na meeting location
/// </summary>
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 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);
// 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<string> { 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<string> { 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<string> 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<string>()
});
PersistAndBroadcast(evt);
_logger.LogInformation("Sabotáž vypršela: CommsBlackout");
}
/// <summary>
/// Check if comms sabotage blocks reporting/meetings
/// </summary>
public bool IsCommsBlocked()
{
return _currentSabotage != null &&
_currentSabotage.Type == SabotageType.CommsBlackout &&
_currentSabotage.State == SabotageState.Active;
}
/// <summary>
/// Check if reactor meltdown blocks reporting/meetings
/// </summary>
public bool IsMeltdownActive()
{
return _currentSabotage != null &&
_currentSabotage.Type == SabotageType.CriticalMeltdown &&
_currentSabotage.State == SabotageState.Active;
}
/// <summary>
/// Get current sabotage info (for state sync)
/// </summary>
public Sabotage? GetCurrentSabotage() => _currentSabotage;
#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; }
}
#endregion
// Stub pro ClientConnection - bude implementováno v Program.cs
public class ClientConnection
{
public required string ClientUuid { get; set; }
public required Func<Message, Task> SendAsync { get; set; }
}
// Extension metoda pro Dictionary
public static class DictionaryExtensions
{
public static bool TryRemove<TKey, TValue>(this Dictionary<TKey, TValue> 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<string> 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; }
}