diff --git a/AdminPanel.cs b/AdminPanel.cs index 60f10da..5b6e75b 100644 --- a/AdminPanel.cs +++ b/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(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 ──────────────────────────── + + /// + /// 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). + /// + private async Task HandleLobbyForceEndAsync(HttpListenerRequest request, HttpListenerResponse response) + { + using var reader = new StreamReader(request.InputStream); + var body = await reader.ReadToEndAsync(); + var data = JsonSerializer.Deserialize(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 }); + } + + /// + /// 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). + /// + private async Task HandleLobbySetPhaseAsync(HttpListenerRequest request, HttpListenerResponse response) + { + using var reader = new StreamReader(request.InputStream); + var body = await reader.ReadToEndAsync(); + var data = JsonSerializer.Deserialize(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 }); + } + + /// + /// 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. + /// + private async Task HandleLobbyUpdateSettingsAsync(HttpListenerRequest request, HttpListenerResponse response) + { + using var reader = new StreamReader(request.InputStream); + var body = await reader.ReadToEndAsync(); + var data = JsonSerializer.Deserialize(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) + /// + /// 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. + /// + 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(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; } + + /// + /// 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. + /// + public bool MapDataSent { get; set; } = false; } diff --git a/GameLogic.cs b/GameLogic.cs index b5a32e9..dfc8427 100644 --- a/GameLogic.cs +++ b/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(); diff --git a/LobbyActor.cs b/LobbyActor.cs index 99cb5c2..1a58a56 100644 --- a/LobbyActor.cs +++ b/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. + + /// + /// Force-end the current game immediately. No winner is declared (the + /// admin is interrupting outside normal win conditions); the lobby falls + /// back to the lobby phase ready for a new round. Safe to call in any + /// phase - the actor will no-op gracefully if there's nothing to end. + /// + public async Task TryForceEndGameAsync(string adminReason = "ended by admin") + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await EnqueueAsync(new AdminForceEndGameAction + { + Reason = adminReason, + Result = done + }); + return await done.Task; + } + + /// + /// Force the lobby into a specific phase. Used for testing / unsticking + /// games where the natural phase-transition didn't fire. Accepts + /// "Lobby", "Loading", "Playing", "Meeting", "Ended" (string for the + /// JSON wire); "Voting" intentionally rejected because Voting is a + /// sub-window of Meeting on the wire (see Phase 1 dead-code comment). + /// + public async Task TryAdminSetPhaseAsync(string targetPhase) + { + if (!System.Enum.TryParse(targetPhase, ignoreCase: true, out var p)) + return false; + if (p == GamePhase.Voting) return false; // dead value + + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await EnqueueAsync(new AdminSetPhaseAction { TargetPhase = p, Result = done }); + return await done.Task; + } + + /// + /// Update mutable lobby settings. Only takes effect during Lobby phase - + /// changing radius/impostor count mid-game would be silly. Returns false + /// if any required field is invalid OR the lobby is past the lobby phase. + /// + public async Task TryUpdateLobbySettingsAsync(double? radius, int? impostorCount, int? taskCount, string? tiePolicy) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await EnqueueAsync(new AdminUpdateSettingsAction + { + NewRadius = radius, + NewImpostorCount = impostorCount, + NewTaskCount = taskCount, + NewTiePolicy = tiePolicy, + Result = done + }); + return await done.Task; + } + public async Task ArchiveAndCloseAsync() { _cts.Cancel(); @@ -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) /// public Sabotage? GetCurrentSabotage() => _currentSabotage; - + #endregion - + + #region P12 Admin actions + + /// + /// Admin force-end-game implementation. Tears down active meeting/sabotage + /// state, sets phase to Ended, and broadcasts a GameEnded event with + /// "admin" as the winning faction so spectators see the interruption + /// reason. The lobby actor stays alive for a return-to-lobby loop. + /// + private void ProcessAdminForceEndGame(AdminForceEndGameAction action) + { + try + { + if (_phase == GamePhase.Lobby || _phase == GamePhase.Ended) + { + action.Result.TrySetResult(false); + return; + } + + _currentMeeting = null; + _currentSabotage = null; + _bodies.Clear(); + _phase = GamePhase.Ended; + + var evt = CreateEvent("GameEnded", "admin", new GameEndedPayload + { + WinningFaction = "Admin", + Reason = action.Reason + }); + PersistAndBroadcast(evt); + + _logger.LogWarning("[Admin] Force-ended game in lobby {Id}: {Reason}", _lobbyId, action.Reason); + action.Result.TrySetResult(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Admin] ForceEndGame failed"); + action.Result.TrySetResult(false); + } + } + + /// + /// Admin set-phase implementation. Skips most of the natural phase- + /// transition side effects (role assignment, map fetch etc.) - it's an + /// override, not a full transition. Used to unstick games or jump to + /// Ended for cleanup. The phase change is broadcast so spectators see + /// the new state on their next push. + /// + private void ProcessAdminSetPhase(AdminSetPhaseAction action) + { + try + { + var prev = _phase; + _phase = action.TargetPhase; + + var evt = CreateEvent("PhaseChanged", "admin", new PhaseChangedPayload + { + Phase = action.TargetPhase + }); + PersistAndBroadcast(evt); + + _logger.LogWarning("[Admin] Force-set phase {Prev} -> {Next} in lobby {Id}", prev, action.TargetPhase, _lobbyId); + action.Result.TrySetResult(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Admin] SetPhase failed"); + action.Result.TrySetResult(false); + } + } + + /// + /// Admin update-settings implementation. Only takes effect during the + /// Lobby phase (changing impostor count mid-game would be incoherent; + /// changing radius requires a re-fetch of map data which we don't try + /// to coordinate from here). Each field is individually optional - only + /// non-null values are applied. + /// + private void ProcessAdminUpdateSettings(AdminUpdateSettingsAction action) + { + try + { + if (_phase != GamePhase.Lobby) + { + action.Result.TrySetResult(false); + return; + } + + if (action.NewRadius.HasValue && action.NewRadius.Value > 0) + Settings.PlayAreaRadius = action.NewRadius.Value; + + if (action.NewImpostorCount.HasValue && action.NewImpostorCount.Value >= 1 && action.NewImpostorCount.Value <= 8) + Settings.ImpostorCount = action.NewImpostorCount.Value; + + if (action.NewTaskCount.HasValue && action.NewTaskCount.Value >= 1 && action.NewTaskCount.Value <= 30) + Settings.TaskCount = action.NewTaskCount.Value; + + if (!string.IsNullOrEmpty(action.NewTiePolicy) && + System.Enum.TryParse(action.NewTiePolicy, ignoreCase: true, out var tp)) + Settings.TiePolicy = tp; + + // Broadcast a settings-changed event so all clients refresh their + // lobby UI with the new values. Reuses the existing PhaseChanged + // payload shape with current phase as a no-op-style notification; + // a dedicated SettingsChanged payload would be cleaner but + // requires touching all three Protocol.cs files. + var evt = CreateEvent("LobbySettingsChanged", "admin", new LobbySettingsChangedPayload + { + Radius = Settings.PlayAreaRadius, + ImpostorCount = Settings.ImpostorCount, + TaskCount = Settings.TaskCount, + TiePolicy = Settings.TiePolicy.ToString() + }); + PersistAndBroadcast(evt); + + _logger.LogInformation("[Admin] Updated settings in lobby {Id}: radius={R} impostors={I} tasks={T} tie={Tp}", + _lobbyId, Settings.PlayAreaRadius, Settings.ImpostorCount, Settings.TaskCount, Settings.TiePolicy); + action.Result.TrySetResult(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Admin] UpdateSettings failed"); + action.Result.TrySetResult(false); + } + } + + #endregion + #region Snapshots private void CheckSnapshot() @@ -1990,6 +2186,33 @@ class SabotageExpireAction : LobbyAction public required string SabotageId { get; set; } } +// ── P12: Admin lobby manipulation actions ──────────────────────────────────── +// Each carries a TaskCompletionSource so the caller can await the +// outcome (true = applied, false = rejected by validation / phase gate). +// The completion is signaled inside the per-action Process* implementation, +// not by the generic ProcessActionAsync trySetResult hook. + +class AdminForceEndGameAction : LobbyAction +{ + public required string Reason { get; set; } + public required TaskCompletionSource Result { get; set; } +} + +class AdminSetPhaseAction : LobbyAction +{ + public required GamePhase TargetPhase { get; set; } + public required TaskCompletionSource Result { get; set; } +} + +class AdminUpdateSettingsAction : LobbyAction +{ + public double? NewRadius { get; set; } + public int? NewImpostorCount { get; set; } + public int? NewTaskCount { get; set; } + public string? NewTiePolicy { get; set; } + public required TaskCompletionSource Result { get; set; } +} + #endregion // Stub pro ClientConnection - bude implementováno v Program.cs diff --git a/OverpassService.cs b/OverpassService.cs index 7c87a09..967f1ac 100644 --- a/OverpassService.cs +++ b/OverpassService.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 }; } + + /// + /// 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). + /// + private bool IsPubliclyAccessibleWay(string highway, Dictionary 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; } /// - /// 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"). /// 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)]; } + + /// + /// 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. + /// + private HashSet CollectPubliclyAccessiblePoints(MapData mapData) + { + var set = new HashSet(); + foreach (var path in mapData.Pathways) + { + if (!path.IsPubliclyAccessible) continue; + foreach (var p in path.Points) + set.Add(p); + } + return set; + } /// - /// 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. /// public List GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20) { var result = new List(); - 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) diff --git a/Protocol.cs b/Protocol.cs index b02544c..887192c 100644 --- a/Protocol.cs +++ b/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; } + + /// + /// 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). + /// + [JsonIgnore] + public bool IsPubliclyAccessible { get; set; } } /// Area like park or garden @@ -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; }