diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 3bc7784..f85c476 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -16,6 +16,17 @@ pub enum DrawMode { DrawThree, } +/// Top-level game mode. Affects scoring and (eventually) timer behaviour. +/// +/// - `Classic`: standard Klondike scoring and timer. +/// - `Zen`: scoring suppressed (stays at 0); intended for relaxed play. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum GameMode { + #[default] + Classic, + Zen, +} + /// Snapshot of game state used for undo. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct StateSnapshot { @@ -29,6 +40,10 @@ struct StateSnapshot { pub struct GameState { pub piles: HashMap, pub draw_mode: DrawMode, + /// Top-level mode (Classic / Zen). Defaults to Classic for backwards + /// compatibility with older save files via `#[serde(default)]`. + #[serde(default)] + pub mode: GameMode, pub score: i32, pub move_count: u32, pub elapsed_seconds: u64, @@ -42,8 +57,13 @@ pub struct GameState { } impl GameState { - /// Creates a new game dealt from the given seed and draw mode. + /// Creates a new Classic-mode game dealt from the given seed and draw mode. pub fn new(seed: u64, draw_mode: DrawMode) -> Self { + Self::new_with_mode(seed, draw_mode, GameMode::Classic) + } + + /// Creates a new game with an explicit `GameMode`. + pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self { let mut deck = Deck::new(); deck.shuffle(seed); let (tableau, stock) = deal_klondike(deck); @@ -61,6 +81,7 @@ impl GameState { Self { piles, draw_mode, + mode, score: 0, move_count: 0, elapsed_seconds: 0, @@ -200,7 +221,11 @@ impl GameState { start }; - let score_delta = score_move(&from, &to); + let score_delta = if self.mode == GameMode::Zen { + 0 + } else { + score_move(&from, &to) + }; self.push_snapshot(); // Execute move @@ -242,7 +267,11 @@ impl GameState { } let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?; self.piles = snapshot.piles; - self.score = (snapshot.score + scoring_undo()).max(0); + self.score = if self.mode == GameMode::Zen { + 0 + } else { + (snapshot.score + scoring_undo()).max(0) + }; self.move_count = snapshot.move_count; self.is_won = false; self.is_auto_completable = false; @@ -508,6 +537,28 @@ mod tests { assert!(g.score >= 0); } + // --- GameMode: Zen --- + + #[test] + fn zen_mode_score_stays_zero_after_undo() { + let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); + g.draw().unwrap(); + g.undo().unwrap(); + assert_eq!(g.score, 0); + } + + #[test] + fn zen_mode_default_is_classic_via_default_trait() { + assert_eq!(GameMode::default(), GameMode::Classic); + } + + #[test] + fn zen_mode_field_persists_through_construction() { + let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen); + assert_eq!(g.mode, GameMode::Zen); + assert_eq!(g.draw_mode, DrawMode::DrawThree); + } + // --- Auto-complete --- #[test] diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 02cc452..1b0f12d 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -100,6 +100,7 @@ fn handle_start_daily_request( if keys.just_pressed(KeyCode::KeyC) { new_game.send(NewGameRequestEvent { seed: Some(daily.seed), + mode: None, }); } } diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 3226100..96b9026 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -1,6 +1,7 @@ //! Cross-system events used by the engine's plugins. use bevy::prelude::Event; +use solitaire_core::game_state::GameMode; use solitaire_core::pile::PileType; /// Request to move `count` cards from `from` to `to`. Fired by input systems, @@ -21,9 +22,11 @@ pub struct DrawRequestEvent; pub struct UndoRequestEvent; /// Request to start a new game. `seed = None` uses a system-time seed. +/// `mode = None` reuses the current game's `GameMode`. #[derive(Event, Debug, Clone, Copy, Default)] pub struct NewGameRequestEvent { pub seed: Option, + pub mode: Option, } /// Fired by `GamePlugin` after any successful state mutation. Rendering and diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 9eda9ce..1b22d11 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -103,7 +103,8 @@ fn handle_new_game( for ev in new_game.read() { let seed = ev.seed.unwrap_or_else(seed_from_system_time); let draw_mode = game.0.draw_mode.clone(); - game.0 = GameState::new(seed, draw_mode); + let mode = ev.mode.unwrap_or(game.0.mode); + game.0 = GameState::new_with_mode(seed, draw_mode, mode); changed.send(StateChangedEvent); } } @@ -253,7 +254,7 @@ mod tests { .map(|c| c.id) .collect(); - app.world_mut().send_event(NewGameRequestEvent { seed: Some(999) }); + app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None }); app.update(); let after: Vec = app @@ -292,11 +293,16 @@ mod tests { fn advance_elapsed_handles_subsecond_deltas_without_skipping() { let mut elapsed = 0; let mut acc = 0.0; - // 16ms × 60 frames/sec ≈ 1 second; should produce 1 tick. - for _ in 0..60 { - advance_elapsed(&mut elapsed, &mut acc, 1.0 / 60.0, false); + // 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick. + for _ in 0..4 { + advance_elapsed(&mut elapsed, &mut acc, 0.25, false); } - assert!(elapsed == 1, "expected 1 second, got {elapsed}"); + assert_eq!(elapsed, 1); + // Repeat once more for a total of 2 seconds. + for _ in 0..4 { + advance_elapsed(&mut elapsed, &mut acc, 0.25, false); + } + assert_eq!(elapsed, 2); } #[test] diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 9e8e3c9..0229d4f 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -69,7 +69,13 @@ fn handle_keyboard( undo.send(UndoRequestEvent); } if keys.just_pressed(KeyCode::KeyN) { - new_game.send(NewGameRequestEvent { seed: None }); + new_game.send(NewGameRequestEvent::default()); + } + if keys.just_pressed(KeyCode::KeyZ) { + new_game.send(NewGameRequestEvent { + seed: None, + mode: Some(solitaire_core::game_state::GameMode::Zen), + }); } if keys.just_pressed(KeyCode::KeyD) { draw.send(DrawRequestEvent); diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 990de02..8991169 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -296,7 +296,7 @@ mod tests { .move_count = 3; app.world_mut() - .send_event(NewGameRequestEvent { seed: Some(999) }); + .send_event(NewGameRequestEvent { seed: Some(999), mode: None }); app.update(); let stats = &app.world().resource::().0; @@ -309,7 +309,7 @@ mod tests { fn new_game_without_moves_does_not_record_abandoned() { let mut app = headless_app(); app.world_mut() - .send_event(NewGameRequestEvent { seed: Some(42) }); + .send_event(NewGameRequestEvent { seed: Some(42), mode: None }); app.update(); let stats = &app.world().resource::().0;