This commit is contained in:
Bandwidth
2026-04-26 18:33:24 +02:00
parent e9c85ac8d3
commit 796ba0906d
5 changed files with 651 additions and 40 deletions

View File

@@ -244,12 +244,72 @@ public class LobbyActor : IDisposable
{
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();
@@ -329,6 +389,15 @@ public class LobbyActor : IDisposable
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í
@@ -1889,9 +1958,136 @@ public class LobbyActor : IDisposable
/// 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()
@@ -1990,6 +2186,33 @@ 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