This commit is contained in:
Bandwidth
2026-04-26 20:49:32 +02:00
parent e0b808faed
commit d886f97e14
66 changed files with 8327 additions and 933 deletions

View File

@@ -31,6 +31,15 @@ namespace Subsystems
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)
@@ -40,6 +49,15 @@ namespace Subsystems
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++;
@@ -63,12 +81,25 @@ namespace Subsystems
_gameClient.OnGameEvent += OnGameEvent;
}
private void OnConnected() => Debug.Log("Successfully connected to the server.");
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));
}
@@ -93,6 +124,9 @@ namespace Subsystems
case "PositionBroadcast":
HandlePositionBroadcast(message as PositionBroadcast);
break;
case "Error":
HandleErrorMessage(message as ErrorMessage);
break;
case "Ack":
case "GameEvent":
break;
@@ -102,6 +136,27 @@ namespace Subsystems
}
}
/// <summary>
/// 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.
/// </summary>
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
@@ -149,6 +204,10 @@ namespace Subsystems
HandleMeetingStarted(gameEvent);
break;
case "PlayerArrivedAtMeeting":
HandlePlayerArrivedAtMeeting(gameEvent);
break;
case "PlayerVoted":
HandlePlayerVoted(gameEvent);
break;
@@ -169,15 +228,30 @@ namespace Subsystems
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":
Debug.LogError("Server could not generate map data.");
HandleMapDataError(gameEvent);
break;
default:
@@ -194,6 +268,10 @@ namespace Subsystems
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();
}
@@ -209,7 +287,13 @@ namespace Subsystems
if (message.Success)
{
Debug.Log($"Joined lobby: {message.LobbyId}");
SceneManager.LoadScene("join loading", LoadSceneMode.Single);
// 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
@@ -314,20 +398,52 @@ namespace Subsystems
var payload = evt.GetPayload<MeetingStartedPayload>();
if (payload == null) return;
State.Phase = GamePhase.Meeting;
State.ActiveMeeting = payload;
State.VotedPlayerIds = new HashSet<string>();
State.LastVoteResult = null;
State.Phase = GamePhase.Meeting;
State.ActiveMeeting = payload;
State.VotedPlayerIds = new HashSet<string>();
State.ArrivedPlayerIds = new HashSet<string>();
State.VoterTargets = new Dictionary<string, string>();
State.VoteTallies = new Dictionary<string, int>();
State.MyVoteTarget = null;
State.LastVoteResult = null;
SyncPlayersFromLobby();
_manager?.uiSubsystem?.ShowMeetingPanel(State.Players, payload);
}
private void HandlePlayerArrivedAtMeeting(GameEvent evt)
{
var payload = evt.GetPayload<PlayerArrivedAtMeetingPayload>();
if (payload == null) return;
State.ArrivedPlayerIds.Add(payload.ClientUuid);
}
private void HandlePlayerVoted(GameEvent evt)
{
var payload = evt.GetPayload<PlayerVotedPayload>();
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)
@@ -358,16 +474,21 @@ namespace Subsystems
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;
if (_gameClient.IsOwner)
SceneManager.LoadScene("create", LoadSceneMode.Single);
else
SceneManager.LoadScene("join loading", LoadSceneMode.Single);
_manager?.uiSubsystem?.HideMeetingPanel();
// Unified lobby: regardless of role, return to create.unity.
SceneManager.LoadScene("create", LoadSceneMode.Single);
}
private void HandleSabotageStarted(GameEvent evt)
@@ -376,6 +497,7 @@ namespace Subsystems
if (payload == null) return;
State.ActiveSabotage = payload;
State.ActiveRepairs.Clear();
_manager?.mapSubsystem?.CreateSabotageMarkers(payload.RepairStations);
if (payload.Type == SabotageType.CriticalMeltdown && payload.Deadline.HasValue)
@@ -384,6 +506,34 @@ namespace Subsystems
_manager?.uiSubsystem?.SetCommsBlackout(true);
}
private void HandleRepairStarted(GameEvent evt)
{
var payload = evt.GetPayload<RepairStartedPayload>();
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<RepairStoppedPayload>();
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;
@@ -407,9 +557,9 @@ namespace Subsystems
// ── Send helpers ──────────────────────────────────────────────────────
public void CreateLobby(double lat, double lon, double radius = 500, int impostorCount = 1, int taskCount = 5)
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);
_gameClient.CreateLobby(new Position(lat, lon), impostorCount, taskCount, null, radius, settings);
}
public void JoinLobby(string joinCode)