diff --git a/build_wasm.sh b/build_wasm.sh index a855bd7..46d0be6 100755 --- a/build_wasm.sh +++ b/build_wasm.sh @@ -65,7 +65,11 @@ wasm-bindgen \ # wasm-opt passes are skipped silently when the tool is not installed. if command -v wasm-opt &> /dev/null; then echo "Running wasm-opt on canvas_bg.wasm..." - wasm-opt -Oz \ + # Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and + # complex memory patterns that wasm-opt -Oz can miscompile, resulting + # in a grey screen on first load. -O2 is speed-optimised and avoids + # the size-focused transforms that trigger the regression. + wasm-opt -O2 \ -o "$OUT_DIR/canvas_bg.wasm" \ "$OUT_DIR/canvas_bg.wasm" else diff --git a/solitaire_core/Cargo.toml b/solitaire_core/Cargo.toml index 5145fa4..f44f2c0 100644 --- a/solitaire_core/Cargo.toml +++ b/solitaire_core/Cargo.toml @@ -4,6 +4,10 @@ version.workspace = true license.workspace = true edition.workspace = true +[features] +default = [] +test-support = [] + [dependencies] serde = { workspace = true } thiserror = { workspace = true } diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs index a734d7a..c55a7d1 100644 --- a/solitaire_core/src/card.rs +++ b/solitaire_core/src/card.rs @@ -111,6 +111,28 @@ pub struct Card { pub face_up: bool, } +impl Card { + /// Creates a card with explicit face orientation. + pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self { + Self { + id, + suit, + rank, + face_up, + } + } + + /// Creates a face-up card. + pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self { + Self::new(id, suit, rank, true) + } + + /// Creates a face-down card. + pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self { + Self::new(id, suit, rank, false) + } +} + #[cfg(test)] mod tests { use super::*; @@ -166,4 +188,19 @@ mod tests { assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red()); assert!(Suit::Clubs.is_black() && Suit::Spades.is_black()); } + + #[test] + fn card_constructors_set_fields() { + let up = Card::face_up(10, Suit::Spades, Rank::Queen); + assert_eq!(up.id, 10); + assert_eq!(up.suit, Suit::Spades); + assert_eq!(up.rank, Rank::Queen); + assert!(up.face_up); + + let down = Card::face_down(11, Suit::Diamonds, Rank::King); + assert_eq!(down.id, 11); + assert_eq!(down.suit, Suit::Diamonds); + assert_eq!(down.rank, Rank::King); + assert!(!down.face_up); + } } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index d9bbfb2..f143e9f 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,6 +1,11 @@ -use crate::card::{Card, Rank}; +use crate::card::Card; use crate::error::MoveError; -use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction}; +use crate::klondike_adapter::{ + KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus, + foundation_from_slot as adapter_foundation_from_slot, + skip_cards_from_count as adapter_skip_cards_from_count, + tableau_from_index as adapter_tableau_from_index, +}; use card_game::{Game, Session, SessionConfig}; use klondike::{ DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, @@ -97,6 +102,7 @@ struct PersistedGameState { pub saved_moves: Vec, } +#[cfg(feature = "test-support")] /// Test-only override state that shadows the real session pile data. /// /// When `test_pile_state` on `GameState` is `Some`, every pile read method @@ -143,8 +149,8 @@ pub struct GameState { pub take_from_foundation: bool, /// Save-file schema version. pub schema_version: u32, - pub adapter: KlondikeAdapter, pub(crate) session: Session, + #[cfg(feature = "test-support")] /// Test pile overrides. Always `None` in production runtime code. pub test_pile_state: Option, } @@ -165,9 +171,8 @@ impl PartialEq for GameState { && self.schema_version == other.schema_version && self.stock_cards() == other.stock_cards() && self.waste_cards() == other.waste_cards() - && (0..4_u8).all(|slot| { - self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok() - }) + && (0..4_u8) + .all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()) && (0..7_usize).all(|index| { let Ok(tableau) = Self::tableau_from_index(index) else { return false; @@ -221,15 +226,15 @@ impl<'de> Deserialize<'de> for GameState { recycle_count: persisted.recycle_count, take_from_foundation: persisted.take_from_foundation, schema_version: persisted.schema_version, - adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation), session: Self::new_session(persisted.seed, persisted.draw_mode), + #[cfg(feature = "test-support")] test_pile_state: None, }; let replay_config = Self::replay_config(game.draw_mode); for saved in persisted.saved_moves { - let instruction = KlondikeInstruction::try_from(saved) - .map_err(serde::de::Error::custom)?; + let instruction = + KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?; if !game .session .state() @@ -271,8 +276,8 @@ impl GameState { recycle_count: 0, take_from_foundation: true, schema_version: GAME_STATE_SCHEMA_VERSION, - adapter: KlondikeAdapter::new(draw_mode, true), session: Self::new_session(seed, draw_mode), + #[cfg(feature = "test-support")] test_pile_state: None, } } @@ -290,15 +295,11 @@ impl GameState { } fn replay_config(draw_mode: DrawMode) -> KlondikeConfig { - KlondikeAdapter::new(draw_mode, true) - .klondike_config() - .clone() + KlondikeAdapter::config_for(draw_mode, true) } fn validation_config(&self) -> KlondikeConfig { - KlondikeAdapter::new(self.draw_mode, self.take_from_foundation) - .klondike_config() - .clone() + KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation) } fn saved_moves(&self) -> Vec { @@ -309,6 +310,14 @@ impl GameState { .collect() } + /// Returns the deterministic instruction history for the current deal. + /// + /// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this + /// sequence is sufficient to replay the game state exactly. + pub fn instruction_history(&self) -> Vec { + self.saved_moves() + } + fn u32_from_len(len: usize) -> u32 { if len > u32::MAX as usize { u32::MAX @@ -332,6 +341,7 @@ impl GameState { } pub fn stock_cards(&self) -> Vec { + #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(ref cards) = state.stock { @@ -342,6 +352,7 @@ impl GameState { } pub fn waste_cards(&self) -> Vec { + #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state && let Some(ref cards) = state.waste { @@ -352,6 +363,7 @@ impl GameState { } pub fn pile(&self, pile: KlondikePile) -> Vec { + #[cfg(feature = "test-support")] if let Some(ref state) = self.test_pile_state { match pile { KlondikePile::Stock => { @@ -385,11 +397,17 @@ impl GameState { } KlondikePile::Tableau(tableau) => { let mut cards = Self::cards_with_face( - state.tableau_face_down_cards(tableau).iter().map(card_from_kl), + state + .tableau_face_down_cards(tableau) + .iter() + .map(card_from_kl), false, ); cards.extend(Self::cards_with_face( - state.tableau_face_up_cards(tableau).iter().map(card_from_kl), + state + .tableau_face_up_cards(tableau) + .iter() + .map(card_from_kl), true, )); cards @@ -398,26 +416,11 @@ impl GameState { } pub fn tableau_from_index(index: usize) -> Result { - match index { - 0 => Ok(Tableau::Tableau1), - 1 => Ok(Tableau::Tableau2), - 2 => Ok(Tableau::Tableau3), - 3 => Ok(Tableau::Tableau4), - 4 => Ok(Tableau::Tableau5), - 5 => Ok(Tableau::Tableau6), - 6 => Ok(Tableau::Tableau7), - _ => Err(MoveError::InvalidSource), - } + adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource) } pub fn foundation_from_slot(slot: u8) -> Result { - match slot { - 0 => Ok(Foundation::Foundation1), - 1 => Ok(Foundation::Foundation2), - 2 => Ok(Foundation::Foundation3), - 3 => Ok(Foundation::Foundation4), - _ => Err(MoveError::InvalidDestination), - } + adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination) } pub fn foundation_cards(&self, slot: u8) -> Result, MoveError> { @@ -425,39 +428,65 @@ impl GameState { Ok(self.pile(KlondikePile::Foundation(foundation))) } + /// Returns `true` when test-only pile overrides are active. + #[cfg(feature = "test-support")] + pub fn has_test_pile_overrides(&self) -> bool { + self.test_pile_state.is_some() + } + + /// Returns `false` in production builds where test pile overrides are absent. + #[cfg(not(feature = "test-support"))] + pub const fn has_test_pile_overrides(&self) -> bool { + false + } + /// Test-support helper: clear all pile overrides so reads come from the /// underlying klondike session again. + #[cfg(feature = "test-support")] pub fn clear_test_pile_overrides(&mut self) { self.test_pile_state = None; } /// Test-support helper: override face-down stock cards returned by /// [`Self::stock_cards`]. + #[cfg(feature = "test-support")] pub fn set_test_stock_cards(&mut self, cards: Vec) { - let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); state.stock = Some(cards); } /// Test-support helper: override face-up waste cards returned by /// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`. + #[cfg(feature = "test-support")] pub fn set_test_waste_cards(&mut self, cards: Vec) { - let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); state.waste = Some(cards); } /// Test-support helper: override cards for a specific tableau column. + #[cfg(feature = "test-support")] pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec) { - let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); state.tableau.insert(tableau, cards); } /// Test-support helper: override cards for a specific foundation pile. + #[cfg(feature = "test-support")] pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec) { - let state = self.test_pile_state.get_or_insert_with(TestPileState::default); + let state = self + .test_pile_state + .get_or_insert_with(TestPileState::default); state.foundation.insert(foundation, cards); } /// Test-support helper: override cards for a specific pile. + #[cfg(feature = "test-support")] pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec) { match pile { KlondikePile::Stock => { @@ -479,22 +508,8 @@ impl GameState { } fn skip_cards_from_usize(skip: usize) -> Result { - match skip { - 0 => Ok(SkipCards::Skip0), - 1 => Ok(SkipCards::Skip1), - 2 => Ok(SkipCards::Skip2), - 3 => Ok(SkipCards::Skip3), - 4 => Ok(SkipCards::Skip4), - 5 => Ok(SkipCards::Skip5), - 6 => Ok(SkipCards::Skip6), - 7 => Ok(SkipCards::Skip7), - 8 => Ok(SkipCards::Skip8), - 9 => Ok(SkipCards::Skip9), - 10 => Ok(SkipCards::Skip10), - 11 => Ok(SkipCards::Skip11), - 12 => Ok(SkipCards::Skip12), - _ => Err(MoveError::RuleViolation("invalid tableau card count".into())), - } + adapter_skip_cards_from_count(skip) + .ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into())) } fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool { @@ -539,9 +554,8 @@ impl GameState { })) } (KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err( - MoveError::RuleViolation( - "cannot move between foundation slots".into(), - )), + MoveError::RuleViolation("cannot move between foundation slots".into()), + ), (KlondikePile::Stock, KlondikePile::Tableau(dst)) => { if count != 1 { return Err(MoveError::RuleViolation( @@ -595,7 +609,9 @@ impl GameState { ) -> Option<(KlondikePile, KlondikePile, usize)> { let state = self.session.state().state().state(); match instruction { - KlondikeInstruction::RotateStock => None, + KlondikeInstruction::RotateStock => { + Some((KlondikePile::Stock, KlondikePile::Stock, 1)) + } KlondikeInstruction::DstFoundation(dst_foundation) => { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { return None; @@ -605,12 +621,17 @@ impl GameState { KlondikePile::Stock => KlondikePile::Stock, KlondikePile::Foundation(_) => return None, }; - Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1)) + Some(( + source, + KlondikePile::Foundation(dst_foundation.foundation), + 1, + )) } KlondikeInstruction::DstTableau(dst_tableau) => { let (source, count) = match dst_tableau.src { KlondikePileStack::Tableau(tableau_stack) => { - let face_up_count = state.tableau_face_up_cards(tableau_stack.tableau).len(); + let face_up_count = + state.tableau_face_up_cards(tableau_stack.tableau).len(); let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?; if count == 0 { return None; @@ -633,16 +654,15 @@ impl GameState { return Err(MoveError::GameAlreadyWon); } - let stock_empty = self - .stock_cards() - .is_empty(); + let stock_empty = self.stock_cards().is_empty(); let waste_empty = self.waste_cards().is_empty(); if stock_empty && waste_empty { return Err(MoveError::StockEmpty); } let recycling = stock_empty && !waste_empty; - self.session.process_instruction(KlondikeInstruction::RotateStock); + self.session + .process_instruction(KlondikeInstruction::RotateStock); if recycling { self.recycle_count = self.recycle_count.saturating_add(1); @@ -692,9 +712,9 @@ impl GameState { return Err(MoveError::RuleViolation("move violates rules".into())); } - let score_delta = self.adapter.score_for_move_with_mode(&from, &to, self.mode); + let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode); let flip_bonus = if self.will_flip_tableau_source(from, count) { - self.adapter.score_for_flip_with_mode(self.mode) + KlondikeAdapter::score_for_flip_with_mode(self.mode) } else { 0 }; @@ -743,8 +763,7 @@ impl GameState { return false; } let suit = pile[0].suit; - pile - .iter() + pile.iter() .enumerate() .all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1) } @@ -779,18 +798,14 @@ impl GameState { self.session .state() .state() - .possible_instructions(&config) + .get_sorted_moves(&config) + .into_iter() .filter_map(|instruction| self.instruction_to_move(instruction)) .collect() } /// Returns `true` when `move_cards(from, to, count)` would currently succeed. - pub fn can_move_cards( - &self, - from: &KlondikePile, - to: &KlondikePile, - count: usize, - ) -> bool { + pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool { if self.is_won || from == to { return false; } @@ -838,62 +853,21 @@ impl GameState { return None; } - let waste = KlondikePile::Stock; - if let Some(slot) = self - .waste_cards() - .last() - .and_then(|card| self.foundation_slot_for(card)) - { - return Some((waste, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?))); - } - - for index in 0..7 { - let tableau = KlondikePile::Tableau(Self::tableau_from_index(index).ok()?); - if let Some(slot) = self - .pile(tableau) - .last() - .and_then(|card| self.foundation_slot_for(card)) - { - return Some((tableau, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?))); - } - } - None - } - - fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool { - let Ok(pile) = self.foundation_cards(slot) else { - return false; - }; - match pile.last() { - Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank), - None => card.rank == Rank::Ace, - } - } - - fn foundation_slot_for(&self, card: &Card) -> Option { - let mut candidate = None; - let mut empty_slot = None; - for slot in 0..4_u8 { - let Ok(pile) = self.foundation_cards(slot) else { - continue; - }; - if pile.is_empty() { - if empty_slot.is_none() { - empty_slot = Some(slot); + self.possible_instructions() + .into_iter() + .find_map(|(from, to, count)| { + if count != 1 { + return None; } - } else if pile.first().map(|c| c.suit) == Some(card.suit) { - candidate = Some(slot); - break; - } - } - let target = candidate.or_else(|| { - if card.rank == Rank::Ace { - empty_slot - } else { - None - } - }); - target.filter(|&slot| self.can_place_on_foundation_slot(card, slot)) + if matches!(from, KlondikePile::Foundation(_)) { + return None; + } + if matches!(to, KlondikePile::Foundation(_)) { + Some((from, to)) + } else { + None + } + }) } /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). @@ -980,7 +954,8 @@ mod tests { assert!( game.possible_instructions() .iter() - .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_))) + .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) + || !matches!(t, KlondikePile::Tableau(_))) ); assert!(game.move_cards(from, to, 1).is_err()); } diff --git a/solitaire_core/src/pile.rs b/solitaire_core/src/pile.rs index 1e0967b..e18ebf6 100644 --- a/solitaire_core/src/pile.rs +++ b/solitaire_core/src/pile.rs @@ -49,18 +49,8 @@ mod tests { #[test] fn pile_top_returns_last_card() { let mut pile = Pile::new(KlondikePile::Stock); - pile.cards.push(Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }); - pile.cards.push(Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Two, - face_up: true, - }); + pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace)); + pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two)); assert_eq!(pile.top().unwrap().id, 1); } @@ -79,30 +69,15 @@ mod tests { #[test] fn claimed_suit_is_none_for_non_foundation() { let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1)); - pile.cards.push(Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }); + pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace)); assert!(pile.claimed_suit().is_none()); } #[test] fn claimed_suit_returns_bottom_card_suit() { let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3)); - pile.cards.push(Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }); - pile.cards.push(Card { - id: 1, - suit: Suit::Hearts, - rank: Rank::Two, - face_up: true, - }); + pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace)); + pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two)); assert_eq!(pile.claimed_suit(), Some(Suit::Hearts)); } } diff --git a/solitaire_core/src/solver.rs b/solitaire_core/src/solver.rs index e3ef85e..5691eb2 100644 --- a/solitaire_core/src/solver.rs +++ b/solitaire_core/src/solver.rs @@ -1,14 +1,13 @@ -//! Klondike solvability checker using deterministic DFS over [`GameState`]. +//! Klondike solvability checker using upstream `card_game::Session::solve()`. //! //! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"** //! toggle and by the hint system when it wants the first move on a winning path. -use std::collections::HashSet; +use card_game::{Session, SessionConfig, SolveError, StateSnapshot}; +use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack}; -use klondike::{Foundation, KlondikePile, Tableau}; - -use crate::card::Card; -use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState}; +use crate::game_state::{DrawMode, GameState}; +use crate::klondike_adapter::KlondikeAdapter; /// Verdict returned by [`try_solve`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -59,14 +58,6 @@ pub struct SolveOutcome { pub first_move: Option, } -#[derive(Debug, Clone)] -struct DfsFrame { - state: GameState, - moves: Vec, - next_index: usize, - first_move: Option, -} - /// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`. pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult { try_solve_with_first_move(seed, draw_mode, config).result @@ -105,6 +96,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome first_move: None, }; } + // Preserve the historical payload contract: winnable verdicts always carry // a first move. An already-won state therefore returns no recommendation. if initial.is_won { @@ -114,174 +106,85 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome }; } - let mut visited: HashSet> = HashSet::with_capacity(effective_state_budget.min(16_384)); - visited.insert(state_key(initial)); + let solver_config = SessionConfig { + inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation), + undo_penalty: 0, + solve_moves_budget: effective_move_budget, + solve_states_budget: effective_state_budget as u64, + }; + let solver_session = Session::new(initial.session.state().state().clone(), solver_config); - let mut states_visited: usize = 1; - let mut moves_considered: u64 = 0; - let mut saw_inconclusive = false; - - let mut stack = vec![DfsFrame { - state: initial.clone(), - moves: candidate_moves(initial), - next_index: 0, - first_move: None, - }]; - - while let Some(frame) = stack.last_mut() { - if frame.state.is_won { - if let Some(first_move) = frame.first_move.clone() { - return SolveOutcome { + match solver_session.solve() { + Ok(Some(solution)) => { + let first_move = solution + .raw_solution() + .iter() + .find_map(snapshot_to_solver_move); + if let Some(first_move) = first_move { + SolveOutcome { result: SolverResult::Winnable, first_move: Some(first_move), - }; + } + } else { + SolveOutcome { + result: SolverResult::Inconclusive, + first_move: None, + } } - stack.pop(); - continue; } - - if frame.next_index >= frame.moves.len() { - stack.pop(); - continue; - } - - if moves_considered >= effective_move_budget { - saw_inconclusive = true; - break; - } - - let next_move = frame.moves[frame.next_index].clone(); - frame.next_index += 1; - moves_considered = moves_considered.saturating_add(1); - - let Some(next_state) = apply_solver_move(&frame.state, &next_move) else { - continue; - }; - - let key = state_key(&next_state); - if visited.contains(&key) { - continue; - } - if states_visited >= effective_state_budget { - saw_inconclusive = true; - continue; - } - - visited.insert(key); - states_visited = states_visited.saturating_add(1); - - let first_move = frame - .first_move - .clone() - .or_else(|| Some(next_move.clone())); - let child_moves = candidate_moves(&next_state); - stack.push(DfsFrame { - state: next_state, - moves: child_moves, - next_index: 0, - first_move, - }); - } - - if saw_inconclusive { - SolveOutcome { - result: SolverResult::Inconclusive, - first_move: None, - } - } else { - SolveOutcome { + Ok(None) => SolveOutcome { result: SolverResult::Unwinnable, first_move: None, - } + }, + Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome { + result: SolverResult::Inconclusive, + first_move: None, + }, } } -fn candidate_moves(game: &GameState) -> Vec { - let mut out: Vec = game - .possible_instructions() - .into_iter() - .map(|(source, dest, count)| SolverMove { - source, - dest, - count, - }) - .collect(); - - if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() { - out.push(SolverMove { +fn snapshot_to_solver_move(snapshot: &StateSnapshot) -> Option { + let source_state = snapshot.state().state(); + match *snapshot.instruction() { + KlondikeInstruction::RotateStock => Some(SolverMove { source: KlondikePile::Stock, dest: KlondikePile::Stock, count: 1, - }); - } + }), + KlondikeInstruction::DstFoundation(dst_foundation) => { + let source = match dst_foundation.src { + KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau), + KlondikePile::Stock => KlondikePile::Stock, + KlondikePile::Foundation(_) => return None, + }; + Some(SolverMove { + source, + dest: KlondikePile::Foundation(dst_foundation.foundation), + count: 1, + }) + } + KlondikeInstruction::DstTableau(dst_tableau) => { + let (source, count) = match dst_tableau.src { + KlondikePileStack::Tableau(tableau_stack) => { + let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len(); + let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?; + if count == 0 { + return None; + } + (KlondikePile::Tableau(tableau_stack.tableau), count) + } + KlondikePileStack::Stock => (KlondikePile::Stock, 1), + KlondikePileStack::Foundation(foundation) => { + (KlondikePile::Foundation(foundation), 1) + } + }; - out -} - -fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option { - let mut next = game.clone(); - if mv.source == KlondikePile::Stock && mv.dest == KlondikePile::Stock { - next.draw().ok()?; - } else { - next.move_cards(mv.source, mv.dest, mv.count).ok()?; - } - Some(next) -} - -fn state_key(game: &GameState) -> Vec { - let mut key = Vec::with_capacity(96); - - append_pile_key(&game.stock_cards(), &mut key); - append_pile_key(&game.waste_cards(), &mut key); - - for foundation in [ - Foundation::Foundation1, - Foundation::Foundation2, - Foundation::Foundation3, - Foundation::Foundation4, - ] { - append_pile_key(&game.pile(KlondikePile::Foundation(foundation)), &mut key); - } - - for tableau in [ - Tableau::Tableau1, - Tableau::Tableau2, - Tableau::Tableau3, - Tableau::Tableau4, - Tableau::Tableau5, - Tableau::Tableau6, - Tableau::Tableau7, - ] { - append_pile_key(&game.pile(KlondikePile::Tableau(tableau)), &mut key); - } - - key.push(game.draw_mode as u32); - key.push(mode_key(game.mode)); - key.push(u32::from(game.take_from_foundation)); - key -} - -fn append_pile_key(cards: &[Card], key: &mut Vec) { - key.push(cards.len() as u32); - for card in cards { - key.push((card.id << 1) | u32::from(card.face_up)); - } -} - -fn mode_key(mode: GameMode) -> u32 { - match mode { - GameMode::Classic => 0, - GameMode::Zen => 1, - GameMode::Challenge => 2, - GameMode::TimeAttack => 3, - GameMode::Difficulty(level) => match level { - DifficultyLevel::Easy => 10, - DifficultyLevel::Medium => 11, - DifficultyLevel::Hard => 12, - DifficultyLevel::Expert => 13, - DifficultyLevel::Grandmaster => 14, - DifficultyLevel::Random => 15, - }, + Some(SolverMove { + source, + dest: KlondikePile::Tableau(dst_tableau.tableau), + count, + }) + } } } diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 0dff645..6441b55 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -3,10 +3,10 @@ //! All saves go through `filename.json.tmp` → `rename()` so a crash or power //! loss during a write never corrupts the saved data. +use chrono::Utc; use std::fs; use std::io; use std::path::{Path, PathBuf}; -use chrono::Utc; use serde::{Deserialize, Serialize}; use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState}; diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index e2d52a6..d2a5122 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -12,9 +12,9 @@ //! without matching on [`SyncBackend`] anywhere else in the codebase. use async_trait::async_trait; -use solitaire_sync::{SyncPayload, SyncResponse}; #[cfg(not(target_arch = "wasm32"))] use solitaire_sync::{ChallengeGoal, LeaderboardEntry}; +use solitaire_sync::{SyncPayload, SyncResponse}; use crate::{SyncError, SyncProvider}; diff --git a/solitaire_server/web/pkg/canvas_bg.wasm b/solitaire_server/web/pkg/canvas_bg.wasm index 4b756f3..a7d2130 100644 Binary files a/solitaire_server/web/pkg/canvas_bg.wasm and b/solitaire_server/web/pkg/canvas_bg.wasm differ diff --git a/solitaire_server/web/pkg/solitaire_wasm.js b/solitaire_server/web/pkg/solitaire_wasm.js index 642842e..0cb1eec 100644 --- a/solitaire_server/web/pkg/solitaire_wasm.js +++ b/solitaire_server/web/pkg/solitaire_wasm.js @@ -122,6 +122,67 @@ export class SolitaireGame { const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr); return ret; } + /** + * Applies the legal move currently at `index` from `debug_legal_moves()`. + * @param {number} index + * @returns {any} + */ + debug_apply_legal_move(index) { + const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index); + return ret; + } + /** + * Applies one debug move encoded as JSON. + * + * JSON must match [`DebugMove`], for example: + * `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or + * `{"kind":"stock_click"}`. + * @param {string} move_json + * @returns {any} + */ + debug_apply_move_json(move_json) { + const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0); + return ret; + } + /** + * Returns all currently-legal debug moves as a JS array. + * + * Includes [`DebugMove::StockClick`] when stock interaction is legal. + * @returns {any} + */ + debug_legal_moves() { + const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); + } + /** + * Returns deterministic instruction history for the current game. + * + * Together with `seed()` and `draw_mode`, this history is replayable. + * @returns {any} + */ + debug_move_history() { + const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); + } + /** + * Returns a comprehensive debug snapshot for automated verification. + * @returns {any} + */ + debug_snapshot() { + const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); + } /** * Draw from stock to waste (or recycle waste → stock when stock is empty). * Returns `{ok, error?, snapshot?}`. @@ -182,6 +243,21 @@ export class SolitaireGame { SolitaireGameFinalization.register(this, this.__wbg_ptr, this); return this; } + /** + * Returns replay moves encoded in the `solitaire_data::Replay` wire format. + * + * This derives move counts from the deterministic instruction history and + * validates that the resulting move stream replays cleanly from the current + * game's seed/draw mode. + * @returns {any} + */ + replay_moves() { + const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); + } /** * The seed used to deal this game. * @returns {number} diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm index 45209df..67d6dd8 100644 Binary files a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm and b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm differ diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index fab0ce7..ab3a5f5 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize}; use solitaire_core::card::Suit; use solitaire_core::error::MoveError; use solitaire_core::game_state::{DrawMode, GameMode, GameState}; -use solitaire_core::klondike_adapter::SavedKlondikePile; +use solitaire_core::klondike_adapter::{ + SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index, +}; use wasm_bindgen::prelude::*; /// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic @@ -55,7 +57,7 @@ pub struct Replay { } /// JS-friendly snapshot of a `GameState` at a particular replay step. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct StateSnapshot { pub step_idx: usize, pub total_steps: usize, @@ -75,7 +77,7 @@ pub struct StateSnapshot { /// means the card back is drawn; in that case `suit` and `rank` are /// still set (so the renderer doesn't need separate "unknown" data), /// just hidden visually. -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] pub struct CardSnapshot { pub id: u32, /// `"clubs" | "diamonds" | "hearts" | "spades"`. @@ -157,8 +159,9 @@ impl ReplayPlayer { } fn snapshot(&self) -> StateSnapshot { - let pile_cards = - |t: KlondikePile| -> Vec { self.game.pile(t).iter().map(CardSnapshot::from).collect() }; + let pile_cards = |t: KlondikePile| -> Vec { + self.game.pile(t).iter().map(CardSnapshot::from).collect() + }; let foundations: [Vec; 4] = [ pile_cards(KlondikePile::Foundation(Foundation::Foundation1)), pile_cards(KlondikePile::Foundation(Foundation::Foundation2)), @@ -180,8 +183,18 @@ impl ReplayPlayer { score: self.game.score, move_count: self.game.move_count, is_won: self.game.is_won, - stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(), - waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(), + stock: self + .game + .stock_cards() + .iter() + .map(CardSnapshot::from) + .collect(), + waste: self + .game + .waste_cards() + .iter() + .map(CardSnapshot::from) + .collect(), foundations, tableaus, } @@ -252,7 +265,7 @@ impl ReplayPlayer { // --------------------------------------------------------------------------- /// Full snapshot of a live `SolitaireGame` for the JS renderer. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct GameSnapshot { pub score: i32, pub move_count: u32, @@ -279,6 +292,174 @@ pub struct ActionResult { pub snapshot: Option, } +/// Debug action understood by the automation-oriented debug API. +/// +/// This mirrors legal player inputs and is intentionally independent from DOM +/// or pointer coordinates so test runners can drive the engine directly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DebugMove { + Move { + from: String, + to: String, + count: usize, + }, + StockClick, +} + +/// Invariant report returned by the debug API after each step. +/// +/// `state_ok` is `true` when no structural violations were detected. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DebugInvariantReport { + pub state_ok: bool, + pub total_cards_seen: usize, + pub duplicate_card_ids: Vec, + pub missing_card_ids: Vec, + pub out_of_range_card_ids: Vec, + pub stock_has_face_up_cards: bool, + pub waste_has_face_down_cards: bool, + pub foundation_has_face_down_cards: bool, + pub tableau_visibility_violation: bool, + pub soft_lock: bool, +} + +/// Full debug snapshot for engine-integration and browser automation tests. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DebugSnapshot { + pub seed: u64, + pub draw_mode: DrawMode, + pub mode: GameMode, + pub state: GameSnapshot, + pub legal_moves: Vec, + pub move_history: Vec, + pub invariants: DebugInvariantReport, + pub state_json: String, +} + +fn pile_name(pile: KlondikePile) -> String { + match pile { + KlondikePile::Stock => "stock".to_string(), + KlondikePile::Foundation(f) => format!("foundation-{}", f as u8), + KlondikePile::Tableau(t) => format!("tableau-{}", t as u8), + } +} + +fn can_stock_click(game: &GameState) -> bool { + !(game.is_won || game.stock_cards().is_empty() && game.waste_cards().is_empty()) +} + +fn legal_moves_for_game(game: &GameState) -> Vec { + let mut moves: Vec = game + .possible_instructions() + .into_iter() + .map(|(from, to, count)| DebugMove::Move { + from: pile_name(from), + to: pile_name(to), + count, + }) + .collect(); + if can_stock_click(game) { + moves.push(DebugMove::StockClick); + } + moves +} + +fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport { + let stock = game.stock_cards(); + let waste = game.waste_cards(); + let foundations = [ + game.pile(KlondikePile::Foundation(Foundation::Foundation1)), + game.pile(KlondikePile::Foundation(Foundation::Foundation2)), + game.pile(KlondikePile::Foundation(Foundation::Foundation3)), + game.pile(KlondikePile::Foundation(Foundation::Foundation4)), + ]; + let tableaus = [ + game.pile(KlondikePile::Tableau(Tableau::Tableau1)), + game.pile(KlondikePile::Tableau(Tableau::Tableau2)), + game.pile(KlondikePile::Tableau(Tableau::Tableau3)), + game.pile(KlondikePile::Tableau(Tableau::Tableau4)), + game.pile(KlondikePile::Tableau(Tableau::Tableau5)), + game.pile(KlondikePile::Tableau(Tableau::Tableau6)), + game.pile(KlondikePile::Tableau(Tableau::Tableau7)), + ]; + + let mut seen = [false; 52]; + let mut duplicate_card_ids = Vec::new(); + let mut out_of_range_card_ids = Vec::new(); + let mut total_cards_seen = 0_usize; + + let mut feed = |cards: &[solitaire_core::card::Card]| { + for card in cards { + total_cards_seen += 1; + if card.id >= 52 { + out_of_range_card_ids.push(card.id); + continue; + } + let idx = card.id as usize; + if seen[idx] { + duplicate_card_ids.push(card.id); + } else { + seen[idx] = true; + } + } + }; + + feed(&stock); + feed(&waste); + for pile in &foundations { + feed(pile); + } + for pile in &tableaus { + feed(pile); + } + + let missing_card_ids = (0_u32..52_u32) + .filter(|id| !seen[*id as usize]) + .collect::>(); + + let stock_has_face_up_cards = stock.iter().any(|c| c.face_up); + let waste_has_face_down_cards = waste.iter().any(|c| !c.face_up); + let foundation_has_face_down_cards = foundations + .iter() + .any(|pile| pile.iter().any(|c| !c.face_up)); + + let tableau_visibility_violation = tableaus.iter().any(|pile| { + let mut seen_face_up = false; + for card in pile { + if card.face_up { + seen_face_up = true; + } else if seen_face_up { + return true; + } + } + false + }); + + 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() + && out_of_range_card_ids.is_empty() + && !stock_has_face_up_cards + && !waste_has_face_down_cards + && !foundation_has_face_down_cards + && !tableau_visibility_violation; + + DebugInvariantReport { + state_ok, + total_cards_seen, + duplicate_card_ids, + missing_card_ids, + out_of_range_card_ids, + stock_has_face_up_cards, + waste_has_face_down_cards, + foundation_has_face_down_cards, + tableau_visibility_violation, + soft_lock, + } +} + /// Interactive Klondike game backed by the real `solitaire_core` rules engine. /// /// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`, @@ -291,8 +472,9 @@ pub struct SolitaireGame { impl SolitaireGame { fn snap(&self) -> GameSnapshot { - let cards = - |t: KlondikePile| -> Vec { self.game.pile(t).iter().map(CardSnapshot::from).collect() }; + let cards = |t: KlondikePile| -> Vec { + self.game.pile(t).iter().map(CardSnapshot::from).collect() + }; let has_moves = { let stock_empty = self.game.stock_cards().is_empty(); let waste_empty = self.game.waste_cards().is_empty(); @@ -306,8 +488,18 @@ impl SolitaireGame { has_moves, undo_count: self.game.undo_count, undo_stack_len: self.game.undo_stack_len(), - stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(), - waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(), + stock: self + .game + .stock_cards() + .iter() + .map(CardSnapshot::from) + .collect(), + waste: self + .game + .waste_cards() + .iter() + .map(CardSnapshot::from) + .collect(), foundations: [ cards(KlondikePile::Foundation(Foundation::Foundation1)), cards(KlondikePile::Foundation(Foundation::Foundation2)), @@ -366,6 +558,138 @@ impl SolitaireGame { } } + fn legal_moves_native(&self) -> Vec { + legal_moves_for_game(&self.game) + } + + fn move_history_native(&self) -> Vec { + self.game.instruction_history() + } + + 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); + let mut replay_moves = Vec::new(); + + for instruction in self.game.instruction_history() { + let replay_move = match instruction { + SavedInstruction::RotateStock => ReplayMove::StockClick, + SavedInstruction::DstFoundation(dst) => ReplayMove::Move { + from: dst.src, + to: SavedKlondikePile::Foundation(dst.foundation), + count: 1, + }, + SavedInstruction::DstTableau(dst) => { + let (from, count) = match dst.src { + SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1), + SavedKlondikePileStack::Foundation(foundation) => { + (SavedKlondikePile::Foundation(foundation), 1) + } + SavedKlondikePileStack::Tableau(tableau_stack) => { + let tableau = + tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else( + || { + format!( + "invalid tableau index in move history: {}", + tableau_stack.tableau.0 + ) + }, + )?; + let face_up_count = replay_game + .pile(KlondikePile::Tableau(tableau)) + .iter() + .rev() + .take_while(|card| card.face_up) + .count(); + let skip = tableau_stack.skip_cards.0 as usize; + let count = face_up_count.checked_sub(skip).ok_or_else(|| { + format!( + "invalid tableau skip in move history: face_up={face_up_count}, skip={skip}" + ) + })?; + if count == 0 { + return Err( + "invalid tableau move in move history: zero-card move".into() + ); + } + (SavedKlondikePile::Tableau(tableau_stack.tableau), count) + } + }; + ReplayMove::Move { + from, + to: SavedKlondikePile::Tableau(dst.tableau), + count, + } + } + }; + + match &replay_move { + ReplayMove::StockClick => replay_game + .draw() + .map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?, + ReplayMove::Move { from, to, count } => { + let src: KlondikePile = (*from) + .try_into() + .map_err(|e| format!("invalid replay source pile: {e}"))?; + let dst: KlondikePile = (*to) + .try_into() + .map_err(|e| format!("invalid replay destination pile: {e}"))?; + replay_game.move_cards(src, dst, *count).map_err(|e| { + format!( + "failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}" + ) + })?; + } + } + + replay_moves.push(replay_move); + } + + Ok(replay_moves) + } + + fn debug_snapshot_native(&self) -> DebugSnapshot { + let legal_moves = self.legal_moves_native(); + let invariants = invariant_report_for_game(&self.game, &legal_moves); + let state_json = serde_json::to_string(&self.game).unwrap_or_default(); + DebugSnapshot { + seed: self.game.seed, + draw_mode: self.game.draw_mode, + mode: self.game.mode, + state: self.snap(), + legal_moves, + move_history: self.move_history_native(), + invariants, + state_json, + } + } + + fn apply_debug_move_native(&mut self, mv: &DebugMove) -> Result<(), String> { + match mv { + DebugMove::StockClick => self.game.draw().map_err(|e| e.to_string()), + DebugMove::Move { from, to, count } => { + let from_pile = Self::pile_from_str(from)?; + let to_pile = Self::pile_from_str(to)?; + if from_pile == KlondikePile::Stock && to_pile == KlondikePile::Stock { + self.game.draw().map_err(|e| e.to_string()) + } else { + self.game + .move_cards(from_pile, to_pile, *count) + .map_err(|e| e.to_string()) + } + } + } + } + + fn apply_legal_move_native(&mut self, index: usize) -> Result<(), String> { + let legal_moves = self.legal_moves_native(); + let mv = legal_moves + .get(index) + .ok_or_else(|| format!("legal move index out of range: {index}"))? + .clone(); + self.apply_debug_move_native(&mv) + } + fn ok_js(&self) -> JsValue { serde_wasm_bindgen::to_value(&ActionResult { ok: true, @@ -497,4 +821,297 @@ impl SolitaireGame { Err(_) => JsValue::NULL, } } + + /// Returns replay moves encoded in the `solitaire_data::Replay` wire format. + /// + /// This derives move counts from the deterministic instruction history and + /// validates that the resulting move stream replays cleanly from the current + /// game's seed/draw mode. + pub fn replay_moves(&self) -> Result { + let moves = self + .replay_moves_native() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns all currently-legal debug moves as a JS array. + /// + /// Includes [`DebugMove::StockClick`] when stock interaction is legal. + pub fn debug_legal_moves(&self) -> Result { + serde_wasm_bindgen::to_value(&self.legal_moves_native()) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns deterministic instruction history for the current game. + /// + /// Together with `seed()` and `draw_mode`, this history is replayable. + pub fn debug_move_history(&self) -> Result { + serde_wasm_bindgen::to_value(&self.move_history_native()) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns a comprehensive debug snapshot for automated verification. + pub fn debug_snapshot(&self) -> Result { + serde_wasm_bindgen::to_value(&self.debug_snapshot_native()) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Applies the legal move currently at `index` from `debug_legal_moves()`. + pub fn debug_apply_legal_move(&mut self, index: usize) -> JsValue { + match self.apply_legal_move_native(index) { + Ok(()) => self.ok_js(), + Err(e) => Self::err_js(e), + } + } + + /// Applies one debug move encoded as JSON. + /// + /// JSON must match [`DebugMove`], for example: + /// `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or + /// `{"kind":"stock_click"}`. + pub fn debug_apply_move_json(&mut self, move_json: &str) -> JsValue { + let parsed = match serde_json::from_str::(move_json) { + Ok(value) => value, + Err(e) => return Self::err_js(format!("invalid debug move JSON: {e}")), + }; + match self.apply_debug_move_native(&parsed) { + Ok(()) => self.ok_js(), + Err(e) => Self::err_js(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::fmt::Write; + + fn pick_move_index(moves: &[DebugMove]) -> Option { + if moves.is_empty() { + return None; + } + if let Some((idx, _)) = moves.iter().enumerate().find(|(_, m)| { + matches!( + m, + DebugMove::Move { + to, + count: 1, + .. + } if to.starts_with("foundation-") + ) + }) { + return Some(idx); + } + if let Some((idx, _)) = moves + .iter() + .enumerate() + .find(|(_, m)| matches!(m, DebugMove::Move { .. })) + { + return Some(idx); + } + Some(0) + } + + fn assert_invariants(snapshot: &DebugSnapshot, seed: u64) { + assert!( + snapshot.invariants.state_ok, + "state invariant failure (seed={seed}): {:?}", + snapshot.invariants + ); + } + + fn board_key(state: &GameSnapshot) -> String { + let mut key = String::new(); + let mut push_cards = |cards: &[CardSnapshot]| { + for card in cards { + let _ = write!( + key, + "{}:{}:{},", + card.id, + card.rank, + if card.face_up { 1 } else { 0 } + ); + } + key.push('|'); + }; + push_cards(&state.stock); + push_cards(&state.waste); + for pile in &state.foundations { + push_cards(pile); + } + for pile in &state.tableaus { + push_cards(pile); + } + key + } + + fn run_autonomous(seed: u64, draw_mode: DrawMode, max_steps: usize) -> DebugSnapshot { + let mut game = SolitaireGame { + game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic), + }; + let mut last_snapshot = game.debug_snapshot_native(); + let mut seen_states = HashSet::new(); + seen_states.insert(board_key(&last_snapshot.state)); + assert_invariants(&last_snapshot, seed); + + for step in 0..max_steps { + if last_snapshot.state.is_won || last_snapshot.legal_moves.is_empty() { + return last_snapshot; + } + let idx = pick_move_index(&last_snapshot.legal_moves).unwrap_or_default(); + if let Err(e) = game.apply_legal_move_native(idx) { + panic!("failed to apply legal move (seed={seed}, step={step}, idx={idx}): {e}"); + } + last_snapshot = game.debug_snapshot_native(); + if !seen_states.insert(board_key(&last_snapshot.state)) { + // Deterministic autoplay returned to an earlier state. + // Treat as a terminal non-winning run, not a harness failure. + return last_snapshot; + } + assert_invariants(&last_snapshot, seed); + } + panic!("autonomous run exceeded step budget (seed={seed}, max_steps={max_steps})"); + } + + #[test] + fn debug_snapshot_exposes_replayable_seed_and_history() { + let seed = 42_u64; + let final_snapshot = run_autonomous(seed, DrawMode::DrawOne, 1500); + assert_eq!(final_snapshot.seed, seed); + assert!( + !final_snapshot.state_json.is_empty(), + "debug snapshot must include serialised current state" + ); + let restored = match SolitaireGame::from_saved(&final_snapshot.state_json) { + Ok(game) => game, + Err(err) => panic!("failed to restore debug snapshot state: {err:?}"), + }; + let restored_snapshot = restored.debug_snapshot_native(); + assert_eq!(restored_snapshot.state, final_snapshot.state); + } + + #[test] + fn replay_moves_export_is_json_compatible_and_replayable() { + let seed = 7_u64; + let draw_mode = DrawMode::DrawThree; + let mut game = SolitaireGame { + game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic), + }; + + for step in 0..64 { + let legal_moves = game.legal_moves_native(); + if legal_moves.is_empty() { + break; + } + let idx = pick_move_index(&legal_moves).unwrap_or_default(); + if let Err(e) = game.apply_legal_move_native(idx) { + panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"); + } + } + + let exported_moves = match game.replay_moves_native() { + Ok(moves) => moves, + Err(err) => panic!("replay export failed: {err}"), + }; + assert!( + !exported_moves.is_empty(), + "progressed game must export a non-empty replay move list" + ); + + let moves_json = match serde_json::to_value(&exported_moves) { + Ok(value) => value, + Err(err) => panic!("failed to serialise exported replay moves: {err}"), + }; + let array = match moves_json.as_array() { + Some(values) => values, + None => panic!("exported replay moves must serialise as a JSON array"), + }; + assert!( + array.iter().all(|entry| { + entry.as_str() == Some("StockClick") || entry.get("Move").is_some() + }), + "replay move JSON must match ReplayMove wire shape" + ); + + let parsed_back: Vec = match serde_json::from_value(moves_json) { + Ok(parsed) => parsed, + Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"), + }; + assert_eq!( + parsed_back, exported_moves, + "replay move JSON must round-trip through ReplayMove" + ); + + let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) { + Some(date) => date, + None => panic!("invalid recorded_at date in test"), + }; + let replay = Replay { + schema_version: 2, + seed, + draw_mode, + mode: GameMode::Classic, + time_seconds: 120, + final_score: game.game.score, + recorded_at, + moves: exported_moves, + }; + let replay_json = match serde_json::to_string(&replay) { + Ok(json) => json, + Err(err) => panic!("failed to serialise replay JSON: {err}"), + }; + + let mut player = match ReplayPlayer::from_json(&replay_json) { + Ok(value) => value, + Err(err) => panic!("failed to construct replay player: {err}"), + }; + loop { + match player.step_native() { + Ok(Some(_)) => {} + Ok(None) => break, + Err(err) => panic!("replay player desynced while applying exported moves: {err}"), + } + } + + let original_state = match serde_json::to_string(&game.game) { + Ok(json) => json, + Err(err) => panic!("failed to serialise original game state: {err}"), + }; + let replayed_state = match serde_json::to_string(&player.game) { + Ok(json) => json, + Err(err) => panic!("failed to serialise replayed game state: {err}"), + }; + assert_eq!( + replayed_state, original_state, + "replayed state must match the live state the moves were exported from" + ); + } + + #[test] + fn debug_api_autonomous_seed_batch_smoke() { + for seed in 0_u64..128_u64 { + let draw_mode = if seed % 2 == 0 { + DrawMode::DrawOne + } else { + DrawMode::DrawThree + }; + let snapshot = run_autonomous(seed, draw_mode, 2000); + assert_invariants(&snapshot, seed); + } + } + + #[test] + #[ignore = "long-running soak for unattended CI pipelines"] + fn debug_api_autonomous_thousands_seed_soak() { + for seed in 10_000_u64..12_000_u64 { + let draw_mode = if seed % 2 == 0 { + DrawMode::DrawOne + } else { + DrawMode::DrawThree + }; + let snapshot = run_autonomous(seed, draw_mode, 3000); + assert_invariants(&snapshot, seed); + } + } }