2274 lines
78 KiB
C#
2274 lines
78 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;
|
|
}
|
|
|
|
// ── 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.
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<bool> TryForceEndGameAsync(string adminReason = "ended by admin")
|
|
{
|
|
var done = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
await EnqueueAsync(new AdminForceEndGameAction
|
|
{
|
|
Reason = adminReason,
|
|
Result = done
|
|
});
|
|
return await done.Task;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public async Task<bool> TryAdminSetPhaseAsync(string targetPhase)
|
|
{
|
|
if (!System.Enum.TryParse<GamePhase>(targetPhase, ignoreCase: true, out var p))
|
|
return false;
|
|
if (p == GamePhase.Voting) return false; // dead value
|
|
|
|
var done = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
await EnqueueAsync(new AdminSetPhaseAction { TargetPhase = p, Result = done });
|
|
return await done.Task;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<bool> TryUpdateLobbySettingsAsync(double? radius, int? impostorCount, int? taskCount, string? tiePolicy)
|
|
{
|
|
var done = new TaskCompletionSource<bool>(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 });
|
|
});
|
|
}
|
|
|
|
/// <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 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<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 P12 Admin actions
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<TiePolicy>(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<bool> 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<bool> Result { get; set; }
|
|
}
|
|
|
|
class AdminSetPhaseAction : LobbyAction
|
|
{
|
|
public required GamePhase TargetPhase { get; set; }
|
|
public required TaskCompletionSource<bool> 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<bool> 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<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; }
|
|
}
|