diff --git a/Cargo.toml b/Cargo.toml index acb2a9b..8d46c41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runecast-protocol" -version = "0.8.4" +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/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/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..5544f17 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, - 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. @@ -159,6 +159,51 @@ 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, + /// 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. 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, + }, + + /// 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, + kind: AdventureEventKind, + 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 // ======================================================================== @@ -526,6 +571,8 @@ impl ServerMessage { Self::GameDelta { .. } => "game_delta", 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", @@ -746,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 426ae8d..b7fb089 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -34,11 +34,39 @@ 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. 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")] @@ -559,6 +587,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 +610,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) // ============================================================================ @@ -746,6 +799,7 @@ mod tests { game_id: "game1".to_string(), state: GameState::InProgress, grid: vec![vec![GridCell { + is_hole: false, letter: 'A', value: 1, multiplier: None, @@ -781,19 +835,50 @@ 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"#)); 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 { 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"#)); 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] @@ -803,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}"#; @@ -814,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 + ); } }