From f9d02106b5aeb2e22d033c656e61fed7c9190ee6 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Tue, 21 Apr 2026 01:35:17 -0400 Subject: [PATCH 1/8] adventure: GameConfig.adventure_level, LevelTargets, AdventureLevelResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameConfig gains optional adventure_level (Some signals an Adventure Mode session, unblocking bot injection + level-pinned config on the backend) and level_targets so the client can render 1/2/3-star thresholds during play. New ServerMessage::AdventureLevelResult emitted after GameOver on adventure runs — carries final score, stars, personal-best flag, and any newly unlocked level. Server writes adventure_progress authoritatively before emitting, so the client treats it as read-only state. --- src/protocol/client_messages.rs | 2 ++ src/protocol/server_messages.rs | 21 +++++++++++++++++++++ src/protocol/types.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/protocol/client_messages.rs b/src/protocol/client_messages.rs index c6cee95..7762717 100644 --- a/src/protocol/client_messages.rs +++ b/src/protocol/client_messages.rs @@ -500,6 +500,8 @@ mod tests { config: Some(GameConfig { regenerate_board_each_round: true, grid_size: 5, + adventure_level: None, + level_targets: None, }), }; let json = serde_json::to_string(&msg).unwrap(); diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index db02ac5..2486ff2 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -159,6 +159,26 @@ pub enum ServerMessage { /// Game was cancelled (not enough players, host left, etc.). GameCancelled { game_id: String, reason: String }, + /// Adventure Mode level ended. Sent after `GameOver` for sessions that + /// had `adventure_level.is_some()` in their config. The client uses + /// this to render the level-complete modal; the server has already + /// upserted the row in `adventure_progress` by the time this is sent. + AdventureLevelResult { + /// Which level was played (1..=50). + level: u32, + /// Human player's final score for this run. + score: i32, + /// Stars awarded (0..=3). 0 = target not hit; 1+ = hit one_star/two_star/three_star. + stars: u8, + /// True iff `score` exceeded the prior `high_score` for this user + level. + personal_best: bool, + /// If this run unlocked a new level, the newly-unlocked level id. + /// `None` if no new level was unlocked (level was already completed + /// or was the campaign finale). + #[serde(skip_serializing_if = "Option::is_none")] + unlocked_level: Option, + }, + // ======================================================================== // Game Event Messages // ======================================================================== @@ -526,6 +546,7 @@ impl ServerMessage { Self::GameDelta { .. } => "game_delta", Self::GameOver { .. } => "game_over", Self::GameCancelled { .. } => "game_cancelled", + Self::AdventureLevelResult { .. } => "adventure_level_result", Self::PlayerJoined { .. } => "player_joined", Self::PlayerLeft { .. } => "player_left", Self::PlayerReconnected { .. } => "player_reconnected", diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 426ae8d..75a4393 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -559,6 +559,18 @@ pub struct GameConfig { /// Default is 5 (standard 5x5 Big Boggle-style). #[serde(default = "default_grid_size")] pub grid_size: u8, + + /// Adventure level ID (1..=50). `Some` signals this session is an + /// Adventure Mode run; the server pins bot difficulty, grid size, and + /// target thresholds from the level manifest. `None` = normal session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adventure_level: Option, + + /// Score thresholds for 1/2/3-star completion of the current Adventure + /// level. Sent from server → client at session start so the UI can + /// render target chips. Only present when `adventure_level.is_some()`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level_targets: Option, } fn default_grid_size() -> u8 { @@ -570,10 +582,23 @@ impl Default for GameConfig { Self { regenerate_board_each_round: false, grid_size: default_grid_size(), + adventure_level: None, + level_targets: None, } } } +/// Per-level score thresholds for Adventure Mode star awards. +/// +/// Hitting `one_star` completes the level; `two_star` and `three_star` +/// are progressive bonuses. Stars are awarded server-side at session end. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LevelTargets { + pub one_star: i32, + pub two_star: i32, + pub three_star: i32, +} + // ============================================================================ // Debug State Types (for diagnostics) // ============================================================================ @@ -781,6 +806,8 @@ mod tests { let config = GameConfig { regenerate_board_each_round: false, grid_size: 5, + adventure_level: None, + level_targets: None, }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains(r#""regenerate_board_each_round":false"#)); @@ -790,6 +817,8 @@ mod tests { let config = GameConfig { regenerate_board_each_round: true, grid_size: 4, + adventure_level: None, + level_targets: None, }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains(r#""regenerate_board_each_round":true"#)); From f5916ab2443ea4dd3fcbe91b1bed905cfa7441da Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Tue, 21 Apr 2026 04:51:30 -0400 Subject: [PATCH 2/8] chore: version bump --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index acb2a9b..041627b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runecast-protocol" -version = "0.8.4" +version = "0.8.5" edition = "2021" description = "Protocol definitions for RuneCast WebSocket communication" From ad7f14292ad0bc8efbcdc0c6a3de124ecfa046e9 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Thu, 23 Apr 2026 16:34:28 -0400 Subject: [PATCH 3/8] adventure: add duration_ms to AdventureLevelResult Carries run duration (ms) from game creation to game_over so the adventure result modal can render "Time 2:34" without an extra round-trip. Optional on the wire via skip_serializing_if so older clients aren't bothered. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/protocol/server_messages.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index 2486ff2..fec3dd7 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -177,6 +177,11 @@ pub enum ServerMessage { /// or was the campaign finale). #[serde(skip_serializing_if = "Option::is_none")] unlocked_level: Option, + /// This run's duration in milliseconds (from game creation to + /// game_over). Lets the client render "Time: 2:34" in the result + /// modal without fetching progress. + #[serde(skip_serializing_if = "Option::is_none")] + duration_ms: Option, }, // ======================================================================== From bcfc5c29b1356b6e426b0fa8dc9eea8367776170 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Thu, 23 Apr 2026 16:48:44 -0400 Subject: [PATCH 4/8] grid: add is_hole field to GridCell for asymmetric boards Opt-in flag, default false, skip_serializing_if not-is_hole so wire format stays unchanged for normal cells. Lets Adventure Mode declare per-cell "no tile" positions that render dark and can't be part of a word path. Bonus/gem/letter-rolling pipeline respects the flag at the backend. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/protocol/types.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 75a4393..f08b318 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -34,6 +34,13 @@ pub struct GridCell { pub multiplier: Option, #[serde(default)] pub has_gem: bool, + /// True when this cell is a "hole" — occupies its position in the + /// layout but can't be selected as part of a word path. Used by + /// Adventure Mode levels that want asymmetric playable regions. + /// Hole cells have no letter contribution and are skipped by the + /// refill pipeline. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_hole: bool, } /// The 5x5 game grid. From 0497d78e3ff0f953cc4136021072a39a32a1d43c Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Thu, 23 Apr 2026 18:00:24 -0400 Subject: [PATCH 5/8] adventure: AdventureEvent server-message for random events Carries a bomb/snake/ufo event from the backend's event scheduler to the client. Includes the post-effect grid snapshot so the client can redraw atomically, plus affected_positions for cell-level animation and a server-localized label for the toast. kind is a string rather than a nested enum so unknown kinds degrade gracefully on older clients. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/protocol/server_messages.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index fec3dd7..ddf950e 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -18,7 +18,7 @@ use crate::protocol::GameType; use super::types::{ AdminGameInfo, DebugBackendGameState, DebugHandlerGameState, DebugLobbyState, DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GamePlayerInfo, GameSnapshot, - Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType, PlayerInfo, + Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType, PlayerInfo, Position, RematchCountdownState, ScoreInfo, SpectatorInfo, TimerVoteState, }; @@ -184,6 +184,25 @@ pub enum ServerMessage { duration_ms: Option, }, + /// Adventure Mode random event (bomb / snake / UFO). Broadcast by + /// the event scheduler when a roll succeeds at round change. + /// `affected_positions` is the set of grid cells the client should + /// animate; `new_grid` is the post-effect board so the client can + /// swap in the updated tiles atomically with the animation. + AdventureEvent { + game_id: String, + /// `"bomb"` | `"snake"` | `"ufo"`. String rather than a nested + /// enum so older clients gracefully ignore unknown kinds. + kind: String, + affected_positions: Vec, + /// Full post-effect grid. Clients apply this as an authoritative + /// snapshot — no delta needed. + new_grid: Grid, + /// Short human-readable label for the event, already localized + /// server-side. Rendered in a brief toast alongside the animation. + label: String, + }, + // ======================================================================== // Game Event Messages // ======================================================================== @@ -552,6 +571,7 @@ impl ServerMessage { Self::GameOver { .. } => "game_over", Self::GameCancelled { .. } => "game_cancelled", Self::AdventureLevelResult { .. } => "adventure_level_result", + Self::AdventureEvent { .. } => "adventure_event", Self::PlayerJoined { .. } => "player_joined", Self::PlayerLeft { .. } => "player_left", Self::PlayerReconnected { .. } => "player_reconnected", From 11b7656eb99540a277f34ee7886f789a9a0a94c3 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Thu, 23 Apr 2026 20:06:21 -0400 Subject: [PATCH 6/8] fix test and update Cargo.toml --- Cargo.toml | 4 ++-- src/protocol/types.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 041627b..8d46c41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runecast-protocol" -version = "0.8.5" +version = "0.8.6" edition = "2021" description = "Protocol definitions for RuneCast WebSocket communication" @@ -8,7 +8,7 @@ description = "Protocol definitions for RuneCast WebSocket communication" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } -serde_with = { version = "3.16", features = ["macros"] } +serde_with = { version = "3.18.0", features = ["macros"] } async-trait = "0.1" [dev-dependencies] diff --git a/src/protocol/types.rs b/src/protocol/types.rs index f08b318..c491e07 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -778,6 +778,7 @@ mod tests { game_id: "game1".to_string(), state: GameState::InProgress, grid: vec![vec![GridCell { + is_hole: false, letter: 'A', value: 1, multiplier: None, From 29dadadf91e6eff1daf90ece6bccd4be0ffc8ea6 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Thu, 23 Apr 2026 20:59:30 -0400 Subject: [PATCH 7/8] review: duration_ms u64 + AdventureEventKind enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Gemini comments on PR #19: 1. AdventureLevelResult.duration_ms is now Option instead of Option. Durations are non-negative, and this matches the rest of the protocol's duration / interval fields (heartbeat_interval_ms: u32, server_time: u64). 2. AdventureEvent.kind is now a typed AdventureEventKind enum instead of a free-form String. Variants Bomb / Snake / Ufo cover the designed event set; the #[serde(other)] Unknown variant catches any future kind a newer server emits without breaking older clients that pre-date the addition. The prior "string for backward compat" rationale didn't hold up — AdventureEvent is itself a new variant, so older clients already fail to deserialize the whole message. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 10 +++++----- src/protocol/server_messages.rs | 19 ++++++++++--------- src/protocol/types.rs | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ac2563b..5fd5a8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,11 +41,11 @@ pub use protocol::{ envelope::{Envelope, MaybeEnveloped}, server_messages::ServerMessage, types::{ - AdminGameInfo, DebugBackendGameState, DebugHandlerGameState, DebugLobbyState, - DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GameConfig, GamePlayerInfo, - GameSnapshot, GameState, GameSummary, Grid, GridCell, LobbyChange, LobbyGameInfo, - LobbyGamePlayerInfo, LobbyPlayerInfo, LobbyType, Multiplier, PlayerInfo, Position, - ScoreInfo, SpectatorInfo, TimerVoteState, + AdminGameInfo, AdventureEventKind, DebugBackendGameState, DebugHandlerGameState, + DebugLobbyState, DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, + GameConfig, GamePlayerInfo, GameSnapshot, GameState, GameSummary, Grid, GridCell, + LobbyChange, LobbyGameInfo, LobbyGamePlayerInfo, LobbyPlayerInfo, LobbyType, Multiplier, + PlayerInfo, Position, ScoreInfo, SpectatorInfo, TimerVoteState, }, LobbySnapshot, }; diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index ddf950e..223f75f 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -16,10 +16,10 @@ use serde::{Deserialize, Serialize}; use crate::protocol::GameType; use super::types::{ - AdminGameInfo, DebugBackendGameState, DebugHandlerGameState, DebugLobbyState, - DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GamePlayerInfo, GameSnapshot, - Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType, PlayerInfo, Position, - RematchCountdownState, ScoreInfo, SpectatorInfo, TimerVoteState, + AdminGameInfo, AdventureEventKind, DebugBackendGameState, DebugHandlerGameState, + DebugLobbyState, DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, + GamePlayerInfo, GameSnapshot, Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType, + PlayerInfo, Position, RematchCountdownState, ScoreInfo, SpectatorInfo, TimerVoteState, }; /// Messages sent from server to client. @@ -179,9 +179,12 @@ pub enum ServerMessage { unlocked_level: Option, /// This run's duration in milliseconds (from game creation to /// game_over). Lets the client render "Time: 2:34" in the result - /// modal without fetching progress. + /// modal without fetching progress. Unsigned since durations + /// are non-negative, and consistent with other duration / + /// interval fields in the protocol (`heartbeat_interval_ms`, + /// `server_time`). #[serde(skip_serializing_if = "Option::is_none")] - duration_ms: Option, + duration_ms: Option, }, /// Adventure Mode random event (bomb / snake / UFO). Broadcast by @@ -191,9 +194,7 @@ pub enum ServerMessage { /// swap in the updated tiles atomically with the animation. AdventureEvent { game_id: String, - /// `"bomb"` | `"snake"` | `"ufo"`. String rather than a nested - /// enum so older clients gracefully ignore unknown kinds. - kind: String, + kind: AdventureEventKind, affected_positions: Vec, /// Full post-effect grid. Clients apply this as an authoritative /// snapshot — no delta needed. diff --git a/src/protocol/types.rs b/src/protocol/types.rs index c491e07..7e65675 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -46,6 +46,27 @@ pub struct GridCell { /// The 5x5 game grid. pub type Grid = Vec>; +/// Adventure Mode random-event kinds. Carried on `ServerMessage::AdventureEvent`. +/// +/// The `Unknown` variant catches any future kind an older client doesn't +/// recognize, so adding new kinds server-side is non-breaking. Clients +/// matching on this enum should always handle the `Unknown` arm (e.g. +/// by rendering a generic "Something happened!" toast). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdventureEventKind { + /// 3×3 blast centered on a random cell; those cells reroll. + Bomb, + /// Poisons a handful of cells for a couple of rounds. + Snake, + /// Steals all multipliers currently on the board. + Ufo, + /// Forward-compat fallback. Serde decodes any unknown kind string + /// to this variant via `#[serde(other)]`. + #[serde(other)] + Unknown, +} + /// Game mode variants. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] From 44021a4eb6533710c2ce89b96966c9bae8bb80d2 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Thu, 23 Apr 2026 21:20:56 -0400 Subject: [PATCH 8/8] review: serde round-trip tests for adventure types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all seven Copilot review comments on PR #19. Each new test is scoped to one type so a future field / rename breaks a single assertion instead of a bundle. types.rs: - test_game_config_serialization: extended to cover adventure_level and level_targets — omitted when None, present with the expected wire names when Some. - test_game_config_deserialization: extended to confirm the new fields default to None on missing input and decode correctly when the client provides them. - test_level_targets_serde_round_trip: new focused test locking in the exact one_star / two_star / three_star wire field names. - test_grid_cell_is_hole_wire_compat: new test confirming is_hole is skipped when false, emitted when true, and defaults to false on deserialization from JSON that omits the field. - test_adventure_event_kind_serde: new test covering the known variants (snake_case names) and the forward-compat #[serde(other)] Unknown fallback. server_messages.rs: - test_message_type: extended with assertions for AdventureLevelResult and AdventureEvent so the match in message_type() can't drift from the enum. - test_adventure_level_result_serialization: new round-trip test covering both the None and Some cases of unlocked_level and duration_ms, verifying skip_serializing_if behavior and the u64 wire shape for duration_ms. - test_adventure_event_serialization: new round-trip test that also exercises an embedded GridCell with is_hole = true so the grid-inside-message case stays wire-compatible. All 51 tests in the protocol crate pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/protocol/server_messages.rs | 158 ++++++++++++++++++++++++++++++++ src/protocol/types.rs | 134 +++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index 223f75f..5544f17 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -793,6 +793,164 @@ mod tests { ServerMessage::error(ErrorCode::NotYourTurn).message_type(), "error" ); + + // Adventure-mode variants — keep these assertions in sync with + // any future additions so the match in `message_type()` can't + // silently fall out of step with the enum. + assert_eq!( + ServerMessage::AdventureLevelResult { + level: 1, + score: 10, + stars: 1, + personal_best: true, + unlocked_level: None, + duration_ms: None, + } + .message_type(), + "adventure_level_result", + ); + assert_eq!( + ServerMessage::AdventureEvent { + game_id: "g".to_string(), + kind: types::AdventureEventKind::Bomb, + affected_positions: vec![], + new_grid: vec![], + label: String::new(), + } + .message_type(), + "adventure_event", + ); + } + + #[test] + fn test_adventure_level_result_serialization() { + // With both optional fields absent: they should be omitted + // entirely via skip_serializing_if so older clients don't + // see unexpected nulls. + let msg = ServerMessage::AdventureLevelResult { + level: 3, + score: 120, + stars: 2, + personal_best: false, + unlocked_level: None, + duration_ms: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"adventure_level_result""#)); + assert!(json.contains(r#""level":3"#)); + assert!(json.contains(r#""score":120"#)); + assert!(json.contains(r#""stars":2"#)); + assert!(json.contains(r#""personal_best":false"#)); + assert!( + !json.contains("unlocked_level"), + "unlocked_level should be omitted when None (got: {json})" + ); + assert!( + !json.contains("duration_ms"), + "duration_ms should be omitted when None (got: {json})" + ); + + // Both optional fields present: serialize with their canonical + // field names. duration_ms must be a plain JSON number (u64); + // JSON-wise that looks the same as a signed int, but exercising + // the path ensures the u64 type didn't accidentally become a + // stringified-int somewhere. + let msg = ServerMessage::AdventureLevelResult { + level: 3, + score: 120, + stars: 3, + personal_best: true, + unlocked_level: Some(4), + duration_ms: Some(123_456), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""unlocked_level":4"#)); + assert!(json.contains(r#""duration_ms":123456"#)); + + // Round-trip — the deserialize path needs to accept the + // serialized form and reproduce the same structure. + let parsed: ServerMessage = serde_json::from_str(&json).unwrap(); + match parsed { + ServerMessage::AdventureLevelResult { + level, + score, + stars, + personal_best, + unlocked_level, + duration_ms, + } => { + assert_eq!(level, 3); + assert_eq!(score, 120); + assert_eq!(stars, 3); + assert!(personal_best); + assert_eq!(unlocked_level, Some(4)); + assert_eq!(duration_ms, Some(123_456)); + } + other => panic!("expected AdventureLevelResult, got {other:?}"), + } + } + + #[test] + fn test_adventure_event_serialization() { + let msg = ServerMessage::AdventureEvent { + game_id: "abc".to_string(), + kind: types::AdventureEventKind::Bomb, + affected_positions: vec![ + types::Position { row: 1, col: 2 }, + types::Position { row: 2, col: 2 }, + ], + new_grid: vec![vec![ + // One plain cell + one hole cell — this lets the test + // assert GridCell's is_hole flag on AdventureEvent's + // embedded Grid field stays wire-compatible. + types::GridCell { + letter: 'A', + value: 1, + multiplier: None, + has_gem: false, + is_hole: false, + }, + types::GridCell { + letter: ' ', + value: 0, + multiplier: None, + has_gem: false, + is_hole: true, + }, + ]], + label: "💣 Bomb!".to_string(), + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"adventure_event""#)); + assert!(json.contains(r#""game_id":"abc""#)); + // kind serializes via the enum's snake_case rename. + assert!(json.contains(r#""kind":"bomb""#)); + // Affected positions are plain { row, col } objects. + assert!(json.contains(r#""affected_positions":[{"row":1,"col":2}"#)); + // new_grid's plain cell omits is_hole; hole cell carries it. + assert!(json.contains(r#""letter":"A""#)); + assert!(json.contains(r#""is_hole":true"#)); + + // Round-trip so we know the type tag + enum field decode correctly. + let parsed: ServerMessage = serde_json::from_str(&json).unwrap(); + match parsed { + ServerMessage::AdventureEvent { + game_id, + kind, + affected_positions, + new_grid, + label, + } => { + assert_eq!(game_id, "abc"); + assert_eq!(kind, types::AdventureEventKind::Bomb); + assert_eq!(affected_positions.len(), 2); + assert_eq!(new_grid[0].len(), 2); + assert!(new_grid[0][1].is_hole); + assert_eq!(label, "💣 Bomb!"); + } + other => panic!("expected AdventureEvent, got {other:?}"), + } } #[test] diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 7e65675..b7fb089 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -841,6 +841,15 @@ mod tests { let json = serde_json::to_string(&config).unwrap(); assert!(json.contains(r#""regenerate_board_each_round":false"#)); assert!(json.contains(r#""grid_size":5"#)); + // None-valued adventure fields are skipped on the wire. + assert!( + !json.contains("adventure_level"), + "adventure_level should be omitted when None" + ); + assert!( + !json.contains("level_targets"), + "level_targets should be omitted when None" + ); // Test with true value and 4x4 grid let config = GameConfig { @@ -852,6 +861,24 @@ mod tests { let json = serde_json::to_string(&config).unwrap(); assert!(json.contains(r#""regenerate_board_each_round":true"#)); assert!(json.contains(r#""grid_size":4"#)); + + // Adventure fields present should serialize with their expected names. + let config = GameConfig { + regenerate_board_each_round: false, + grid_size: 4, + adventure_level: Some(7), + level_targets: Some(LevelTargets { + one_star: 30, + two_star: 60, + three_star: 90, + }), + }; + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains(r#""adventure_level":7"#)); + assert!(json.contains(r#""level_targets""#)); + assert!(json.contains(r#""one_star":30"#)); + assert!(json.contains(r#""two_star":60"#)); + assert!(json.contains(r#""three_star":90"#)); } #[test] @@ -861,6 +888,14 @@ mod tests { let config: GameConfig = serde_json::from_str(json).unwrap(); assert!(!config.regenerate_board_each_round); assert_eq!(config.grid_size, 5, "grid_size should default to 5"); + assert!( + config.adventure_level.is_none(), + "adventure_level defaults to None" + ); + assert!( + config.level_targets.is_none(), + "level_targets defaults to None" + ); // Test deserializing with explicit true let json = r#"{"regenerate_board_each_round":true}"#; @@ -872,11 +907,110 @@ mod tests { let config: GameConfig = serde_json::from_str(json).unwrap(); assert!(!config.regenerate_board_each_round); assert_eq!(config.grid_size, 5); + assert!(config.adventure_level.is_none()); + assert!(config.level_targets.is_none()); // Test deserializing with grid_size: 4 let json = r#"{"grid_size":4}"#; let config: GameConfig = serde_json::from_str(json).unwrap(); assert_eq!(config.grid_size, 4); assert!(!config.regenerate_board_each_round); + + // Adventure fields present should deserialize through. + let json = r#"{ + "grid_size": 4, + "adventure_level": 12, + "level_targets": { "one_star": 50, "two_star": 75, "three_star": 100 } + }"#; + let config: GameConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.adventure_level, Some(12)); + assert_eq!( + config.level_targets, + Some(LevelTargets { + one_star: 50, + two_star: 75, + three_star: 100 + }) + ); + } + + #[test] + fn test_level_targets_serde_round_trip() { + // Lock in the exact wire-level field names — renaming any of + // them would silently break every client that had already + // cached adventure progress. + let targets = LevelTargets { + one_star: 100, + two_star: 200, + three_star: 300, + }; + let json = serde_json::to_value(targets).expect("LevelTargets should serialize"); + let expected = serde_json::json!({ + "one_star": 100, + "two_star": 200, + "three_star": 300, + }); + assert_eq!(json, expected); + + let round_tripped: LevelTargets = + serde_json::from_value(json).expect("LevelTargets should deserialize"); + assert_eq!(round_tripped, targets); + } + + #[test] + fn test_grid_cell_is_hole_wire_compat() { + // Default (non-hole) cells should omit `is_hole` on the wire — + // otherwise every existing cell in every broadcast pays the + // bytes and older clients see an unexpected field. Hole cells + // must carry the flag. + let plain = GridCell { + letter: 'A', + value: 1, + multiplier: None, + has_gem: false, + is_hole: false, + }; + let json = serde_json::to_string(&plain).unwrap(); + assert!( + !json.contains("is_hole"), + "is_hole should be omitted when false (got: {json})" + ); + + let hole = GridCell { + letter: ' ', + value: 0, + multiplier: None, + has_gem: false, + is_hole: true, + }; + let json = serde_json::to_string(&hole).unwrap(); + assert!(json.contains(r#""is_hole":true"#)); + + // Missing is_hole on the wire should deserialize to false. + let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#; + let cell: GridCell = serde_json::from_str(incoming).unwrap(); + assert!(!cell.is_hole, "is_hole should default to false when absent"); + } + + #[test] + fn test_adventure_event_kind_serde() { + // Known kinds round-trip through snake_case names. + let json = serde_json::to_string(&AdventureEventKind::Bomb).unwrap(); + assert_eq!(json, r#""bomb""#); + assert_eq!( + serde_json::from_str::(r#""snake""#).unwrap(), + AdventureEventKind::Snake + ); + assert_eq!( + serde_json::from_str::(r#""ufo""#).unwrap(), + AdventureEventKind::Ufo + ); + + // #[serde(other)] catches any future kind an older client + // doesn't recognize — load-bearing for forward compatibility. + assert_eq!( + serde_json::from_str::(r#""earthquake""#).unwrap(), + AdventureEventKind::Unknown + ); } }