using GeoSus.Client; using System.Collections; using System.Threading.Tasks; using UnityEngine; using System.Collections.Generic; using Subsystems; using System.Linq; using UnityEngine.SceneManagement; namespace Subsystems { public class GameManager_Network { private const string _serverAddress = "geosus.honzuvkod.dev"; private const int _serverPort = 7777; private GameClient _gameClient; private GameManager _manager; private bool _pendingMapBuild; /// /// Authoritative game state; written here, read by GameManager_UI. /// public GameState State { get; } = new GameState(); public GameManager_Network(GameClient gameClient, GameManager manager) { _gameClient = gameClient; _manager = manager; RegisterEventHandlers(); } public async void OpenConnection() { // Snapshot the lobby we believed we were in BEFORE the new connect // attempt. If the client SDK preserved it across a transient drop // (P9 fix), this is non-null and we'll send a Reconnect message // post-handshake to re-associate with the lobby on the server side. // Without it, the next CastVote / TaskComplete / etc. would arrive // on a fresh connection the server doesn't recognize and bounce // with NOT_IN_LOBBY. var rejoinLobbyId = _gameClient.LobbyId; int retries = 0; int delayMs = 5000; while (true) { Task state = _gameClient.ConnectAsync(_serverAddress, _serverPort); await state; if (state.Result) { Debug.Log("Connected to server."); // Re-attach to the prior lobby if we had one. Server-side // HandleReconnectAsync will replay missed events and ack // with a ReconnectResponse carrying the snapshot. if (!string.IsNullOrEmpty(rejoinLobbyId)) { Debug.Log($"Re-associating with lobby {rejoinLobbyId} after reconnect."); _gameClient.Reconnect(rejoinLobbyId); } break; } retries++; if (retries >= 10) { Debug.LogError("Failed to connect after 10 attempts. Giving up."); break; } Debug.Log($"Failed to connect (attempt {retries}). Retrying in {delayMs / 1000}s..."); await Task.Delay(delayMs); delayMs = Mathf.Min(delayMs * 2, 30000); } } public void RegisterEventHandlers() { _gameClient.OnConnected += OnConnected; _gameClient.OnDisconnected += OnDisconnected; _gameClient.OnError += OnError; _gameClient.OnMessage += OnMessage; _gameClient.OnGameEvent += OnGameEvent; } private void OnConnected() { Debug.Log("Successfully connected to the server."); // Tear the reconnect overlay down once the socket is healthy. // No-op if it wasn't shown. _manager?.uiSubsystem?.HideReconnecting(); } private void OnError(string e) => Debug.LogError($"Network error: {e}"); private void OnDisconnected(string reason) { Debug.Log($"Disconnected: {reason}"); // Show the reconnect overlay only if the user is mid-game; we // don't want it flashing during a clean shutdown ("Disposed") or // before a real game has started. if (reason != "Disposed" && State.Phase != GamePhase.Lobby) _manager?.uiSubsystem?.ShowReconnecting(); if (reason != "Disposed" && _manager != null) _manager.StartCoroutine(ReconnectAfterDelay(3f)); } private IEnumerator ReconnectAfterDelay(float seconds) { yield return new UnityEngine.WaitForSeconds(seconds); Debug.Log("Attempting to reconnect..."); OpenConnection(); } private void OnMessage(Message message) { switch (message.Type) { case "CreateLobbyResponse": HandleCreateLobbyResponse(message as CreateLobbyResponse); break; case "JoinLobbyResponse": HandleJoinLobbyResponse(message as JoinLobbyResponse); break; case "PositionBroadcast": HandlePositionBroadcast(message as PositionBroadcast); break; case "Error": HandleErrorMessage(message as ErrorMessage); break; case "Ack": case "GameEvent": break; default: Debug.Log("Received message of type: " + message.Type); break; } } /// /// P9 defensive path: if the server tells us NOT_IN_LOBBY but we still /// believe we have a lobby (LobbyId preserved across the transient /// disconnect), the lobby association on the server's side of the new /// connection is missing - typically a race between OpenConnection's /// Reconnect call and an in-flight action message that beat it. Retry /// the Reconnect; if the second attempt also bounces, the lobby really /// is gone and we'll surface the error to the user. /// private void HandleErrorMessage(ErrorMessage err) { if (err == null) return; Debug.Log($"Server error: code={err.ErrorCode} text={err.ErrorText}"); if (err.ErrorCode == "NOT_IN_LOBBY" && !string.IsNullOrEmpty(_gameClient.LobbyId)) { Debug.Log($"NOT_IN_LOBBY but we still have LobbyId={_gameClient.LobbyId}; resending Reconnect."); _gameClient.Reconnect(_gameClient.LobbyId); } } private void OnGameEvent(GameEvent gameEvent) { // Always sync player list from lobby state after any event SyncPlayersFromLobby(); switch (gameEvent.EventType) { case "PlayerJoined": case "PlayerLeft": case "HostChanged": _manager?.uiSubsystem?.NotifyLobbyChanged(); break; case "GameStarting": State.Phase = GamePhase.Loading; HandleGameStarting(); break; case "MapDataReady": HandleMapDataReady(); break; case "GameStarted": State.Phase = GamePhase.Playing; break; case "RoleAssigned": HandleRoleAssigned(gameEvent); break; case "TaskCompleted": HandleTaskCompleted(gameEvent); break; case "PlayerKilled": HandlePlayerKilled(gameEvent); break; case "BodyReported": case "EmergencyMeetingCalled": Toast("Meeting called! Head to the meeting point."); break; case "MeetingStarted": HandleMeetingStarted(gameEvent); break; case "PlayerArrivedAtMeeting": HandlePlayerArrivedAtMeeting(gameEvent); break; case "PlayerVoted": HandlePlayerVoted(gameEvent); break; case "VotingClosed": HandleVotingClosed(gameEvent); break; case "GameEnded": HandleGameEnded(gameEvent); break; case "ReturnedToLobby": HandleReturnedToLobby(); break; case "SabotageStarted": HandleSabotageStarted(gameEvent); break; case "RepairStarted": HandleRepairStarted(gameEvent); break; case "RepairStopped": HandleRepairStopped(gameEvent); break; case "SabotageRepaired": case "SabotageMeltdown": case "SabotageExpired": State.ActiveSabotage = null; State.ActiveRepairs.Clear(); _manager?.uiSubsystem?.HideSabotageTimer(); _manager?.mapSubsystem?.ClearSabotageMarkers(); break; case "TaskStarted": // Server now broadcasts when a player begins a task. Phase 1 // only acks; Phase 2/3 will surface this to other players. break; case "MapDataError": HandleMapDataError(gameEvent); break; default: Debug.Log("GameEvent: " + gameEvent.EventType); break; } } // ── Lobby responses ─────────────────────────────────────────────────── private void HandleCreateLobbyResponse(CreateLobbyResponse message) { if (message == null) return; if (message.Success) { Debug.Log($"Lobby created. Code: {message.JoinCode}"); // P13b: snapshot the server's authoritative settings into // GameState so HUD / proximity code can read distances and // cooldowns from a single source of truth instead of hardcodes. State.Settings = _gameClient.CurrentLobbyState?.Settings; SceneManager.LoadScene("create", LoadSceneMode.Single); _manager?.uiSubsystem?.NotifyLobbyChanged(); } else { Debug.LogError("Failed to create lobby: " + message.Error); } } private void HandleJoinLobbyResponse(JoinLobbyResponse message) { if (message == null) return; if (message.Success) { Debug.Log($"Joined lobby: {message.LobbyId}"); // P13b: same settings snapshot path as host. Joiners read the // server's snapshot taken at lobby creation; they cannot edit. State.Settings = _gameClient.CurrentLobbyState?.Settings; // Unified lobby: both host and joiners land on create.unity. // LobbyDisplayUI handles the role split internally (start // button for host, waiting text for joiners). SceneManager.LoadScene("create", LoadSceneMode.Single); _manager?.uiSubsystem?.NotifyLobbyChanged(); } else { Debug.LogError("Failed to join lobby: " + message.Error); } } // ── Game flow ───────────────────────────────────────────────────────── private void HandleGameStarting() { _pendingMapBuild = false; // Reset per-game state State.MyRole = null; State.IsDead = false; State.MyTasks = new List(); State.MyCompletedTaskIds = new HashSet(); State.TotalCompleted = 0; State.TotalRequired = 0; State.ActiveMeeting = null; State.LastVoteResult = null; State.VotedPlayerIds = new HashSet(); State.ActiveSabotage = null; State.GameEndData = null; State.KillCooldownRemaining = 0; SceneManager.LoadScene("Client", LoadSceneMode.Single); } private void HandleMapDataReady() { _pendingMapBuild = true; TryBuildMapAndMarkers(); } public void OnClientSceneReady() { TryBuildMapAndMarkers(); } private void TryBuildMapAndMarkers() { if (!_pendingMapBuild) return; if (_manager?.mapSubsystem == null || !_manager.mapSubsystem.IsSceneReady) return; if (_gameClient?.CurrentLobbyState?.MapData == null) return; _manager.mapSubsystem.BuildMap(); _manager.mapSubsystem.CreateTaskMarkers(_gameClient.MyTasks); _pendingMapBuild = false; Debug.Log("[Network] Map built."); } private void HandleRoleAssigned(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null || payload.ClientUuid != _gameClient.ClientUuid) return; State.MyRole = payload.Role; State.MyTasks = payload.Tasks ?? new List(); State.MyCompletedTaskIds.Clear(); Debug.Log($"Role: {payload.Role}, Tasks: {State.MyTasks.Count}"); _manager?.taskSubsystem?.Initialize(State.MyTasks); } private void HandleTaskCompleted(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; // Track if it's our task if (payload.ClientUuid == _gameClient.ClientUuid) State.MyCompletedTaskIds.Add(payload.TaskId); State.TotalCompleted = payload.TotalCompleted; State.TotalRequired = payload.TotalTasks; _manager?.uiSubsystem?.UpdateTaskProgress(payload.TotalCompleted, payload.TotalTasks); _manager?.mapSubsystem?.RemoveTaskMarker(payload.TaskId); } private void HandlePlayerKilled(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; _manager?.mapSubsystem?.CreateBodyMarker(payload.BodyId, payload.Location); if (payload.VictimId == _gameClient.ClientUuid) { State.IsDead = true; _manager?.uiSubsystem?.OnLocalPlayerDied(); } // Update player state in our list var p = State.Players.Find(x => x.ClientUuid == payload.VictimId); if (p != null) p.State = PlayerState.Dead; } private void HandleMeetingStarted(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; State.Phase = GamePhase.Meeting; State.ActiveMeeting = payload; State.VotedPlayerIds = new HashSet(); State.ArrivedPlayerIds = new HashSet(); State.VoterTargets = new Dictionary(); State.VoteTallies = new Dictionary(); State.MyVoteTarget = null; State.LastVoteResult = null; SyncPlayersFromLobby(); _manager?.uiSubsystem?.ShowMeetingPanel(State.Players, payload); } private void HandlePlayerArrivedAtMeeting(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; State.ArrivedPlayerIds.Add(payload.ClientUuid); } private void HandlePlayerVoted(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; // Server allows vote changes within a 2s rate limit, so we always // overwrite the voter's previous target rather than appending. string target = payload.TargetId ?? GameState.VoteSkip; State.VotedPlayerIds.Add(payload.VoterId); State.VoterTargets[payload.VoterId] = target; RecomputeVoteTallies(); if (payload.VoterId == _gameClient.ClientUuid) State.MyVoteTarget = target; } private void RecomputeVoteTallies() { State.VoteTallies.Clear(); foreach (var t in State.VoterTargets.Values) { if (string.IsNullOrEmpty(t)) continue; State.VoteTallies.TryGetValue(t, out var count); State.VoteTallies[t] = count + 1; } } private void HandleVotingClosed(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; State.Phase = GamePhase.Playing; State.ActiveMeeting = null; State.LastVoteResult = payload; // Mark ejected player dead in our list if (!string.IsNullOrEmpty(payload.EjectedPlayerId)) { var p = State.Players.Find(x => x.ClientUuid == payload.EjectedPlayerId); if (p != null) p.State = PlayerState.Dead; } _manager?.uiSubsystem?.ShowVoteResult(payload, State.Players); _manager?.mapSubsystem?.ClearBodyMarkers(); } private void HandleGameEnded(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; State.Phase = GamePhase.Ended; State.GameEndData = payload; // If the round ended while the meeting/vote-result overlay was // still up (e.g. ejection won the game outright), the auto-close // coroutine would otherwise fire 5s later and tear down the // meeting panel while the GameEndPanel sits on top - leaving a // glimpse of the dead overlay during the transition. _manager?.uiSubsystem?.HideMeetingPanel(); _manager?.uiSubsystem?.ShowGameEndPanel(payload, _gameClient.ClientUuid); } private void HandleReturnedToLobby() { State.Phase = GamePhase.Lobby; _manager?.uiSubsystem?.HideMeetingPanel(); // Bodies survive the scene reload because the marker GameObjects are // parented under MapCenterPoint (which lives in the persistent // Client.unity scene). Without this clear, returning to lobby and // starting a new round leaves stale corpses on the map of the new // round. Server already cleared its `_bodies` set in // ProcessReturnToLobby; this is the client-side mirror that was // missing in HandleVotingClosed's symmetry. _manager?.mapSubsystem?.ClearBodyMarkers(); _manager?.mapSubsystem?.ClearSabotageMarkers(); // Unified lobby: regardless of role, return to create.unity. SceneManager.LoadScene("create", LoadSceneMode.Single); } private void HandleSabotageStarted(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null) return; State.ActiveSabotage = payload; State.ActiveRepairs.Clear(); _manager?.mapSubsystem?.CreateSabotageMarkers(payload.RepairStations); if (payload.Type == SabotageType.CriticalMeltdown && payload.Deadline.HasValue) _manager?.uiSubsystem?.ShowSabotageTimer(payload.Deadline.Value); if (payload.Type == SabotageType.CommsBlackout) _manager?.uiSubsystem?.SetCommsBlackout(true); } private void HandleRepairStarted(GameEvent evt) { var payload = evt.GetPayload(); if (payload == null || string.IsNullOrEmpty(payload.StationId)) return; State.ActiveRepairs.Add(payload.StationId); } private void HandleRepairStopped(GameEvent evt) { // A player abandoned a repair station mid-fix. The station is no // longer counted as active for the simultaneous-repair coaching; // the marker stays on the map until the sabotage resolves. var payload = evt.GetPayload(); if (payload != null && !string.IsNullOrEmpty(payload.StationId)) State.ActiveRepairs.Remove(payload.StationId); } private void HandleMapDataError(GameEvent evt) { // Server failed to fetch Overpass data. Without this the loading // screen would hang forever. Drop back to lobby and surface the // failure so the player can re-host or try a different center. Debug.LogError("[Network] Server could not generate map data."); State.Phase = GamePhase.Lobby; _manager?.uiSubsystem?.ShowToast("Map fetch failed. Returning to lobby."); LeaveLobby(); } private void HandlePositionBroadcast(PositionBroadcast broadcast) { if (broadcast == null) return; _manager?.mapSubsystem?.UpdatePlayerAvatars(_gameClient.PlayerPositions, _gameClient.ClientUuid); } // ── Helpers ─────────────────────────────────────────────────────────── private void SyncPlayersFromLobby() { var lobby = _gameClient.CurrentLobbyState; if (lobby?.Players != null) State.Players = lobby.Players; } private void Toast(string message) { State.ToastMessage = message; State.ToastExpiry = UnityEngine.Time.time + 4f; } // ── Send helpers ────────────────────────────────────────────────────── public void CreateLobby(double lat, double lon, double radius = 500, int impostorCount = 1, int taskCount = 5, GameSettingsOverrides settings = null) { _gameClient.CreateLobby(new Position(lat, lon), impostorCount, taskCount, null, radius, settings); } public void JoinLobby(string joinCode) { try { _gameClient.JoinLobby(joinCode); } catch (System.Exception ex) { Debug.LogError("JoinLobby error: " + ex.Message); } } public void LeaveLobby() { _gameClient.LeaveLobby(); State.Phase = GamePhase.Lobby; SceneManager.LoadScene(_manager?.firstMenuScene ?? "main menu asi idk lol", LoadSceneMode.Single); } public void StartGame() { _gameClient.StartGame(); } } }