runecast_protocol/protocol/
types.rs

1//! Shared types used in protocol messages.
2//!
3//! These types are used by both client and server messages and represent
4//! the core data structures of the game.
5
6use serde::{Deserialize, Serialize};
7
8// Re-export model types that are part of the protocol
9// These would come from crate::models in the actual backend
10// For now, we define them here for the protocol module to be self-contained
11
12/// Grid position (row, column).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Position {
15    pub row: usize,
16    pub col: usize,
17}
18
19/// Letter multiplier on a grid cell.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Multiplier {
23    DoubleLetter,
24    TripleLetter,
25    DoubleWord,
26}
27
28/// A single cell in the game grid.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GridCell {
31    pub letter: char,
32    pub value: u8,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub multiplier: Option<Multiplier>,
35    #[serde(default)]
36    pub has_gem: bool,
37}
38
39/// The 5x5 game grid.
40pub type Grid = Vec<Vec<GridCell>>;
41
42/// Game mode variants.
43#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum GameMode {
46    Solo,
47    #[default]
48    Multiplayer,
49    Adventure,
50}
51
52// ============================================================================
53// Lobby Types
54// ============================================================================
55
56/// Type of lobby - determines how players join.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum LobbyType {
60    /// Lobby tied to a specific Discord channel
61    Channel,
62    /// Custom lobby with a shareable code
63    Custom,
64}
65
66/// Game type for game pools within a lobby.
67///
68/// Each lobby can have multiple game pools, one per game type. Players wait in pools for matchmaking.
69/// Players join a game pool to find matches for that game type.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum GameType {
73    /// Free-for-all open game (default)
74    Open,
75    /// 2v2 team game
76    TwoVTwo,
77    /// Adventure/co-op mode
78    Adventure,
79}
80
81impl std::fmt::Display for GameType {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            GameType::Open => write!(f, "open"),
85            GameType::TwoVTwo => write!(f, "two_v_two"),
86            GameType::Adventure => write!(f, "adventure"),
87        }
88    }
89}
90
91/// Player information in the lobby (pre-game).
92#[serde_with::serde_as]
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct LobbyPlayerInfo {
95    /// User ID
96    #[serde_as(as = "serde_with::DisplayFromStr")]
97    pub user_id: i64,
98    pub username: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub avatar_url: Option<String>,
101    /// Profile banner URL (Discord CDN)
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub banner_url: Option<String>,
104    /// Profile accent color (integer representation)
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub accent_color: Option<i32>,
107    /// The player's status within a game pool, if they are in one.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub current_game_pool: Option<GameType>,
110    /// Game they are in, if any
111    pub active_game_id: Option<String>,
112    /// Game they are spectating, if any
113    pub spectate_game_id: Option<String>,
114}
115
116/// Summary of a game visible from the lobby.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct GameSummary {
119    pub game_id: String,
120    pub state: GameState,
121    pub current_round: u8,
122    pub max_rounds: u8,
123    pub player_count: u8,
124    pub spectator_count: u8,
125}
126
127/// High-level game state (not the full game data).
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum GameState {
131    /// Waiting for players / not started
132    Idle,
133    /// Players are queueing
134    Queueing,
135    /// Game is starting (countdown)
136    Starting,
137    /// Game is in progress
138    InProgress,
139    /// Game has ended
140    Finished,
141    /// Game was cancelled
142    Cancelled,
143}
144
145// ============================================================================
146// In-Game Types
147// ============================================================================
148
149/// Player information during a game.
150#[serde_with::serde_as]
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct PlayerInfo {
153    /// User ID (string to preserve JS number precision)
154    #[serde_as(as = "serde_with::DisplayFromStr")]
155    pub user_id: i64,
156    pub username: String,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub avatar_url: Option<String>,
159    pub score: i32,
160    /// Gems collected (0-10, used for powers)
161    #[serde(default)]
162    pub gems: i32,
163    /// Team number for team modes
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub team: Option<i32>,
166    /// Whether the player is currently connected
167    #[serde(default = "default_true")]
168    pub is_connected: bool,
169}
170
171fn default_true() -> bool {
172    true
173}
174
175/// Player info specifically for `GameStarted` message (includes turn order).
176#[serde_with::serde_as]
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct GamePlayerInfo {
179    #[serde_as(as = "serde_with::DisplayFromStr")]
180    pub user_id: i64,
181    pub username: String,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub avatar_url: Option<String>,
184    pub turn_order: u8,
185    pub score: i32,
186    pub gems: i32,
187    pub is_connected: bool,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub team: Option<i32>,
190}
191
192/// Spectator information.
193#[serde_with::serde_as]
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct SpectatorInfo {
196    #[serde_as(as = "serde_with::DisplayFromStr")]
197    pub user_id: i64,
198    pub username: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub avatar_url: Option<String>,
201}
202
203/// Score information for results/leaderboards.
204#[serde_with::serde_as]
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ScoreInfo {
207    #[serde_as(as = "serde_with::DisplayFromStr")]
208    pub user_id: i64,
209    pub username: String,
210    pub score: i32,
211}
212
213/// Player info in lobby game list (simplified).
214#[serde_with::serde_as]
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct LobbyGamePlayerInfo {
217    #[serde_as(as = "serde_with::DisplayFromStr")]
218    pub user_id: i64,
219    pub username: String,
220    pub score: i32,
221}
222
223/// Game info as shown in lobby games list.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct LobbyGameInfo {
226    pub game_id: String,
227    pub current_round: i32,
228    pub max_rounds: i32,
229    pub players: Vec<LobbyGamePlayerInfo>,
230}
231
232// ============================================================================
233// Snapshot Types
234// ============================================================================
235
236/// Complete snapshot of the game state.
237#[serde_with::serde_as]
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct GameSnapshot {
240    pub game_id: String,
241    pub state: GameState,
242    pub grid: Grid,
243    pub players: Vec<PlayerInfo>,
244    pub spectators: Vec<SpectatorInfo>,
245    #[serde_as(as = "serde_with::DisplayFromStr")]
246    pub current_turn: i64,
247    pub round: u8,
248    pub max_rounds: u8,
249    pub used_words: Vec<String>,
250    pub timer_vote_state: TimerVoteState,
251    /// Your player info (for the receiving client)
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub your_player: Option<PlayerInfo>,
254    /// When the turn timer expires (if active)
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub timer_expiration_time: Option<chrono::DateTime<chrono::Utc>>,
257}
258
259// ============================================================================
260// Timer Vote Types
261// ============================================================================
262
263/// State of the timer vote system.
264///
265/// The timer vote allows players to collectively vote to start a turn timer
266/// on the current player. This prevents indefinite stalling.
267#[serde_with::serde_as]
268#[derive(Debug, Clone, Serialize, Deserialize, Default)]
269#[serde(tag = "status", rename_all = "snake_case")]
270pub enum TimerVoteState {
271    /// No vote in progress, button is idle
272    #[default]
273    Idle,
274
275    /// Vote is in progress
276    VoteInProgress {
277        /// User ID of who initiated the vote
278        #[serde_as(as = "serde_with::DisplayFromStr")]
279        initiator_id: i64,
280        /// User IDs of players who have voted yes
281        #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
282        voters: Vec<i64>,
283        /// Total votes needed to pass
284        votes_needed: u32,
285        /// When the vote expires
286        expires_at: chrono::DateTime<chrono::Utc>,
287    },
288
289    /// Timer is actively counting down
290    TimerActive {
291        /// When the timer expires
292        expires_at: chrono::DateTime<chrono::Utc>,
293        /// Target player ID (user ID)
294        #[serde_as(as = "serde_with::DisplayFromStr")]
295        target_player_id: i64,
296    },
297
298    /// Vote failed, in cooldown before another can start
299    Cooldown {
300        /// When the cooldown expires
301        expires_at: chrono::DateTime<chrono::Utc>,
302    },
303
304    /// Feature disabled (not enough players)
305    Disabled,
306}
307
308// ============================================================================
309// Rematch Countdown Types
310// ============================================================================
311
312/// State of the auto-rematch countdown after a game ends.
313///
314/// When a game ends, players are automatically re-queued and a countdown begins.
315/// Any player can trigger an early start, or opt out to return to the lobby.
316#[serde_with::serde_as]
317#[derive(Debug, Clone, Serialize, Deserialize, Default)]
318#[serde(tag = "status", rename_all = "snake_case")]
319pub enum RematchCountdownState {
320    /// No rematch countdown active
321    #[default]
322    Idle,
323
324    /// Countdown is active - new game will start when it reaches 0
325    Active {
326        /// When the countdown expires (absolute time for clock sync)
327        expires_at: chrono::DateTime<chrono::Utc>,
328        /// Seconds remaining (for initial state display)
329        seconds_remaining: u32,
330        /// Player IDs still in the rematch pool
331        #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
332        player_ids: Vec<i64>,
333        /// The game pool/game type for the rematch
334        game_type: GameType,
335    },
336
337    /// A player triggered an early start
338    Starting {
339        /// Who triggered the early start (None if countdown expired naturally)
340        #[serde(skip_serializing_if = "Option::is_none")]
341        #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
342        triggered_by: Option<i64>,
343    },
344}
345
346// ============================================================================
347// Delta Types (for efficient state updates)
348// ============================================================================
349
350/// Changes to lobby state (for delta updates instead of full snapshots).
351#[serde_with::serde_as]
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(tag = "change_type", rename_all = "snake_case")]
354pub enum LobbyChange {
355    /// A player joined the lobby
356    PlayerJoined { player: LobbyPlayerInfo },
357
358    /// A player left the lobby
359    PlayerLeft {
360        #[serde_as(as = "serde_with::DisplayFromStr")]
361        player_id: i64,
362        #[serde(skip_serializing_if = "Option::is_none")]
363        reason: Option<String>,
364    },
365
366    /// A player's connection state changed
367    PlayerConnectionChanged {
368        #[serde_as(as = "serde_with::DisplayFromStr")]
369        player_id: i64,
370        is_connected: bool,
371    },
372
373    /// A game's state changed
374    GameStateChanged { game_id: String, state: GameState },
375
376    /// Pool count updated for a game
377    PoolUpdated { game_id: String, pool_count: u32 },
378
379    /// Host changed
380    HostChanged { new_host_id: String },
381}
382
383/// Changes to game state (for delta updates).
384#[serde_with::serde_as]
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[serde(tag = "change_type", rename_all = "snake_case")]
387pub enum GameChange {
388    /// Grid was updated (after word submission)
389    GridUpdated {
390        grid: Grid,
391        /// Positions that were replaced
392        #[serde(skip_serializing_if = "Option::is_none")]
393        replaced_positions: Option<Vec<Position>>,
394    },
395
396    /// A player's score changed
397    ScoreUpdated {
398        #[serde_as(as = "serde_with::DisplayFromStr")]
399        player_id: i64,
400        score: i32,
401        gems: i32,
402    },
403
404    /// Turn changed to another player
405    TurnChanged {
406        #[serde_as(as = "serde_with::DisplayFromStr")]
407        player_id: i64,
408    },
409
410    /// Round number changed
411    RoundChanged { round: u8 },
412
413    /// A word was added to used words
414    WordUsed { word: String },
415
416    /// A spectator joined the game
417    SpectatorJoined { spectator: SpectatorInfo },
418
419    /// A spectator left the game
420    SpectatorLeft {
421        #[serde_as(as = "serde_with::DisplayFromStr")]
422        spectator_id: i64,
423    },
424
425    /// A player's connection state changed
426    PlayerConnectionChanged {
427        #[serde_as(as = "serde_with::DisplayFromStr")]
428        player_id: i64,
429        is_connected: bool,
430    },
431}
432
433// ============================================================================
434// Admin Types
435// ============================================================================
436
437/// Admin game info (for admin panel).
438#[serde_with::serde_as]
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct AdminGameInfo {
441    pub game_id: String,
442    pub state: GameState,
443    pub created_at: chrono::DateTime<chrono::Utc>,
444    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
445    pub players: Vec<i64>,
446}
447
448// ============================================================================
449// Error Types
450// ============================================================================
451
452/// Standard error codes for protocol errors.
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum ErrorCode {
456    // Connection errors
457    NotAuthenticated,
458    SessionExpired,
459    InvalidSession,
460
461    // Lobby errors
462    LobbyNotFound,
463    LobbyFull,
464    NotInLobby,
465    AlreadyInLobby,
466
467    // Game errors
468    GameNotFound,
469    GameInProgress,
470    GameNotActive,
471    NotInGame,
472    AlreadyInGame,
473
474    // Turn errors
475    NotYourTurn,
476    InvalidAction,
477    ActionTimeout,
478
479    // Word submission errors
480    InvalidPath,
481    PathTooShort,
482    WordNotInDictionary,
483    WordAlreadyUsed,
484
485    // Permission errors
486    NotHost,
487    NotEnoughPlayers,
488    TooManyPlayers,
489
490    // Resource errors
491    InsufficientGems,
492
493    // Rate limiting
494    TooManyRequests,
495    MessageTooLarge,
496
497    // Generic
498    InvalidRequest,
499    InternalError,
500}
501
502impl ErrorCode {
503    /// Get a human-readable message for this error code.
504    #[must_use]
505    pub fn message(&self) -> &'static str {
506        match self {
507            Self::NotAuthenticated => "Not authenticated",
508            Self::SessionExpired => "Session expired",
509            Self::InvalidSession => "Invalid session",
510            Self::LobbyNotFound => "Lobby not found",
511            Self::LobbyFull => "Lobby is full",
512            Self::NotInLobby => "You must be in a lobby",
513            Self::AlreadyInLobby => "Already in a lobby",
514            Self::GameNotFound => "Game not found",
515            Self::GameInProgress => "A game is already in progress",
516            Self::GameNotActive => "Game is not active",
517            Self::NotInGame => "You are not in this game",
518            Self::AlreadyInGame => "You are already in this game",
519            Self::NotYourTurn => "It's not your turn",
520            Self::InvalidAction => "Invalid action",
521            Self::ActionTimeout => "Action timed out",
522            Self::InvalidPath => "Invalid path - letters must be adjacent",
523            Self::PathTooShort => "Word must be at least 3 letters",
524            Self::WordNotInDictionary => "Word not found in dictionary",
525            Self::WordAlreadyUsed => "Word has already been used",
526            Self::NotHost => "Only the host can do this",
527            Self::NotEnoughPlayers => "Not enough players",
528            Self::TooManyPlayers => "Too many players",
529            Self::InsufficientGems => "Not enough gems",
530            Self::TooManyRequests => "Too many requests",
531            Self::MessageTooLarge => "Message too large",
532            Self::InvalidRequest => "Invalid request",
533            Self::InternalError => "Internal server error",
534        }
535    }
536}
537
538impl std::fmt::Display for ErrorCode {
539    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540        write!(f, "{}", self.message())
541    }
542}
543
544// ============================================================================
545// Game Configuration Types
546// ============================================================================
547
548/// Configuration options for starting a new game.
549///
550/// These options customize game behavior for a single game session.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct GameConfig {
553    /// If true, regenerate the entire board at the start of each round.
554    /// Default is false (board persists across rounds).
555    #[serde(default)]
556    pub regenerate_board_each_round: bool,
557
558    /// Grid size (4 or 5). Determines which dice set is used.
559    /// Default is 5 (standard 5x5 Big Boggle-style).
560    #[serde(default = "default_grid_size")]
561    pub grid_size: u8,
562}
563
564fn default_grid_size() -> u8 {
565    5
566}
567
568impl Default for GameConfig {
569    fn default() -> Self {
570        Self {
571            regenerate_board_each_round: false,
572            grid_size: default_grid_size(),
573        }
574    }
575}
576
577// ============================================================================
578// Debug State Types (for diagnostics)
579// ============================================================================
580
581/// Player info in debug state response.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct DebugPlayerInfo {
584    pub user_id: i64,
585    pub username: String,
586}
587
588/// WebSocket connection context in debug state response.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct DebugWebsocketContext {
591    pub lobby_id: Option<String>,
592    pub game_id: Option<String>,
593    pub is_spectating: bool,
594}
595
596/// Lobby state in debug state response.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598#[serde(untagged)]
599pub enum DebugLobbyState {
600    Found {
601        lobby_id: String,
602        player_in_lobby: bool,
603        lobby_player_ids: Vec<i64>,
604        active_game_id: Option<String>,
605    },
606    Error {
607        error: String,
608    },
609}
610
611/// Backend game state in debug state response.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(untagged)]
614pub enum DebugBackendGameState {
615    Found {
616        game_id: String,
617        player_in_session_players: bool,
618        spectator_in_session: bool,
619        session_player_ids: Vec<i64>,
620        session_spectator_ids: Vec<i64>,
621        lobby_id: String,
622    },
623    Error {
624        error: String,
625    },
626}
627
628/// Handler game state in debug state response.
629#[derive(Debug, Clone, Serialize, Deserialize)]
630#[serde(untagged)]
631pub enum DebugHandlerGameState {
632    Found {
633        game_id: String,
634        player_in_handler_game: bool,
635        handler_player_ids: Vec<i64>,
636        current_turn_index: usize,
637        round: u8,
638        state: String,
639    },
640    Error {
641        error: String,
642    },
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn test_position_serialization() {
651        let pos = Position { row: 2, col: 3 };
652        let json = serde_json::to_string(&pos).unwrap();
653        assert_eq!(json, r#"{"row":2,"col":3}"#);
654    }
655
656    #[test]
657    fn test_multiplier_serialization() {
658        assert_eq!(
659            serde_json::to_string(&Multiplier::DoubleLetter).unwrap(),
660            r#""double_letter""#
661        );
662        assert_eq!(
663            serde_json::to_string(&Multiplier::DoubleWord).unwrap(),
664            r#""double_word""#
665        );
666    }
667
668    #[test]
669    fn test_timer_vote_state_serialization() {
670        let idle = TimerVoteState::Idle;
671        let json = serde_json::to_string(&idle).unwrap();
672        assert!(json.contains(r#""status":"idle""#));
673
674        let now = chrono::Utc::now();
675        let vote = TimerVoteState::VoteInProgress {
676            initiator_id: 123,
677            voters: vec![456],
678            votes_needed: 2,
679            expires_at: now,
680        };
681        let json = serde_json::to_string(&vote).unwrap();
682        assert!(json.contains(r#""status":"vote_in_progress""#));
683        assert!(json.contains(r#""initiator_id":"123""#));
684        assert!(json.contains(r#""expires_at""#));
685
686        let active = TimerVoteState::TimerActive {
687            expires_at: now,
688            target_player_id: 789,
689        };
690        let json = serde_json::to_string(&active).unwrap();
691        assert!(json.contains(r#""status":"timer_active""#));
692        assert!(json.contains(r#""target_player_id":"789""#));
693
694        let cooldown = TimerVoteState::Cooldown { expires_at: now };
695        let json = serde_json::to_string(&cooldown).unwrap();
696        assert!(json.contains(r#""status":"cooldown""#));
697        assert!(json.contains(r#""expires_at""#));
698    }
699
700    #[test]
701    fn test_lobby_change_serialization() {
702        let change = LobbyChange::PlayerJoined {
703            player: LobbyPlayerInfo {
704                user_id: 123,
705                username: "TestUser".to_string(),
706                avatar_url: None,
707                banner_url: None,
708                accent_color: None,
709                current_game_pool: None,
710                active_game_id: None,
711                spectate_game_id: None,
712            },
713        };
714        let json = serde_json::to_string(&change).unwrap();
715        assert!(json.contains(r#""change_type":"player_joined""#));
716    }
717
718    #[test]
719    fn test_error_code_message() {
720        assert_eq!(ErrorCode::NotYourTurn.message(), "It's not your turn");
721        assert_eq!(
722            ErrorCode::WordNotInDictionary.message(),
723            "Word not found in dictionary"
724        );
725    }
726
727    #[test]
728    fn test_game_snapshot_serialization() {
729        let player = PlayerInfo {
730            user_id: 1,
731            username: "Player1".to_string(),
732            avatar_url: None,
733            score: 10,
734            gems: 5,
735            team: None,
736            is_connected: true,
737        };
738
739        let spectator = SpectatorInfo {
740            user_id: 2,
741            username: "Spec1".to_string(),
742            avatar_url: Some("http://avatar.url".to_string()),
743        };
744
745        let snapshot = GameSnapshot {
746            game_id: "game1".to_string(),
747            state: GameState::InProgress,
748            grid: vec![vec![GridCell {
749                letter: 'A',
750                value: 1,
751                multiplier: None,
752                has_gem: false,
753            }]],
754            players: vec![player],
755            spectators: vec![spectator],
756            current_turn: 1,
757            round: 1,
758            max_rounds: 3,
759            used_words: vec!["WORD".to_string()],
760            timer_vote_state: TimerVoteState::default(),
761            your_player: None,
762            timer_expiration_time: None,
763        };
764
765        let json = serde_json::to_string(&snapshot).unwrap();
766        assert!(json.contains(r#""game_id":"game1""#));
767        assert!(json.contains(r#""players":[{"user_id":"1""#));
768        assert!(json.contains(r#""spectators":[{"user_id":"2""#));
769        assert!(json.contains(r#""current_turn":"1""#));
770    }
771
772    #[test]
773    fn test_game_config_default() {
774        let config = GameConfig::default();
775        assert!(!config.regenerate_board_each_round);
776    }
777
778    #[test]
779    fn test_game_config_serialization() {
780        // Test with default value (false)
781        let config = GameConfig {
782            regenerate_board_each_round: false,
783            grid_size: 5,
784        };
785        let json = serde_json::to_string(&config).unwrap();
786        assert!(json.contains(r#""regenerate_board_each_round":false"#));
787        assert!(json.contains(r#""grid_size":5"#));
788
789        // Test with true value and 4x4 grid
790        let config = GameConfig {
791            regenerate_board_each_round: true,
792            grid_size: 4,
793        };
794        let json = serde_json::to_string(&config).unwrap();
795        assert!(json.contains(r#""regenerate_board_each_round":true"#));
796        assert!(json.contains(r#""grid_size":4"#));
797    }
798
799    #[test]
800    fn test_game_config_deserialization() {
801        // Test deserializing with explicit false
802        let json = r#"{"regenerate_board_each_round":false}"#;
803        let config: GameConfig = serde_json::from_str(json).unwrap();
804        assert!(!config.regenerate_board_each_round);
805        assert_eq!(config.grid_size, 5, "grid_size should default to 5");
806
807        // Test deserializing with explicit true
808        let json = r#"{"regenerate_board_each_round":true}"#;
809        let config: GameConfig = serde_json::from_str(json).unwrap();
810        assert!(config.regenerate_board_each_round);
811
812        // Test deserializing with missing field (should use default)
813        let json = r"{}";
814        let config: GameConfig = serde_json::from_str(json).unwrap();
815        assert!(!config.regenerate_board_each_round);
816        assert_eq!(config.grid_size, 5);
817
818        // Test deserializing with grid_size: 4
819        let json = r#"{"grid_size":4}"#;
820        let config: GameConfig = serde_json::from_str(json).unwrap();
821        assert_eq!(config.grid_size, 4);
822        assert!(!config.regenerate_board_each_round);
823    }
824}