using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace GeoSus.Client { /// /// Comprehensive headless simulator client for testing all game aspects. /// Supports both autonomous simulation and step-by-step controlled testing. /// public class SimulatorClient : IDisposable { private readonly GameClient _client; private readonly Random _random = new Random(); private CancellationTokenSource? _cts; private Task? _simulationTask; private PlayerState _myState = PlayerState.Alive; #region Public Properties public string ClientUuid => _client.ClientUuid; public string DisplayName => _client.DisplayName; public bool IsConnected => _client.IsConnected; public LobbyState? LobbyState => _client.CurrentLobbyState; public string? LobbyId => _client.LobbyId; public string? JoinCode => _client.JoinCode; public PlayerRole? Role => _client.MyRole; public PlayerState State => _myState; public Position Position => _client.MyPosition; public bool IsAlive => _myState == PlayerState.Alive; public bool IsDead => _myState == PlayerState.Dead; public bool IsImpostor => _client.MyRole == PlayerRole.Impostor; public bool IsCrew => _client.MyRole == PlayerRole.Crew; // Tasks public List MyTasks => _client.MyTasks; public HashSet CompletedTaskIds { get; } = new HashSet(); public string? CurrentTaskId { get; private set; } // Game state tracking public int TotalKills { get; private set; } public int TasksCompleted { get; private set; } public int MeetingsAttended { get; private set; } public int VotesCast { get; private set; } public int BodiesReported { get; private set; } public bool WasEjected { get; private set; } public bool WasKilled { get; private set; } public string? LastError { get; private set; } public string? GameResult { get; private set; } public string? WinningFaction { get; private set; } public bool GameEnded => GameResult != null; // Nearby entities public Dictionary NearbyPlayers => _client.PlayerPositions; public List NearbyBodies => _client.Bodies; // Meeting state public bool InMeeting { get; private set; } public bool HasVotedThisMeeting { get; private set; } public string? CurrentMeetingId { get; private set; } public Position? MeetingLocation { get; private set; } public DateTime? MeetingVotingEndTime { get; private set; } public DateTime? MeetingDiscussionEndTime { get; private set; } /// /// Returns true if the discussion phase has ended and voting can begin. /// public bool CanVote => InMeeting && (!MeetingDiscussionEndTime.HasValue || DateTime.UtcNow >= MeetingDiscussionEndTime.Value); // Current game phase from lobby state public string GamePhase => _client.CurrentLobbyState?.Phase.ToString() ?? "Unknown"; // Sabotage state public bool SabotageActive { get; private set; } public string? CurrentSabotageId { get; private set; } public SabotageType? CurrentSabotageType { get; private set; } public DateTime? SabotageDeadline { get; private set; } public List RepairStations { get; private set; } = new List(); public int SabotagesStarted { get; private set; } public int SabotagesRepaired { get; private set; } public bool IsRepairing { get; private set; } public string? RepairingStationId { get; private set; } /// /// Returns true if comms are blocked (can't report/meeting) /// public bool IsCommsBlocked => SabotageActive && CurrentSabotageType == SabotageType.CommsBlackout; /// /// Returns true if there's a critical meltdown countdown /// public bool IsMeltdownActive => SabotageActive && CurrentSabotageType == SabotageType.CriticalMeltdown; #endregion #region Events public event Action? OnLog; public event Action? OnError; public event Action? OnGameEvent; public event Action? OnKilled; public event Action? OnEjected; public event Action? OnGameEnded; public event Action? OnMeetingStarted; public event Action? OnMeetingEnded; public event Action? OnSabotageStarted; public event Action? OnSabotageRepaired; public event Action? OnMeltdown; #endregion #region Constructor public SimulatorClient(string clientUuid, string displayName) { _client = new GameClient(clientUuid, displayName); _client.OnConnected += () => Log("Připojen k serveru"); _client.OnDisconnected += (reason) => Log($"Odpojen: {reason}"); _client.OnError += (error) => { LastError = error; Log($"Chyba: {error}"); OnError?.Invoke(error); }; _client.OnMessage += HandleMessage; _client.OnGameEvent += HandleGameEvent; } #endregion #region Message Handlers private void HandleMessage(Message msg) { switch (msg) { case CreateLobbyResponse r: if (r.Success) Log($"Lobby vytvořeno: {r.JoinCode}"); else LogError($"Lobby creation failed"); break; case JoinLobbyResponse r: if (r.Success) Log($"Připojen do lobby: {r.LobbyId}"); else LogError("Join lobby failed"); break; case Ack a when !a.Success: LastError = a.Error; Log($"Akce zamítnuta: {a.Error}"); break; } } private void HandleGameEvent(GameEvent evt) { OnGameEvent?.Invoke(evt.EventType, evt.Payload); switch (evt.EventType) { case "GameStarting": Log("Game loading - fetching map data..."); break; case "MapDataReady": var mapPayload = evt.GetPayload(); var buildingCount = mapPayload?.MapData?.Buildings?.Count ?? 0; var pathwayCount = mapPayload?.MapData?.Pathways?.Count ?? 0; Log($"Map data received: {buildingCount} buildings, {pathwayCount} pathways - sending confirmation"); break; case "PlayerMapDataReceived": var progressPayload = evt.GetPayload(); if (progressPayload != null) { Log($"Player {progressPayload.DisplayName} ready ({progressPayload.PlayersReady}/{progressPayload.TotalPlayers})"); } break; case "GameStarted": Log("Hra začala!"); break; case "RoleAssigned": var rolePayload = evt.GetPayload(); if (rolePayload?.ClientUuid == ClientUuid) { Log($"Moje role: {rolePayload.Role}"); } break; case "PlayerKilled": var killPayload = evt.GetPayload(); Log($"Hráč {killPayload?.VictimId} byl zabit"); if (killPayload?.VictimId == ClientUuid) { WasKilled = true; _myState = PlayerState.Dead; Log("BYL JSEM ZABIT!"); OnKilled?.Invoke(); } break; case "MeetingStarted": var meetingPayload = evt.GetPayload(); InMeeting = true; HasVotedThisMeeting = false; CurrentMeetingId = meetingPayload?.MeetingId; MeetingLocation = meetingPayload?.MeetingLocation; MeetingVotingEndTime = meetingPayload?.VotingEndTime; MeetingDiscussionEndTime = meetingPayload?.DiscussionEndTime; Log($"MEETING ZAČAL! Typ: {meetingPayload?.Type}, Lokace: {MeetingLocation?.Lat:F4},{MeetingLocation?.Lon:F4}"); if (MeetingDiscussionEndTime.HasValue) { var discussionMs = (MeetingDiscussionEndTime.Value - DateTime.UtcNow).TotalMilliseconds; Log($" Diskuze do: {MeetingDiscussionEndTime.Value:HH:mm:ss} ({discussionMs:F0}ms)"); } OnMeetingStarted?.Invoke(); break; case "VotingStarted": Log("Hlasování začalo!"); break; case "PlayerVoted": var voteInfoPayload = evt.GetPayload(); Log($"Hráč {voteInfoPayload?.VoterId} hlasoval"); break; case "VotingClosed": var votePayload = evt.GetPayload(); InMeeting = false; CurrentMeetingId = null; MeetingLocation = null; MeetingVotingEndTime = null; MeetingDiscussionEndTime = null; if (votePayload?.EjectedPlayerId != null) { Log($"Hráč {votePayload.EjectedPlayerId} byl VYHOZEN! (remíza: {votePayload.WasTie})"); if (votePayload.EjectedPlayerId == ClientUuid) { WasEjected = true; _myState = PlayerState.Dead; Log("BYL JSEM VYHOZEN!"); OnEjected?.Invoke(); } } else { Log("Nikdo nebyl vyhozen (skip nebo remíza)"); } OnMeetingEnded?.Invoke(); break; case "TaskCompleted": var taskPayload = evt.GetPayload(); if (taskPayload?.ClientUuid == ClientUuid) { TasksCompleted++; CompletedTaskIds.Add(taskPayload.TaskId); CurrentTaskId = null; Log($"TASK DOKONČEN: {taskPayload.TaskId} (celkem: {TasksCompleted})"); } break; case "GameEnded": var endPayload = evt.GetPayload(); GameResult = endPayload?.Reason; WinningFaction = endPayload?.WinningFaction; Log($"=== HRA SKONČILA! Vítěz: {endPayload?.WinningFaction} - {endPayload?.Reason} ==="); OnGameEnded?.Invoke(endPayload?.WinningFaction ?? "Unknown"); break; // Sabotage events case "SabotageStarted": var sabStartPayload = evt.GetPayload(); if (sabStartPayload != null) { SabotageActive = true; CurrentSabotageId = sabStartPayload.SabotageId; CurrentSabotageType = sabStartPayload.Type; SabotageDeadline = sabStartPayload.Deadline; RepairStations = sabStartPayload.RepairStations; Log($"⚠ SABOTÁŽ SPUŠTĚNA: {sabStartPayload.Type}!"); if (sabStartPayload.Deadline.HasValue) { var remaining = (sabStartPayload.Deadline.Value - DateTime.UtcNow).TotalSeconds; Log($" ⏱ DEADLINE: {remaining:F0}s - musíte opravit nebo prohrajete!"); } foreach (var station in sabStartPayload.RepairStations) { Log($" 📍 Stanice {station.Name}: {station.Location.Lat:F4},{station.Location.Lon:F4}"); } OnSabotageStarted?.Invoke(sabStartPayload.Type); } break; case "RepairStarted": var repStartPayload = evt.GetPayload(); if (repStartPayload?.PlayerId == ClientUuid) { IsRepairing = true; RepairingStationId = repStartPayload.StationId; Log($"🔧 Zahájil jsem opravu stanice {repStartPayload.StationId}"); } else { Log($"🔧 Hráč {repStartPayload?.PlayerId} opravuje {repStartPayload?.StationId}"); } break; case "RepairStopped": var repStopPayload = evt.GetPayload(); if (repStopPayload?.PlayerId == ClientUuid) { IsRepairing = false; RepairingStationId = null; Log($"❌ Oprava přerušena: {repStopPayload.StationId}"); } break; case "SabotageRepaired": case "SabotageExpired": var sabRepPayload = evt.GetPayload(); if (sabRepPayload != null) { SabotageActive = false; CurrentSabotageId = null; CurrentSabotageType = null; SabotageDeadline = null; RepairStations.Clear(); IsRepairing = false; RepairingStationId = null; SabotagesRepaired++; var repairers = sabRepPayload.RepairerIds.Count > 0 ? string.Join(", ", sabRepPayload.RepairerIds) : "auto-expire"; Log($"✅ SABOTÁŽ OPRAVENA: {sabRepPayload.Type} (opravili: {repairers})"); OnSabotageRepaired?.Invoke(sabRepPayload.Type); } break; case "SabotageMeltdown": var meltdownPayload = evt.GetPayload(); Log($"💥 MELTDOWN! Sabotáž nebyla opravena včas - Impostoři vyhráli!"); OnMeltdown?.Invoke(); break; } } #endregion #region Connection & Lobby public async Task ConnectAsync(string host, int port) { return await _client.ConnectAsync(host, port); } public void Disconnect() { StopSimulation(); _client.Disconnect(); } public void Update() { _client.Update(); } public void CreateLobby(Position center, int impostorCount = 1, int taskCount = 5, string? password = null) { _client.CreateLobby(center, impostorCount, taskCount, password); } /// /// Async wrapper for CreateLobby - waits for lobby to be created /// public async Task CreateLobbyAsync(string? password, Position center, double radius = 500, int impostorCount = 1, int taskCount = 5) { _client.CreateLobby(center, impostorCount, taskCount, password, radius); // Wait for lobby creation response for (int i = 0; i < 50; i++) // 5 seconds timeout { Update(); if (!string.IsNullOrEmpty(JoinCode)) return true; if (LastError != null && LastError.Contains("lobby")) return false; await Task.Delay(100); } return false; } public void JoinLobby(string joinCode, string? password = null) { _client.JoinLobby(joinCode, password); } /// /// Async wrapper for JoinLobby - waits for join confirmation /// public async Task JoinLobbyAsync(string joinCode, string? password = null) { _client.JoinLobby(joinCode, password); // Wait for join response for (int i = 0; i < 50; i++) { Update(); if (!string.IsNullOrEmpty(LobbyId)) return true; if (LastError != null && LastError.Contains("join")) return false; await Task.Delay(100); } return false; } public void LeaveLobby() { _client.LeaveLobby(); } public void StartGame() { _client.StartGame(); } /// /// Async wrapper for StartGame - waits for game to start /// public async Task StartGameAsync() { _client.StartGame(); // Wait for game start for (int i = 0; i < 50; i++) { Update(); if (Role.HasValue) return true; await Task.Delay(100); } return false; } #endregion #region Movement public void MoveTo(Position position) { _client.UpdatePosition(position); } public void MoveTowards(Position target, double maxDistanceMeters) { var current = Position; var distance = current.DistanceTo(target); if (distance <= maxDistanceMeters) { MoveTo(target); } else { var ratio = maxDistanceMeters / distance; var newPos = new Position( current.Lat + (target.Lat - current.Lat) * ratio, current.Lon + (target.Lon - current.Lon) * ratio ); MoveTo(newPos); } } public Position GetRandomPositionNear(Position center, double radiusMeters) { var angle = _random.NextDouble() * 2 * Math.PI; var distance = _random.NextDouble() * radiusMeters; var lat = center.Lat + (distance / 111000) * Math.Cos(angle); var lon = center.Lon + (distance / (111000 * Math.Cos(center.Lat * Math.PI / 180))) * Math.Sin(angle); return new Position(lat, lon); } #endregion #region Kill Actions (Impostor) public bool TryKill(string targetUuid) { if (!IsImpostor || !IsAlive) { Log("Nemohu zabíjet - nejsem živý impostor"); return false; } Log($">>> POKUS O ZABITÍ: {targetUuid}"); _client.Kill(targetUuid); TotalKills++; return true; } public string? FindKillTarget(double maxDistance = 5.0) { return _client.FindNearbyPlayer(maxDistance, aliveOnly: true); } public bool TryKillNearby(double maxDistance = 5.0) { var target = FindKillTarget(maxDistance); if (target != null) { return TryKill(target); } return false; } #endregion #region Report & Meeting Actions public bool TryReportBody(string bodyId) { if (!IsAlive) { Log("Nemohu reportovat - jsem mrtvý"); return false; } Log($">>> REPORTUJI TĚLO: {bodyId}"); _client.ReportBody(bodyId); BodiesReported++; return true; } public Body? FindNearbyBody(double maxDistance = 5.0) { return _client.FindNearbyBody(maxDistance); } public bool TryReportNearbyBody(double maxDistance = 5.0) { var body = FindNearbyBody(maxDistance); if (body != null) { return TryReportBody(body.BodyId); } return false; } public bool TryCallEmergencyMeeting() { if (!IsAlive) { Log("Nemohu svolat meeting - jsem mrtvý"); return false; } Log(">>> SVOLÁVÁM EMERGENCY MEETING!"); _client.CallEmergencyMeeting(); return true; } #endregion #region Voting Actions public bool TryVote(string? targetUuid) { if (!IsAlive) { Log("Nemohu hlasovat - jsem mrtvý"); return false; } if (!InMeeting) { Log("Nemohu hlasovat - není meeting"); return false; } var voteTarget = targetUuid ?? "SKIP"; Log($">>> HLASUJI PRO: {voteTarget}"); _client.Vote(targetUuid); VotesCast++; HasVotedThisMeeting = true; MeetingsAttended++; return true; } public bool TryVoteSkip() { return TryVote(null); } public bool TryVoteRandom() { if (!InMeeting || !IsAlive) return false; // Pick a random alive player (or skip) var alivePlayers = NearbyPlayers.Values .Where(p => p.State == PlayerState.Alive && p.ClientUuid != ClientUuid) .ToList(); if (alivePlayers.Count == 0 || _random.NextDouble() < 0.3) { return TryVoteSkip(); } var target = alivePlayers[_random.Next(alivePlayers.Count)]; return TryVote(target.ClientUuid); } /// /// Vote for the player with the most suspicion (for crew) or a random crew (for impostor) /// public bool TryVoteSmart() { if (!InMeeting || !IsAlive) return false; var alivePlayers = NearbyPlayers.Values .Where(p => p.State == PlayerState.Alive && p.ClientUuid != ClientUuid) .ToList(); if (alivePlayers.Count == 0) { return TryVoteSkip(); } // Impostors vote randomly among non-impostors or skip if (IsImpostor) { if (_random.NextDouble() < 0.5) { return TryVoteSkip(); } var target = alivePlayers[_random.Next(alivePlayers.Count)]; return TryVote(target.ClientUuid); } // Crew votes randomly for now (could be smarter with suspicion tracking) if (_random.NextDouble() < 0.2) { return TryVoteSkip(); } var crewTarget = alivePlayers[_random.Next(alivePlayers.Count)]; return TryVote(crewTarget.ClientUuid); } #endregion #region Task Actions public bool TryCompleteTask(string taskId) { if (IsImpostor) { Log($"Nemohu dělat tasky - jsem impostor"); return false; } if (CompletedTaskIds.Contains(taskId)) { Log($"Task {taskId} již dokončen"); return false; } Log($">>> DOKONČUJI TASK: {taskId}"); _client.CompleteTask(taskId); return true; } public GameTask? FindNearbyTask(double maxDistance = 5.0) { foreach (var task in MyTasks) { if (CompletedTaskIds.Contains(task.TaskId)) continue; if (Position.DistanceTo(task.Location) <= maxDistance) { return task; } } return null; } public GameTask? GetNextIncompleteTask() { return MyTasks.FirstOrDefault(t => !CompletedTaskIds.Contains(t.TaskId)); } public int GetRemainingTaskCount() { return MyTasks.Count - CompletedTaskIds.Count; } #endregion #region Sabotage Actions (Impostor) /// /// Start a sabotage (impostor only) /// public bool TrySabotage(SabotageType sabotageType) { if (!IsImpostor) { Log("Nemohu sabotovat - nejsem impostor"); return false; } if (!IsAlive) { Log("Nemohu sabotovat - jsem mrtvý"); return false; } if (SabotageActive) { Log($"Nemohu sabotovat - již probíhá sabotáž: {CurrentSabotageType}"); return false; } if (InMeeting) { Log("Nemohu sabotovat - probíhá meeting"); return false; } Log($">>> SPOUŠTÍM SABOTÁŽ: {sabotageType}"); _client.Send(new StartSabotage { SabotageType = sabotageType }); SabotagesStarted++; return true; } /// /// Start repairing at a repair station (crew or impostor can repair) /// public bool TryStartRepair(string stationId) { if (!IsAlive) { Log("Nemohu opravovat - jsem mrtvý"); return false; } if (!SabotageActive) { Log("Nemohu opravovat - není aktivní sabotáž"); return false; } if (IsRepairing) { Log($"Již opravuji stanici: {RepairingStationId}"); return false; } var station = RepairStations.FirstOrDefault(s => s.StationId == stationId); if (station == null) { Log($"Opravná stanice {stationId} neexistuje"); return false; } var distance = Position.DistanceTo(station.Location); if (distance > 5.0) { Log($"Opravná stanice {stationId} je příliš daleko: {distance:F1}m"); return false; } Log($">>> ZAČÍNÁM OPRAVU stanice: {stationId}"); _client.Send(new ActivateRepairStation { StationId = stationId }); return true; } /// /// Stop repairing current station /// public bool TryStopRepair() { if (!IsRepairing || RepairingStationId == null) { Log("Nejsem u opravné stanice"); return false; } Log($">>> UKONČUJI OPRAVU stanice: {RepairingStationId}"); _client.Send(new DeactivateRepairStation { StationId = RepairingStationId }); return true; } /// /// Find nearest repair station for current sabotage /// public RepairStationInfo? FindNearestRepairStation(double maxDistance = double.MaxValue) { if (!SabotageActive) return null; RepairStationInfo? nearest = null; double nearestDist = maxDistance; foreach (var station in RepairStations) { if (station.IsRepaired) continue; var dist = Position.DistanceTo(station.Location); if (dist < nearestDist) { nearestDist = dist; nearest = station; } } return nearest; } /// /// Move to nearest repair station /// public bool MoveTowardsNearestRepairStation(double speed = 1.0) { var station = FindNearestRepairStation(); if (station == null) return false; MoveTowards(station.Location, speed); return true; } /// /// Check if at repair station and can start repair /// public bool IsAtRepairStation(string stationId, double maxDistance = 5.0) { var station = RepairStations.FirstOrDefault(s => s.StationId == stationId); if (station == null) return false; return Position.DistanceTo(station.Location) <= maxDistance; } /// /// Automatic repair: find nearest station, move to it, and start repair /// public bool TryAutoRepair() { if (!SabotageActive) return false; if (IsRepairing) return false; var station = FindNearestRepairStation(5.0); if (station != null && !station.IsRepaired) { return TryStartRepair(station.StationId); } return false; } #endregion #region Autonomous Simulation public void StartSimulation() { if (_simulationTask != null) return; _cts = new CancellationTokenSource(); _simulationTask = Task.Run(() => SimulationLoopAsync(_cts.Token)); Log("Simulace spuštěna"); } public void StopSimulation() { if (_cts == null) return; _cts.Cancel(); try { _simulationTask?.Wait(1000); } catch { } _simulationTask = null; _cts = null; Log("Simulace zastavena"); } private async Task SimulationLoopAsync(CancellationToken ct) { var center = LobbyState?.PlayAreaCenter ?? new Position(50.0, 14.0); var radius = LobbyState?.PlayAreaRadius ?? 500; MoveTo(center); while (!ct.IsCancellationRequested && !GameEnded) { try { Update(); // In meeting - handle voting if (InMeeting) { // First move to meeting location if (MeetingLocation.HasValue && IsAlive) { var meetLoc = MeetingLocation.Value; var distToMeeting = Position.DistanceTo(meetLoc); if (distToMeeting > 5) { MoveTowards(meetLoc, 20); await Task.Delay(200, ct); continue; } } if (IsAlive && !HasVotedThisMeeting) { // Wait a bit before voting (simulate discussion) await Task.Delay(500 + _random.Next(1500), ct); Update(); if (InMeeting && !HasVotedThisMeeting) { TryVoteSmart(); } } // Wait for meeting to end await Task.Delay(300, ct); continue; } // Impostor logic if (IsImpostor && IsAlive) { await ImpostorActionAsync(center, radius, ct); } // Alive Crew logic else if (IsCrew && IsAlive) { await CrewActionAsync(center, radius, ct); } // Dead player (ghost) - can still do tasks else if (IsCrew && IsDead) { await GhostTaskActionAsync(center, radius, ct); } // Dead impostor - just watch else if (IsImpostor && IsDead) { await Task.Delay(1000, ct); } await Task.Delay(300, ct); } catch (OperationCanceledException) { break; } catch (Exception ex) { Log($"Simulation error: {ex.Message}"); await Task.Delay(1000, ct); } } Log("Simulace dokončena"); } private async Task ImpostorActionAsync(Position center, double radius, CancellationToken ct) { // Try to kill nearby player var target = FindKillTarget(8.0); if (target != null) { // Higher chance to kill if alone with victim var nearbyCount = NearbyPlayers.Values.Count(p => p.State == PlayerState.Alive && p.ClientUuid != ClientUuid && Position.DistanceTo(p.Position) < 20); var killChance = nearbyCount <= 1 ? 0.9 : 0.3; if (_random.NextDouble() < killChance) { TryKill(target); await Task.Delay(300, ct); // Move away from body var escapePos = GetRandomPositionNear(Position, 30); MoveTowards(escapePos, 15); await Task.Delay(500, ct); return; } } // Wander around looking for isolated targets var wanderPos = GetRandomPositionNear(center, radius * 0.6); MoveTowards(wanderPos, 5); } private async Task CrewActionAsync(Position center, double radius, CancellationToken ct) { // Priority 1: Report nearby bodies var body = FindNearbyBody(8.0); if (body != null) { Log($"NAŠEL JSEM TĚLO: {body.BodyId}"); // Move closer if needed if (Position.DistanceTo(body.Location) > 3) { MoveTowards(body.Location, 3); await Task.Delay(200, ct); } TryReportBody(body.BodyId); await Task.Delay(500, ct); return; } // Priority 2: Do tasks (instant completion) var nearbyTask = FindNearbyTask(5.0); if (nearbyTask != null) { TryCompleteTask(nearbyTask.TaskId); await Task.Delay(500, ct); return; } // Priority 3: Move to next task var nextTask = GetNextIncompleteTask(); if (nextTask != null) { MoveTowards(nextTask.Location, 5); } else { // All tasks done, wander var wanderPos = GetRandomPositionNear(center, radius * 0.5); MoveTowards(wanderPos, 3); } } private async Task GhostTaskActionAsync(Position center, double radius, CancellationToken ct) { // Ghosts can complete tasks faster (no danger) var nearbyTask = FindNearbyTask(10.0); if (nearbyTask != null) { TryCompleteTask(nearbyTask.TaskId); await Task.Delay(100, ct); return; } // Move to next task var nextTask = GetNextIncompleteTask(); if (nextTask != null) { // Ghosts move much faster MoveTowards(nextTask.Location, 20); } } #endregion #region Logging private void Log(string message) { OnLog?.Invoke(message); } private void LogError(string message) { LastError = message; OnError?.Invoke(message); Log($"ERROR: {message}"); } #endregion #region Stats public string GetStats() { return $"[{DisplayName}] Role={Role}, State={State}, Kills={TotalKills}, Tasks={TasksCompleted}/{MyTasks.Count}, " + $"Reports={BodiesReported}, Votes={VotesCast}, Meetings={MeetingsAttended}, " + $"Killed={WasKilled}, Ejected={WasEjected}"; } public void PrintDetailedStats() { Log("========== DETAILED STATS =========="); Log($" Display Name: {DisplayName}"); Log($" Client UUID: {ClientUuid}"); Log($" Role: {Role}"); Log($" Final State: {State}"); Log($" --- Actions ---"); Log($" Kills Attempted: {TotalKills}"); Log($" Tasks Completed: {TasksCompleted}/{MyTasks.Count}"); Log($" Bodies Reported: {BodiesReported}"); Log($" Votes Cast: {VotesCast}"); Log($" Meetings Attended: {MeetingsAttended}"); Log($" --- Fate ---"); Log($" Was Killed: {WasKilled}"); Log($" Was Ejected: {WasEjected}"); Log($" --- Game Result ---"); Log($" Winning Faction: {WinningFaction}"); Log($" Result: {GameResult}"); Log("====================================="); } #endregion public void Dispose() { StopSimulation(); _client.Dispose(); } #region Map Data Access /// /// Get the map data payload from the current lobby state (if available) /// public MapDataPayload? MapData => _client.CurrentLobbyState?.MapData; /// /// Check if map data is available for the current lobby /// public bool HasMapData => MapData != null; /// /// Check if map data loading is complete (true if loaded or Overpass disabled) /// public bool IsMapDataReady => _client.CurrentLobbyState?.MapDataReady ?? false; /// /// Get the play area center from lobby state /// public Position? PlayAreaCenter => _client.CurrentLobbyState?.PlayAreaCenter; /// /// Get the play area radius from lobby state /// public double PlayAreaRadius => _client.CurrentLobbyState?.PlayAreaRadius ?? 500; #endregion } /// /// Comprehensive test suite for Overpass API and reachability testing /// public class OverpassApiTests { private readonly string _serverHost; private readonly int _serverPort; private readonly Action? _logger; public OverpassApiTests(string serverHost = "localhost", int serverPort = 7777, Action? logger = null) { _serverHost = serverHost; _serverPort = serverPort; _logger = logger; } private void Log(string message) { _logger?.Invoke(message); Console.WriteLine(message); } /// /// Run all Overpass API tests /// public async Task RunAllTestsAsync(Position testCenter, double testRadius = 500) { var results = new OverpassTestResults(); Log("═══════════════════════════════════════════════════════════════"); Log(" OVERPASS API & REACHABILITY TEST SUITE"); Log("═══════════════════════════════════════════════════════════════"); Log($"Test Center: {testCenter.Lat:F6}, {testCenter.Lon:F6}"); Log($"Test Radius: {testRadius}m"); Log(""); // Test 1: Create lobby and verify map data is fetched Log("TEST 1: Map Data Fetch on Lobby Creation"); Log("─────────────────────────────────────────"); results.MapDataFetchTest = await TestMapDataFetchAsync(testCenter, testRadius); LogTestResult("Map Data Fetch", results.MapDataFetchTest); await Task.Delay(1000); // Delay between tests // Test 2: Verify map data structure Log(""); Log("TEST 2: Map Data Structure Validation"); Log("─────────────────────────────────────────"); results.MapDataStructureTest = await TestMapDataStructureAsync(testCenter, testRadius); LogTestResult("Map Data Structure", results.MapDataStructureTest); await Task.Delay(1000); // Delay between tests // Test 3: Verify task positions are on reachable paths Log(""); Log("TEST 3: Task Position Reachability"); Log("─────────────────────────────────────────"); results.TaskReachabilityTest = await TestTaskReachabilityAsync(testCenter, testRadius); LogTestResult("Task Reachability", results.TaskReachabilityTest); await Task.Delay(1000); // Delay between tests // Test 4: Verify repair station positions are reachable Log(""); Log("TEST 4: Repair Station Reachability (Sabotage)"); Log("─────────────────────────────────────────"); results.RepairStationReachabilityTest = await TestRepairStationReachabilityAsync(testCenter, testRadius); LogTestResult("Repair Station Reachability", results.RepairStationReachabilityTest); await Task.Delay(1000); // Delay between tests // Test 5: Verify positions are within play area Log(""); Log("TEST 5: Play Area Boundary Validation"); Log("─────────────────────────────────────────"); results.PlayAreaBoundaryTest = await TestPlayAreaBoundaryAsync(testCenter, testRadius); LogTestResult("Play Area Boundary", results.PlayAreaBoundaryTest); await Task.Delay(1000); // Delay between tests // Test 6: Test multiple lobby creations for consistency Log(""); Log("TEST 6: Map Data Consistency Across Lobbies"); Log("─────────────────────────────────────────"); results.ConsistencyTest = await TestMapDataConsistencyAsync(testCenter, testRadius); LogTestResult("Map Data Consistency", results.ConsistencyTest); // Summary Log(""); Log("═══════════════════════════════════════════════════════════════"); Log(" TEST SUMMARY"); Log("═══════════════════════════════════════════════════════════════"); results.PrintSummary(Log); return results; } private void LogTestResult(string testName, TestResult result) { var status = result.Passed ? "✓ PASS" : "✗ FAIL"; Log($" [{status}] {testName}"); if (!string.IsNullOrEmpty(result.Details)) { foreach (var line in result.Details.Split('\n')) { Log($" {line}"); } } if (!result.Passed && !string.IsNullOrEmpty(result.Error)) { Log($" ERROR: {result.Error}"); } } /// /// Wait for map data to be loaded with polling and timeout /// Note: Map data is now fetched when game starts, not on lobby creation /// private async Task WaitForMapDataAsync(SimulatorClient client, SimulatorClient? otherClient, int timeoutMs = 10000, int pollIntervalMs = 500) { var sw = System.Diagnostics.Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < timeoutMs) { // Update client to receive any pending messages client.Update(); otherClient?.Update(); // Check if client has map data loaded if (client.HasMapData) { Log($" Map data loaded after {sw.ElapsedMilliseconds}ms"); return true; } // Also check if MapDataReady indicates no data expected (Overpass disabled) if (client.IsMapDataReady && !client.HasMapData) { Log($" Map data not available (Overpass may be disabled)"); return false; } await Task.Delay(pollIntervalMs); } Log($" Timeout waiting for map data ({timeoutMs}ms)"); return false; } /// /// Wait for game to start after map data is received (Loading phase complete) /// private async Task WaitForGameStartAfterMapDataAsync(SimulatorClient owner, SimulatorClient player, int timeoutMs = 20000, int pollIntervalMs = 500) { var sw = System.Diagnostics.Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < timeoutMs) { owner.Update(); player.Update(); // Game is fully started when phase is Playing and roles are assigned if (owner.GamePhase == "Playing" && owner.Role.HasValue) { Log($" Game started after {sw.ElapsedMilliseconds}ms"); return true; } await Task.Delay(pollIntervalMs); } Log($" Timeout waiting for game start ({timeoutMs}ms) - Phase: {owner.GamePhase}"); return false; } /// /// Start a game and wait for map data to be loaded /// The new flow is: StartGame -> Loading phase -> MapData fetched -> All confirm -> Playing /// private async Task StartGameAndWaitForMapDataAsync(SimulatorClient owner, SimulatorClient player, int timeoutMs = 25000) { Log(" Starting game (this will trigger map data fetch)..."); owner.StartGame(); // Wait for map data first (update both clients so both send confirmations) if (!await WaitForMapDataAsync(owner, player, timeoutMs)) { return false; } // Ensure both clients update to send MapDataReceived confirmation for (int i = 0; i < 10; i++) { owner.Update(); player.Update(); await Task.Delay(100); } // Wait for game to actually start (after all confirmations) return await WaitForGameStartAfterMapDataAsync(owner, player, 10000); } private async Task TestMapDataFetchAsync(Position center, double radius) { var result = new TestResult { TestName = "MapDataFetch" }; SimulatorClient? owner = null; SimulatorClient? player = null; try { // Create owner and player (need 2 players to start game) owner = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "MapOwner"); player = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "MapPlayer"); // Connect owner Log(" Connecting owner to server..."); if (!await owner.ConnectAsync(_serverHost, _serverPort)) { result.Error = $"Failed to connect owner to server at {_serverHost}:{_serverPort}"; return result; } // Create lobby with specific center Log($" Creating lobby at {center.Lat:F6}, {center.Lon:F6} with radius {radius}m..."); if (!await owner.CreateLobbyAsync(null, center, radius)) { result.Error = $"Failed to create lobby: {owner.LastError ?? "Unknown error"}"; return result; } Log($" Lobby created: {owner.JoinCode}"); // Connect player Log(" Connecting player to server..."); if (!await player.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Failed to connect player"; return result; } // Join player to lobby if (!await player.JoinLobbyAsync(owner.JoinCode!)) { result.Error = "Failed to join player to lobby"; return result; } // Start game - this triggers map data fetch Log(" Starting game (triggers Overpass API fetch)..."); if (!await StartGameAndWaitForMapDataAsync(owner, player, timeoutMs: 25000)) { result.Error = "Timeout waiting for map data (Overpass API may be slow or unavailable)"; return result; } if (owner.HasMapData) { var mapData = owner.MapData!; var buildings = mapData.GetBuildings(); var pathways = mapData.GetPathways(); var areas = mapData.GetAreas(); var pois = mapData.GetPOIs(); result.Passed = true; result.Details = $"Buildings: {buildings.Count}\n" + $"Pathways: {pathways.Count}\n" + $"Areas: {areas.Count}\n" + $"POIs: {pois.Count}"; } else { result.Error = "Map data was not received after game start"; } } catch (Exception ex) { result.Error = ex.Message; } finally { owner?.Dispose(); player?.Dispose(); } return result; } private async Task TestMapDataStructureAsync(Position center, double radius) { var result = new TestResult { TestName = "MapDataStructure" }; SimulatorClient? owner = null; SimulatorClient? player = null; try { owner = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "StructOwner"); player = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "StructPlayer"); if (!await owner.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Failed to connect owner"; return result; } if (!await owner.CreateLobbyAsync(null, center, radius)) { result.Error = "Failed to create lobby"; return result; } if (!await player.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Failed to connect player"; return result; } if (!await player.JoinLobbyAsync(owner.JoinCode!)) { result.Error = "Failed to join player to lobby"; return result; } // Start game to trigger map data fetch Log(" Starting game (triggers Overpass API fetch)..."); if (!await StartGameAndWaitForMapDataAsync(owner, player, timeoutMs: 25000)) { result.Error = "Timeout waiting for map data"; return result; } if (!owner.HasMapData) { result.Error = "No map data available"; return result; } var mapData = owner.MapData!; var buildings = mapData.GetBuildings(); var pathways = mapData.GetPathways(); var areas = mapData.GetAreas(); var pois = mapData.GetPOIs(); var issues = new List(); var stats = new List(); // Check buildings have valid outlines int validBuildings = 0; foreach (var building in buildings) { if (building.Outline == null || building.Outline.Count < 3) { issues.Add($"Building {building.Id}: Invalid outline (< 3 points)"); } else { validBuildings++; } } stats.Add($"Valid buildings: {validBuildings}/{buildings.Count}"); // Check pathways have valid points int validPathways = 0; foreach (var pathway in pathways) { if (pathway.Points == null || pathway.Points.Count < 2) { issues.Add($"Pathway {pathway.Id}: Invalid points (< 2)"); } else { validPathways++; } } stats.Add($"Valid pathways: {validPathways}/{pathways.Count}"); // Check areas int validAreas = 0; foreach (var area in areas) { if (area.Outline == null || area.Outline.Count < 3) { issues.Add($"Area {area.Id}: Invalid outline"); } else { validAreas++; } } stats.Add($"Valid areas: {validAreas}/{areas.Count}"); // Check POIs have valid positions int validPOIs = 0; foreach (var poi in pois) { if (poi.Location.Lat != 0 || poi.Location.Lon != 0) { validPOIs++; } } stats.Add($"Valid POIs: {validPOIs}/{pois.Count}"); result.Passed = issues.Count == 0; result.Details = string.Join("\n", stats); if (issues.Count > 0) { result.Error = string.Join("; ", issues.Take(5)); } } catch (Exception ex) { result.Error = ex.Message; } finally { owner?.Dispose(); player?.Dispose(); } return result; } private async Task TestTaskReachabilityAsync(Position center, double radius) { var result = new TestResult { TestName = "TaskReachability" }; SimulatorClient? owner = null; SimulatorClient? player = null; try { // Create lobby owner owner = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "TaskOwner"); player = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "TaskPlayer"); if (!await owner.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Owner failed to connect"; return result; } if (!await owner.CreateLobbyAsync(null, center, radius, 1, 5)) { result.Error = "Failed to create lobby"; return result; } var joinCode = owner.JoinCode; if (!await player.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Player failed to connect"; return result; } if (!await player.JoinLobbyAsync(joinCode!)) { result.Error = "Player failed to join"; return result; } // Start game - this triggers map data fetch Log(" Starting game (triggers Overpass API fetch)..."); if (!await StartGameAndWaitForMapDataAsync(owner, player, timeoutMs: 25000)) { result.Error = "Timeout waiting for game to start"; return result; } // Check task positions var mapData = owner.MapData; var tasks = owner.MyTasks.Count > 0 ? owner.MyTasks : player.MyTasks; if (tasks.Count == 0) { result.Error = "No tasks were assigned"; return result; } int tasksInPlayArea = 0; int tasksOnPathways = 0; var details = new List(); foreach (var task in tasks) { var distFromCenter = task.Location.DistanceTo(center); bool inPlayArea = distFromCenter <= radius; if (inPlayArea) tasksInPlayArea++; // Check if task is near any pathway bool nearPathway = false; if (mapData != null) { var pathways = mapData.GetPathways(); foreach (var pathway in pathways) { foreach (var point in pathway.Points) { if (task.Location.DistanceTo(point) < 15) // Within 15m of pathway { nearPathway = true; break; } } if (nearPathway) break; } } if (nearPathway) tasksOnPathways++; details.Add($"{task.Name}: {distFromCenter:F0}m from center, {(inPlayArea ? "in" : "OUT OF")} play area, {(nearPathway ? "near" : "NOT NEAR")} pathway"); } result.Passed = tasksInPlayArea == tasks.Count; result.Details = $"Tasks in play area: {tasksInPlayArea}/{tasks.Count}\n" + $"Tasks near pathways: {tasksOnPathways}/{tasks.Count}\n" + string.Join("\n", details.Take(3)); if (tasksInPlayArea < tasks.Count) { result.Error = $"{tasks.Count - tasksInPlayArea} tasks are outside play area!"; } } catch (Exception ex) { result.Error = ex.Message; } finally { owner?.Dispose(); player?.Dispose(); } return result; } private async Task TestRepairStationReachabilityAsync(Position center, double radius) { var result = new TestResult { TestName = "RepairStationReachability" }; SimulatorClient? owner = null; SimulatorClient? player = null; try { // Create lobby with 2 players owner = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "SabOwner"); player = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "SabPlayer"); if (!await owner.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Owner connect failed"; return result; } if (!await owner.CreateLobbyAsync(null, center, radius, 1, 3)) { result.Error = "Create lobby failed"; return result; } var joinCode = owner.JoinCode; if (!await player.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Player connect failed"; return result; } if (!await player.JoinLobbyAsync(joinCode!)) { result.Error = "Join lobby failed"; return result; } // Start game - this triggers map data fetch Log(" Starting game (triggers Overpass API fetch)..."); if (!await StartGameAndWaitForMapDataAsync(owner, player, timeoutMs: 25000)) { result.Error = "Timeout waiting for game to start"; return result; } // Find impostor and trigger sabotage var impostor = owner.IsImpostor ? owner : (player.IsImpostor ? player : null); if (impostor == null) { result.Error = "No impostor found"; return result; } // Wait for sabotage cooldown await Task.Delay(1000); // Try to start a sabotage impostor.TrySabotage(SabotageType.CriticalMeltdown); await Task.Delay(1500); // Check repair station positions var crew = owner.IsCrew ? owner : player; var stations = crew.RepairStations; if (stations.Count == 0) { // Sabotage might have been blocked, try CommsBlackout impostor.TrySabotage(SabotageType.CommsBlackout); await Task.Delay(1000); stations = crew.RepairStations; } if (stations.Count == 0) { result.Passed = true; // No sabotage was possible, but that's OK result.Details = "Sabotage cooldown active, skipping repair station test"; return result; } int stationsInPlayArea = 0; var details = new List(); foreach (var station in stations) { var distFromCenter = station.Location.DistanceTo(center); bool inPlayArea = distFromCenter <= radius; if (inPlayArea) stationsInPlayArea++; details.Add($"{station.Name}: {distFromCenter:F0}m from center ({(inPlayArea ? "OK" : "OUT!")})"); } result.Passed = stationsInPlayArea == stations.Count; result.Details = $"Stations in play area: {stationsInPlayArea}/{stations.Count}\n" + string.Join("\n", details); if (stationsInPlayArea < stations.Count) { result.Error = $"{stations.Count - stationsInPlayArea} repair stations are outside play area!"; } } catch (Exception ex) { result.Error = ex.Message; } finally { owner?.Dispose(); player?.Dispose(); } return result; } private async Task TestPlayAreaBoundaryAsync(Position center, double radius) { var result = new TestResult { TestName = "PlayAreaBoundary" }; SimulatorClient? owner = null; SimulatorClient? player = null; try { owner = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "BoundOwner"); player = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), "BoundPlayer"); if (!await owner.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Connect failed"; return result; } if (!await owner.CreateLobbyAsync(null, center, radius)) { result.Error = "Create lobby failed"; return result; } if (!await player.ConnectAsync(_serverHost, _serverPort)) { result.Error = "Player connect failed"; return result; } if (!await player.JoinLobbyAsync(owner.JoinCode!)) { result.Error = "Join lobby failed"; return result; } // Start game - this triggers map data fetch Log(" Starting game (triggers Overpass API fetch)..."); if (!await StartGameAndWaitForMapDataAsync(owner, player, timeoutMs: 25000)) { result.Passed = true; result.Details = "No map data to validate (Overpass might be disabled or timed out)"; return result; } if (!owner.HasMapData) { result.Passed = true; result.Details = "No map data to validate (Overpass might be disabled)"; return result; } var mapData = owner.MapData!; var pathways = mapData.GetPathways(); var pois = mapData.GetPOIs(); int outsidePathwayPoints = 0; int totalPathwayPoints = 0; int outsidePOIs = 0; int totalPOIs = pois.Count; // Check all pathway points foreach (var pathway in pathways) { foreach (var point in pathway.Points) { totalPathwayPoints++; if (point.DistanceTo(center) > radius * 1.1) // Allow 10% margin { outsidePathwayPoints++; } } } // Check all POIs foreach (var poi in pois) { if (poi.Location.DistanceTo(center) > radius * 1.1) { outsidePOIs++; } } // Allow some points outside (Overpass query might include slightly outside data) double pathwayOutsidePercent = totalPathwayPoints > 0 ? (outsidePathwayPoints * 100.0 / totalPathwayPoints) : 0; double poiOutsidePercent = totalPOIs > 0 ? (outsidePOIs * 100.0 / totalPOIs) : 0; result.Passed = pathwayOutsidePercent < 20 && poiOutsidePercent < 20; result.Details = $"Pathway points outside boundary: {outsidePathwayPoints}/{totalPathwayPoints} ({pathwayOutsidePercent:F1}%)\n" + $"POIs outside boundary: {outsidePOIs}/{totalPOIs} ({poiOutsidePercent:F1}%)"; if (!result.Passed) { result.Error = "Too many map elements outside play area boundary"; } } catch (Exception ex) { result.Error = ex.Message; } finally { owner?.Dispose(); player?.Dispose(); } return result; } private async Task TestMapDataConsistencyAsync(Position center, double radius) { var result = new TestResult { TestName = "MapDataConsistency" }; var owners = new List(); var players = new List(); try { // Create 3 lobbies at the same location and compare map data var mapDataResults = new List<(int buildings, int pathways, int pois)>(); for (int i = 0; i < 3; i++) { Log($" Creating lobby {i + 1}/3..."); var owner = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), $"ConsOwner{i}"); var player = new SimulatorClient(Guid.NewGuid().ToString("N").Substring(0, 8), $"ConsPlayer{i}"); owners.Add(owner); players.Add(player); if (!await owner.ConnectAsync(_serverHost, _serverPort)) continue; if (!await owner.CreateLobbyAsync(null, center, radius)) continue; if (!await player.ConnectAsync(_serverHost, _serverPort)) continue; if (!await player.JoinLobbyAsync(owner.JoinCode!)) continue; // Start game to trigger map data fetch if (await StartGameAndWaitForMapDataAsync(owner, player, timeoutMs: 25000)) { if (owner.HasMapData) { var md = owner.MapData!; mapDataResults.Add((md.GetBuildings().Count, md.GetPathways().Count, md.GetPOIs().Count)); } } await Task.Delay(300); } if (mapDataResults.Count < 2) { result.Passed = true; result.Details = "Not enough successful map data fetches to compare consistency"; return result; } // Check if all results are the same (should be cached) var first = mapDataResults[0]; bool allSame = mapDataResults.All(r => r == first); result.Passed = allSame; result.Details = string.Join("\n", mapDataResults.Select((r, i) => $"Lobby {i + 1}: {r.buildings} buildings, {r.pathways} pathways, {r.pois} POIs")); if (!allSame) { result.Error = "Map data varies between lobbies at same location (caching issue?)"; } } catch (Exception ex) { result.Error = ex.Message; } finally { foreach (var o in owners) o?.Dispose(); foreach (var p in players) p?.Dispose(); } return result; } } /// /// Results from a single test /// public class TestResult { public string TestName { get; set; } = ""; public bool Passed { get; set; } public string? Details { get; set; } public string? Error { get; set; } } /// /// Aggregate results from all Overpass tests /// public class OverpassTestResults { public TestResult MapDataFetchTest { get; set; } = new(); public TestResult MapDataStructureTest { get; set; } = new(); public TestResult TaskReachabilityTest { get; set; } = new(); public TestResult RepairStationReachabilityTest { get; set; } = new(); public TestResult PlayAreaBoundaryTest { get; set; } = new(); public TestResult ConsistencyTest { get; set; } = new(); public int TotalTests => 6; public int PassedTests => new[] { MapDataFetchTest, MapDataStructureTest, TaskReachabilityTest, RepairStationReachabilityTest, PlayAreaBoundaryTest, ConsistencyTest }.Count(t => t.Passed); public int FailedTests => TotalTests - PassedTests; public bool AllPassed => PassedTests == TotalTests; public void PrintSummary(Action log) { log($" Total Tests: {TotalTests}"); log($" Passed: {PassedTests}"); log($" Failed: {FailedTests}"); log(""); if (AllPassed) { log(" ✓ ALL OVERPASS TESTS PASSED!"); } else { log(" ✗ SOME TESTS FAILED:"); if (!MapDataFetchTest.Passed) log($" - Map Data Fetch: {MapDataFetchTest.Error}"); if (!MapDataStructureTest.Passed) log($" - Map Data Structure: {MapDataStructureTest.Error}"); if (!TaskReachabilityTest.Passed) log($" - Task Reachability: {TaskReachabilityTest.Error}"); if (!RepairStationReachabilityTest.Passed) log($" - Repair Station Reachability: {RepairStationReachabilityTest.Error}"); if (!PlayAreaBoundaryTest.Passed) log($" - Play Area Boundary: {PlayAreaBoundaryTest.Error}"); if (!ConsistencyTest.Passed) log($" - Consistency: {ConsistencyTest.Error}"); } } } }