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, Serialize};
11use uuid::Uuid;
12
13use crate::error_codes::ErrorCode;
14
15// ── Type aliases ────────────────────────────────────────────────────
16
17/// Unique identifier for players.
18pub type PlayerId = Uuid;
19
20/// Unique identifier for rooms.
21pub type RoomId = Uuid;
22
23// ── Enums ───────────────────────────────────────────────────────────
24
25/// Relay transport protocol selection.
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
27#[serde(rename_all = "lowercase")]
28pub enum RelayTransport {
29    /// TCP transport (reliable, ordered delivery).
30    /// Recommended for: Turn-based games, lobby systems, RPGs.
31    Tcp,
32    /// UDP transport (low-latency, unreliable).
33    /// Recommended for: FPS, racing games, real-time action.
34    Udp,
35    /// WebSocket transport (reliable, browser-compatible).
36    /// Recommended for: WebGL builds, browser games, cross-platform.
37    Websocket,
38    /// Automatic selection based on room size and game type.
39    /// Default: UDP for 2-4 players, TCP for 5+ players, WebSocket for browser builds.
40    #[default]
41    Auto,
42}
43
44/// Encoding format for sequenced game data payloads.
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum GameDataEncoding {
48    /// JSON payloads delivered over text frames.
49    #[default]
50    Json,
51    /// MessagePack payloads delivered over binary frames.
52    #[serde(rename = "message_pack")]
53    MessagePack,
54    /// Rkyv zero-copy binary format for maximum performance.
55    /// Recommended for: High-frequency updates, large player counts, latency-sensitive games.
56    #[serde(rename = "rkyv")]
57    Rkyv,
58}
59
60/// Connection information for P2P establishment.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "type")]
63pub enum ConnectionInfo {
64    /// Direct IP:port connection (for Mirror, FishNet, Unity NetCode direct).
65    #[serde(rename = "direct")]
66    Direct { host: String, port: u16 },
67    /// Unity Relay allocation (for Unity NetCode via Unity Relay).
68    #[serde(rename = "unity_relay")]
69    UnityRelay {
70        allocation_id: String,
71        connection_data: String,
72        key: String,
73    },
74    /// Built-in relay server (for Unity NetCode, FishNet, Mirror).
75    #[serde(rename = "relay")]
76    Relay {
77        /// Relay server host.
78        host: String,
79        /// Relay server port (TCP or UDP depending on transport).
80        port: u16,
81        /// Transport protocol (TCP, UDP, or Auto).
82        #[serde(default)]
83        transport: RelayTransport,
84        /// Allocation ID (room ID).
85        allocation_id: String,
86        /// Client authentication token (opaque server-issued value).
87        token: String,
88        /// Assigned client ID (set by server after connection).
89        #[serde(skip_serializing_if = "Option::is_none")]
90        client_id: Option<u16>,
91    },
92    /// WebRTC connection info (for Matchbox).
93    #[serde(rename = "webrtc")]
94    WebRTC {
95        sdp: Option<String>,
96        ice_candidates: Vec<String>,
97    },
98    /// Custom connection data (extensible for other types).
99    #[serde(rename = "custom")]
100    Custom { data: serde_json::Value },
101}
102
103/// Describes why a spectator state change occurred.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum SpectatorStateChangeReason {
107    #[default]
108    Joined,
109    VoluntaryLeave,
110    Disconnected,
111    Removed,
112    RoomClosed,
113}
114
115/// Lobby readiness state.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
117#[serde(rename_all = "snake_case")]
118pub enum LobbyState {
119    #[default]
120    Waiting,
121    Lobby,
122    Finalized,
123}
124
125// ── Structs ─────────────────────────────────────────────────────────
126
127/// Information about a player in a room.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct PlayerInfo {
130    pub id: PlayerId,
131    pub name: String,
132    pub is_authority: bool,
133    pub is_ready: bool,
134    pub connected_at: String,
135    /// Connection info for P2P establishment (provided when player is ready).
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub connection_info: Option<ConnectionInfo>,
138}
139
140/// Information about a spectator watching a room.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct SpectatorInfo {
143    pub id: PlayerId,
144    pub name: String,
145    pub connected_at: String,
146}
147
148/// Peer connection information for game start.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct PeerConnectionInfo {
151    pub player_id: PlayerId,
152    pub player_name: String,
153    pub is_authority: bool,
154    pub relay_type: String,
155    /// Connection info provided by the peer for P2P establishment.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub connection_info: Option<ConnectionInfo>,
158}
159
160/// Rate limit information for an application.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct RateLimitInfo {
163    /// Requests allowed per minute.
164    pub per_minute: u32,
165    /// Requests allowed per hour.
166    pub per_hour: u32,
167    /// Requests allowed per day.
168    pub per_day: u32,
169}
170
171/// Describes negotiated protocol capabilities for a specific SDK.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ProtocolInfoPayload {
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub platform: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub sdk_version: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub minimum_version: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub recommended_version: Option<String>,
182    #[serde(default)]
183    pub capabilities: Vec<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub notes: Option<String>,
186    #[serde(default)]
187    pub game_data_formats: Vec<GameDataEncoding>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub player_name_rules: Option<PlayerNameRulesPayload>,
190}
191
192/// Describes the characters a deployment allows inside `player_name`.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct PlayerNameRulesPayload {
195    pub max_length: usize,
196    pub min_length: usize,
197    pub allow_unicode_alphanumeric: bool,
198    pub allow_spaces: bool,
199    pub allow_leading_trailing_whitespace: bool,
200    #[serde(default)]
201    pub allowed_symbols: Vec<char>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub additional_allowed_characters: Option<String>,
204}
205
206// ── Payload structs ─────────────────────────────────────────────────
207
208/// Payload for the `RoomJoined` server message.
209/// Boxed in `ServerMessage` to reduce enum size.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct RoomJoinedPayload {
212    pub room_id: RoomId,
213    pub room_code: String,
214    pub player_id: PlayerId,
215    pub game_name: String,
216    pub max_players: u8,
217    pub supports_authority: bool,
218    pub current_players: Vec<PlayerInfo>,
219    pub is_authority: bool,
220    pub lobby_state: LobbyState,
221    pub ready_players: Vec<PlayerId>,
222    pub relay_type: String,
223    /// List of spectators currently watching (if any).
224    #[serde(default)]
225    pub current_spectators: Vec<SpectatorInfo>,
226}
227
228/// Payload for the `Reconnected` server message.
229/// Boxed in `ServerMessage` to reduce enum size.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ReconnectedPayload {
232    pub room_id: RoomId,
233    pub room_code: String,
234    pub player_id: PlayerId,
235    pub game_name: String,
236    pub max_players: u8,
237    pub supports_authority: bool,
238    pub current_players: Vec<PlayerInfo>,
239    pub is_authority: bool,
240    pub lobby_state: LobbyState,
241    pub ready_players: Vec<PlayerId>,
242    pub relay_type: String,
243    /// List of spectators currently watching (if any).
244    #[serde(default)]
245    pub current_spectators: Vec<SpectatorInfo>,
246    /// Events that occurred while disconnected.
247    pub missed_events: Vec<ServerMessage>,
248}
249
250/// Payload for the `SpectatorJoined` server message.
251/// Boxed in `ServerMessage` to reduce enum size.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SpectatorJoinedPayload {
254    pub room_id: RoomId,
255    pub room_code: String,
256    pub spectator_id: PlayerId,
257    pub game_name: String,
258    pub current_players: Vec<PlayerInfo>,
259    pub current_spectators: Vec<SpectatorInfo>,
260    pub lobby_state: LobbyState,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub reason: Option<SpectatorStateChangeReason>,
263}
264
265// ── Messages ────────────────────────────────────────────────────────
266
267/// Message types sent from client to server.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(tag = "type", content = "data")]
270pub enum ClientMessage {
271    /// Authenticate with App ID (MUST be first message).
272    /// App ID is a public identifier (not a secret!) that identifies the game application.
273    Authenticate {
274        /// Public App ID (safe to embed in game builds, e.g., "mb_app_abc123...").
275        app_id: String,
276        /// SDK version for debugging and analytics.
277        #[serde(skip_serializing_if = "Option::is_none")]
278        sdk_version: Option<String>,
279        /// Platform information (e.g., "unity", "godot", "unreal").
280        #[serde(skip_serializing_if = "Option::is_none")]
281        platform: Option<String>,
282        /// Preferred game data encoding (defaults to JSON text frames).
283        #[serde(skip_serializing_if = "Option::is_none")]
284        game_data_format: Option<GameDataEncoding>,
285    },
286    /// Join or create a room for a specific game.
287    JoinRoom {
288        game_name: String,
289        room_code: Option<String>,
290        player_name: String,
291        max_players: Option<u8>,
292        supports_authority: Option<bool>,
293        /// Preferred relay transport protocol (TCP, UDP, or Auto).
294        /// If not specified, defaults to Auto.
295        #[serde(default)]
296        relay_transport: Option<RelayTransport>,
297    },
298    /// Leave the current room.
299    LeaveRoom,
300    /// Send game data to other players in the room.
301    GameData { data: serde_json::Value },
302    /// Request to become or connect to authoritative server.
303    AuthorityRequest { become_authority: bool },
304    /// Signal readiness to start the game in lobby.
305    PlayerReady,
306    /// Provide connection info for P2P establishment.
307    ProvideConnectionInfo { connection_info: ConnectionInfo },
308    /// Heartbeat to maintain connection.
309    Ping,
310    /// Reconnect to a room after disconnection.
311    Reconnect {
312        player_id: PlayerId,
313        room_id: RoomId,
314        /// Authentication token generated on initial join.
315        auth_token: String,
316    },
317    /// Join a room as a spectator (read-only observer).
318    JoinAsSpectator {
319        game_name: String,
320        room_code: String,
321        spectator_name: String,
322    },
323    /// Leave spectator mode.
324    LeaveSpectator,
325}
326
327/// Message types sent from server to client.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(tag = "type", content = "data")]
330pub enum ServerMessage {
331    /// Authentication successful.
332    Authenticated {
333        /// App name for confirmation.
334        app_name: String,
335        /// Organization name (if any).
336        #[serde(skip_serializing_if = "Option::is_none")]
337        organization: Option<String>,
338        /// Rate limits for this app.
339        rate_limits: RateLimitInfo,
340    },
341    /// SDK/protocol compatibility details advertised after authentication.
342    ProtocolInfo(ProtocolInfoPayload),
343    /// Authentication failed.
344    AuthenticationError {
345        /// Error message.
346        error: String,
347        /// Error code for programmatic handling.
348        error_code: ErrorCode,
349    },
350    /// Successfully joined a room (boxed to reduce enum size).
351    RoomJoined(Box<RoomJoinedPayload>),
352    /// Failed to join room.
353    RoomJoinFailed {
354        reason: String,
355        #[serde(skip_serializing_if = "Option::is_none")]
356        error_code: Option<ErrorCode>,
357    },
358    /// Successfully left room.
359    RoomLeft,
360    /// Another player joined the room.
361    PlayerJoined { player: PlayerInfo },
362    /// Another player left the room.
363    PlayerLeft { player_id: PlayerId },
364    /// Game data from another player.
365    GameData {
366        from_player: PlayerId,
367        data: serde_json::Value,
368    },
369    /// Binary game data payload from another player.
370    /// Uses `Vec<u8>` with `serde_bytes` for efficient serialization.
371    GameDataBinary {
372        from_player: PlayerId,
373        encoding: GameDataEncoding,
374        #[serde(with = "serde_bytes")]
375        payload: Vec<u8>,
376    },
377    /// Authority status changed.
378    AuthorityChanged {
379        authority_player: Option<PlayerId>,
380        you_are_authority: bool,
381    },
382    /// Authority request response.
383    AuthorityResponse {
384        granted: bool,
385        reason: Option<String>,
386        #[serde(skip_serializing_if = "Option::is_none")]
387        error_code: Option<ErrorCode>,
388    },
389    /// Lobby state changed (room full, player readiness changed, etc.).
390    LobbyStateChanged {
391        lobby_state: LobbyState,
392        ready_players: Vec<PlayerId>,
393        all_ready: bool,
394    },
395    /// Game is starting with peer connection information.
396    GameStarting {
397        peer_connections: Vec<PeerConnectionInfo>,
398    },
399    /// Pong response to ping.
400    Pong,
401    /// Reconnection successful (boxed to reduce enum size).
402    Reconnected(Box<ReconnectedPayload>),
403    /// Reconnection failed.
404    ReconnectionFailed {
405        reason: String,
406        error_code: ErrorCode,
407    },
408    /// Another player reconnected to the room.
409    PlayerReconnected { player_id: PlayerId },
410    /// Successfully joined a room as spectator (boxed to reduce enum size).
411    SpectatorJoined(Box<SpectatorJoinedPayload>),
412    /// Failed to join as spectator.
413    SpectatorJoinFailed {
414        reason: String,
415        #[serde(skip_serializing_if = "Option::is_none")]
416        error_code: Option<ErrorCode>,
417    },
418    /// Successfully left spectator mode.
419    SpectatorLeft {
420        #[serde(skip_serializing_if = "Option::is_none")]
421        room_id: Option<RoomId>,
422        #[serde(skip_serializing_if = "Option::is_none")]
423        room_code: Option<String>,
424        #[serde(skip_serializing_if = "Option::is_none")]
425        reason: Option<SpectatorStateChangeReason>,
426        #[serde(default)]
427        current_spectators: Vec<SpectatorInfo>,
428    },
429    /// Another spectator joined the room.
430    NewSpectatorJoined {
431        spectator: SpectatorInfo,
432        #[serde(default)]
433        current_spectators: Vec<SpectatorInfo>,
434        #[serde(skip_serializing_if = "Option::is_none")]
435        reason: Option<SpectatorStateChangeReason>,
436    },
437    /// Another spectator left the room.
438    SpectatorDisconnected {
439        spectator_id: PlayerId,
440        #[serde(skip_serializing_if = "Option::is_none")]
441        reason: Option<SpectatorStateChangeReason>,
442        #[serde(default)]
443        current_spectators: Vec<SpectatorInfo>,
444    },
445    /// Error message.
446    Error {
447        message: String,
448        #[serde(skip_serializing_if = "Option::is_none")]
449        error_code: Option<ErrorCode>,
450    },
451}