Skip to main content

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    /// True when this cell is a "hole" — occupies its position in the
38    /// layout but can't be selected as part of a word path. Used by
39    /// Adventure Mode levels that want asymmetric playable regions.
40    /// Hole cells have no letter contribution and are skipped by the
41    /// refill pipeline.
42    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
43    pub is_hole: bool,
44    /// True when this cell has been **poisoned** by a Snake adventure
45    /// event. Poisoned letters contribute negative value when used in
46    /// a word (sign-flipped — `value` becomes `-value` for scoring),
47    /// so players want to avoid them. Poison clears when the cell is
48    /// rerolled (word consumption or another event hitting the cell).
49    /// Adventure-only; multiplayer sessions never set this.
50    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51    pub is_poisoned: bool,
52    /// True when this cell has been **abducted** by a UFO adventure
53    /// event. Abducted cells are temporarily unselectable (mirrors
54    /// `is_hole` for path validation) and reroll back to normal on a
55    /// later round. Adventure-only; multiplayer sessions never set
56    /// this.
57    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58    pub is_abducted: bool,
59}
60
61/// The 5x5 game grid.
62pub type Grid = Vec<Vec<GridCell>>;
63
64/// Adventure Mode random-event kinds. Carried on `ServerMessage::AdventureEvent`.
65///
66/// The `Unknown` variant catches any future kind an older client doesn't
67/// recognize, so adding new kinds server-side is non-breaking. Clients
68/// matching on this enum should always handle the `Unknown` arm (e.g.
69/// by rendering a generic "Something happened!" toast).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum AdventureEventKind {
73    /// 3×3 blast centered on a random cell; those cells reroll.
74    Bomb,
75    /// Poisons a handful of cells for a couple of rounds.
76    Snake,
77    /// Steals all multipliers currently on the board.
78    Ufo,
79    /// Forward-compat fallback. Serde decodes any unknown kind string
80    /// to this variant via `#[serde(other)]`.
81    #[serde(other)]
82    Unknown,
83}
84
85/// Game mode variants.
86#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum GameMode {
89    Solo,
90    #[default]
91    Multiplayer,
92    Adventure,
93}
94
95// ============================================================================
96// Lobby Types
97// ============================================================================
98
99/// Type of lobby - determines how players join.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum LobbyType {
103    /// Lobby tied to a specific Discord channel
104    Channel,
105    /// Custom lobby with a shareable code
106    Custom,
107}
108
109/// Game type for game pools within a lobby.
110///
111/// Each lobby can have multiple game pools, one per game type. Players wait in pools for matchmaking.
112/// Players join a game pool to find matches for that game type.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum GameType {
116    /// Free-for-all open game (default)
117    Open,
118    /// 2v2 team game
119    TwoVTwo,
120    /// Adventure/co-op mode
121    Adventure,
122    /// Configurable unranked custom ("sandbox") game.
123    Sandbox,
124    /// Daily Challenge — solo timed run on the shared daily board.
125    Daily,
126    /// RuneBox — timed simultaneous word hunt. No turns, no bonus tiles and no
127    /// tile replacement; a word scores its letter count. All players work the
128    /// same static board at once for the round's duration.
129    RuneBox,
130    /// Forward-compat fallback for game types this client doesn't know.
131    #[serde(other)]
132    Unknown,
133}
134
135/// `Unknown`, so that a payload predating a field carrying `GameType` decodes
136/// to the forward-compat variant instead of failing outright. Deliberately not
137/// `Open`: a wrong-but-plausible default is worse than an explicitly unknown
138/// one, because it reads as a real answer.
139impl Default for GameType {
140    fn default() -> Self {
141        Self::Unknown
142    }
143}
144
145impl std::fmt::Display for GameType {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            GameType::Open => write!(f, "open"),
149            GameType::TwoVTwo => write!(f, "two_v_two"),
150            GameType::Adventure => write!(f, "adventure"),
151            GameType::Sandbox => write!(f, "sandbox"),
152            GameType::Daily => write!(f, "daily"),
153            GameType::RuneBox => write!(f, "rune_box"),
154            GameType::Unknown => write!(f, "unknown"),
155        }
156    }
157}
158
159// ============================================================================
160// Player names
161// ============================================================================
162
163/// What to call a player, in the context this message was sent in.
164///
165/// Show [`runecast`](Self::runecast) when it is set, otherwise
166/// [`discord`](Self::discord). A viewer who has turned on austere nicknames is
167/// shown `discord` always. [`shown`](Self::shown) applies that rule.
168///
169/// ```
170/// use runecast_protocol::PlayerName;
171///
172/// let name = PlayerName::new("Ada", Some("Rune Queen".to_string()));
173/// assert_eq!(name.shown(false), "Rune Queen");
174/// assert_eq!(name.shown(true), "Ada");
175/// ```
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PlayerName {
178    /// The name Discord itself would show here: the player's nickname in this
179    /// server (when the lobby is in one and they have one there), else their
180    /// Discord display name, else their username.
181    pub discord: String,
182    /// The nickname the player chose in RuneCast, if any.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub runecast: Option<String>,
185}
186
187impl PlayerName {
188    /// A name from its two layers.
189    #[must_use]
190    pub fn new(discord: impl Into<String>, runecast: Option<String>) -> Self {
191        Self {
192            discord: discord.into(),
193            runecast,
194        }
195    }
196
197    /// The name to display, given the viewer's austere-nicknames setting.
198    #[must_use]
199    pub fn shown(&self, austere: bool) -> &str {
200        match &self.runecast {
201            Some(chosen) if !austere && !chosen.is_empty() => chosen,
202            _ => &self.discord,
203        }
204    }
205}
206
207/// Player information in the lobby (pre-game).
208#[serde_with::serde_as]
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct LobbyPlayerInfo {
211    /// User ID
212    #[serde_as(as = "serde_with::DisplayFromStr")]
213    pub user_id: i64,
214    /// The player's raw Discord username: the unique handle. Display
215    /// [`name`](Self::name) instead when it is present.
216    pub username: String,
217    /// What to call this player. Absent from servers older than protocol
218    /// 0.20; fall back to `username`.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub name: Option<PlayerName>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub avatar_url: Option<String>,
223    /// Profile banner URL (Discord CDN)
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub banner_url: Option<String>,
226    /// Profile accent color (integer representation)
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub accent_color: Option<i32>,
229    /// The player's status within a game pool, if they are in one.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub current_game_pool: Option<GameType>,
232    /// Game they are in, if any
233    pub active_game_id: Option<String>,
234    /// Game they are spectating, if any
235    pub spectate_game_id: Option<String>,
236}
237
238/// Summary of a game visible from the lobby.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct GameSummary {
241    pub game_id: String,
242    pub state: GameState,
243    pub current_round: u8,
244    pub max_rounds: u8,
245    pub player_count: u8,
246    pub spectator_count: u8,
247}
248
249/// High-level game state (not the full game data).
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum GameState {
253    /// Waiting for players / not started
254    Idle,
255    /// Players are queueing
256    Queueing,
257    /// Game is starting (countdown)
258    Starting,
259    /// Game is in progress
260    InProgress,
261    /// Game has ended
262    Finished,
263    /// Game was cancelled
264    Cancelled,
265}
266
267// ============================================================================
268// In-Game Types
269// ============================================================================
270
271/// Player information during a game.
272#[serde_with::serde_as]
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct PlayerInfo {
275    /// User ID (string to preserve JS number precision)
276    #[serde_as(as = "serde_with::DisplayFromStr")]
277    pub user_id: i64,
278    /// The player's raw Discord username: the unique handle. Display
279    /// [`name`](Self::name) instead when it is present.
280    pub username: String,
281    /// What to call this player. Absent from servers older than protocol
282    /// 0.20; fall back to `username`.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub name: Option<PlayerName>,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub avatar_url: Option<String>,
287    pub score: i32,
288    /// Gems collected (0-10, used for powers)
289    #[serde(default)]
290    pub gems: i32,
291    /// Team number for team modes
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub team: Option<i32>,
294    /// Whether the player is currently connected
295    #[serde(default = "default_true")]
296    pub is_connected: bool,
297}
298
299fn default_true() -> bool {
300    true
301}
302
303/// Player info specifically for `GameStarted` message (includes turn order).
304#[serde_with::serde_as]
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct GamePlayerInfo {
307    #[serde_as(as = "serde_with::DisplayFromStr")]
308    pub user_id: i64,
309    /// The player's raw Discord username: the unique handle. Display
310    /// [`name`](Self::name) instead when it is present.
311    pub username: String,
312    /// What to call this player. Absent from servers older than protocol
313    /// 0.20; fall back to `username`.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub name: Option<PlayerName>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub avatar_url: Option<String>,
318    pub turn_order: u8,
319    pub score: i32,
320    pub gems: i32,
321    pub is_connected: bool,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub team: Option<i32>,
324}
325
326/// Spectator information.
327#[serde_with::serde_as]
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SpectatorInfo {
330    #[serde_as(as = "serde_with::DisplayFromStr")]
331    pub user_id: i64,
332    /// The player's raw Discord username: the unique handle. Display
333    /// [`name`](Self::name) instead when it is present.
334    pub username: String,
335    /// What to call this player. Absent from servers older than protocol
336    /// 0.20; fall back to `username`.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub name: Option<PlayerName>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub avatar_url: Option<String>,
341}
342
343/// Score information for results/leaderboards.
344#[serde_with::serde_as]
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ScoreInfo {
347    #[serde_as(as = "serde_with::DisplayFromStr")]
348    pub user_id: i64,
349    /// The player's raw Discord username: the unique handle. Display
350    /// [`name`](Self::name) instead when it is present.
351    pub username: String,
352    /// What to call this player. Absent from servers older than protocol
353    /// 0.20; fall back to `username`.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub name: Option<PlayerName>,
356    pub score: i32,
357}
358
359/// One player's standing in a RuneBox **match**, as of the end of some round.
360///
361/// A match is several rounds; [`ScoreInfo`] describes a single round. Both are
362/// sent on `RuneBoxRoundEnded` because they answer different questions — "who
363/// won this round" and "who is winning the match".
364///
365/// Carries no word list, by construction. RuneBox found-sets are independent,
366/// so another player's words stay worth full value to you and naming them at
367/// round end would hand over the answers for a board that is still fresh in
368/// everyone's memory.
369#[serde_with::serde_as]
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct RuneBoxStanding {
372    /// Serializes as a quoted STRING, like every snowflake on this wire — an
373    /// id above 2^53 silently loses precision as a JSON number in JS.
374    #[serde_as(as = "serde_with::DisplayFromStr")]
375    pub player_id: i64,
376    /// The player's raw Discord username: the unique handle. Display
377    /// [`name`](Self::name) instead when it is present.
378    pub username: String,
379    /// What to call this player. Absent from servers older than protocol
380    /// 0.20; fall back to `username`.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub name: Option<PlayerName>,
383    /// Rounds this player has won outright.
384    ///
385    /// The deciding number in a two-player match, which is best-of-3 rather
386    /// than elimination — there is nobody to eliminate until it is over. In a
387    /// three-or-more-player match the field still counts round wins, but
388    /// elimination is what decides the match, so nothing reads it.
389    pub round_wins: u8,
390    /// This player's scores summed across every round played so far.
391    pub total_score: i32,
392    /// True once this player has been eliminated and moved to spectating.
393    pub eliminated: bool,
394}
395
396/// Player info in lobby game list (simplified).
397#[serde_with::serde_as]
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct LobbyGamePlayerInfo {
400    #[serde_as(as = "serde_with::DisplayFromStr")]
401    pub user_id: i64,
402    /// The player's raw Discord username: the unique handle. Display
403    /// [`name`](Self::name) instead when it is present.
404    pub username: String,
405    /// What to call this player. Absent from servers older than protocol
406    /// 0.20; fall back to `username`.
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub name: Option<PlayerName>,
409    pub score: i32,
410}
411
412/// Game info as shown in lobby games list.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct LobbyGameInfo {
415    pub game_id: String,
416    /// Which game type / pool this game belongs to (Open/Sandbox/Adventure),
417    /// so clients can distinguish concurrent games in one lobby.
418    pub game_type: GameType,
419    pub current_round: i32,
420    pub max_rounds: i32,
421    pub players: Vec<LobbyGamePlayerInfo>,
422}
423
424// ============================================================================
425// Snapshot Types
426// ============================================================================
427
428/// Complete snapshot of the game state.
429#[serde_with::serde_as]
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct GameSnapshot {
432    pub game_id: String,
433    /// Which mode this game is, so the snapshot is self-describing.
434    ///
435    /// Without this a client has to *infer* the mode from indirect tells — the
436    /// lobby games list, the joined pool, or the presence of a per-viewer
437    /// field. That inference was load-bearing for RuneBox and it read the
438    /// wrong thing more than once, so the type rides on the snapshot itself.
439    ///
440    /// `#[serde(default)]` (→ `GameType::Unknown`) keeps a payload written
441    /// before this field existed decodable; it is NOT an invitation to omit
442    /// the field when building one. Every construction site must set it, which
443    /// is why this is a plain field and not an `Option`.
444    #[serde(default)]
445    pub game_type: GameType,
446    pub state: GameState,
447    pub grid: Grid,
448    pub players: Vec<PlayerInfo>,
449    pub spectators: Vec<SpectatorInfo>,
450    #[serde_as(as = "serde_with::DisplayFromStr")]
451    pub current_turn: i64,
452    pub round: u8,
453    pub max_rounds: u8,
454    pub used_words: Vec<String>,
455    pub timer_vote_state: TimerVoteState,
456    /// Your player info (for the receiving client)
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub your_player: Option<PlayerInfo>,
459    /// When the turn timer expires (if active)
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub timer_expiration_time: Option<chrono::DateTime<chrono::Utc>>,
462    /// `Some` marks an unranked custom (sandbox) game, mirroring
463    /// `GameStarted.custom`, so the unranked badge survives reconnect/spectate.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub custom: Option<CustomMeta>,
466    /// Server-authoritative time left in a Daily Challenge run, in milliseconds.
467    /// Mirrors `GameStarted.time_remaining_ms` so a daily run that recovers via
468    /// the snapshot/reconnect path keeps its deadline. Omitted otherwise.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub time_remaining_ms: Option<u64>,
471    /// The receiving player's own found words (RuneBox only).
472    ///
473    /// Restores the word list after a reconnect. The authoritative score
474    /// already rides on `PlayerInfo`, but without this a reconnecting player
475    /// sees an empty list and gets a confusing "already found" when
476    /// re-submitting a word they had already scored. Omitted for every other
477    /// mode, whose found-words concept is the game-global `used_words`.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub your_found_words: Option<Vec<String>>,
480}
481
482// ============================================================================
483// Timer Vote Types
484// ============================================================================
485
486/// State of the timer vote system.
487///
488/// The timer vote allows players to collectively vote to start a turn timer
489/// on the current player. This prevents indefinite stalling.
490#[serde_with::serde_as]
491#[derive(Debug, Clone, Serialize, Deserialize, Default)]
492#[serde(tag = "status", rename_all = "snake_case")]
493pub enum TimerVoteState {
494    /// No vote in progress, button is idle
495    #[default]
496    Idle,
497
498    /// Vote is in progress
499    VoteInProgress {
500        /// User ID of who initiated the vote
501        #[serde_as(as = "serde_with::DisplayFromStr")]
502        initiator_id: i64,
503        /// User IDs of players who have voted yes
504        #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
505        voters: Vec<i64>,
506        /// Total votes needed to pass
507        votes_needed: u32,
508        /// When the vote expires
509        expires_at: chrono::DateTime<chrono::Utc>,
510    },
511
512    /// Timer is actively counting down
513    TimerActive {
514        /// When the timer expires
515        expires_at: chrono::DateTime<chrono::Utc>,
516        /// Target player ID (user ID)
517        #[serde_as(as = "serde_with::DisplayFromStr")]
518        target_player_id: i64,
519    },
520
521    /// Vote failed, in cooldown before another can start
522    Cooldown {
523        /// When the cooldown expires
524        expires_at: chrono::DateTime<chrono::Utc>,
525    },
526
527    /// Feature disabled (not enough players)
528    Disabled,
529}
530
531// ============================================================================
532// Rematch Countdown Types
533// ============================================================================
534
535/// State of the auto-rematch countdown after a game ends.
536///
537/// When a game ends, players are automatically re-queued and a countdown begins.
538/// Any player can trigger an early start, or opt out to return to the lobby.
539#[serde_with::serde_as]
540#[derive(Debug, Clone, Serialize, Deserialize, Default)]
541#[serde(tag = "status", rename_all = "snake_case")]
542pub enum RematchCountdownState {
543    /// No rematch countdown active
544    #[default]
545    Idle,
546
547    /// Countdown is active - new game will start when it reaches 0
548    Active {
549        /// When the countdown expires (absolute time for clock sync)
550        expires_at: chrono::DateTime<chrono::Utc>,
551        /// Seconds remaining (for initial state display)
552        seconds_remaining: u32,
553        /// Player IDs still in the rematch pool
554        #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
555        player_ids: Vec<i64>,
556        /// The game pool/game type for the rematch
557        game_type: GameType,
558    },
559
560    /// A player triggered an early start
561    Starting {
562        /// Who triggered the early start (None if countdown expired naturally)
563        #[serde(skip_serializing_if = "Option::is_none")]
564        #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
565        triggered_by: Option<i64>,
566    },
567}
568
569// ============================================================================
570// Delta Types (for efficient state updates)
571// ============================================================================
572
573/// Changes to lobby state (for delta updates instead of full snapshots).
574#[serde_with::serde_as]
575#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(tag = "change_type", rename_all = "snake_case")]
577pub enum LobbyChange {
578    /// A player joined the lobby
579    PlayerJoined { player: LobbyPlayerInfo },
580
581    /// A player left the lobby
582    PlayerLeft {
583        #[serde_as(as = "serde_with::DisplayFromStr")]
584        player_id: i64,
585        #[serde(skip_serializing_if = "Option::is_none")]
586        reason: Option<String>,
587    },
588
589    /// A player's connection state changed
590    PlayerConnectionChanged {
591        #[serde_as(as = "serde_with::DisplayFromStr")]
592        player_id: i64,
593        is_connected: bool,
594    },
595
596    /// A game's state changed
597    GameStateChanged { game_id: String, state: GameState },
598
599    /// Pool count updated for a game
600    PoolUpdated { game_id: String, pool_count: u32 },
601
602    /// Host changed
603    HostChanged { new_host_id: String },
604}
605
606/// Changes to game state (for delta updates).
607#[serde_with::serde_as]
608#[derive(Debug, Clone, Serialize, Deserialize)]
609#[serde(tag = "change_type", rename_all = "snake_case")]
610pub enum GameChange {
611    /// Grid was updated (after word submission)
612    GridUpdated {
613        grid: Grid,
614        /// Positions that were replaced
615        #[serde(skip_serializing_if = "Option::is_none")]
616        replaced_positions: Option<Vec<Position>>,
617    },
618
619    /// A player's score changed
620    ScoreUpdated {
621        #[serde_as(as = "serde_with::DisplayFromStr")]
622        player_id: i64,
623        score: i32,
624        gems: i32,
625    },
626
627    /// Turn changed to another player
628    TurnChanged {
629        #[serde_as(as = "serde_with::DisplayFromStr")]
630        player_id: i64,
631    },
632
633    /// Round number changed
634    RoundChanged { round: u8 },
635
636    /// A word was added to used words
637    WordUsed { word: String },
638
639    /// A spectator joined the game
640    SpectatorJoined { spectator: SpectatorInfo },
641
642    /// A spectator left the game
643    SpectatorLeft {
644        #[serde_as(as = "serde_with::DisplayFromStr")]
645        spectator_id: i64,
646    },
647
648    /// A player's connection state changed
649    PlayerConnectionChanged {
650        #[serde_as(as = "serde_with::DisplayFromStr")]
651        player_id: i64,
652        is_connected: bool,
653    },
654}
655
656// ============================================================================
657// Admin Types
658// ============================================================================
659
660/// Admin game info (for admin panel).
661#[serde_with::serde_as]
662#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct AdminGameInfo {
664    pub game_id: String,
665    pub state: GameState,
666    pub created_at: chrono::DateTime<chrono::Utc>,
667    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
668    pub players: Vec<i64>,
669}
670
671// ============================================================================
672// Error Types
673// ============================================================================
674
675/// Standard error codes for protocol errors.
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "snake_case")]
678pub enum ErrorCode {
679    // Connection errors
680    NotAuthenticated,
681    SessionExpired,
682    InvalidSession,
683
684    // Lobby errors
685    LobbyNotFound,
686    LobbyFull,
687    NotInLobby,
688    AlreadyInLobby,
689
690    // Game errors
691    GameNotFound,
692    GameInProgress,
693    GameNotActive,
694    NotInGame,
695    AlreadyInGame,
696
697    // Turn errors
698    NotYourTurn,
699    InvalidAction,
700    ActionTimeout,
701
702    // Word submission errors
703    InvalidPath,
704    PathTooShort,
705    WordNotInDictionary,
706    WordAlreadyUsed,
707
708    // Permission errors
709    NotHost,
710    NotEnoughPlayers,
711    TooManyPlayers,
712    /// Caller lacks the capability for an entitlement-gated action
713    /// (e.g. starting or configuring a custom sandbox game when
714    /// `sandbox_enabled` is false for them). Server-authoritative.
715    NotAuthorized,
716
717    // Resource errors
718    InsufficientGems,
719
720    // Rate limiting
721    TooManyRequests,
722    MessageTooLarge,
723
724    // Generic
725    InvalidRequest,
726    InternalError,
727}
728
729impl ErrorCode {
730    /// Get a human-readable message for this error code.
731    #[must_use]
732    pub fn message(&self) -> &'static str {
733        match self {
734            Self::NotAuthenticated => "Not authenticated",
735            Self::SessionExpired => "Session expired",
736            Self::InvalidSession => "Invalid session",
737            Self::LobbyNotFound => "Lobby not found",
738            Self::LobbyFull => "Lobby is full",
739            Self::NotInLobby => "You must be in a lobby",
740            Self::AlreadyInLobby => "Already in a lobby",
741            Self::GameNotFound => "Game not found",
742            Self::GameInProgress => "A game is already in progress",
743            Self::GameNotActive => "Game is not active",
744            Self::NotInGame => "You are not in this game",
745            Self::AlreadyInGame => "You are already in this game",
746            Self::NotYourTurn => "It's not your turn",
747            Self::InvalidAction => "Invalid action",
748            Self::ActionTimeout => "Action timed out",
749            Self::InvalidPath => "Invalid path - letters must be adjacent",
750            Self::PathTooShort => "Word must be at least 3 letters",
751            Self::WordNotInDictionary => "Word not found in dictionary",
752            Self::WordAlreadyUsed => "Word has already been used",
753            Self::NotHost => "Only the host can do this",
754            Self::NotEnoughPlayers => "Not enough players",
755            Self::TooManyPlayers => "Too many players",
756            Self::NotAuthorized => "You are not authorized to do this",
757            Self::InsufficientGems => "Not enough gems",
758            Self::TooManyRequests => "Too many requests",
759            Self::MessageTooLarge => "Message too large",
760            Self::InvalidRequest => "Invalid request",
761            Self::InternalError => "Internal server error",
762        }
763    }
764}
765
766impl std::fmt::Display for ErrorCode {
767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768        write!(f, "{}", self.message())
769    }
770}
771
772// ============================================================================
773// Bot Types
774// ============================================================================
775
776/// Bot difficulty on the wire. Lowercase to match the backend's
777/// `game_players.bot_difficulty` CHECK constraint (`easy|medium|hard`).
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(rename_all = "lowercase")]
780pub enum BotDifficulty {
781    Easy,
782    Medium,
783    Hard,
784}
785
786/// One bot seat requested for a custom game.
787#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
788pub struct BotSpec {
789    pub difficulty: BotDifficulty,
790}
791
792// ============================================================================
793// Game Configuration Types
794// ============================================================================
795
796/// Random-event config for a custom (sandbox) game. Absent for FFA/Adventure.
797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
798pub struct EventConfig {
799    /// Kinds eligible to fire (ambient or fire-now). Empty = none.
800    #[serde(default)]
801    pub enabled_kinds: Vec<AdventureEventKind>,
802    /// Ambient per-turn probability one enabled kind fires (0.0–1.0).
803    #[serde(default)]
804    pub frequency: f64,
805}
806
807/// Configuration options for starting a new game.
808///
809/// These options customize game behavior for a single game session.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct GameConfig {
812    /// If true, regenerate the entire board at the start of each round.
813    /// Default is false (board persists across rounds).
814    #[serde(default)]
815    pub regenerate_board_each_round: bool,
816
817    /// Grid size (4 or 5). Determines which dice set is used.
818    /// Default is 5 (standard 5x5 Big Boggle-style).
819    #[serde(default = "default_grid_size")]
820    pub grid_size: u8,
821
822    /// Adventure level ID (1..=50). `Some` signals this session is an
823    /// Adventure Mode run; the server pins bot difficulty, grid size, and
824    /// target thresholds from the level manifest. `None` = normal session.
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub adventure_level: Option<u32>,
827
828    /// Score thresholds for 1/2/3-star completion of the current Adventure
829    /// level. Sent from server → client at session start so the UI can
830    /// render target chips. Only present when `adventure_level.is_some()`.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub level_targets: Option<LevelTargets>,
833
834    /// Number of rounds for a custom game. `None` = server default (5).
835    /// Honored only for custom (sandbox) games; ignored for FFA.
836    #[serde(default, skip_serializing_if = "Option::is_none")]
837    pub num_rounds: Option<u8>,
838
839    /// Bot seats to add (custom games only). Empty for FFA.
840    #[serde(default, skip_serializing_if = "Vec::is_empty")]
841    pub bots: Vec<BotSpec>,
842
843    /// `Some` marks this session as a custom (unranked) sandbox game.
844    /// Mirrors `adventure_level` — both gate the same ranked-skip paths.
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub custom: Option<CustomMeta>,
847
848    /// Random-event config for custom games. `None` for FFA/Adventure.
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub events: Option<EventConfig>,
851}
852
853fn default_grid_size() -> u8 {
854    5
855}
856
857impl Default for GameConfig {
858    fn default() -> Self {
859        Self {
860            regenerate_board_each_round: false,
861            grid_size: default_grid_size(),
862            adventure_level: None,
863            level_targets: None,
864            num_rounds: None,
865            bots: Vec::new(),
866            custom: None,
867            events: None,
868        }
869    }
870}
871
872/// Marker payload for a custom (sandbox) game. Empty for now; a struct
873/// (not a bare bool) so we can carry custom metadata later without a
874/// wire break.
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
876pub struct CustomMeta {}
877
878/// Per-level score thresholds for Adventure Mode star awards.
879///
880/// Hitting `one_star` completes the level; `two_star` and `three_star`
881/// are progressive bonuses. Stars are awarded server-side at session end.
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
883pub struct LevelTargets {
884    pub one_star: i32,
885    pub two_star: i32,
886    pub three_star: i32,
887}
888
889// ============================================================================
890// Debug State Types (for diagnostics)
891// ============================================================================
892
893/// Player info in debug state response.
894#[derive(Debug, Clone, Serialize, Deserialize)]
895pub struct DebugPlayerInfo {
896    pub user_id: i64,
897    pub username: String,
898}
899
900/// WebSocket connection context in debug state response.
901#[derive(Debug, Clone, Serialize, Deserialize)]
902pub struct DebugWebsocketContext {
903    pub lobby_id: Option<String>,
904    pub game_id: Option<String>,
905    pub is_spectating: bool,
906}
907
908/// Lobby state in debug state response.
909#[derive(Debug, Clone, Serialize, Deserialize)]
910#[serde(untagged)]
911pub enum DebugLobbyState {
912    Found {
913        lobby_id: String,
914        player_in_lobby: bool,
915        lobby_player_ids: Vec<i64>,
916        active_game_id: Option<String>,
917    },
918    Error {
919        error: String,
920    },
921}
922
923/// Backend game state in debug state response.
924#[derive(Debug, Clone, Serialize, Deserialize)]
925#[serde(untagged)]
926pub enum DebugBackendGameState {
927    Found {
928        game_id: String,
929        player_in_session_players: bool,
930        spectator_in_session: bool,
931        session_player_ids: Vec<i64>,
932        session_spectator_ids: Vec<i64>,
933        lobby_id: String,
934    },
935    Error {
936        error: String,
937    },
938}
939
940/// Handler game state in debug state response.
941#[derive(Debug, Clone, Serialize, Deserialize)]
942#[serde(untagged)]
943pub enum DebugHandlerGameState {
944    Found {
945        game_id: String,
946        player_in_handler_game: bool,
947        handler_player_ids: Vec<i64>,
948        current_turn_index: usize,
949        round: u8,
950        state: String,
951    },
952    Error {
953        error: String,
954    },
955}
956
957/// Type of news notification. Mirrors the canonical values the backend
958/// stores in `news.notification_type` (validated against the
959/// `ALLOWED_NOTIFICATION_TYPES` list at the API boundary).
960///
961/// `#[serde(other)]` keeps old clients functional when the server
962/// introduces a new variant — unknown values deserialize to
963/// `Unknown` instead of erroring out. Mirrors the
964/// `AdventureEventKind::Unknown` pattern already in this crate.
965#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
966#[serde(rename_all = "snake_case")]
967pub enum NewsNotificationType {
968    Maintenance,
969    Announcement,
970    Update,
971    /// Fallback for variants the server adds after this client was built.
972    #[serde(other)]
973    Unknown,
974}
975
976impl NewsNotificationType {
977    /// Parse a wire-string into the typed enum. Unknown / unrecognized
978    /// strings map to `Unknown` so callers don't have to handle parse
979    /// errors — backend's `validate_news_payload` already rejects
980    /// unknown values at the API boundary, so this only kicks in
981    /// defensively for forward-compat.
982    #[must_use]
983    pub fn from_wire_str(s: &str) -> Self {
984        match s {
985            "maintenance" => Self::Maintenance,
986            "announcement" => Self::Announcement,
987            "update" => Self::Update,
988            _ => Self::Unknown,
989        }
990    }
991}
992
993impl From<&str> for NewsNotificationType {
994    fn from(s: &str) -> Self {
995        Self::from_wire_str(s)
996    }
997}
998
999impl From<&String> for NewsNotificationType {
1000    fn from(s: &String) -> Self {
1001        Self::from_wire_str(s.as_str())
1002    }
1003}
1004
1005/// A news/notification item as sent over the wire (server → client).
1006///
1007/// Mirrors the backend's `NewsItem` model but stripped of fields that
1008/// shouldn't leave the server (`created_by` user id).
1009///
1010/// Used in [`ServerMessage::NewsAnnounced`](crate::ServerMessage::NewsAnnounced) for live push
1011/// and is shape-compatible with what `/api/news` returns, so the same UI code path can render
1012/// either source.
1013#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
1014pub struct NewsItemPayload {
1015    pub id: String,
1016    pub title: String,
1017    pub message: String,
1018    pub notification_type: NewsNotificationType,
1019    pub created_at: chrono::DateTime<chrono::Utc>,
1020    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1021    /// 0 = popup stays until dismissed; > 0 = auto-hide after N seconds.
1022    pub auto_hide_seconds: u32,
1023    /// When true the frontend re-shows the popup every page load even if
1024    /// the user has dismissed it (dismissal becomes session-scoped only).
1025    pub refresh_on_every_login: bool,
1026    /// Higher priority wins when multiple items are active.
1027    pub priority: i32,
1028}
1029
1030/// Build provenance for one artifact (frontend or backend). The wire contract
1031/// for the backend's `GET /api/version`, and the shape the frontend mirrors for
1032/// its own baked-in info. Every field degrades to `"unknown"` (or `dirty=false`)
1033/// rather than failing a build, so a build without git context still produces a
1034/// well-formed value.
1035#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1036pub struct BuildInfo {
1037    /// Semver from the manifest (Cargo.toml / package.json), e.g. `"0.16.0"`.
1038    pub version: String,
1039    /// Short commit hash, e.g. `"a1b2c3d"`, or `"unknown"`.
1040    pub git_sha: String,
1041    /// `git describe --tags --always --dirty`, e.g. `"v0.16.0"`,
1042    /// `"v0.16.0-3-ga1b2c3d"`, `"v0.16.0-dirty"`, or `"unknown"`.
1043    pub git_describe: String,
1044    /// Branch at build, e.g. `"master"`, or `"unknown"`.
1045    pub git_branch: String,
1046    /// True if the working tree had uncommitted changes at build.
1047    pub dirty: bool,
1048    /// RFC 3339 UTC build timestamp, e.g. `"2026-07-14T11:20:05Z"`, or `"unknown"`.
1049    pub built_at: String,
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[test]
1057    fn build_info_round_trips_snake_case() {
1058        let info = BuildInfo {
1059            version: "0.16.0".to_string(),
1060            git_sha: "a1b2c3d".to_string(),
1061            git_describe: "v0.16.0-2-ga1b2c3d".to_string(),
1062            git_branch: "master".to_string(),
1063            dirty: true,
1064            built_at: "2026-07-14T11:20:05Z".to_string(),
1065        };
1066        let json = serde_json::to_string(&info).unwrap();
1067        // snake_case field names on the wire.
1068        assert!(json.contains(r#""git_sha":"a1b2c3d""#));
1069        assert!(json.contains(r#""git_describe":"#));
1070        assert!(json.contains(r#""git_branch":"master""#));
1071        assert!(json.contains(r#""dirty":true"#));
1072        assert!(json.contains(r#""built_at":"#));
1073        // Round-trips back to an equal value.
1074        let back: BuildInfo = serde_json::from_str(&json).unwrap();
1075        assert_eq!(back, info);
1076    }
1077
1078    #[test]
1079    fn test_position_serialization() {
1080        let pos = Position { row: 2, col: 3 };
1081        let json = serde_json::to_string(&pos).unwrap();
1082        assert_eq!(json, r#"{"row":2,"col":3}"#);
1083    }
1084
1085    #[test]
1086    fn test_multiplier_serialization() {
1087        assert_eq!(
1088            serde_json::to_string(&Multiplier::DoubleLetter).unwrap(),
1089            r#""double_letter""#
1090        );
1091        assert_eq!(
1092            serde_json::to_string(&Multiplier::DoubleWord).unwrap(),
1093            r#""double_word""#
1094        );
1095    }
1096
1097    #[test]
1098    fn test_timer_vote_state_serialization() {
1099        let idle = TimerVoteState::Idle;
1100        let json = serde_json::to_string(&idle).unwrap();
1101        assert!(json.contains(r#""status":"idle""#));
1102
1103        let now = chrono::Utc::now();
1104        let vote = TimerVoteState::VoteInProgress {
1105            initiator_id: 123,
1106            voters: vec![456],
1107            votes_needed: 2,
1108            expires_at: now,
1109        };
1110        let json = serde_json::to_string(&vote).unwrap();
1111        assert!(json.contains(r#""status":"vote_in_progress""#));
1112        assert!(json.contains(r#""initiator_id":"123""#));
1113        assert!(json.contains(r#""expires_at""#));
1114
1115        let active = TimerVoteState::TimerActive {
1116            expires_at: now,
1117            target_player_id: 789,
1118        };
1119        let json = serde_json::to_string(&active).unwrap();
1120        assert!(json.contains(r#""status":"timer_active""#));
1121        assert!(json.contains(r#""target_player_id":"789""#));
1122
1123        let cooldown = TimerVoteState::Cooldown { expires_at: now };
1124        let json = serde_json::to_string(&cooldown).unwrap();
1125        assert!(json.contains(r#""status":"cooldown""#));
1126        assert!(json.contains(r#""expires_at""#));
1127    }
1128
1129    #[test]
1130    fn test_lobby_change_serialization() {
1131        let change = LobbyChange::PlayerJoined {
1132            player: LobbyPlayerInfo {
1133                user_id: 123,
1134                username: "TestUser".to_string(),
1135                name: None,
1136                avatar_url: None,
1137                banner_url: None,
1138                accent_color: None,
1139                current_game_pool: None,
1140                active_game_id: None,
1141                spectate_game_id: None,
1142            },
1143        };
1144        let json = serde_json::to_string(&change).unwrap();
1145        assert!(json.contains(r#""change_type":"player_joined""#));
1146    }
1147
1148    #[test]
1149    fn test_error_code_message() {
1150        assert_eq!(ErrorCode::NotYourTurn.message(), "It's not your turn");
1151        assert_eq!(
1152            ErrorCode::WordNotInDictionary.message(),
1153            "Word not found in dictionary"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_game_snapshot_serialization() {
1159        let player = PlayerInfo {
1160            user_id: 1,
1161            username: "Player1".to_string(),
1162            name: None,
1163            avatar_url: None,
1164            score: 10,
1165            gems: 5,
1166            team: None,
1167            is_connected: true,
1168        };
1169
1170        let spectator = SpectatorInfo {
1171            user_id: 2,
1172            username: "Spec1".to_string(),
1173            name: None,
1174            avatar_url: Some("http://avatar.url".to_string()),
1175        };
1176
1177        let snapshot = GameSnapshot {
1178            game_id: "game1".to_string(),
1179            game_type: GameType::Open,
1180            state: GameState::InProgress,
1181            grid: vec![vec![GridCell {
1182                is_hole: false,
1183                letter: 'A',
1184                value: 1,
1185                multiplier: None,
1186                has_gem: false,
1187                is_poisoned: false,
1188                is_abducted: false,
1189            }]],
1190            players: vec![player],
1191            spectators: vec![spectator],
1192            current_turn: 1,
1193            round: 1,
1194            max_rounds: 3,
1195            used_words: vec!["WORD".to_string()],
1196            timer_vote_state: TimerVoteState::default(),
1197            your_player: None,
1198            timer_expiration_time: None,
1199            custom: None,
1200            time_remaining_ms: None,
1201            your_found_words: None,
1202        };
1203
1204        let json = serde_json::to_string(&snapshot).unwrap();
1205        assert!(json.contains(r#""game_id":"game1""#));
1206        assert!(json.contains(r#""players":[{"user_id":"1""#));
1207        assert!(json.contains(r#""spectators":[{"user_id":"2""#));
1208        assert!(json.contains(r#""current_turn":"1""#));
1209    }
1210
1211    #[test]
1212    fn test_game_config_default() {
1213        let config = GameConfig::default();
1214        assert!(!config.regenerate_board_each_round);
1215    }
1216
1217    #[test]
1218    fn test_game_config_serialization() {
1219        // Test with default value (false)
1220        let config = GameConfig {
1221            regenerate_board_each_round: false,
1222            grid_size: 5,
1223            adventure_level: None,
1224            level_targets: None,
1225            num_rounds: None,
1226            bots: Vec::new(),
1227            custom: None,
1228            events: None,
1229        };
1230        let json = serde_json::to_string(&config).unwrap();
1231        assert!(json.contains(r#""regenerate_board_each_round":false"#));
1232        assert!(json.contains(r#""grid_size":5"#));
1233        // None-valued adventure fields are skipped on the wire.
1234        assert!(
1235            !json.contains("adventure_level"),
1236            "adventure_level should be omitted when None"
1237        );
1238        assert!(
1239            !json.contains("level_targets"),
1240            "level_targets should be omitted when None"
1241        );
1242
1243        // Test with true value and 4x4 grid
1244        let config = GameConfig {
1245            regenerate_board_each_round: true,
1246            grid_size: 4,
1247            adventure_level: None,
1248            level_targets: None,
1249            num_rounds: None,
1250            bots: Vec::new(),
1251            custom: None,
1252            events: None,
1253        };
1254        let json = serde_json::to_string(&config).unwrap();
1255        assert!(json.contains(r#""regenerate_board_each_round":true"#));
1256        assert!(json.contains(r#""grid_size":4"#));
1257
1258        // Adventure fields present should serialize with their expected names.
1259        let config = GameConfig {
1260            regenerate_board_each_round: false,
1261            grid_size: 4,
1262            adventure_level: Some(7),
1263            level_targets: Some(LevelTargets {
1264                one_star: 30,
1265                two_star: 60,
1266                three_star: 90,
1267            }),
1268            num_rounds: None,
1269            bots: Vec::new(),
1270            custom: None,
1271            events: None,
1272        };
1273        let json = serde_json::to_string(&config).unwrap();
1274        assert!(json.contains(r#""adventure_level":7"#));
1275        assert!(json.contains(r#""level_targets""#));
1276        assert!(json.contains(r#""one_star":30"#));
1277        assert!(json.contains(r#""two_star":60"#));
1278        assert!(json.contains(r#""three_star":90"#));
1279    }
1280
1281    #[test]
1282    fn test_game_config_deserialization() {
1283        // Test deserializing with explicit false
1284        let json = r#"{"regenerate_board_each_round":false}"#;
1285        let config: GameConfig = serde_json::from_str(json).unwrap();
1286        assert!(!config.regenerate_board_each_round);
1287        assert_eq!(config.grid_size, 5, "grid_size should default to 5");
1288        assert!(
1289            config.adventure_level.is_none(),
1290            "adventure_level defaults to None"
1291        );
1292        assert!(
1293            config.level_targets.is_none(),
1294            "level_targets defaults to None"
1295        );
1296
1297        // Test deserializing with explicit true
1298        let json = r#"{"regenerate_board_each_round":true}"#;
1299        let config: GameConfig = serde_json::from_str(json).unwrap();
1300        assert!(config.regenerate_board_each_round);
1301
1302        // Test deserializing with missing field (should use default)
1303        let json = r"{}";
1304        let config: GameConfig = serde_json::from_str(json).unwrap();
1305        assert!(!config.regenerate_board_each_round);
1306        assert_eq!(config.grid_size, 5);
1307        assert!(config.adventure_level.is_none());
1308        assert!(config.level_targets.is_none());
1309
1310        // Test deserializing with grid_size: 4
1311        let json = r#"{"grid_size":4}"#;
1312        let config: GameConfig = serde_json::from_str(json).unwrap();
1313        assert_eq!(config.grid_size, 4);
1314        assert!(!config.regenerate_board_each_round);
1315
1316        // Adventure fields present should deserialize through.
1317        let json = r#"{
1318            "grid_size": 4,
1319            "adventure_level": 12,
1320            "level_targets": { "one_star": 50, "two_star": 75, "three_star": 100 }
1321        }"#;
1322        let config: GameConfig = serde_json::from_str(json).unwrap();
1323        assert_eq!(config.adventure_level, Some(12));
1324        assert_eq!(
1325            config.level_targets,
1326            Some(LevelTargets {
1327                one_star: 50,
1328                two_star: 75,
1329                three_star: 100
1330            })
1331        );
1332    }
1333
1334    #[test]
1335    fn test_level_targets_serde_round_trip() {
1336        // Lock in the exact wire-level field names — renaming any of
1337        // them would silently break every client that had already
1338        // cached adventure progress.
1339        let targets = LevelTargets {
1340            one_star: 100,
1341            two_star: 200,
1342            three_star: 300,
1343        };
1344        let json = serde_json::to_value(targets).expect("LevelTargets should serialize");
1345        let expected = serde_json::json!({
1346            "one_star": 100,
1347            "two_star": 200,
1348            "three_star": 300,
1349        });
1350        assert_eq!(json, expected);
1351
1352        let round_tripped: LevelTargets =
1353            serde_json::from_value(json).expect("LevelTargets should deserialize");
1354        assert_eq!(round_tripped, targets);
1355    }
1356
1357    #[test]
1358    fn test_grid_cell_is_hole_wire_compat() {
1359        // Default (non-hole) cells should omit `is_hole` on the wire —
1360        // otherwise every existing cell in every broadcast pays the
1361        // bytes and older clients see an unexpected field. Hole cells
1362        // must carry the flag.
1363        let plain = GridCell {
1364            letter: 'A',
1365            value: 1,
1366            multiplier: None,
1367            has_gem: false,
1368            is_hole: false,
1369            is_poisoned: false,
1370            is_abducted: false,
1371        };
1372        let json = serde_json::to_string(&plain).unwrap();
1373        assert!(
1374            !json.contains("is_hole"),
1375            "is_hole should be omitted when false (got: {json})"
1376        );
1377
1378        let hole = GridCell {
1379            letter: ' ',
1380            value: 0,
1381            multiplier: None,
1382            has_gem: false,
1383            is_hole: true,
1384            is_poisoned: false,
1385            is_abducted: false,
1386        };
1387        let json = serde_json::to_string(&hole).unwrap();
1388        assert!(json.contains(r#""is_hole":true"#));
1389
1390        // Missing is_hole on the wire should deserialize to false.
1391        let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#;
1392        let cell: GridCell = serde_json::from_str(incoming).unwrap();
1393        assert!(!cell.is_hole, "is_hole should default to false when absent");
1394    }
1395
1396    #[test]
1397    fn test_grid_cell_is_poisoned_wire_compat() {
1398        // Default (non-poisoned) cells must omit `is_poisoned` on the
1399        // wire so every existing cell in non-adventure (or non-snaked)
1400        // broadcasts doesn't carry the field.
1401        let plain = GridCell {
1402            letter: 'A',
1403            value: 1,
1404            multiplier: None,
1405            has_gem: false,
1406            is_hole: false,
1407            is_poisoned: false,
1408            is_abducted: false,
1409        };
1410        let json = serde_json::to_string(&plain).unwrap();
1411        assert!(
1412            !json.contains("is_poisoned"),
1413            "is_poisoned should be omitted when false (got: {json})"
1414        );
1415
1416        let poisoned = GridCell {
1417            letter: 'E',
1418            value: 1,
1419            multiplier: None,
1420            has_gem: false,
1421            is_hole: false,
1422            is_poisoned: true,
1423            is_abducted: false,
1424        };
1425        let json = serde_json::to_string(&poisoned).unwrap();
1426        assert!(json.contains(r#""is_poisoned":true"#));
1427
1428        // Older client/server payloads without the field deserialize
1429        // to is_poisoned=false — wire-compat with pre-v0.9.0.
1430        let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#;
1431        let cell: GridCell = serde_json::from_str(incoming).unwrap();
1432        assert!(
1433            !cell.is_poisoned,
1434            "is_poisoned should default to false when absent"
1435        );
1436    }
1437
1438    #[test]
1439    fn test_grid_cell_is_abducted_wire_compat() {
1440        // Mirrors the is_hole / is_poisoned wire-compat contract:
1441        // default-false cells omit the field; abducted cells carry it.
1442        let plain = GridCell {
1443            letter: 'A',
1444            value: 1,
1445            multiplier: None,
1446            has_gem: false,
1447            is_hole: false,
1448            is_poisoned: false,
1449            is_abducted: false,
1450        };
1451        let json = serde_json::to_string(&plain).unwrap();
1452        assert!(
1453            !json.contains("is_abducted"),
1454            "is_abducted should be omitted when false (got: {json})"
1455        );
1456
1457        let abducted = GridCell {
1458            letter: 'T',
1459            value: 1,
1460            multiplier: None,
1461            has_gem: false,
1462            is_hole: false,
1463            is_poisoned: false,
1464            is_abducted: true,
1465        };
1466        let json = serde_json::to_string(&abducted).unwrap();
1467        assert!(json.contains(r#""is_abducted":true"#));
1468
1469        // Pre-v0.9.0 payloads deserialize cleanly with is_abducted=false.
1470        let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#;
1471        let cell: GridCell = serde_json::from_str(incoming).unwrap();
1472        assert!(
1473            !cell.is_abducted,
1474            "is_abducted should default to false when absent"
1475        );
1476    }
1477
1478    #[test]
1479    fn test_news_notification_type_serde() {
1480        // Known variants round-trip through snake_case.
1481        let json = serde_json::to_string(&NewsNotificationType::Maintenance).unwrap();
1482        assert_eq!(json, r#""maintenance""#);
1483        assert_eq!(
1484            serde_json::from_str::<NewsNotificationType>(r#""announcement""#).unwrap(),
1485            NewsNotificationType::Announcement
1486        );
1487
1488        // #[serde(other)] catches future server-side variants — load-
1489        // bearing for forward compatibility with older clients.
1490        assert_eq!(
1491            serde_json::from_str::<NewsNotificationType>(r#""patch_notes""#).unwrap(),
1492            NewsNotificationType::Unknown
1493        );
1494
1495        // from_wire_str / From<&str> agree with serde.
1496        assert_eq!(
1497            NewsNotificationType::from_wire_str("maintenance"),
1498            NewsNotificationType::Maintenance
1499        );
1500        assert_eq!(
1501            NewsNotificationType::from("update"),
1502            NewsNotificationType::Update
1503        );
1504        assert_eq!(
1505            NewsNotificationType::from_wire_str("unknown_future_type"),
1506            NewsNotificationType::Unknown
1507        );
1508    }
1509
1510    #[test]
1511    fn bot_spec_round_trips_lowercase() {
1512        let spec = BotSpec {
1513            difficulty: BotDifficulty::Hard,
1514        };
1515        let json = serde_json::to_string(&spec).unwrap();
1516        assert_eq!(json, r#"{"difficulty":"hard"}"#);
1517        let back: BotSpec = serde_json::from_str(&json).unwrap();
1518        assert_eq!(back.difficulty, BotDifficulty::Hard);
1519    }
1520
1521    #[test]
1522    fn test_adventure_event_kind_serde() {
1523        // Known kinds round-trip through snake_case names.
1524        let json = serde_json::to_string(&AdventureEventKind::Bomb).unwrap();
1525        assert_eq!(json, r#""bomb""#);
1526        assert_eq!(
1527            serde_json::from_str::<AdventureEventKind>(r#""snake""#).unwrap(),
1528            AdventureEventKind::Snake
1529        );
1530        assert_eq!(
1531            serde_json::from_str::<AdventureEventKind>(r#""ufo""#).unwrap(),
1532            AdventureEventKind::Ufo
1533        );
1534
1535        // #[serde(other)] catches any future kind an older client
1536        // doesn't recognize — load-bearing for forward compatibility.
1537        assert_eq!(
1538            serde_json::from_str::<AdventureEventKind>(r#""earthquake""#).unwrap(),
1539            AdventureEventKind::Unknown
1540        );
1541    }
1542
1543    #[test]
1544    fn default_gameconfig_wire_unchanged() {
1545        let json = serde_json::to_string(&GameConfig::default()).unwrap();
1546        assert!(json.contains(r#""grid_size":5"#));
1547        assert!(!json.contains("num_rounds")); // skipped when None
1548        assert!(!json.contains(r#""bots""#) || json.contains(r#""bots":[]"#));
1549        assert!(!json.contains("custom")); // skipped when None
1550    }
1551
1552    #[test]
1553    fn sandbox_gameconfig_round_trips() {
1554        let cfg = GameConfig {
1555            num_rounds: Some(7),
1556            bots: vec![BotSpec {
1557                difficulty: BotDifficulty::Easy,
1558            }],
1559            custom: Some(CustomMeta {}),
1560            grid_size: 6,
1561            ..GameConfig::default()
1562        };
1563        let json = serde_json::to_string(&cfg).unwrap();
1564        let back: GameConfig = serde_json::from_str(&json).unwrap();
1565        assert_eq!(back.num_rounds, Some(7));
1566        assert_eq!(back.bots.len(), 1);
1567        assert!(back.custom.is_some());
1568        assert_eq!(back.grid_size, 6);
1569    }
1570
1571    #[test]
1572    fn legacy_gameconfig_json_still_parses() {
1573        let cfg: GameConfig =
1574            serde_json::from_str(r#"{"regenerate_board_each_round":true,"grid_size":4}"#).unwrap();
1575        assert!(cfg.regenerate_board_each_round);
1576        assert!(cfg.bots.is_empty());
1577        assert!(cfg.num_rounds.is_none());
1578        assert!(cfg.custom.is_none());
1579    }
1580
1581    #[test]
1582    fn event_config_round_trips() {
1583        let cfg = EventConfig {
1584            enabled_kinds: vec![AdventureEventKind::Bomb, AdventureEventKind::Ufo],
1585            frequency: 0.5,
1586        };
1587        let json = serde_json::to_string(&cfg).unwrap();
1588        let back: EventConfig = serde_json::from_str(&json).unwrap();
1589        assert_eq!(back.enabled_kinds.len(), 2);
1590        assert!((back.frequency - 0.5).abs() < 1e-9);
1591    }
1592
1593    #[test]
1594    fn gameconfig_without_events_is_back_compat() {
1595        let json = serde_json::to_string(&GameConfig::default()).unwrap();
1596        assert!(!json.contains("events"));
1597        let back: GameConfig = serde_json::from_str(&json).unwrap();
1598        assert!(back.events.is_none());
1599    }
1600
1601    #[test]
1602    fn lobby_game_info_game_type_round_trips() {
1603        // Construct a LobbyGameInfo with game_type: Sandbox, serialize,
1604        // assert the wire string, and deserialize back.
1605        let info = LobbyGameInfo {
1606            game_id: "game-42".to_string(),
1607            game_type: GameType::Sandbox,
1608            current_round: 2,
1609            max_rounds: 5,
1610            players: vec![],
1611        };
1612        let json = serde_json::to_string(&info).unwrap();
1613        assert!(
1614            json.contains(r#""game_type":"sandbox""#),
1615            r#"expected "game_type":"sandbox" in JSON, got: {json}"#
1616        );
1617        let back: LobbyGameInfo = serde_json::from_str(&json).unwrap();
1618        assert_eq!(back.game_type, GameType::Sandbox);
1619        assert_eq!(back.game_id, "game-42");
1620    }
1621
1622    #[test]
1623    fn game_type_sandbox_serializes_snake_case() {
1624        let gt = GameType::Sandbox;
1625        let json = serde_json::to_string(&gt).unwrap();
1626        assert_eq!(json, "\"sandbox\"");
1627        let back: GameType = serde_json::from_str("\"sandbox\"").unwrap();
1628        assert_eq!(back, GameType::Sandbox);
1629        assert_eq!(gt.to_string(), "sandbox");
1630    }
1631
1632    #[test]
1633    fn game_type_daily_serde_roundtrip() {
1634        let j = serde_json::to_string(&GameType::Daily).unwrap();
1635        assert_eq!(j, "\"daily\"");
1636        let back: GameType = serde_json::from_str("\"daily\"").unwrap();
1637        assert!(matches!(back, GameType::Daily));
1638        assert_eq!(GameType::Daily.to_string(), "daily");
1639    }
1640
1641    #[test]
1642    fn game_type_unknown_is_deserialize_fallback() {
1643        // Known strings still map correctly.
1644        assert_eq!(
1645            serde_json::from_str::<GameType>("\"open\"").unwrap(),
1646            GameType::Open
1647        );
1648        assert_eq!(
1649            serde_json::from_str::<GameType>("\"sandbox\"").unwrap(),
1650            GameType::Sandbox
1651        );
1652        // An unknown/future string degrades to Unknown instead of erroring.
1653        assert_eq!(
1654            serde_json::from_str::<GameType>("\"some_future_mode\"").unwrap(),
1655            GameType::Unknown
1656        );
1657    }
1658
1659    #[test]
1660    fn game_snapshot_custom_field_round_trips() {
1661        // Build a minimal GameSnapshot with custom = Some(CustomMeta {})
1662        let player = PlayerInfo {
1663            user_id: 1,
1664            username: "Player1".to_string(),
1665            name: None,
1666            avatar_url: None,
1667            score: 0,
1668            gems: 0,
1669            team: None,
1670            is_connected: true,
1671        };
1672        let base = GameSnapshot {
1673            game_id: "snap1".to_string(),
1674            game_type: GameType::Sandbox,
1675            state: GameState::InProgress,
1676            grid: vec![],
1677            players: vec![player],
1678            spectators: vec![],
1679            current_turn: 1,
1680            round: 1,
1681            max_rounds: 3,
1682            used_words: vec![],
1683            timer_vote_state: TimerVoteState::default(),
1684            your_player: None,
1685            timer_expiration_time: None,
1686            custom: Some(CustomMeta {}),
1687            time_remaining_ms: None,
1688            your_found_words: None,
1689        };
1690
1691        // Serialized JSON must contain "custom"
1692        let json = serde_json::to_string(&base).unwrap();
1693        assert!(
1694            json.contains("\"custom\""),
1695            "expected \"custom\" in JSON, got: {json}"
1696        );
1697
1698        // Must round-trip to Some
1699        let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1700        assert!(back.custom.is_some());
1701
1702        // None must be omitted from JSON
1703        let no_custom = GameSnapshot {
1704            custom: None,
1705            ..base
1706        };
1707        let json_none = serde_json::to_string(&no_custom).unwrap();
1708        assert!(
1709            !json_none.contains("\"custom\""),
1710            "expected no \"custom\" in JSON with None, got: {json_none}"
1711        );
1712        let back_none: GameSnapshot = serde_json::from_str(&json_none).unwrap();
1713        assert!(back_none.custom.is_none());
1714    }
1715
1716    #[test]
1717    fn game_snapshot_time_remaining_omitted_when_none_roundtrips_when_some() {
1718        let base = GameSnapshot {
1719            game_id: "g".to_string(),
1720            game_type: GameType::Daily,
1721            state: GameState::InProgress,
1722            grid: vec![],
1723            players: vec![],
1724            spectators: vec![],
1725            current_turn: 1,
1726            round: 1,
1727            max_rounds: 1,
1728            used_words: vec![],
1729            timer_vote_state: TimerVoteState::default(),
1730            your_player: None,
1731            timer_expiration_time: None,
1732            custom: None,
1733            time_remaining_ms: None,
1734            your_found_words: None,
1735        };
1736        // Omitted when None.
1737        let json_none = serde_json::to_string(&base).unwrap();
1738        assert!(!json_none.contains("time_remaining_ms"));
1739        // Present + round-trips when Some.
1740        let with = GameSnapshot {
1741            time_remaining_ms: Some(45_000),
1742            ..base
1743        };
1744        let json = serde_json::to_string(&with).unwrap();
1745        assert!(json.contains("\"time_remaining_ms\":45000"));
1746        let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1747        assert_eq!(back.time_remaining_ms, Some(45_000));
1748    }
1749
1750    #[test]
1751    fn game_type_runebox_round_trips_as_snake_case() {
1752        let json = serde_json::to_string(&GameType::RuneBox).unwrap();
1753        assert_eq!(json, "\"rune_box\"");
1754        let back: GameType = serde_json::from_str(&json).unwrap();
1755        assert_eq!(back, GameType::RuneBox);
1756        assert_eq!(GameType::RuneBox.to_string(), "rune_box");
1757    }
1758
1759    #[test]
1760    fn unknown_game_type_still_degrades_rather_than_failing() {
1761        // Adding a variant must not break the forward-compat fallback that lets
1762        // an older client survive a newer server.
1763        let back: GameType = serde_json::from_str("\"some_future_mode\"").unwrap();
1764        assert_eq!(back, GameType::Unknown);
1765    }
1766
1767    #[test]
1768    fn game_snapshot_found_words_omitted_when_none_roundtrips_when_some() {
1769        let base = GameSnapshot {
1770            game_id: "g".to_string(),
1771            game_type: GameType::RuneBox,
1772            state: GameState::InProgress,
1773            grid: vec![],
1774            players: vec![],
1775            spectators: vec![],
1776            current_turn: 1,
1777            round: 1,
1778            max_rounds: 1,
1779            used_words: vec![],
1780            timer_vote_state: TimerVoteState::default(),
1781            your_player: None,
1782            timer_expiration_time: None,
1783            custom: None,
1784            time_remaining_ms: None,
1785            your_found_words: None,
1786        };
1787        // Every non-RuneBox mode must serialize exactly as it did before.
1788        let json_none = serde_json::to_string(&base).unwrap();
1789        assert!(!json_none.contains("your_found_words"));
1790
1791        let with = GameSnapshot {
1792            your_found_words: Some(vec!["RUNES".to_string()]),
1793            ..base
1794        };
1795        let json = serde_json::to_string(&with).unwrap();
1796        assert!(json.contains("\"your_found_words\":[\"RUNES\"]"));
1797        let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1798        assert_eq!(back.your_found_words, Some(vec!["RUNES".to_string()]));
1799    }
1800
1801    /// Minimal snapshot for the `game_type` tests below.
1802    fn snapshot_of(game_type: GameType) -> GameSnapshot {
1803        GameSnapshot {
1804            game_id: "g".to_string(),
1805            game_type,
1806            state: GameState::InProgress,
1807            grid: vec![],
1808            players: vec![],
1809            spectators: vec![],
1810            current_turn: 1,
1811            round: 1,
1812            max_rounds: 1,
1813            used_words: vec![],
1814            timer_vote_state: TimerVoteState::default(),
1815            your_player: None,
1816            timer_expiration_time: None,
1817            custom: None,
1818            time_remaining_ms: None,
1819            your_found_words: None,
1820        }
1821    }
1822
1823    /// The whole point of the field: a snapshot says what mode it is, on the
1824    /// wire, without the client inferring it from anything else.
1825    #[test]
1826    fn game_snapshot_carries_its_game_type_on_the_wire() {
1827        let json = serde_json::to_string(&snapshot_of(GameType::RuneBox)).unwrap();
1828        assert!(
1829            json.contains("\"game_type\":\"rune_box\""),
1830            "expected game_type on the snapshot, got: {json}"
1831        );
1832        let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1833        assert_eq!(back.game_type, GameType::RuneBox);
1834    }
1835
1836    /// `game_type` is NOT `skip_serializing_if` — unlike `custom` /
1837    /// `time_remaining_ms` / `your_found_words`, it must be present for every
1838    /// mode. A client that has to ask "is this field missing, or is this an old
1839    /// server?" is back to inferring, which is what the field exists to end.
1840    #[test]
1841    fn game_type_is_emitted_for_every_mode_never_omitted() {
1842        for (game_type, tag) in [
1843            (GameType::Open, "open"),
1844            (GameType::Sandbox, "sandbox"),
1845            (GameType::Adventure, "adventure"),
1846            (GameType::Daily, "daily"),
1847            (GameType::RuneBox, "rune_box"),
1848        ] {
1849            let json = serde_json::to_string(&snapshot_of(game_type)).unwrap();
1850            assert!(
1851                json.contains(&format!("\"game_type\":\"{tag}\"")),
1852                "{game_type:?} must serialize its game_type, got: {json}"
1853            );
1854        }
1855    }
1856
1857    /// A snapshot written before this field existed must still decode — as
1858    /// `Unknown`, not as a plausible-looking `Open`. Guards the `#[serde(default)]`
1859    /// AND the `Default` impl: without either, this fails.
1860    #[test]
1861    fn snapshot_without_game_type_decodes_as_unknown() {
1862        // `timer_vote_state` is spliced in rather than hand-modelled: this test
1863        // is about the ABSENCE of game_type, and a hand-written literal for an
1864        // unrelated type would make it fail for the wrong reason.
1865        let votes = serde_json::to_string(&TimerVoteState::default()).unwrap();
1866        let legacy = format!(
1867            r#"{{
1868            "game_id": "g",
1869            "state": "in_progress",
1870            "grid": [],
1871            "players": [],
1872            "spectators": [],
1873            "current_turn": "1",
1874            "round": 1,
1875            "max_rounds": 1,
1876            "used_words": [],
1877            "timer_vote_state": {votes}
1878        }}"#
1879        );
1880        let back: GameSnapshot =
1881            serde_json::from_str(&legacy).expect("a pre-game_type snapshot must still decode");
1882        assert_eq!(back.game_type, GameType::Unknown);
1883    }
1884
1885    /// The property that makes ADDING a field to a snapshot safe, and so the
1886    /// direct counterpart to the test above.
1887    ///
1888    /// A snapshot carrying a field the reader has never heard of must decode,
1889    /// ignoring it. That is what lets a v0.18.0 server send `game_type` to a
1890    /// v0.17.0 reader — and what will let the next field be added the same way.
1891    /// It holds because no protocol type sets `deny_unknown_fields`; this test
1892    /// is what stops someone adding it.
1893    #[test]
1894    fn snapshot_ignores_fields_it_does_not_know() {
1895        let mut json = serde_json::to_string(&snapshot_of(GameType::Open)).unwrap();
1896        // Splice in a field from a hypothetical future version.
1897        json = json.replace('{', r#"{"a_field_from_the_future":[1,2,3],"#);
1898
1899        let back: GameSnapshot =
1900            serde_json::from_str(&json).expect("an unknown field must be ignored, not rejected");
1901        assert_eq!(back.game_type, GameType::Open);
1902    }
1903
1904    #[test]
1905    fn player_name_shows_the_runecast_nickname_unless_austere() {
1906        let name = PlayerName::new("Ada", Some("Rune Queen".to_string()));
1907        assert_eq!(name.shown(false), "Rune Queen");
1908        assert_eq!(name.shown(true), "Ada");
1909    }
1910
1911    #[test]
1912    fn player_name_without_a_runecast_nickname_shows_discord_either_way() {
1913        let name = PlayerName::new("Ada", None);
1914        assert_eq!(name.shown(false), "Ada");
1915        assert_eq!(name.shown(true), "Ada");
1916    }
1917
1918    #[test]
1919    fn player_name_with_an_empty_runecast_nickname_shows_discord() {
1920        assert_eq!(
1921            PlayerName::new("Ada", Some(String::new())).shown(false),
1922            "Ada"
1923        );
1924    }
1925
1926    #[test]
1927    fn player_name_omits_an_absent_runecast_nickname_on_the_wire() {
1928        let json = serde_json::to_string(&PlayerName::new("Ada", None)).unwrap();
1929        assert_eq!(json, r#"{"discord":"Ada"}"#);
1930        let json = serde_json::to_string(&PlayerName::new("Ada", Some("Q".into()))).unwrap();
1931        assert_eq!(json, r#"{"discord":"Ada","runecast":"Q"}"#);
1932        let back: PlayerName = serde_json::from_str(r#"{"discord":"Ada"}"#).unwrap();
1933        assert_eq!(back, PlayerName::new("Ada", None));
1934    }
1935
1936    #[test]
1937    fn a_player_struct_carries_its_name_beside_username() {
1938        let p = SpectatorInfo {
1939            user_id: 1_098_406_821_124_911_175,
1940            username: "ada_l".into(),
1941            name: Some(PlayerName::new("Ada", Some("Rune Queen".into()))),
1942            avatar_url: None,
1943        };
1944        let json = serde_json::to_string(&p).unwrap();
1945        assert!(json.contains(r#""username":"ada_l""#), "{json}");
1946        assert!(
1947            json.contains(r#""name":{"discord":"Ada","runecast":"Rune Queen"}"#),
1948            "{json}"
1949        );
1950    }
1951
1952    #[test]
1953    fn a_player_without_a_resolved_name_has_no_name_key() {
1954        let p = ScoreInfo {
1955            user_id: 1,
1956            username: "ada_l".into(),
1957            name: None,
1958            score: 3,
1959        };
1960        let json = serde_json::to_string(&p).unwrap();
1961        assert!(!json.contains("\"name\""), "{json}");
1962    }
1963
1964    /// A server older than 0.20 sends no `name`; a 0.20 client still parses it.
1965    #[test]
1966    fn json_from_an_older_server_parses_with_no_name() {
1967        let old = r#"{"user_id":"1","username":"ada_l","score":3}"#;
1968        let p: ScoreInfo = serde_json::from_str(old).unwrap();
1969        assert_eq!(p.username, "ada_l");
1970        assert!(p.name.is_none());
1971    }
1972}