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

@@ -181,6 +181,19 @@ public class AdminPanel
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 });
@@ -435,6 +448,111 @@ public class AdminPanel
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
{
@@ -615,7 +747,8 @@ public class AdminPanel
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")
};
@@ -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;
}

View File

@@ -352,18 +352,28 @@ 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();
var totalTasks = crewPlayers.Sum(p => p.Tasks.Count);

View File

@@ -250,6 +250,66 @@ public class LobbyActor : IDisposable
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í
@@ -1892,6 +1961,133 @@ public class LobbyActor : IDisposable
#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

View File

@@ -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"))
@@ -327,6 +328,143 @@ out skel qt;
};
}
/// <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)
{
return highway switch
@@ -369,17 +507,38 @@ 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)
@@ -394,12 +553,58 @@ out skel qt;
}
/// <summary>
/// Get multiple well-distributed reachable positions (e.g., for placing multiple tasks)
/// 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). 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--)

View File

@@ -253,6 +253,19 @@ public class MapPathway
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; }