diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 107f649..0e44968 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -8,8 +8,8 @@ use crate::klondike_adapter::{ }; use card_game::{Card, Game as _, Session, SessionConfig}; use klondike::{ - DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, - KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, + DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, + KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, }; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -150,27 +150,28 @@ pub struct TestPileState { pub tableau: std::collections::HashMap>, /// Per-foundation overrides. Missing keys fall back to the session. pub foundation: std::collections::HashMap>, + /// Override for the derived `move_count()`. `None` means "use session + /// history length". + pub move_count: Option, + /// Override for the derived `is_won()`. `None` means "use session win + /// state". + pub won: Option, + /// Override for the derived `is_auto_completable()`. `None` means "derive + /// from session state". + pub auto_completable: Option, } /// Full state of an in-progress Klondike Solitaire game. #[derive(Debug, Clone)] pub struct GameState { - /// Whether the player draws one or three cards from the stock per turn. - pub draw_mode: DrawMode, /// Top-level mode (Classic / Zen). pub mode: GameMode, /// Current game score. Can be negative (undo penalties subtract from score). pub score: i32, - /// Total moves made this game, including draws and stock recycles. - pub move_count: u32, /// Seconds elapsed since the game started, used for time-bonus scoring. pub elapsed_seconds: u64, /// RNG seed used to deal this game. Same seed always produces the same layout. pub seed: u64, - /// True once all 52 cards are on the foundations. No further moves are accepted. - pub is_won: bool, - /// True when the game can be completed without further input. - pub is_auto_completable: bool, /// Number of times `undo()` has been successfully invoked this game. pub undo_count: u32, /// Number of times the waste pile has been recycled back to stock this game. @@ -195,14 +196,14 @@ pub struct GameState { impl PartialEq for GameState { fn eq(&self, other: &Self) -> bool { - self.draw_mode == other.draw_mode + self.draw_mode() == other.draw_mode() && self.mode == other.mode && self.score == other.score - && self.move_count == other.move_count + && self.move_count() == other.move_count() && self.elapsed_seconds == other.elapsed_seconds && self.seed == other.seed - && self.is_won == other.is_won - && self.is_auto_completable == other.is_auto_completable + && self.is_won() == other.is_won() + && self.is_auto_completable() == other.is_auto_completable() && self.undo_count == other.undo_count && self.recycle_count == other.recycle_count && self.take_from_foundation == other.take_from_foundation @@ -225,7 +226,7 @@ impl Eq for GameState {} impl Serialize for GameState { fn serialize(&self, serializer: S) -> Result { PersistedGameState { - draw_mode: self.draw_mode, + draw_mode: self.draw_mode(), mode: self.mode, score: self.score, elapsed_seconds: self.elapsed_seconds, @@ -256,14 +257,10 @@ impl<'de> Deserialize<'de> for GameState { } let mut game = Self { - draw_mode: persisted.draw_mode, mode: persisted.mode, score: persisted.score, - move_count: 0, elapsed_seconds: persisted.elapsed_seconds, seed: persisted.seed, - is_won: false, - is_auto_completable: false, undo_count: persisted.undo_count, // Rebuilt from the replay loop below; persisted value may be stale // due to the pre-Phase-3 undo drift bug. @@ -282,7 +279,7 @@ impl<'de> Deserialize<'de> for GameState { test_pile_state: None, }; - let replay_config = Self::replay_config(game.draw_mode); + let replay_config = Self::replay_config(persisted.draw_mode); for any in persisted.saved_moves { // AnyInstruction::V4 arrives directly from upstream serde (schema v4). // AnyInstruction::V3 was serialised with u8 indices (schema v3) and is @@ -318,9 +315,6 @@ impl<'de> Deserialize<'de> for GameState { } } - game.move_count = Self::u32_from_len(game.session.history().len()); - game.is_won = game.check_win(); - game.is_auto_completable = !game.is_won && game.check_auto_complete(); Ok(game) } } @@ -334,14 +328,10 @@ impl GameState { /// Creates a new game with an explicit `GameMode`. pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self { Self { - draw_mode, mode, score: 0, - move_count: 0, elapsed_seconds: 0, seed, - is_won: false, - is_auto_completable: false, undo_count: 0, recycle_count: 0, take_from_foundation: true, @@ -353,6 +343,51 @@ impl GameState { } } + /// Whether the player draws one or three cards from the stock per turn. + /// Derived from the underlying session config (set once at deal time). + pub fn draw_mode(&self) -> DrawMode { + match self.session.config().inner.draw_stock { + DrawStockConfig::DrawOne => DrawMode::DrawOne, + DrawStockConfig::DrawThree => DrawMode::DrawThree, + } + } + + /// Total moves made this game (draws, recycles, and card moves), derived + /// from the session's instruction history length. + pub fn move_count(&self) -> u32 { + #[cfg(feature = "test-support")] + if let Some(ref state) = self.test_pile_state + && let Some(count) = state.move_count + { + return count; + } + Self::u32_from_len(self.session.history().len()) + } + + /// True once all 52 cards are on the foundations. No further moves are + /// accepted. Derived from the session win state. + pub fn is_won(&self) -> bool { + #[cfg(feature = "test-support")] + if let Some(ref state) = self.test_pile_state + && let Some(won) = state.won + { + return won; + } + self.check_win() + } + + /// True when the game can be completed without further player input + /// (and is not already won). Derived from the session state. + pub fn is_auto_completable(&self) -> bool { + #[cfg(feature = "test-support")] + if let Some(ref state) = self.test_pile_state + && let Some(auto) = state.auto_completable + { + return auto; + } + !self.check_win() && self.check_auto_complete() + } + fn new_session(seed: u64, draw_mode: DrawMode) -> Session { Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode)) } @@ -375,7 +410,7 @@ impl GameState { } fn validation_config(&self) -> KlondikeConfig { - KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation) + KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation) } /// Collects the session instruction history as upstream types for schema v4 @@ -529,6 +564,44 @@ impl GameState { self.test_pile_state = None; } + /// Test-support helper: re-deal the current seed under a different draw + /// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need + /// a specific mode use this instead of mutating a field. + #[cfg(feature = "test-support")] + pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) { + self.session = Self::new_session(self.seed, draw_mode); + } + + /// Test-support helper: override the value returned by [`Self::is_won`] + /// without driving the session to a genuine win. + #[cfg(feature = "test-support")] + pub fn set_test_won(&mut self, won: bool) { + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); + state.won = Some(won); + } + + /// Test-support helper: override the value returned by + /// [`Self::is_auto_completable`]. + #[cfg(feature = "test-support")] + pub fn set_test_auto_completable(&mut self, auto_completable: bool) { + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); + state.auto_completable = Some(auto_completable); + } + + /// Test-support helper: override the value returned by + /// [`Self::move_count`] without applying real moves. + #[cfg(feature = "test-support")] + pub fn set_test_move_count(&mut self, move_count: u32) { + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); + state.move_count = Some(move_count); + } + /// Test-support helper: override face-down stock cards returned by /// [`Self::stock_cards`]. #[cfg(feature = "test-support")] @@ -623,7 +696,7 @@ impl GameState { let next_count = self.recycle_count.saturating_add(1); let penalty = KlondikeAdapter::score_for_recycle_with_mode( next_count, - self.draw_mode == DrawMode::DrawThree, + self.draw_mode() == DrawMode::DrawThree, self.mode, ); (penalty, true) @@ -800,7 +873,7 @@ impl GameState { /// Draw cards from stock to waste. When stock is empty, recycles waste back to stock. pub fn draw(&mut self) -> Result<(), MoveError> { - if self.is_won { + if self.is_won() { return Err(MoveError::GameAlreadyWon); } @@ -823,7 +896,6 @@ impl GameState { self.recycle_count = self.recycle_count.saturating_add(1); } self.score = (self.score + score_delta).max(0); - self.move_count = Self::u32_from_len(self.session.history().len()); Ok(()) } @@ -834,7 +906,7 @@ impl GameState { to: KlondikePile, count: usize, ) -> Result<(), MoveError> { - if self.is_won { + if self.is_won() { return Err(MoveError::GameAlreadyWon); } if from == to { @@ -869,15 +941,12 @@ impl GameState { self.session.process_instruction(instruction); self.score = (self.score + score_delta).max(0); - self.move_count = Self::u32_from_len(self.session.history().len()); - self.is_won = self.check_win(); - self.is_auto_completable = !self.is_won && self.check_auto_complete(); Ok(()) } /// Restore the most recent undo snapshot and apply the undo score penalty (-15). pub fn undo(&mut self) -> Result<(), MoveError> { - if self.is_won { + if self.is_won() { return Err(MoveError::GameAlreadyWon); } if self.mode == GameMode::Challenge { @@ -905,9 +974,6 @@ impl GameState { // This correctly reverses any recycle or move penalty that was applied // before adding the −15 undo penalty. self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode); - self.move_count = Self::u32_from_len(self.session.history().len()); - self.is_won = self.check_win(); - self.is_auto_completable = !self.is_won && self.check_auto_complete(); self.undo_count = self.undo_count.saturating_add(1); Ok(()) } @@ -925,7 +991,7 @@ impl GameState { /// Returns all currently valid `(from, to, count)` moves. pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> { - if self.is_won { + if self.is_won() { return Vec::new(); } @@ -941,7 +1007,7 @@ impl GameState { /// Returns `true` when `move_cards(from, to, count)` would currently succeed. pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool { - if self.is_won || from == to { + if self.is_won() || from == to { return false; } let from_pile = self.pile(*from); @@ -984,7 +1050,7 @@ impl GameState { /// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent. pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> { - if !self.is_auto_completable || self.is_won { + if !self.is_auto_completable() || self.is_won() { return None; } diff --git a/solitaire_core/src/proptest_tests.rs b/solitaire_core/src/proptest_tests.rs index b63ee75..c0a58aa 100644 --- a/solitaire_core/src/proptest_tests.rs +++ b/solitaire_core/src/proptest_tests.rs @@ -89,7 +89,7 @@ fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) { /// available), using `move_idx` to select among the legal options. /// Returns `true` when a move was successfully applied. fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool { - if game.is_won { + if game.is_won() { return false; } let instructions = game.possible_instructions(); @@ -218,10 +218,10 @@ proptest! { // Snapshot the state before the move. let before_ids = all_cards(&game); - let before_move_count = game.move_count; + let before_move_count = game.move_count(); // Apply one move. - if !apply_one_move(&mut game, move_idx) || game.is_won { + if !apply_one_move(&mut game, move_idx) || game.is_won() { return Ok(()); // nothing to undo } @@ -236,7 +236,7 @@ proptest! { "pile layout after undo differs from the pre-move snapshot", ); prop_assert_eq!( - game.move_count, + game.move_count(), before_move_count, "move_count after undo must equal the pre-move value", ); diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index b1a65ce..89342e7 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -39,6 +39,7 @@ keyring-core = { workspace = true } jni = { workspace = true } [dev-dependencies] +solitaire_core = { workspace = true, features = ["test-support"] } solitaire_server = { path = "../solitaire_server" } solitaire_sync = { workspace = true } axum = { workspace = true } diff --git a/solitaire_data/src/solver.rs b/solitaire_data/src/solver.rs index 4f320da..2cc0204 100644 --- a/solitaire_data/src/solver.rs +++ b/solitaire_data/src/solver.rs @@ -93,7 +93,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome // Preserve the historical payload contract: winnable verdicts always carry // a first move. An already-won state therefore returns no recommendation. - if initial.is_won { + if initial.is_won() { return SolveOutcome { result: SolverResult::Unwinnable, first_move: None, @@ -101,7 +101,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome } let solver_config = SessionConfig { - inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation), + inner: KlondikeAdapter::config_for(initial.draw_mode(), initial.take_from_foundation), undo_penalty: 0, solve_moves_budget: config.move_budget, solve_states_budget: config.state_budget as u64, diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 880cb0c..2938d5b 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -85,13 +85,13 @@ pub fn game_state_file_path() -> Option { pub fn load_game_state_from(path: &Path) -> Option { let data = fs::read(path).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?; - if gs.is_won { None } else { Some(gs) } + if gs.is_won() { None } else { Some(gs) } } /// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won` /// because a completed game should not be resumed. pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> { - if gs.is_won { + if gs.is_won() { return Ok(()); } if let Some(parent) = path.parent() { @@ -386,8 +386,8 @@ mod tests { let loaded = load_game_state_from(&path).expect("load"); assert_eq!(loaded.seed, gs.seed); - assert_eq!(loaded.draw_mode, gs.draw_mode); - assert!(!loaded.is_won); + assert_eq!(loaded.draw_mode(), gs.draw_mode()); + assert!(!loaded.is_won()); } #[test] @@ -411,7 +411,7 @@ mod tests { let _ = fs::remove_file(&path); let mut gs = GameState::new(99, DrawMode::DrawOne); - gs.is_won = true; + gs.set_test_won(true); save_game_state_to(&path, &gs).expect("save should be no-op, not error"); assert!( !path.exists(), diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 4018c3e..4062c01 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -819,7 +819,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .draw_mode = solitaire_core::DrawMode::DrawThree; + .set_test_draw_mode(solitaire_core::DrawMode::DrawThree); app.world_mut().write_message(GameWonEvent { score: 500, @@ -868,7 +868,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .draw_mode = solitaire_core::DrawMode::DrawThree; + .set_test_draw_mode(solitaire_core::DrawMode::DrawThree); app.world_mut().write_message(GameWonEvent { score: 500, diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 882e194..5b50584 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -76,14 +76,14 @@ fn detect_auto_complete( } changed.clear(); - if game.0.is_won { + if game.0.is_won() { state.active = false; return; } - if game.0.is_auto_completable && !state.active { + if game.0.is_auto_completable() && !state.active { state.active = true; state.cooldown = AUTO_COMPLETE_INITIAL_DELAY; - } else if !game.0.is_auto_completable && state.active { + } else if !game.0.is_auto_completable() && state.active { // `is_auto_completable` only becomes false after an explicit undo // (which puts a card back on the tableau or re-fills the stock/waste) // or a new-game reset — never as a transient gap during a normal @@ -209,7 +209,7 @@ mod tests { Tableau::Tableau1, vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); - g.is_auto_completable = true; + g.set_test_auto_completable(true); let expected = ( KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Foundation(Foundation::Foundation1), @@ -228,7 +228,7 @@ mod tests { fn detect_activates_when_auto_completable() { let mut app = headless_app(); let mut g = GameState::new(42, DrawMode::DrawOne); - g.is_auto_completable = true; + g.set_test_auto_completable(true); app.world_mut().resource_mut::().0 = g; app.world_mut().write_message(StateChangedEvent); app.update(); @@ -263,7 +263,7 @@ mod tests { let mut app = headless_app(); // Inject a won game state — active should not be set. let (mut gs, _) = seeded_state_with_auto_move(); - gs.is_won = true; + gs.set_test_won(true); app.world_mut().resource_mut::().0 = gs; app.world_mut().write_message(StateChangedEvent); app.update(); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index dc0a75d..e9218c6 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -734,7 +734,7 @@ fn sync_cards( // Without this, the buffer sits at waste_base uncovered during the animation // and its rank/suit peek behind the incoming card. let waste_buffer_id: Option = { - let visible = match game.draw_mode { + let visible = match game.draw_mode() { DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, }; @@ -903,7 +903,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, // while new cards animate in from the stock. Draw-One shows 1; Draw-Three // shows up to 3 fanned in X (matching the standard Klondike presentation). let render_start = if is_waste { - let visible = match game.draw_mode { + let visible = match game.draw_mode() { DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, }; @@ -918,7 +918,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, let mut y_offset = 0.0_f32; let rendered_len = cards[render_start..].len(); for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() { - let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { + let x_offset = if is_waste && matches!(game.draw_mode(), DrawMode::DrawThree) { // When len > visible, slot 0 is a hidden buffer card kept at // x=0 to prevent a flash during the draw tween. When len ≤ // visible (small pile), every card is visible and should fan diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index c0f0038..2b0ff86 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -437,7 +437,7 @@ fn tableau_or_stack_pos( base.x, base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), ) - } else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree { + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree { let pile_len = game.waste_cards().len(); let visible_start = pile_len.saturating_sub(3); let slot = index.saturating_sub(visible_start) as f32; diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index bcb2f24..a168a28 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -408,7 +408,7 @@ fn start_deal_anim( return; } // Only animate a fresh deal (no moves made yet). - if game.0.move_count != 0 { + if game.0.move_count() != 0 { return; } let Some(layout) = layout else { return }; diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 0f3ed05..837b58d 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -154,7 +154,7 @@ impl Plugin for GamePlugin { let saved = path.as_deref().and_then(load_game_state_from); let prompt_worthy = saved .as_ref() - .is_some_and(|g| g.move_count > 0 && !g.is_won); + .is_some_and(|g| g.move_count() > 0 && !g.is_won()); let (initial_state, pending_restore) = if prompt_worthy { ( GameState::new(seed_from_system_time(), DrawMode::DrawOne), @@ -302,7 +302,7 @@ fn tick_elapsed_time( *skip_next_delta = false; return; } - let is_won = game.0.is_won; + let is_won = game.0.is_won(); advance_elapsed( &mut game.0.elapsed_seconds, &mut accumulator, @@ -424,7 +424,7 @@ fn handle_new_game( for ev in new_game.read() { // If an active game is in progress, intercept and show a confirm dialog. // A game is "active" when moves have been made and it is not yet won. - let needs_confirm = game.0.move_count > 0 && !game.0.is_won; + let needs_confirm = game.0.move_count() > 0 && !game.0.is_won(); // Skip confirmation if a ConfirmNewGameScreen already exists (prevents // duplicates) or if the event itself was already confirmed by the // player pressing Y on the modal — without the `confirmed` check the @@ -464,7 +464,7 @@ fn handle_new_game( // where SettingsPlugin is not installed. let draw_mode = settings .as_ref() - .map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode); + .map_or_else(|| game.0.draw_mode(), |s| s.0.draw_mode); let mode = ev.mode.unwrap_or(game.0.mode); // Solver-backed retry: when the player has opted in to @@ -823,7 +823,7 @@ fn handle_draw( if stock.is_empty() { Vec::new() } else { - let draw_count = match game.0.draw_mode { + let draw_count = match game.0.draw_mode() { DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, }; @@ -865,7 +865,7 @@ fn handle_move( path: Option>, ) { for ev in moves.read() { - let was_won = game.0.is_won; + let was_won = game.0.is_won(); // Identify the card that will be exposed (and may flip face-up) by the move. // It's the card just below the bottom of the moving stack in the source pile. let source_cards = pile_cards(&game.0, &ev.from); @@ -910,7 +910,7 @@ fn handle_move( foundation_done.write(FoundationCompletedEvent { slot, suit }); } changed.write(StateChangedEvent); - if !was_won && game.0.is_won { + if !was_won && game.0.is_won() { won.write(GameWonEvent { score: game.0.score, time_seconds: game.0.elapsed_seconds, @@ -986,7 +986,7 @@ pub fn record_replay_on_win( let win_move_index = recording.moves.len().checked_sub(1); let replay = Replay::new( game.0.seed, - game.0.draw_mode, + game.0.draw_mode(), game.0.mode, ev.time_seconds, ev.score, @@ -1093,13 +1093,13 @@ fn check_no_moves( // Despawn game-over overlay whenever moves become available again or game is won. let moves_ok = has_legal_moves(&game.0); - if moves_ok || game.0.is_won { + if moves_ok || game.0.is_won() { for entity in &game_over_screens { commands.entity(entity).despawn(); } } - if game.0.is_won { + if game.0.is_won() { return; } @@ -1248,7 +1248,7 @@ fn auto_save_game_state( // or there's a pending restore the player hasn't answered — saving // the fresh-deal placeholder we seeded GameStateResource with at // startup would clobber the real saved game on disk. - if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 || pending.0.is_some() + if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some() { return; } @@ -1596,7 +1596,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count = 1; + .set_test_move_count(1); // Re-arm the timer past the threshold every frame and pump // updates until the save fires. Caps at 16 iterations — a @@ -1845,7 +1845,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count = 5; + .set_test_move_count(5); app.world_mut().write_message(NewGameRequestEvent { seed: None, mode: None, @@ -1869,7 +1869,7 @@ mod tests { let mut app = test_app_with_input(42); // move_count stays at 0 (fresh game). assert_eq!( - app.world().resource::().0.move_count, + app.world().resource::().0.move_count(), 0, "test assumes a fresh game with no moves" ); @@ -2362,7 +2362,7 @@ mod tests { app.update(); // Game state was reseeded — move_count is 0 on the new game. - assert_eq!(app.world().resource::().0.move_count, 0); + assert_eq!(app.world().resource::().0.move_count(), 0); } #[test] @@ -2436,7 +2436,7 @@ mod tests { // The chosen seed is non-deterministic (system time), // but the new game must have been started cleanly: // move_count back to 0, undo stack empty. - assert_eq!(app.world().resource::().0.move_count, 0); + assert_eq!(app.world().resource::().0.move_count(), 0); assert_eq!( app.world() .resource::() @@ -2496,7 +2496,7 @@ mod tests { ); // New game completed: a fresh deal carries 0 moves. assert_eq!( - app.world().resource::().0.move_count, + app.world().resource::().0.move_count(), 0, "completed new game must be in fresh-deal state", ); diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index fe2fe58..1d68ec8 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -1154,7 +1154,7 @@ fn handle_hint_button( return; } let Some(ref g) = game else { return }; - if g.0.is_won { + if g.0.is_won() { info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string())); return; } @@ -2106,10 +2106,10 @@ fn update_won_previously( let Ok(mut text) = q.single_mut() else { return; }; - let won_before = !game.0.is_won + let won_before = !game.0.is_won() && history.as_ref().is_some_and(|h| { h.0.replays.iter().any(|r| { - r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode + r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode }) }); let next = if won_before { @@ -2279,11 +2279,11 @@ fn update_hud( }; } if let Ok(mut t) = moves_q.single_mut() { - **t = format!("Moves: {}", g.move_count); + **t = format!("Moves: {}", g.move_count()); } if let Ok(mut t) = mode_q.single_mut() { **t = match g.mode { - GameMode::Classic => match g.draw_mode { + GameMode::Classic => match g.draw_mode() { DrawMode::DrawOne => String::new(), DrawMode::DrawThree => "Draw 3".to_string(), }, @@ -2296,7 +2296,7 @@ fn update_hud( // --- Daily challenge constraint (with time-low colour warning) --- if let Ok((mut t, mut color)) = challenge_q.single_mut() { - if g.is_won { + if g.is_won() { **t = String::new(); } else if let Some(dc) = daily.as_deref() { **t = challenge_hud_text(dc); @@ -2334,7 +2334,7 @@ fn update_hud( // --- Draw-cycle indicator (Draw-Three mode only) --- if let Ok(mut t) = draw_cycle_q.single_mut() { - **t = if g.is_won || g.draw_mode != DrawMode::DrawThree { + **t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree { // Hide when not in Draw-Three or after the game is won. String::new() } else { @@ -2774,7 +2774,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count = 42; + .set_test_move_count(42); app.update(); assert_eq!(read_hud_text::(&mut app), "Moves: 42"); } @@ -2962,7 +2962,7 @@ mod tests { max_time_secs: Some(300), }); // Mark the game as won — HudChallenge should be empty. - app.world_mut().resource_mut::().0.is_won = true; + app.world_mut().resource_mut::().0.set_test_won(true); app.update(); assert_eq!(read_hud_text::(&mut app), ""); } @@ -3012,7 +3012,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count += 1; + .set_test_move_count(1); app.update(); assert_eq!(read_hud_text::(&mut app), "AUTO"); } @@ -3024,7 +3024,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count += 1; + .set_test_move_count(1); app.update(); assert_eq!(read_hud_text::(&mut app), ""); } diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 266cf1e..2904e0b 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -305,7 +305,7 @@ fn handle_keyboard_hint( let Some(ref g) = game else { return }; - if g.0.is_won { + if g.0.is_won() { info_toast.write(InfoToastEvent( "Game won! Press N for a new game".to_string(), )); @@ -1153,7 +1153,7 @@ fn card_position( y_offset -= layout.card_size.y * step; } Vec2::new(base.x, base.y + y_offset) - } else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree { + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree { // In Draw-Three mode the top 3 waste cards are fanned in X to match // card_plugin::card_positions(). Hit-testing must use the same offsets // so clicking the visually rightmost (top) card actually registers. diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index 0d79e51..4a38976 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -340,7 +340,7 @@ fn handle_forfeit_request( if !forfeit_screens.is_empty() { return; } - let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won); + let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won()); if !game_in_progress { toast.write(InfoToastEvent("No game to forfeit".to_string())); return; @@ -1025,7 +1025,7 @@ mod tests { app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.init_resource::>(); let mut game = GameState::new(1, DrawMode::DrawOne); - game.is_won = true; + game.set_test_won(true); app.insert_resource(GameStateResource(game)); app.update(); diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index c753c9f..01272a9 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -63,7 +63,7 @@ impl PendingHintTask { /// Spawn a new solver task for `state` with `config`. Drops any /// previously in-flight task first (cancel-on-replace). pub fn spawn(&mut self, state: GameState, config: SolverConfig) { - let move_count_at_spawn = state.move_count; + let move_count_at_spawn = state.move_count(); let handle = AsyncComputeTaskPool::get().spawn(async move { let outcome = try_solve_from_state(&state, &config); match outcome.result { @@ -156,7 +156,7 @@ pub fn poll_pending_hint_task( pending.inner = None; let Some(g) = game else { return }; - if g.0.move_count != move_count_at_spawn { + if g.0.move_count() != move_count_at_spawn { return; } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index dd6aed5..834a8b8 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -534,7 +534,7 @@ fn update_stats_on_win( let prev_streak = stats.0.win_streak_current; stats .0 - .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode); + .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode()); // Per-mode best score / fastest win — additive on top of the // lifetime totals tracked by `update_on_win`. TimeAttack is a // no-op inside the helper because it has its own session-level @@ -588,7 +588,7 @@ fn update_stats_on_new_game( mut toast: MessageWriter, ) { for _ in events.read() { - if game.0.move_count > 0 && !game.0.is_won { + if game.0.move_count() > 0 && !game.0.is_won() { let streak = stats.0.win_streak_current; stats.0.record_abandoned(); persist(&path, &stats.0, "abandoned game"); @@ -614,7 +614,7 @@ fn handle_forfeit( mut auto_complete: Option>, ) { for _ in events.read() { - if game.0.move_count > 0 && !game.0.is_won { + if game.0.move_count() > 0 && !game.0.is_won() { let streak = stats.0.win_streak_current; stats.0.record_abandoned(); persist(&path, &stats.0, "forfeit"); @@ -1327,7 +1327,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .draw_mode = solitaire_core::DrawMode::DrawThree; + .set_test_draw_mode(solitaire_core::DrawMode::DrawThree); app.world_mut().write_message(GameWonEvent { score: 500, @@ -1373,7 +1373,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count = 3; + .set_test_move_count(3); app.world_mut().write_message(NewGameRequestEvent { seed: Some(999), @@ -1699,7 +1699,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count = 1; + .set_test_move_count(1); app.world_mut().write_message(ForfeitEvent); app.update(); @@ -1725,7 +1725,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .move_count = 1; + .set_test_move_count(1); app.world_mut().write_message(ForfeitEvent); app.update(); diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index c978e96..64f7814 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -331,7 +331,7 @@ fn push_replay_on_win( } let replay = Replay::new( game.0.seed, - game.0.draw_mode, + game.0.draw_mode(), game.0.mode, ev.time_seconds, ev.score, diff --git a/solitaire_engine/src/time_attack_plugin.rs b/solitaire_engine/src/time_attack_plugin.rs index 68edc66..97a8910 100644 --- a/solitaire_engine/src/time_attack_plugin.rs +++ b/solitaire_engine/src/time_attack_plugin.rs @@ -182,7 +182,7 @@ fn advance_time_attack( // No shared screen-state enum currently covers every overlay. Pause the // countdown whenever gameplay is blocked by a modal, the pause flag, or a // just-won board state. - if paused.is_some_and(|p| p.0) || game.0.is_won || !modal_scrims.is_empty() { + if paused.is_some_and(|p| p.0) || game.0.is_won() || !modal_scrims.is_empty() { return; } session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0); diff --git a/solitaire_engine/src/weekly_goals_plugin.rs b/solitaire_engine/src/weekly_goals_plugin.rs index ca5cce3..40ae69e 100644 --- a/solitaire_engine/src/weekly_goals_plugin.rs +++ b/solitaire_engine/src/weekly_goals_plugin.rs @@ -83,7 +83,7 @@ fn evaluate_weekly_goals( let ctx = WeeklyGoalContext { time_seconds: ev.time_seconds, used_undo: game.0.undo_count > 0, - draw_mode: game.0.draw_mode, + draw_mode: game.0.draw_mode(), }; for def in WEEKLY_GOALS { if !def.matches(&ctx) { diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index bb24d4a..70d895b 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -194,8 +194,8 @@ impl ReplayPlayer { step_idx: self.step_idx, total_steps: self.moves.len(), score: self.game.score, - move_count: self.game.move_count, - is_won: self.game.is_won, + move_count: self.game.move_count(), + is_won: self.game.is_won(), stock: self .game .stock_cards() @@ -359,7 +359,7 @@ fn pile_name(pile: KlondikePile) -> String { } fn can_stock_click(game: &GameState) -> bool { - !(game.is_won || game.stock_cards().is_empty() && game.waste_cards().is_empty()) + !(game.is_won() || game.stock_cards().is_empty() && game.waste_cards().is_empty()) } fn legal_moves_for_game(game: &GameState) -> Vec { @@ -450,7 +450,7 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb false }); - let soft_lock = !game.is_won && stock.is_empty() && waste.is_empty() && legal_moves.is_empty(); + let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty(); let state_ok = duplicate_card_ids.is_empty() && missing_card_ids.is_empty() @@ -496,9 +496,9 @@ impl SolitaireGame { }; GameSnapshot { score: self.game.score, - move_count: self.game.move_count, - is_won: self.game.is_won, - is_auto_completable: self.game.is_auto_completable, + move_count: self.game.move_count(), + is_won: self.game.is_won(), + is_auto_completable: self.game.is_auto_completable(), has_moves, undo_count: self.game.undo_count, undo_stack_len: self.game.undo_stack_len(), @@ -582,7 +582,7 @@ impl SolitaireGame { fn replay_moves_native(&self) -> Result, String> { let mut replay_game = - GameState::new_with_mode(self.game.seed, self.game.draw_mode, self.game.mode); + GameState::new_with_mode(self.game.seed, self.game.draw_mode(), self.game.mode); let mut replay_moves = Vec::new(); for instruction in self.game.instruction_history() { @@ -668,7 +668,7 @@ impl SolitaireGame { let state_json = serde_json::to_string(&self.game).unwrap_or_default(); DebugSnapshot { seed: self.game.seed, - draw_mode: self.game.draw_mode, + draw_mode: self.game.draw_mode(), mode: self.game.mode, state: self.snap(), legal_moves, @@ -822,7 +822,7 @@ impl SolitaireGame { /// waste by calling `draw()` so the next step can try again. Returns the /// post-move snapshot, or `null` when no progress is possible. pub fn auto_complete_step(&mut self) -> JsValue { - if !self.game.is_auto_completable { + if !self.game.is_auto_completable() { return JsValue::NULL; } if let Some((from, to)) = self.game.next_auto_complete_move() {