fixes 2
This commit is contained in:
178
AdminPanel.cs
178
AdminPanel.cs
@@ -180,7 +180,20 @@ public class AdminPanel
|
||||
case "/admin/api/broadcast":
|
||||
await HandleBroadcastAsync(request, response);
|
||||
break;
|
||||
|
||||
|
||||
// ── P12: lobby manipulation endpoints ────────────────────
|
||||
case "/admin/api/lobby/end":
|
||||
await HandleLobbyForceEndAsync(request, response);
|
||||
break;
|
||||
|
||||
case "/admin/api/lobby/phase":
|
||||
await HandleLobbySetPhaseAsync(request, response);
|
||||
break;
|
||||
|
||||
case "/admin/api/lobby/settings":
|
||||
await HandleLobbyUpdateSettingsAsync(request, response);
|
||||
break;
|
||||
|
||||
case "/admin/api/logout":
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true });
|
||||
@@ -412,10 +425,10 @@ public class AdminPanel
|
||||
using var reader = new StreamReader(request.InputStream);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
|
||||
|
||||
var message = data.GetProperty("message").GetString();
|
||||
var lobbyId = data.TryGetProperty("lobbyId", out var l) ? l.GetString() : null;
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(lobbyId))
|
||||
{
|
||||
// Broadcast všem
|
||||
@@ -430,10 +443,115 @@ public class AdminPanel
|
||||
var lobby = _lobbyManager.GetLobby(lobbyId);
|
||||
lobby?.BroadcastSystemMessage(message!);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogInformation("Admin broadcast: {Message}", message);
|
||||
await WriteJsonAsync(response, new { success = true });
|
||||
}
|
||||
|
||||
// ── P12: lobby manipulation endpoint handlers ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Force-end a game in a specific lobby. POST { "lobbyId": "...", "reason": "optional" }.
|
||||
/// Returns { success: true } on application, 404 if lobby missing, or
|
||||
/// 409 if the lobby was already in Lobby/Ended phase (nothing to end).
|
||||
/// </summary>
|
||||
private async Task HandleLobbyForceEndAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
using var reader = new StreamReader(request.InputStream);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
|
||||
var lobbyId = data.GetProperty("lobbyId").GetString();
|
||||
var reason = data.TryGetProperty("reason", out var r) ? (r.GetString() ?? "ended by admin") : "ended by admin";
|
||||
|
||||
var lobby = _lobbyManager.GetLobby(lobbyId!);
|
||||
if (lobby == null)
|
||||
{
|
||||
response.StatusCode = 404;
|
||||
await WriteJsonAsync(response, new { error = "Lobby not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
var ok = await lobby.TryForceEndGameAsync(reason);
|
||||
if (!ok)
|
||||
{
|
||||
response.StatusCode = 409;
|
||||
await WriteJsonAsync(response, new { error = "Cannot force-end - lobby not in an active game phase" });
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteJsonAsync(response, new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force a lobby into a specific phase. POST { "lobbyId": "...", "phase": "Playing|Meeting|Ended|..." }.
|
||||
/// Voting is rejected (it's not a real wire phase - lives inside Meeting).
|
||||
/// </summary>
|
||||
private async Task HandleLobbySetPhaseAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
using var reader = new StreamReader(request.InputStream);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
|
||||
var lobbyId = data.GetProperty("lobbyId").GetString();
|
||||
var phase = data.GetProperty("phase").GetString();
|
||||
|
||||
var lobby = _lobbyManager.GetLobby(lobbyId!);
|
||||
if (lobby == null)
|
||||
{
|
||||
response.StatusCode = 404;
|
||||
await WriteJsonAsync(response, new { error = "Lobby not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
var ok = await lobby.TryAdminSetPhaseAsync(phase!);
|
||||
if (!ok)
|
||||
{
|
||||
response.StatusCode = 400;
|
||||
await WriteJsonAsync(response, new { error = "Invalid phase (rejected: 'Voting' is not a wire phase)" });
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteJsonAsync(response, new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update lobby settings. POST { "lobbyId": "...", "radius": ..., "impostorCount": ..., "taskCount": ..., "tiePolicy": "..." }.
|
||||
/// All settings fields optional - only those present in the body are
|
||||
/// applied. Lobby must be in Lobby phase; mid-game settings changes are
|
||||
/// rejected to keep gameplay consistent.
|
||||
/// </summary>
|
||||
private async Task HandleLobbyUpdateSettingsAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
using var reader = new StreamReader(request.InputStream);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
|
||||
var lobbyId = data.GetProperty("lobbyId").GetString();
|
||||
|
||||
double? radius = data.TryGetProperty("radius", out var rd) && rd.ValueKind == JsonValueKind.Number ? rd.GetDouble() : (double?)null;
|
||||
int? impostors = data.TryGetProperty("impostorCount", out var ic) && ic.ValueKind == JsonValueKind.Number ? ic.GetInt32() : (int?)null;
|
||||
int? tasks = data.TryGetProperty("taskCount", out var tc) && tc.ValueKind == JsonValueKind.Number ? tc.GetInt32() : (int?)null;
|
||||
string? tiePolicy = data.TryGetProperty("tiePolicy", out var tp) ? tp.GetString() : null;
|
||||
|
||||
var lobby = _lobbyManager.GetLobby(lobbyId!);
|
||||
if (lobby == null)
|
||||
{
|
||||
response.StatusCode = 404;
|
||||
await WriteJsonAsync(response, new { error = "Lobby not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
var ok = await lobby.TryUpdateLobbySettingsAsync(radius, impostors, tasks, tiePolicy);
|
||||
if (!ok)
|
||||
{
|
||||
response.StatusCode = 409;
|
||||
await WriteJsonAsync(response, new { error = "Cannot update settings - lobby is past the Lobby phase" });
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteJsonAsync(response, new { success = true });
|
||||
}
|
||||
|
||||
#region ═══════════════════════════════════════════════════════════════════
|
||||
// WEBSOCKET - SPECTATE
|
||||
@@ -469,8 +587,11 @@ public class AdminPanel
|
||||
|
||||
try
|
||||
{
|
||||
// Pošli initial state
|
||||
await SendLobbyStateAsync(ws, lobby);
|
||||
// Pošli initial state - include the heavy mapData payload exactly
|
||||
// once per spectator session. The push loop below sends just the
|
||||
// dynamic fields (positions/votes/sabotage/etc.) at 5Hz.
|
||||
await SendLobbyStateAsync(ws, lobby, includeMapData: true);
|
||||
connection.MapDataSent = true;
|
||||
|
||||
var buffer = new byte[4096];
|
||||
var updateInterval = TimeSpan.FromMilliseconds(200);
|
||||
@@ -508,7 +629,10 @@ public class AdminPanel
|
||||
lobby = _lobbyManager.GetLobby(lobbyId);
|
||||
if (lobby != null)
|
||||
{
|
||||
await SendLobbyStateAsync(ws, lobby);
|
||||
// Subsequent ticks: skip mapData (heavy + immutable during
|
||||
// a game). Spectator's frontend cached it from the initial
|
||||
// push.
|
||||
await SendLobbyStateAsync(ws, lobby, includeMapData: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -549,7 +673,15 @@ public class AdminPanel
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby)
|
||||
/// <summary>
|
||||
/// Push the lobby state to a spectator's WebSocket. When `includeMapData`
|
||||
/// is false (the default for the periodic 5Hz push loop) the heavy
|
||||
/// Overpass payload is omitted - the frontend keeps the copy it received
|
||||
/// on the initial push. This was the dominant cost in the "admin panel
|
||||
/// slow to show spectating" complaint: a typical lobby's mapData is tens
|
||||
/// of KB and serializing it 5x per second per spectator added up fast.
|
||||
/// </summary>
|
||||
private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby, bool includeMapData = true)
|
||||
{
|
||||
var state = new
|
||||
{
|
||||
@@ -560,7 +692,7 @@ public class AdminPanel
|
||||
joinCode = lobby.JoinCode,
|
||||
phase = lobby.Phase.ToString(),
|
||||
phaseEndTime = lobby.PhaseEndTime?.ToString("o"),
|
||||
|
||||
|
||||
players = lobby.GetPlayers().Select(p => new
|
||||
{
|
||||
id = p.PlayerId,
|
||||
@@ -574,7 +706,7 @@ public class AdminPanel
|
||||
killCooldownEnd = p.KillCooldownEnd?.ToString("o"),
|
||||
votedFor = p.VotedFor
|
||||
}),
|
||||
|
||||
|
||||
tasks = lobby.GetTasks().Select(t => new
|
||||
{
|
||||
id = t.TaskId,
|
||||
@@ -583,7 +715,7 @@ public class AdminPanel
|
||||
lng = t.Location.Lon,
|
||||
completedBy = t.CompletedBy.ToList()
|
||||
}),
|
||||
|
||||
|
||||
bodies = lobby.GetBodies().Select(b => new
|
||||
{
|
||||
victimId = b.VictimId,
|
||||
@@ -591,7 +723,7 @@ public class AdminPanel
|
||||
lng = b.Position.Lon,
|
||||
reportedAt = b.ReportedAt?.ToString("o")
|
||||
}),
|
||||
|
||||
|
||||
sabotage = lobby.ActiveSabotage != null ? new
|
||||
{
|
||||
type = lobby.ActiveSabotage.Type.ToString(),
|
||||
@@ -604,9 +736,9 @@ public class AdminPanel
|
||||
isRepaired = rs.IsRepaired
|
||||
})
|
||||
} : null,
|
||||
|
||||
|
||||
votes = lobby.Phase == GamePhase.Voting ? lobby.GetVotes() : null,
|
||||
|
||||
|
||||
settings = new
|
||||
{
|
||||
center = new { lat = lobby.Settings.PlayAreaCenter.Lat, lng = lobby.Settings.PlayAreaCenter.Lon },
|
||||
@@ -614,15 +746,16 @@ public class AdminPanel
|
||||
impostorCount = lobby.Settings.ImpostorCount,
|
||||
taskCount = lobby.Settings.TaskCount
|
||||
},
|
||||
|
||||
mapData = lobby.MapData // Overpass data pro vykreslení mapy
|
||||
|
||||
// mapData on first push only - frontend caches it client-side.
|
||||
mapData = includeMapData ? lobby.MapData : null
|
||||
},
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
|
||||
|
||||
var json = JsonSerializer.Serialize(state, JsonOptions.Default);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
|
||||
if (ws.State == WebSocketState.Open)
|
||||
{
|
||||
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
@@ -792,4 +925,13 @@ public class SpectatorConnection
|
||||
public required WebSocket WebSocket { get; set; }
|
||||
public required string LobbyId { get; set; }
|
||||
public required string SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// P12: per-spectator flag to ensure we serialize the heavy `mapData`
|
||||
/// payload only once at session start. The previous push loop included
|
||||
/// it on every 200ms tick, which dominated CPU + bandwidth and made the
|
||||
/// admin panel feel laggy ("slow to show spectating") even though the
|
||||
/// position/vote/role updates were already arriving promptly.
|
||||
/// </summary>
|
||||
public bool MapDataSent { get; set; } = false;
|
||||
}
|
||||
|
||||
24
GameLogic.cs
24
GameLogic.cs
@@ -352,17 +352,27 @@ public class GameLogic
|
||||
var aliveCrew = players.Values.Count(p => p.State == PlayerState.Alive && p.Role == PlayerRole.Crew);
|
||||
var aliveImpostors = players.Values.Count(p => p.State == PlayerState.Alive && p.Role == PlayerRole.Impostor);
|
||||
|
||||
// Impostoři vyhráli - mají většinu nebo rovnost
|
||||
if (aliveImpostors >= aliveCrew && aliveCrew > 0)
|
||||
{
|
||||
return (true, "Impostor", "Impostoři mají převahu");
|
||||
}
|
||||
|
||||
// Všichni impostoři mrtví
|
||||
// Všichni impostoři mrtví -> crew vyhrál (kontrolujeme nejdřív, ošetří
|
||||
// i edge case 0 impostorů + 0 crew - vrátí "crew vyhrál" místo aby
|
||||
// padlo dál do impostor-win větve)
|
||||
if (aliveImpostors == 0)
|
||||
{
|
||||
return (true, "Crew", "Všichni impostoři eliminováni");
|
||||
}
|
||||
|
||||
// Žádní crewmati naživu -> impostoři vyhráli (kritický fix: P9 -
|
||||
// při ejekci posledního crewmate stará podmínka `aliveCrew > 0`
|
||||
// shodila tuto větev a hra pokračovala s 0 crew naživu).
|
||||
if (aliveCrew == 0)
|
||||
{
|
||||
return (true, "Impostor", "Všichni crewmati eliminováni");
|
||||
}
|
||||
|
||||
// Impostoři vyhráli - mají většinu nebo rovnost (oba > 0)
|
||||
if (aliveImpostors >= aliveCrew)
|
||||
{
|
||||
return (true, "Impostor", "Impostoři mají převahu");
|
||||
}
|
||||
|
||||
// Všechny tasky hotové (počítáme pouze crew tasky)
|
||||
var crewPlayers = players.Values.Where(p => p.Role == PlayerRole.Crew).ToList();
|
||||
|
||||
229
LobbyActor.cs
229
LobbyActor.cs
@@ -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
|
||||
|
||||
@@ -196,7 +196,8 @@ out skel qt;
|
||||
PathType = ClassifyPathType(highwayType),
|
||||
Name = tags.GetValueOrDefault("name"),
|
||||
IsWalkable = IsWalkableHighway(highwayType),
|
||||
Width = EstimatePathWidth(highwayType)
|
||||
Width = EstimatePathWidth(highwayType),
|
||||
IsPubliclyAccessible = IsPubliclyAccessibleWay(highwayType, tags)
|
||||
});
|
||||
}
|
||||
else if (tags.ContainsKey("leisure"))
|
||||
@@ -326,6 +327,143 @@ out skel qt;
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P11: Strict public-access check for task placement. Returns true only
|
||||
/// when the way is unambiguously safe and legal for a foot player to
|
||||
/// stand on. Bandwidth's directive: "brutal check even at the cost of
|
||||
/// quality" - we'd rather refuse 80% of marginal candidates than place
|
||||
/// one task on a private driveway or a busy primary road. The `any
|
||||
/// pathway point` fallback in GetRandomReachablePosition still works for
|
||||
/// rural/forest scenarios where this filter rejects everything.
|
||||
///
|
||||
/// Hard rejects:
|
||||
/// - access tag in {private, no, customers, permit, forestry,
|
||||
/// agricultural, military, employees, delivery}
|
||||
/// - foot tag in {no, private, discouraged}
|
||||
/// - highway types known to be unsafe foot terrain (busy roads) or
|
||||
/// ambiguous (service ways often = parking lots / driveways unless
|
||||
/// explicitly tagged otherwise).
|
||||
/// </summary>
|
||||
private bool IsPubliclyAccessibleWay(string highway, Dictionary<string, string> tags)
|
||||
{
|
||||
// Hard-reject the highway types we never want a task on.
|
||||
switch (highway)
|
||||
{
|
||||
case "motorway":
|
||||
case "motorway_link":
|
||||
case "trunk":
|
||||
case "trunk_link":
|
||||
case "primary":
|
||||
case "primary_link":
|
||||
case "secondary":
|
||||
case "secondary_link":
|
||||
case "tertiary":
|
||||
case "tertiary_link":
|
||||
// Roads people drive fast on - even if foot is technically
|
||||
// allowed, putting a task target here invites tragedy.
|
||||
return false;
|
||||
case "construction":
|
||||
case "proposed":
|
||||
case "abandoned":
|
||||
case "razed":
|
||||
// Not even a real path right now.
|
||||
return false;
|
||||
case "raceway":
|
||||
case "bus_guideway":
|
||||
case "escape":
|
||||
// Specialized infrastructure, never appropriate.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Access tag: explicit private/restricted = reject.
|
||||
if (tags.TryGetValue("access", out var access))
|
||||
{
|
||||
access = access.ToLowerInvariant();
|
||||
switch (access)
|
||||
{
|
||||
case "private":
|
||||
case "no":
|
||||
case "customers":
|
||||
case "permit":
|
||||
case "forestry":
|
||||
case "agricultural":
|
||||
case "military":
|
||||
case "employees":
|
||||
case "delivery":
|
||||
case "destination":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// foot tag: explicit foot=no = reject regardless of highway type.
|
||||
if (tags.TryGetValue("foot", out var foot))
|
||||
{
|
||||
foot = foot.ToLowerInvariant();
|
||||
switch (foot)
|
||||
{
|
||||
case "no":
|
||||
case "private":
|
||||
case "discouraged":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// motor_vehicle / vehicle tags can flag a way as no-vehicles only,
|
||||
// which usually implies pedestrians are welcome - we don't reject
|
||||
// on those, just note them as positive evidence in the explicit-
|
||||
// approval branch below.
|
||||
|
||||
// Service ways: commonly driveways, parking lots, alleys. Reject by
|
||||
// default unless the access/foot/service tag explicitly opens it up.
|
||||
if (highway == "service")
|
||||
{
|
||||
// service=alley is generally walkable; service=driveway, parking_aisle,
|
||||
// emergency_access are not.
|
||||
if (tags.TryGetValue("service", out var serviceType))
|
||||
{
|
||||
serviceType = serviceType.ToLowerInvariant();
|
||||
if (serviceType == "alley") return true;
|
||||
// All other service subtypes default to "no" without explicit access=yes.
|
||||
if (access != null && (access == "yes" || access == "permissive" || access == "public"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
// No service subtag - too ambiguous, reject.
|
||||
// (Players can still walk on these IRL, but for "absolutely public"
|
||||
// we want clearer signal.)
|
||||
if (access == "yes" || access == "permissive" || access == "public")
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// bridleway: technically horse paths; foot may or may not be allowed.
|
||||
// Require explicit foot=yes/designated to opt in.
|
||||
if (highway == "bridleway")
|
||||
{
|
||||
if (foot != null && (foot == "yes" || foot == "designated" || foot == "permissive"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Highway types we trust as inherently public foot terrain.
|
||||
switch (highway)
|
||||
{
|
||||
case "footway":
|
||||
case "path":
|
||||
case "pedestrian":
|
||||
case "steps":
|
||||
case "cycleway":
|
||||
case "residential":
|
||||
case "living_street":
|
||||
case "track":
|
||||
return true;
|
||||
}
|
||||
|
||||
// Everything else: reject by default. The brutal-mode point is to
|
||||
// not gamble on tags we don't recognize.
|
||||
return false;
|
||||
}
|
||||
|
||||
private double EstimatePathWidth(string highway)
|
||||
{
|
||||
@@ -369,49 +507,116 @@ out skel qt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a random reachable position suitable for placing a task or repair station
|
||||
/// Get a random reachable position suitable for placing a task or repair station.
|
||||
///
|
||||
/// P11 cascade (each tier falls through to the next on empty result):
|
||||
/// 1. Reachable AND on a publicly-accessible way (brutal mode preferred).
|
||||
/// 2. Any reachable position (covers the rare case where reachability
|
||||
/// analysis hit a way we don't deem "absolutely public" but is
|
||||
/// still pathway-connected).
|
||||
/// 3. Any walkable pathway point in radius (used in dense urban OR
|
||||
/// rural - the fallback Bandwidth specifically asked for: "When
|
||||
/// starting a game when there are almost no roads at all (at a
|
||||
/// field or in a forest) we can place tasks wherever").
|
||||
/// </summary>
|
||||
public Position? GetRandomReachablePosition(MapData mapData, Random random, double minDistFromCenter = 0)
|
||||
{
|
||||
// Tier 1: reachable AND on a publicly-accessible way.
|
||||
var publicWayPoints = CollectPubliclyAccessiblePoints(mapData);
|
||||
var candidates = mapData.ReachablePositions
|
||||
.Where(p => p.DistanceTo(mapData.Center) >= minDistFromCenter)
|
||||
.Where(p => publicWayPoints.Contains(p))
|
||||
.ToList();
|
||||
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Fallback to any pathway point
|
||||
// Tier 2: any reachable, ignoring the public-only filter.
|
||||
candidates = mapData.ReachablePositions
|
||||
.Where(p => p.DistanceTo(mapData.Center) >= minDistFromCenter)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Tier 3: any walkable pathway point in radius (rural fallback).
|
||||
candidates = mapData.Pathways
|
||||
.Where(p => p.IsWalkable)
|
||||
.SelectMany(p => p.Points)
|
||||
.Where(p => p.DistanceTo(mapData.Center) <= mapData.RadiusMeters)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
|
||||
|
||||
return candidates[random.Next(candidates.Count)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set of all positions that lie on a publicly-accessible pathway. Built
|
||||
/// once per call site (cheap; pathway count is bounded by the radius).
|
||||
/// HashSet of Position relies on Position.Equals/GetHashCode being
|
||||
/// content-based; if that ever changes, swap to a custom comparer.
|
||||
/// </summary>
|
||||
private HashSet<Position> CollectPubliclyAccessiblePoints(MapData mapData)
|
||||
{
|
||||
var set = new HashSet<Position>();
|
||||
foreach (var path in mapData.Pathways)
|
||||
{
|
||||
if (!path.IsPubliclyAccessible) continue;
|
||||
foreach (var p in path.Points)
|
||||
set.Add(p);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get multiple well-distributed reachable positions (e.g., for placing multiple tasks)
|
||||
/// Get multiple well-distributed reachable positions (e.g., for placing
|
||||
/// multiple tasks). Same P11 tier cascade as GetRandomReachablePosition:
|
||||
/// public-only first, fall through to reachable-any, then any walkable
|
||||
/// pathway point if even that's empty.
|
||||
/// </summary>
|
||||
public List<Position> GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20)
|
||||
{
|
||||
var result = new List<Position>();
|
||||
var available = mapData.ReachablePositions.ToList();
|
||||
|
||||
|
||||
// Tier 1: reachable AND public.
|
||||
var publicWayPoints = CollectPubliclyAccessiblePoints(mapData);
|
||||
var available = mapData.ReachablePositions
|
||||
.Where(p => publicWayPoints.Contains(p))
|
||||
.ToList();
|
||||
|
||||
// Tier 2: any reachable.
|
||||
if (available.Count == 0)
|
||||
available = mapData.ReachablePositions.ToList();
|
||||
|
||||
// Tier 3: any walkable pathway point in radius. Logged so the server
|
||||
// op can see when the brutal filter exhausted itself - useful in
|
||||
// testing to know whether the area has decent OSM coverage.
|
||||
if (available.Count == 0)
|
||||
{
|
||||
available = mapData.Pathways
|
||||
.Where(p => p.IsWalkable)
|
||||
.SelectMany(p => p.Points)
|
||||
.Where(p => p.DistanceTo(mapData.Center) <= mapData.RadiusMeters)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (available.Count > 0)
|
||||
_logger.LogInformation("[Overpass] Task placement falling back to any-pathway-point - no publicly-accessible reachable geometry within radius {R}m of {Lat},{Lon}",
|
||||
mapData.RadiusMeters, mapData.Center.Lat, mapData.Center.Lon);
|
||||
}
|
||||
|
||||
// Shuffle available positions
|
||||
for (int i = available.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = random.Next(i + 1);
|
||||
(available[i], available[j]) = (available[j], available[i]);
|
||||
}
|
||||
|
||||
|
||||
foreach (var pos in available)
|
||||
{
|
||||
if (result.Count >= count) break;
|
||||
|
||||
|
||||
// Check minimum spacing from already selected positions
|
||||
bool tooClose = result.Any(r => r.DistanceTo(pos) < minSpacing);
|
||||
if (!tooClose)
|
||||
|
||||
33
Protocol.cs
33
Protocol.cs
@@ -248,11 +248,24 @@ public class MapPathway
|
||||
public string? Name { get; set; }
|
||||
public bool IsWalkable { get; set; } = true;
|
||||
public double Width { get; set; } = 2.0;
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsFullyReachable { get; set; }
|
||||
[JsonIgnore]
|
||||
public bool IsPartiallyReachable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// P11: True only when this pathway is unambiguously publicly accessible
|
||||
/// for foot traffic - no `access=private`, no `foot=no`, and the highway
|
||||
/// type itself is one we trust to be a place a player can safely and
|
||||
/// legally stand. Server-internal (JsonIgnore) - clients don't need it.
|
||||
/// Used by OverpassService.GetPubliclyAccessiblePositions to constrain
|
||||
/// task placement to "absolutely public" geometry per Bandwidth's brutal
|
||||
/// filter directive. Falls back to any-pathway-point when this set is
|
||||
/// empty (rural / forest / open-field scenario).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPubliclyAccessible { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Area like park or garden</summary>
|
||||
@@ -759,6 +772,24 @@ public class ReturnedToLobbyPayload
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── P12: Admin lobby manipulation event payloads ────────────────────────────
|
||||
// Broadcast by LobbyActor when an admin uses the new admin-panel endpoints
|
||||
// (force-phase, edit-settings). Spectators and live clients re-render against
|
||||
// these so they see admin overrides without having to reload.
|
||||
|
||||
public class PhaseChangedPayload
|
||||
{
|
||||
public GamePhase Phase { get; set; }
|
||||
}
|
||||
|
||||
public class LobbySettingsChangedPayload
|
||||
{
|
||||
public double Radius { get; set; }
|
||||
public int ImpostorCount { get; set; }
|
||||
public int TaskCount { get; set; }
|
||||
public string TiePolicy { get; set; } = "NoEject";
|
||||
}
|
||||
|
||||
public class HostChangedPayload
|
||||
{
|
||||
public required string NewHostId { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user