signal-fish-server 0.1.0

A lightweight, in-memory WebSocket signaling server for peer-to-peer game networking
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;

use super::types::{
    PeerConnectionInfo, PlayerId, PlayerInfo, RoomId, SpectatorInfo, DEFAULT_REGION_ID,
};

// ============================================================================
// ROOM LIFECYCLE DOCUMENTATION
// ============================================================================
//
// This module defines the core room lifecycle state machine for the Signal Fish
// signaling server. Rooms progress through three main states with specific
// triggers and rules governing each transition.
//
// ## Room Lifecycle States
//
// ```text
// [*] --> Waiting: Room Created
//
// Waiting --> Lobby: Room Full (players == max_players)
// Lobby --> Waiting: Player Leaves (players < max_players)
// Lobby --> Finalized: All Players Ready
//
// Finalized --> [*]: Game Started (Room Cleanup)
// Waiting --> [*]: Room Expired (Empty/Inactive Timeout)
// Lobby --> [*]: Room Expired (Inactive Timeout)
// ```
//
// ### 1. Waiting State
//
// - **Description**: Initial state when a room is created. The room is open
//   and accepting new players.
// - **Characteristics**:
//   - Room has fewer players than `max_players`
//   - Players can join freely
//   - No ready state tracking
//   - Room can expire if empty for too long
//
// ### 2. Lobby State
//
// - **Description**: Room is full and players are coordinating readiness to
//   start the game.
// - **Characteristics**:
//   - Room has exactly `max_players` players
//   - Players can mark themselves ready/unready via `PlayerReady` messages
//   - `ready_players` list tracks who is ready
//   - Cannot accept new players (room is full)
//   - Transitions to Finalized when all players ready
//   - Broadcasts `LobbyStateChanged` when ready state changes
//
// ### 3. Finalized State
//
// - **Description**: All players ready, game starting. Peer connection
//   information is exchanged.
// - **Characteristics**:
//   - All players have marked ready
//   - `game_finalized_at` timestamp recorded
//   - `GameStarting` message sent with peer connections
//   - Room typically cleaned up shortly after
//   - No further state transitions possible
//
// ## Key State Transitions and Protocol Messages
//
// ### Waiting → Lobby
// - **Trigger**: Room becomes full (player count reaches `max_players`)
// - **Condition**: `should_enter_lobby()` returns true
// - **Action**: Calls `enter_lobby()`, sets `lobby_started_at` timestamp
// - **Message**: Broadcasts `LobbyStateChanged` with `lobby_state: "lobby"`
//
// ### Lobby → Waiting
// - **Trigger**: A player leaves, bringing player count below `max_players`
// - **Action**: Revert to Waiting state, clear `ready_players` list
// - **Messages**: Broadcasts `PlayerLeft` and `LobbyStateChanged`
//
// ### Lobby → Finalized
// - **Trigger**: All players in lobby mark themselves ready
// - **Condition**: `all_players_ready()` returns true
// - **Action**: Calls `finalize_game()`, sets `game_finalized_at` timestamp
// - **Message**: Broadcasts `GameStarting` with `PeerConnectionInfo` for all players
//
// ## Protocol Message Flow Example (2 Players)
//
// ```text
// Player1                Server                   Player2
//   |                      |                          |
//   |-- JoinRoom --------->|                          |
//   |<-- RoomJoined -------|                          |
//   |                      |<------- JoinRoom --------|
//   |<-- PlayerJoined -----|--- RoomJoined ---------->|
//   |<-- LobbyStateChanged-|--- LobbyStateChanged --->|
//   |                      |      (state: lobby)      |
//   |-- PlayerReady ------>|                          |
//   |<-- LobbyStateChanged-|--- LobbyStateChanged --->|
//   |                      |<------- PlayerReady -----|
//   |<-- LobbyStateChanged-|--- LobbyStateChanged --->|
//   |<-- GameStarting -----|--- GameStarting -------->|
// ```
//
// ## Related Client Messages
//
// - `JoinRoom`: Join or create a room (triggers room creation or player join)
// - `PlayerReady`: Toggle player ready state in lobby
// - `LeaveRoom`: Leave a room (may trigger Lobby → Waiting transition)
// - `Reconnect`: Reconnect to a room after disconnection
//
// ## Related Server Messages
//
// - `RoomJoined`: Confirm successful room join
// - `PlayerJoined`: Notify others when a player joins
// - `PlayerLeft`: Notify others when a player leaves
// - `LobbyStateChanged`: Notify lobby state changes (waiting/lobby) and ready status
// - `GameStarting`: Notify game finalization with peer connection info
//
// ## Edge Cases
//
// - **Single Player Rooms** (`max_players = 1`): Room does NOT enter Lobby
//   state per `should_enter_lobby()`. Player immediately receives connection info.
//
// - **Player Disconnection in Lobby**: If player disconnects and room drops
//   below `max_players`, room reverts to Waiting state and all ready states
//   are cleared.
//
// - **Authority Player Leaves**: If the authority player disconnects, authority
//   is cleared (`authority_player = None`) with no automatic reassignment.
//
// - **Stale Finalization**: Ready state version tracking prevents multiple
//   server instances from finalizing the same room (distributed lock protection).
//
// ## Timestamps and Activity Tracking
//
// Rooms track several timestamps for lifecycle management:
// - `created_at`: Room creation time
// - `last_activity`: Last message/event (updated via `update_activity()`)
// - `lobby_started_at`: When lobby state was entered
// - `game_finalized_at`: When game was finalized
//
// Activity is updated on: player joins/leaves, GameData messages, ready toggles,
// and authority requests.
//
// ## Full Documentation
//
// For complete details including:
// - Player lifecycle within rooms
// - Authority protocol rules
// - Spectator lifecycle
// - Reconnection flow
// - Message flow examples
// - Code references and test coverage
//
// See: [`docs/architecture/room-lifecycle.md`](../../../docs/architecture/room-lifecycle.md)
//
// ============================================================================

/// Room lobby state
#[derive(
    Debug,
    Clone,
    Serialize,
    Deserialize,
    PartialEq,
    Eq,
    Default,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
)]
#[rkyv(compare(PartialEq))]
#[serde(rename_all = "snake_case")]
pub enum LobbyState {
    #[default]
    Waiting,
    Lobby,
    Finalized,
}

/// Room configuration and state
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Room {
    pub id: RoomId,
    pub code: String,
    pub game_name: String,
    pub max_players: u8,
    pub supports_authority: bool,
    pub players: HashMap<PlayerId, PlayerInfo>,
    pub authority_player: Option<PlayerId>,
    pub lobby_state: LobbyState,
    pub ready_players: Vec<PlayerId>,
    pub lobby_started_at: Option<chrono::DateTime<chrono::Utc>>,
    pub game_finalized_at: Option<chrono::DateTime<chrono::Utc>>,
    pub relay_type: String,
    /// Deployment region currently hosting this room.
    pub region_id: String,
    /// Owning application for per-app rate limiting and access control.
    pub application_id: Option<Uuid>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub last_activity: chrono::DateTime<chrono::Utc>,
    /// Spectators watching the room (read-only observers)
    pub spectators: HashMap<PlayerId, SpectatorInfo>,
    /// Maximum number of spectators allowed (None = unlimited)
    pub max_spectators: Option<u8>,
}

impl Room {
    #[allow(dead_code)]
    pub fn new(
        game_name: String,
        room_code: String,
        max_players: u8,
        supports_authority: bool,
        relay_type: String,
    ) -> Self {
        let now = chrono::Utc::now();
        Self {
            id: Uuid::new_v4(),
            code: room_code,
            game_name,
            max_players,
            supports_authority,
            players: HashMap::new(),
            authority_player: None,
            lobby_state: LobbyState::Waiting,
            ready_players: Vec::new(),
            lobby_started_at: None,
            game_finalized_at: None,
            relay_type,
            region_id: DEFAULT_REGION_ID.to_string(),
            application_id: None,
            created_at: now,
            last_activity: now,
            spectators: HashMap::new(),
            max_spectators: None, // Unlimited spectators by default
        }
    }

    /// Update the last activity timestamp
    #[allow(dead_code)]
    pub fn update_activity(&mut self) {
        self.last_activity = chrono::Utc::now();
    }

    /// Check if room is expired based on the given timeouts
    #[allow(dead_code)]
    pub fn is_expired(
        &self,
        empty_timeout: chrono::Duration,
        inactive_timeout: chrono::Duration,
    ) -> bool {
        let now = chrono::Utc::now();

        if self.players.is_empty() {
            // Empty room - check against creation time
            now.signed_duration_since(self.created_at) > empty_timeout
        } else {
            // Room has players - check against last activity
            now.signed_duration_since(self.last_activity) > inactive_timeout
        }
    }

    #[allow(dead_code)]
    pub fn can_join(&self) -> bool {
        self.players.len() < self.max_players as usize
    }

    #[allow(dead_code)]
    pub fn add_player(&mut self, player: PlayerInfo) -> bool {
        if self.can_join() {
            self.players.insert(player.id, player);
            true
        } else {
            false
        }
    }

    #[allow(dead_code)]
    pub fn remove_player(&mut self, player_id: &PlayerId) -> Option<PlayerInfo> {
        let removed = self.players.remove(player_id);

        // If the authority player left, clear authority
        if self.authority_player == Some(*player_id) {
            self.authority_player = None;
        }

        removed
    }

    #[allow(dead_code)]
    pub fn set_authority(&mut self, player_id: Option<PlayerId>) -> bool {
        // Check if room supports authority
        if !self.supports_authority {
            return false;
        }

        match player_id {
            Some(id) if self.players.contains_key(&id) => {
                // Remove authority from previous player
                if let Some(prev_auth) = self.authority_player {
                    if let Some(player) = self.players.get_mut(&prev_auth) {
                        player.is_authority = false;
                    }
                }

                // Set new authority
                self.authority_player = Some(id);
                if let Some(player) = self.players.get_mut(&id) {
                    player.is_authority = true;
                }
                true
            }
            None => {
                // Clear authority
                if let Some(prev_auth) = self.authority_player.take() {
                    if let Some(player) = self.players.get_mut(&prev_auth) {
                        player.is_authority = false;
                    }
                }
                true
            }
            Some(_) => false, // Player not in room
        }
    }

    #[allow(dead_code)]
    pub fn clear_authority(&mut self) -> bool {
        self.set_authority(None)
    }

    /// Check if room should transition to lobby state
    #[allow(dead_code)]
    pub fn should_enter_lobby(&self) -> bool {
        self.lobby_state == LobbyState::Waiting
            && self.players.len() == self.max_players as usize
            && self.max_players > 1
    }

    /// Transition room to lobby state
    #[allow(dead_code)]
    pub fn enter_lobby(&mut self) -> bool {
        if self.should_enter_lobby() {
            self.lobby_state = LobbyState::Lobby;
            self.lobby_started_at = Some(chrono::Utc::now());
            self.ready_players.clear();
            true
        } else {
            false
        }
    }

    /// Mark a player as ready in lobby
    #[allow(dead_code)]
    pub fn set_player_ready(&mut self, player_id: &PlayerId, ready: bool) -> bool {
        if self.lobby_state != LobbyState::Lobby || !self.players.contains_key(player_id) {
            return false;
        }

        if ready && !self.ready_players.contains(player_id) {
            self.ready_players.push(*player_id);
        } else if !ready {
            self.ready_players.retain(|id| id != player_id);
        }

        // Update player ready status
        if let Some(player) = self.players.get_mut(player_id) {
            player.is_ready = ready;
        }

        true
    }

    /// Check if all players are ready in lobby
    #[allow(dead_code)]
    pub fn all_players_ready(&self) -> bool {
        if self.lobby_state != LobbyState::Lobby {
            return false;
        }
        self.ready_players.len() == self.players.len() && !self.players.is_empty()
    }

    /// Finalize the game and prepare for peer connections
    #[allow(dead_code)]
    pub fn finalize_game(&mut self) -> bool {
        if self.lobby_state == LobbyState::Lobby && self.all_players_ready() {
            self.lobby_state = LobbyState::Finalized;
            self.game_finalized_at = Some(chrono::Utc::now());
            true
        } else {
            false
        }
    }

    /// Get peer connection information for all players
    #[allow(dead_code)]
    pub fn get_peer_connections(&self) -> Vec<PeerConnectionInfo> {
        self.players
            .values()
            .map(|player| PeerConnectionInfo {
                player_id: player.id,
                player_name: player.name.clone(),
                is_authority: player.is_authority,
                relay_type: self.relay_type.clone(),
                connection_info: player.connection_info.clone(),
            })
            .collect()
    }

    /// Check if room is finalized and ready for cleanup
    #[allow(dead_code)]
    pub fn is_finalized(&self) -> bool {
        self.lobby_state == LobbyState::Finalized
    }

    /// Check if spectators can join this room
    #[allow(dead_code)]
    pub fn can_spectate(&self) -> bool {
        if let Some(max_spectators) = self.max_spectators {
            self.spectators.len() < max_spectators as usize
        } else {
            true // Unlimited spectators
        }
    }

    /// Add a spectator to the room
    #[allow(dead_code)]
    pub fn add_spectator(&mut self, spectator: SpectatorInfo) -> bool {
        if self.can_spectate() {
            self.spectators.insert(spectator.id, spectator);
            true
        } else {
            false
        }
    }

    /// Remove a spectator from the room
    #[allow(dead_code)]
    pub fn remove_spectator(&mut self, spectator_id: &PlayerId) -> Option<SpectatorInfo> {
        self.spectators.remove(spectator_id)
    }

    /// Get list of all spectators
    #[allow(dead_code)]
    pub fn get_spectators(&self) -> Vec<SpectatorInfo> {
        self.spectators.values().cloned().collect()
    }
}