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

@@ -132,7 +132,20 @@ public class GameClient : IDisposable
return false;
}
public void Disconnect(string reason = "User disconnected")
/// <summary>
/// Tears down the socket and crypto session. When `transient` is true
/// (network drop, decrypt-failure cascade, anything we expect to retry),
/// the lobby/role/task/state caches are preserved so the post-reconnect
/// flow can re-associate via Reconnect(LobbyId). Default false matches
/// pre-P9 behavior (full state wipe) for explicit user disconnects.
///
/// Critical for the P9 reconnect bug: previously every Disconnect path
/// nuked LobbyId, so by the time GameManager_Network's reconnect coroutine
/// fired, the client had no idea which lobby it had been in - the
/// post-handshake Reconnect call had nothing to send and the server
/// answered the next vote/action with NOT_IN_LOBBY.
/// </summary>
public void Disconnect(string reason = "User disconnected", bool transient = false)
{
_cts?.Cancel();
_tcpClient?.Close();
@@ -140,15 +153,22 @@ public class GameClient : IDisposable
_stream = null;
_encryption?.Dispose();
_encryption = null;
LobbyId = null;
JoinCode = null;
CurrentLobbyState = null;
MyRole = null;
MyTasks.Clear();
PlayerPositions.Clear();
Bodies.Clear();
if (!transient)
{
LobbyId = null;
JoinCode = null;
CurrentLobbyState = null;
MyRole = null;
MyTasks.Clear();
PlayerPositions.Clear();
Bodies.Clear();
}
// PlayerPositions are stale anyway after a drop, but we keep them so
// the UI doesn't blink avatars off-map mid-meeting; the next position
// broadcast overwrites them. LastEventId is intentionally preserved
// so the Reconnect message can ask the server for missed events.
Dispatcher.Post(() => OnDisconnected?.Invoke(reason));
}
@@ -236,7 +256,8 @@ public class GameClient : IDisposable
decryptFailures++;
if (decryptFailures >= 3)
{
Disconnect("Too many decryption failures");
// Transient: keep LobbyId for the reconnect coroutine.
Disconnect("Too many decryption failures", transient: true);
return;
}
continue;
@@ -253,7 +274,9 @@ public class GameClient : IDisposable
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
Disconnect($"Connection error: {ex.Message}");
// Transient: TCP RST / read failure is exactly what reconnect was
// designed for. Keep LobbyId so post-reconnect flow can re-attach.
Disconnect($"Connection error: {ex.Message}", transient: true);
}
}
@@ -508,7 +531,7 @@ public class GameClient : IDisposable
#region Game Actions
public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500)
public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500, GameSettingsOverrides? settings = null)
{
Send(new CreateLobby
{
@@ -516,7 +539,8 @@ public class GameClient : IDisposable
PlayAreaRadius = playAreaRadius,
ImpostorCount = impostorCount,
TaskCount = taskCount,
Password = password
Password = password,
Settings = settings
});
}

View File

@@ -49,6 +49,11 @@ public enum PlayerRole { Crew, Impostor }
public enum PlayerState { Alive, Dead }
[JsonConverter(typeof(StringEnumConverter))]
// NOTE: `Voting` is reserved-but-unused on the wire as of 2026. The server
// keeps the entire vote cycle inside `Meeting` and uses MeetingStartedPayload
// timestamps (DiscussionEndTime / VotingEndTime) to distinguish sub-phases.
// The enum value is preserved here for serialization compatibility with old
// saves; new code should not assign it.
public enum GamePhase { Lobby, Loading, Playing, Meeting, Voting, Ended }
[JsonConverter(typeof(StringEnumConverter))]
@@ -184,6 +189,14 @@ public class CreateLobby : Message
[JsonProperty("taskCount")]
public int TaskCount { get; set; } = 5;
/// <summary>
/// P13b: optional per-lobby settings overrides supplied by the host.
/// Any field left null falls through to the server's current default
/// (snapshotted at lobby creation, immutable thereafter for this lobby).
/// </summary>
[JsonProperty("settings")]
public GameSettingsOverrides? Settings { get; set; }
}
public class CreateLobbyResponse : Message
@@ -623,17 +636,26 @@ public class PlayerEjectedPayload
public PlayerRole Role { get; set; }
}
public class TaskStartedPayload
{
[JsonProperty("clientUuid")]
public string ClientUuid { get; set; } = "";
[JsonProperty("taskId")]
public string TaskId { get; set; } = "";
}
public class TaskCompletedPayload
{
[JsonProperty("clientUuid")]
public string ClientUuid { get; set; } = "";
[JsonProperty("taskId")]
public string TaskId { get; set; } = "";
[JsonProperty("totalCompleted")]
public int TotalCompleted { get; set; }
[JsonProperty("totalTasks")]
public int TotalTasks { get; set; }
}
@@ -713,10 +735,10 @@ public class RepairStartedPayload
{
[JsonProperty("sabotageId")]
public string SabotageId { get; set; } = "";
[JsonProperty("stationId")]
public string StationId { get; set; } = "";
[JsonProperty("playerId")]
public string PlayerId { get; set; } = "";
}
@@ -725,10 +747,10 @@ public class RepairStoppedPayload
{
[JsonProperty("sabotageId")]
public string SabotageId { get; set; } = "";
[JsonProperty("stationId")]
public string StationId { get; set; } = "";
[JsonProperty("playerId")]
public string PlayerId { get; set; } = "";
}
@@ -790,6 +812,162 @@ public class LobbyState
/// <summary>True if map data has been loaded (or Overpass is disabled)</summary>
[JsonProperty("mapDataReady")]
public bool MapDataReady { get; set; } = true;
/// <summary>
/// P13b: full per-lobby settings snapshot. Clients use this for HUD
/// (button visibility, countdown timings, etc.) instead of hardcoded
/// values. Always populated for new server builds; old client builds
/// can ignore the field.
/// </summary>
[JsonProperty("settings")]
public GameSettings? Settings { get; set; }
}
/// <summary>
/// P13b: per-lobby gameplay settings on the wire. Server populates this from
/// its per-lobby snapshot so clients can drive HUD logic from authoritative
/// values rather than hardcoded constants.
/// </summary>
public class GameSettings
{
// Round shape
[JsonProperty("maxPlayers")]
public int MaxPlayers { get; set; }
[JsonProperty("impostorCount")]
public int ImpostorCount { get; set; }
[JsonProperty("taskCount")]
public int TaskCount { get; set; }
[JsonProperty("tiePolicy")]
public string TiePolicy { get; set; } = "NoEject";
// Distances (m)
[JsonProperty("killDistanceM")]
public double KillDistanceM { get; set; }
[JsonProperty("reportDistanceM")]
public double ReportDistanceM { get; set; }
[JsonProperty("taskStartDistanceM")]
public double TaskStartDistanceM { get; set; }
[JsonProperty("meetingArrivalRadiusM")]
public double MeetingArrivalRadiusM { get; set; }
[JsonProperty("emergencyMeetingCallRadiusM")]
public double EmergencyMeetingCallRadiusM { get; set; }
[JsonProperty("repairStationDistanceM")]
public double RepairStationDistanceM { get; set; }
// Cooldowns / counts
[JsonProperty("killCooldownMs")]
public int KillCooldownMs { get; set; }
[JsonProperty("emergencyMeetingCooldownMs")]
public int EmergencyMeetingCooldownMs { get; set; }
[JsonProperty("maxEmergencyMeetingsPerPlayer")]
public int MaxEmergencyMeetingsPerPlayer { get; set; }
// Meeting phases (ms)
[JsonProperty("arrivalBaseMs")]
public int ArrivalBaseMs { get; set; }
[JsonProperty("allowedLateMs")]
public int AllowedLateMs { get; set; }
[JsonProperty("discussionPhaseMs")]
public int DiscussionPhaseMs { get; set; }
[JsonProperty("votingPhaseMs")]
public int VotingPhaseMs { get; set; }
// Sabotage
[JsonProperty("sabotageCooldownMs")]
public int SabotageCooldownMs { get; set; }
[JsonProperty("commsBlackoutDurationMs")]
public int CommsBlackoutDurationMs { get; set; }
[JsonProperty("criticalMeltdownDeadlineMs")]
public int CriticalMeltdownDeadlineMs { get; set; }
[JsonProperty("repairStationHoldMs")]
public int RepairStationHoldMs { get; set; }
}
/// <summary>
/// P13b: host-supplied overrides at CreateLobby. Every field is nullable so
/// the host can opt into changing only what they care about; null = use the
/// server's current default at the moment of lobby creation.
/// </summary>
public class GameSettingsOverrides
{
[JsonProperty("maxPlayers")]
public int? MaxPlayers { get; set; }
[JsonProperty("impostorCount")]
public int? ImpostorCount { get; set; }
[JsonProperty("taskCount")]
public int? TaskCount { get; set; }
[JsonProperty("tiePolicy")]
public string? TiePolicy { get; set; }
[JsonProperty("killDistanceM")]
public double? KillDistanceM { get; set; }
[JsonProperty("reportDistanceM")]
public double? ReportDistanceM { get; set; }
[JsonProperty("taskStartDistanceM")]
public double? TaskStartDistanceM { get; set; }
[JsonProperty("meetingArrivalRadiusM")]
public double? MeetingArrivalRadiusM { get; set; }
[JsonProperty("emergencyMeetingCallRadiusM")]
public double? EmergencyMeetingCallRadiusM { get; set; }
[JsonProperty("repairStationDistanceM")]
public double? RepairStationDistanceM { get; set; }
[JsonProperty("killCooldownMs")]
public int? KillCooldownMs { get; set; }
[JsonProperty("emergencyMeetingCooldownMs")]
public int? EmergencyMeetingCooldownMs { get; set; }
[JsonProperty("maxEmergencyMeetingsPerPlayer")]
public int? MaxEmergencyMeetingsPerPlayer { get; set; }
[JsonProperty("arrivalBaseMs")]
public int? ArrivalBaseMs { get; set; }
[JsonProperty("allowedLateMs")]
public int? AllowedLateMs { get; set; }
[JsonProperty("discussionPhaseMs")]
public int? DiscussionPhaseMs { get; set; }
[JsonProperty("votingPhaseMs")]
public int? VotingPhaseMs { get; set; }
[JsonProperty("sabotageCooldownMs")]
public int? SabotageCooldownMs { get; set; }
[JsonProperty("commsBlackoutDurationMs")]
public int? CommsBlackoutDurationMs { get; set; }
[JsonProperty("criticalMeltdownDeadlineMs")]
public int? CriticalMeltdownDeadlineMs { get; set; }
[JsonProperty("repairStationHoldMs")]
public int? RepairStationHoldMs { get; set; }
}
// Map data classes for rendering - compact format from server