Skip to main content

signal_fish_client/
protocol.rs

1//! Wire-compatible protocol types for the Signal Fish signaling protocol.
2//!
3//! Every type in this module produces identical JSON to the server's
4//! `protocol::messages` and `protocol::types` modules. Key adaptations:
5//!
6//! - `bytes::Bytes` → `Vec<u8>` with `#[serde(with = "serde_bytes")]`
7//! - `chrono::DateTime<Utc>` → `String` (ISO 8601)
8//! - No `rkyv` derives (server-only concern)
9
10use serde::{Deserialize, Deserializer, Serialize};
11use uuid::Uuid;
12
13use crate::error_codes::ErrorCode;
14
15pub mod binary;
16pub use binary::{
17    decode_v2_binary_game_data, decode_v3_binary_game_data, V2BinaryGameDataFrame,
18    V3BinaryGameDataFrame,
19};
20
21// ── Type aliases ────────────────────────────────────────────────────
22
23/// Unique identifier for players.
24pub type PlayerId = Uuid;
25
26/// Unique identifier for rooms.
27pub type RoomId = Uuid;
28
29// ── Enums ───────────────────────────────────────────────────────────
30
31/// Relay transport protocol selection.
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum RelayTransport {
35    /// TCP transport (reliable, ordered delivery).
36    /// Recommended for: Turn-based games, lobby systems, RPGs.
37    Tcp,
38    /// UDP transport (low-latency, unreliable).
39    /// Recommended for: FPS, racing games, real-time action.
40    Udp,
41    /// WebSocket transport (reliable, browser-compatible).
42    /// Recommended for: WebGL builds, browser games, cross-platform.
43    Websocket,
44    /// Automatic selection based on room size and game type.
45    /// Default: UDP for 2-4 players, TCP for 5+ players, WebSocket for browser builds.
46    #[default]
47    Auto,
48}
49
50/// Encoding format for sequenced game data payloads.
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
52#[serde(rename_all = "snake_case")]
53pub enum GameDataEncoding {
54    /// JSON payloads delivered over text frames.
55    #[default]
56    Json,
57    /// MessagePack payloads delivered over binary frames.
58    #[serde(rename = "message_pack")]
59    MessagePack,
60    /// Rkyv zero-copy binary format.
61    ///
62    /// **Caveat:** the current server never advertises or negotiates rkyv —
63    /// requesting it does not fail, it silently downgrades to JSON (the
64    /// server's `game_data_formats` will not include `"rkyv"`). Treat this
65    /// variant as reserved until the server ships rkyv negotiation.
66    #[serde(rename = "rkyv")]
67    Rkyv,
68}
69
70/// Protocol-v3 delivery policy for relayed game data.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum DeliveryClass {
74    /// Preserve every message or disconnect the recipient loudly.
75    #[default]
76    Reliable,
77    /// Retain only the newest undelivered value for a sender-defined key.
78    Latest,
79    /// Deliver opportunistically without sender backpressure.
80    Volatile,
81}
82
83/// Why an exact server-stamped sequence range was omitted.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum DeliveryGapReason {
87    LatestSuperseded,
88    LatestDroppedFull,
89    VolatileDropped,
90    UnsupportedFormat,
91}
92
93/// Exact inclusive sequence range omitted for one recipient.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct DeliveryGap {
96    pub from_player: PlayerId,
97    pub epoch: u32,
98    pub from_seq: u64,
99    pub to_seq: u64,
100    pub reason: DeliveryGapReason,
101}
102
103/// Cumulative reliable-delivery outcomes.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ReliableDeliveryCounters {
106    pub delivered: u64,
107    pub abandoned: u64,
108    pub unsupported_format: u64,
109}
110
111/// Cumulative keyed-latest delivery outcomes.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
113pub struct LatestDeliveryCounters {
114    pub delivered: u64,
115    pub superseded: u64,
116    pub dropped_full: u64,
117    pub abandoned: u64,
118    pub unsupported_format: u64,
119}
120
121/// Cumulative volatile-delivery outcomes.
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
123pub struct VolatileDeliveryCounters {
124    pub delivered: u64,
125    pub dropped: u64,
126    pub abandoned: u64,
127    pub unsupported_format: u64,
128}
129
130/// Cumulative delivery outcomes partitioned by delivery class.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
132pub struct DeliveryCountersByClass {
133    pub reliable: ReliableDeliveryCounters,
134    pub latest: LatestDeliveryCounters,
135    pub volatile: VolatileDeliveryCounters,
136}
137
138/// Exact protocol-v3 accountability report.
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct DeliveryReportPayload {
141    pub per_class: DeliveryCountersByClass,
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub gaps: Vec<DeliveryGap>,
144}
145
146/// Maximum exact omission ranges carried in one delivery report.
147pub const DELIVERY_REPORT_MAX_GAPS: usize = 256;
148
149/// Completeness of the control-event replay on reconnect.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum ReplayStatus {
153    Complete,
154    Truncated,
155    Unavailable,
156}
157
158/// Authoritative per-sender relay baseline supplied on reconnect.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160pub struct SenderWatermark {
161    pub player_id: PlayerId,
162    pub epoch: u32,
163    pub seq: u64,
164}
165
166/// Session topology selected by the server for a finalized room (protocol v3).
167///
168/// The server is authoritative: it chooses the topology and communicates the
169/// result via [`SessionPlanPayload`]. The client obeys it; it never computes a
170/// topology itself.
171#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(rename_all = "snake_case")]
173pub enum Topology {
174    /// Server relay hub — the v2 behavior, always available (the "relay floor").
175    Relay,
176    /// Star topology around a single elected host/authority.
177    Host,
178    /// Full mesh: every peer connects to every other peer.
179    Mesh,
180}
181
182/// Data-path transport selected by the server for a finalized room (protocol v3).
183///
184/// Distinct from the [`Transport`](crate::Transport) I/O trait: `TransportKind`
185/// is a wire *value* the server sends to describe how peers should exchange game
186/// data, whereas `Transport` is the byte channel to the signaling server.
187#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
188#[serde(rename_all = "snake_case")]
189pub enum TransportKind {
190    /// Server WebSocket fan-out — the mandatory floor every client supports.
191    Relay,
192    /// Direct IP:port connection (LAN / routable host).
193    Direct,
194    /// Peer-to-peer WebRTC data channel.
195    ///
196    /// `rename_all = "snake_case"` would emit `web_rtc`; the protocol requires
197    /// the token `webrtc`, so the variant is renamed explicitly. This token
198    /// deliberately matches [`ConnectionInfo::WebRTC`]'s `#[serde(rename = "webrtc")]`.
199    #[serde(rename = "webrtc")]
200    WebRtc,
201}
202
203/// Signaling-message transport exposed by the server for this connection.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum MessageTransport {
207    /// RFC 6455 WebSocket text/binary frames.
208    Websocket,
209}
210
211/// Connection information for P2P establishment.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type")]
214pub enum ConnectionInfo {
215    /// Direct IP:port connection (for Mirror, FishNet, Unity NetCode direct).
216    #[serde(rename = "direct")]
217    Direct { host: String, port: u16 },
218    /// Unity Relay allocation (for Unity NetCode via Unity Relay).
219    #[serde(rename = "unity_relay")]
220    UnityRelay {
221        allocation_id: String,
222        connection_data: String,
223        key: String,
224    },
225    /// Built-in relay server (for Unity NetCode, FishNet, Mirror).
226    #[serde(rename = "relay")]
227    Relay {
228        /// Relay server host.
229        host: String,
230        /// Relay server port (TCP or UDP depending on transport).
231        port: u16,
232        /// Transport protocol (TCP, UDP, or Auto).
233        #[serde(default)]
234        transport: RelayTransport,
235        /// Allocation ID (room ID).
236        allocation_id: String,
237        /// Client authentication token (opaque server-issued value).
238        token: String,
239        /// Assigned client ID (set by server after connection).
240        #[serde(skip_serializing_if = "Option::is_none")]
241        client_id: Option<u16>,
242    },
243    /// WebRTC connection info (for Matchbox).
244    #[serde(rename = "webrtc")]
245    WebRTC {
246        sdp: Option<String>,
247        ice_candidates: Vec<String>,
248    },
249    /// Custom connection data (extensible for other types).
250    #[serde(rename = "custom")]
251    Custom { data: serde_json::Value },
252}
253
254/// Describes why a spectator state change occurred.
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
256#[serde(rename_all = "snake_case")]
257pub enum SpectatorStateChangeReason {
258    #[default]
259    Joined,
260    VoluntaryLeave,
261    Disconnected,
262    Removed,
263    RoomClosed,
264}
265
266/// Lobby readiness state.
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
268#[serde(rename_all = "snake_case")]
269pub enum LobbyState {
270    #[default]
271    Waiting,
272    Lobby,
273    Finalized,
274}
275
276// ── Structs ─────────────────────────────────────────────────────────
277
278/// Information about a player in a room.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct PlayerInfo {
281    pub id: PlayerId,
282    pub name: String,
283    pub is_authority: bool,
284    pub is_ready: bool,
285    pub connected_at: String,
286    /// Connection info for P2P establishment (provided when player is ready).
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub connection_info: Option<ConnectionInfo>,
289    /// Current server-tracked incarnation epoch (protocol v3 only).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub epoch: Option<u32>,
292    /// Relay tail represented by this snapshot (protocol v3 only).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub seq: Option<u64>,
295}
296
297/// Information about a spectator watching a room.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct SpectatorInfo {
300    pub id: PlayerId,
301    pub name: String,
302    pub connected_at: String,
303}
304
305/// Peer connection information for game start.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct PeerConnectionInfo {
308    pub player_id: PlayerId,
309    pub player_name: String,
310    pub is_authority: bool,
311    pub relay_type: String,
312    /// Connection info provided by the peer for P2P establishment.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub connection_info: Option<ConnectionInfo>,
315}
316
317/// Rate limit information for an application.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct RateLimitInfo {
320    /// Requests allowed per minute.
321    pub per_minute: u32,
322    /// Requests allowed per hour.
323    pub per_hour: u32,
324    /// Requests allowed per day.
325    pub per_day: u32,
326}
327
328/// Describes negotiated protocol capabilities for a specific SDK.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct ProtocolInfoPayload {
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub platform: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub sdk_version: Option<String>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub minimum_version: Option<String>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub recommended_version: Option<String>,
339    #[serde(default)]
340    pub capabilities: Vec<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub notes: Option<String>,
343    #[serde(default)]
344    pub game_data_formats: Vec<GameDataEncoding>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub player_name_rules: Option<PlayerNameRulesPayload>,
347    /// Protocol version negotiated for this connection (protocol v3+ only).
348    ///
349    /// `None` for a negotiated v2 connection, keeping the v2 wire contract
350    /// byte-identical.
351    #[serde(
352        default,
353        skip_serializing_if = "Option::is_none",
354        deserialize_with = "deserialize_present_optional"
355    )]
356    pub protocol_version: Option<u16>,
357    /// Lowest protocol version this deployment accepts (protocol v3+ only).
358    #[serde(
359        default,
360        skip_serializing_if = "Option::is_none",
361        deserialize_with = "deserialize_present_optional"
362    )]
363    pub min_protocol_version: Option<u16>,
364    /// Highest protocol version this deployment speaks (protocol v3+ only).
365    #[serde(
366        default,
367        skip_serializing_if = "Option::is_none",
368        deserialize_with = "deserialize_present_optional"
369    )]
370    pub max_protocol_version: Option<u16>,
371    /// Server message transports available to this connection (v3 only).
372    #[serde(
373        default,
374        skip_serializing_if = "Option::is_none",
375        deserialize_with = "deserialize_present_optional"
376    )]
377    pub transports: Option<Vec<MessageTransport>>,
378}
379
380/// Describes the characters a deployment allows inside `player_name`.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct PlayerNameRulesPayload {
383    pub max_length: usize,
384    pub min_length: usize,
385    pub allow_unicode_alphanumeric: bool,
386    pub allow_spaces: bool,
387    pub allow_leading_trailing_whitespace: bool,
388    #[serde(default)]
389    pub allowed_symbols: Vec<char>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub additional_allowed_characters: Option<String>,
392}
393
394/// A STUN/TURN server for WebRTC ICE negotiation (protocol v3).
395///
396/// `username`/`credential` are present only for TURN servers; bare STUN entries
397/// omit them, keeping the wire bytes minimal.
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub struct IceServer {
400    /// STUN/TURN URLs (e.g. `stun:stun.l.google.com:19302`).
401    pub urls: Vec<String>,
402    /// TURN username (omitted for credential-less STUN servers).
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub username: Option<String>,
405    /// TURN credential (omitted for credential-less STUN servers).
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub credential: Option<String>,
408}
409
410/// A peer the recipient should connect to within a [`SessionPlanPayload`] (protocol v3).
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412pub struct SessionPeer {
413    /// The other peer's identifier.
414    pub player_id: PlayerId,
415    /// The other peer's display name.
416    pub player_name: String,
417    /// Whether this peer is the session's authoritative host.
418    pub is_authority: bool,
419    /// Whether the recipient sends the WebRTC offer to this peer.
420    ///
421    /// Server-assigned (a deterministic "designated offerer"). Obey it
422    /// verbatim — the client never computes who initiates.
423    pub initiate: bool,
424}
425
426// ── Payload structs ─────────────────────────────────────────────────
427
428/// Payload for the `RoomJoined` server message.
429/// Boxed in `ServerMessage` to reduce enum size.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct RoomJoinedPayload {
432    pub room_id: RoomId,
433    pub room_code: String,
434    pub player_id: PlayerId,
435    pub game_name: String,
436    pub max_players: u8,
437    pub supports_authority: bool,
438    pub current_players: Vec<PlayerInfo>,
439    pub is_authority: bool,
440    pub lobby_state: LobbyState,
441    pub ready_players: Vec<PlayerId>,
442    pub relay_type: String,
443    /// List of spectators currently watching (if any).
444    #[serde(default)]
445    pub current_spectators: Vec<SpectatorInfo>,
446    /// ICE (STUN/TURN) servers for early WebRTC candidate gathering during the
447    /// lobby wait (protocol v3 only; "ICE pre-gather"). Empty — and absent from
448    /// the wire via `skip_serializing_if` — for v2 connections, keeping the v2
449    /// JSON byte-identical.
450    #[serde(default, skip_serializing_if = "Vec::is_empty")]
451    pub ice_servers: Vec<IceServer>,
452    /// Server-issued token for a later unexpected-disconnect reconnect.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub reconnection_token: Option<String>,
455}
456
457/// Payload for the `Reconnected` server message.
458/// Boxed in `ServerMessage` to reduce enum size.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct ReconnectedPayload {
461    pub room_id: RoomId,
462    pub room_code: String,
463    pub player_id: PlayerId,
464    pub game_name: String,
465    pub max_players: u8,
466    pub supports_authority: bool,
467    pub current_players: Vec<PlayerInfo>,
468    pub is_authority: bool,
469    pub lobby_state: LobbyState,
470    pub ready_players: Vec<PlayerId>,
471    pub relay_type: String,
472    /// List of spectators currently watching (if any).
473    #[serde(default)]
474    pub current_spectators: Vec<SpectatorInfo>,
475    /// ICE (STUN/TURN) servers for early WebRTC candidate gathering (protocol
476    /// v3 only). Empty — and absent from the wire — for v2 connections.
477    #[serde(default, skip_serializing_if = "Vec::is_empty")]
478    pub ice_servers: Vec<IceServer>,
479    /// Events that occurred while disconnected.
480    pub missed_events: Vec<ServerMessage>,
481    /// Completeness of `missed_events` (protocol v3 only).
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub replay: Option<ReplayStatus>,
484    /// Authoritative relay baselines for every current player (v3 only).
485    #[serde(default, skip_serializing_if = "Vec::is_empty")]
486    pub sender_watermarks: Vec<SenderWatermark>,
487    /// Fresh token replacing the consumed reconnect token (v3 only).
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub reconnection_token: Option<String>,
490}
491
492/// Payload for the `SpectatorJoined` server message.
493/// Boxed in `ServerMessage` to reduce enum size.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct SpectatorJoinedPayload {
496    pub room_id: RoomId,
497    pub room_code: String,
498    pub spectator_id: PlayerId,
499    pub game_name: String,
500    pub current_players: Vec<PlayerInfo>,
501    pub current_spectators: Vec<SpectatorInfo>,
502    pub lobby_state: LobbyState,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub reason: Option<SpectatorStateChangeReason>,
505}
506
507/// Payload for the `SessionPlan` server message (protocol v3).
508/// Boxed in `ServerMessage` to reduce enum size.
509///
510/// Sent per-recipient when a room finalizes to a non-relay session (and again
511/// on late-join or host re-election). Each recipient receives a plan tailored
512/// to it: `peers` excludes the recipient, and each `initiate` flag is set from
513/// the recipient's perspective.
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct SessionPlanPayload {
516    /// Chosen session topology (`relay`, `host`, or `mesh`).
517    pub topology: Topology,
518    /// Chosen data-path transport (`relay`, `direct`, or `webrtc`).
519    pub transport: TransportKind,
520    /// The elected host, present only for `host` topology.
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub host: Option<PlayerId>,
523    /// Peers this recipient should connect to (excludes the recipient itself).
524    pub peers: Vec<SessionPeer>,
525    /// ICE (STUN/TURN) servers for WebRTC; omitted for non-WebRTC plans.
526    #[serde(default, skip_serializing_if = "Vec::is_empty")]
527    pub ice_servers: Vec<IceServer>,
528    /// The universal fallback transport — always [`TransportKind::Relay`], the floor.
529    pub fallback: TransportKind,
530}
531
532// ── Messages ────────────────────────────────────────────────────────
533
534/// Message types sent from client to server.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536#[serde(tag = "type", content = "data")]
537pub enum ClientMessage {
538    /// Authenticate with App ID (MUST be first message).
539    /// App ID is a public identifier (not a secret!) that identifies the game application.
540    Authenticate {
541        /// Public App ID (safe to embed in game builds, e.g., "mb_app_abc123...").
542        app_id: String,
543        /// SDK version for debugging and analytics.
544        #[serde(skip_serializing_if = "Option::is_none")]
545        sdk_version: Option<String>,
546        /// Platform information (e.g., "unity", "godot", "unreal").
547        #[serde(skip_serializing_if = "Option::is_none")]
548        platform: Option<String>,
549        /// Preferred game data encoding (defaults to JSON text frames).
550        #[serde(skip_serializing_if = "Option::is_none")]
551        game_data_format: Option<GameDataEncoding>,
552        /// Highest protocol version the client speaks (protocol v3+).
553        ///
554        /// Omitted by default so the server treats the client as v2 relay-only
555        /// and the wire bytes stay identical to v2.
556        #[serde(skip_serializing_if = "Option::is_none")]
557        protocol_version: Option<u16>,
558        /// Data-path transports the client can actually fulfill (protocol v3+).
559        #[serde(skip_serializing_if = "Option::is_none")]
560        supported_transports: Option<Vec<TransportKind>>,
561        /// Session topologies the client can participate in (protocol v3+).
562        #[serde(skip_serializing_if = "Option::is_none")]
563        supported_topologies: Option<Vec<Topology>>,
564    },
565    /// Join or create a room for a specific game.
566    JoinRoom {
567        game_name: String,
568        room_code: Option<String>,
569        player_name: String,
570        max_players: Option<u8>,
571        supports_authority: Option<bool>,
572        /// Preferred relay transport protocol (TCP, UDP, or Auto).
573        /// If not specified, defaults to Auto.
574        #[serde(default)]
575        relay_transport: Option<RelayTransport>,
576    },
577    /// Leave the current room.
578    LeaveRoom,
579    /// Send game data to other players in the room.
580    GameData {
581        data: serde_json::Value,
582        #[serde(
583            default,
584            deserialize_with = "deserialize_present_optional",
585            skip_serializing_if = "Option::is_none"
586        )]
587        class: Option<DeliveryClass>,
588        #[serde(
589            default,
590            deserialize_with = "deserialize_present_optional",
591            skip_serializing_if = "Option::is_none"
592        )]
593        key: Option<u32>,
594    },
595    /// Request to become or connect to authoritative server.
596    AuthorityRequest { become_authority: bool },
597    /// Signal readiness to start the game in lobby.
598    PlayerReady,
599    /// Provide connection info for P2P establishment.
600    ProvideConnectionInfo { connection_info: ConnectionInfo },
601    /// Heartbeat to maintain connection.
602    Ping,
603    /// Reconnect to a room after disconnection.
604    Reconnect {
605        player_id: PlayerId,
606        room_id: RoomId,
607        /// Authentication token generated on initial join.
608        auth_token: String,
609    },
610    /// Join a room as a spectator (read-only observer).
611    JoinAsSpectator {
612        game_name: String,
613        room_code: String,
614        spectator_name: String,
615    },
616    /// Leave spectator mode.
617    LeaveSpectator,
618    /// Explicitly start the game, finalizing the lobby with its current members
619    /// (protocol v2).
620    ///
621    /// Accepted only when every current player is ready. If the room has a
622    /// designated authority, only that authority may start; otherwise any
623    /// member may. On success the server broadcasts `GameStarting` (and, for a
624    /// negotiated v3 non-relay room, a per-recipient `SessionPlan`).
625    StartGame,
626    /// Relay an opaque WebRTC signal to a single peer.
627    ///
628    /// **Protocol v3 only.** Rejected on a relay-floor (v2) connection.
629    ///
630    /// The `signal` is forwarded verbatim by the server. It is typically a
631    /// [`PeerSignal`](crate::PeerSignal) (`{"Offer"|"Answer"|"IceCandidate": …}`)
632    /// but is modeled as `serde_json::Value` so unknown future shapes never
633    /// fail to round-trip.
634    Signal {
635        /// The recipient peer.
636        to: PlayerId,
637        /// The opaque signal payload (offer/answer/ICE candidate).
638        signal: serde_json::Value,
639    },
640    /// Report whether a data-path transport to peers is currently established.
641    ///
642    /// **Protocol v3 only.** Informational; the server fans it out as
643    /// `PeerTransportStatus` and uses it for fallback decisions.
644    TransportStatus {
645        /// The transport being reported on.
646        transport: TransportKind,
647        /// Whether that transport is currently connected.
648        connected: bool,
649    },
650}
651
652/// Message types sent from server to client.
653#[derive(Debug, Clone, Serialize, Deserialize)]
654#[serde(tag = "type", content = "data")]
655pub enum ServerMessage {
656    /// Authentication successful.
657    Authenticated {
658        /// App name for confirmation.
659        app_name: String,
660        /// Organization name (if any).
661        #[serde(skip_serializing_if = "Option::is_none")]
662        organization: Option<String>,
663        /// Rate limits for this app.
664        rate_limits: RateLimitInfo,
665    },
666    /// SDK/protocol compatibility details advertised after authentication.
667    ProtocolInfo(ProtocolInfoPayload),
668    /// Authentication failed.
669    AuthenticationError {
670        /// Error message.
671        error: String,
672        /// Error code for programmatic handling.
673        error_code: ErrorCode,
674    },
675    /// Successfully joined a room (boxed to reduce enum size).
676    RoomJoined(Box<RoomJoinedPayload>),
677    /// Failed to join room.
678    RoomJoinFailed {
679        reason: String,
680        #[serde(skip_serializing_if = "Option::is_none")]
681        error_code: Option<ErrorCode>,
682    },
683    /// Successfully left room.
684    RoomLeft,
685    /// Another player joined the room.
686    PlayerJoined { player: PlayerInfo },
687    /// Another player left the room.
688    PlayerLeft {
689        player_id: PlayerId,
690        #[serde(default, skip_serializing_if = "Option::is_none")]
691        epoch: Option<u32>,
692        #[serde(default, skip_serializing_if = "Option::is_none")]
693        final_seq: Option<u64>,
694    },
695    /// Game data from another player.
696    GameData {
697        from_player: PlayerId,
698        data: serde_json::Value,
699        #[serde(default, skip_serializing_if = "Option::is_none")]
700        seq: Option<u64>,
701        #[serde(default, skip_serializing_if = "Option::is_none")]
702        epoch: Option<u32>,
703        #[serde(default, skip_serializing_if = "Option::is_none")]
704        class: Option<DeliveryClass>,
705        #[serde(default, skip_serializing_if = "Option::is_none")]
706        key: Option<u32>,
707    },
708    /// Binary game data payload from another player.
709    /// Uses `Vec<u8>` with `serde_bytes` for efficient serialization.
710    GameDataBinary {
711        from_player: PlayerId,
712        encoding: GameDataEncoding,
713        #[serde(with = "serde_bytes")]
714        payload: Vec<u8>,
715        #[serde(default, skip_serializing_if = "Option::is_none")]
716        seq: Option<u64>,
717        #[serde(default, skip_serializing_if = "Option::is_none")]
718        epoch: Option<u32>,
719    },
720    /// Authority status changed.
721    AuthorityChanged {
722        authority_player: Option<PlayerId>,
723        you_are_authority: bool,
724    },
725    /// Authority request response.
726    AuthorityResponse {
727        granted: bool,
728        reason: Option<String>,
729        #[serde(skip_serializing_if = "Option::is_none")]
730        error_code: Option<ErrorCode>,
731    },
732    /// Lobby state changed (room full, player readiness changed, etc.).
733    LobbyStateChanged {
734        lobby_state: LobbyState,
735        ready_players: Vec<PlayerId>,
736        all_ready: bool,
737    },
738    /// Game is starting with peer connection information.
739    GameStarting {
740        peer_connections: Vec<PeerConnectionInfo>,
741    },
742    /// Pong response to ping.
743    Pong,
744    /// Reconnection successful (boxed to reduce enum size).
745    Reconnected(Box<ReconnectedPayload>),
746    /// Reconnection failed.
747    ReconnectionFailed {
748        reason: String,
749        error_code: ErrorCode,
750    },
751    /// Another player reconnected to the room.
752    PlayerReconnected {
753        player_id: PlayerId,
754        #[serde(default, skip_serializing_if = "Option::is_none")]
755        epoch: Option<u32>,
756    },
757    /// Successfully joined a room as spectator (boxed to reduce enum size).
758    SpectatorJoined(Box<SpectatorJoinedPayload>),
759    /// Failed to join as spectator.
760    SpectatorJoinFailed {
761        reason: String,
762        #[serde(skip_serializing_if = "Option::is_none")]
763        error_code: Option<ErrorCode>,
764    },
765    /// Successfully left spectator mode.
766    SpectatorLeft {
767        #[serde(skip_serializing_if = "Option::is_none")]
768        room_id: Option<RoomId>,
769        #[serde(skip_serializing_if = "Option::is_none")]
770        room_code: Option<String>,
771        #[serde(skip_serializing_if = "Option::is_none")]
772        reason: Option<SpectatorStateChangeReason>,
773        #[serde(default)]
774        current_spectators: Vec<SpectatorInfo>,
775    },
776    /// Another spectator joined the room.
777    NewSpectatorJoined {
778        spectator: SpectatorInfo,
779        #[serde(default)]
780        current_spectators: Vec<SpectatorInfo>,
781        #[serde(skip_serializing_if = "Option::is_none")]
782        reason: Option<SpectatorStateChangeReason>,
783    },
784    /// Another spectator left the room.
785    SpectatorDisconnected {
786        spectator_id: PlayerId,
787        #[serde(skip_serializing_if = "Option::is_none")]
788        reason: Option<SpectatorStateChangeReason>,
789        #[serde(default)]
790        current_spectators: Vec<SpectatorInfo>,
791    },
792    /// Error message.
793    Error {
794        message: String,
795        #[serde(skip_serializing_if = "Option::is_none")]
796        error_code: Option<ErrorCode>,
797    },
798    /// An opaque WebRTC signal relayed from a peer.
799    ///
800    /// **Protocol v3 only.** Sent only on a v3-negotiated connection.
801    ///
802    /// `signal` is modeled as `serde_json::Value` (typically a
803    /// [`PeerSignal`](crate::PeerSignal)) so unknown future shapes always
804    /// round-trip. Convert with `PeerSignal::try_from(&signal)`.
805    Signal {
806        /// The peer the signal came from.
807        from: PlayerId,
808        /// The opaque signal payload (offer/answer/ICE candidate).
809        signal: serde_json::Value,
810    },
811    /// A new peer to connect to after the session was finalized — a late joiner.
812    ///
813    /// **Protocol v3 only.** Sent only on a v3-negotiated connection.
814    NewPeer {
815        /// The new peer's identifier.
816        peer_id: PlayerId,
817        /// Whether the recipient sends the WebRTC offer to this peer.
818        /// Server-assigned; obey verbatim.
819        you_initiate: bool,
820    },
821    /// Per-recipient session plan for a finalized non-relay room.
822    ///
823    /// **Protocol v3 only.** Boxed to reduce enum size. May be received multiple
824    /// times (host re-election / late-join); each one fully replaces the
825    /// previous plan.
826    SessionPlan(Box<SessionPlanPayload>),
827    /// A peer's data-path transport state changed. Informational.
828    ///
829    /// **Protocol v3 only.** Sent only on a v3-negotiated connection.
830    PeerTransportStatus {
831        /// The peer whose transport state changed.
832        peer_id: PlayerId,
833        /// The transport being reported on.
834        transport: TransportKind,
835        /// Whether that transport is currently connected for the peer.
836        connected: bool,
837    },
838    /// Periodic cumulative relay-delivery diagnostics (protocol v3 only).
839    RelayStats {
840        interval_ms: u64,
841        sent_to_you: u64,
842        dropped_for_you: u64,
843        backpressure_events: u64,
844    },
845    /// Graceful server-shutdown advisory (protocol v3 only).
846    GoingAway {
847        deadline_ms: u64,
848        #[serde(default, skip_serializing_if = "Option::is_none")]
849        retry_after_secs: Option<u64>,
850    },
851    /// Exact delivery accountability (protocol v3 only).
852    DeliveryReport(Box<DeliveryReportPayload>),
853}
854
855/// Preserve omission as `None` while rejecting explicit `null`.
856fn deserialize_present_optional<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
857where
858    D: Deserializer<'de>,
859    T: Deserialize<'de>,
860{
861    T::deserialize(deserializer).map(Some)
862}
863
864/// The negotiated protocol version to restore from a reconnect's `missed_events`,
865/// if any — the last versioned (v3+) [`ProtocolInfo`](ServerMessage::ProtocolInfo)
866/// replayed in the batch.
867///
868/// A replayed v2 `ProtocolInfo` (version `None`) is ignored so it can never
869/// silently downgrade an already-negotiated v3 session; later versioned entries
870/// win over earlier ones. Shared by the async and polling clients so the two
871/// reconnect paths cannot drift apart.
872///
873/// Gated to its consumers (the async client needs `tokio-runtime`; the polling
874/// client needs `polling-client`) so it is not dead code in a build with neither.
875#[must_use]
876#[cfg(any(feature = "tokio-runtime", feature = "polling-client"))]
877pub(crate) fn replayed_negotiated_version(missed_events: &[ServerMessage]) -> Option<u16> {
878    missed_events.iter().rev().find_map(|msg| match msg {
879        ServerMessage::ProtocolInfo(info) => info.protocol_version,
880        _ => None,
881    })
882}