diff --git a/.gitignore b/.gitignore index 1cf071f..4d000c1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,14 @@ solitaire_server/e2e/test-results/ deploy/matomo-secret.yaml deploy/*-secret.yaml deploy/*-auth-secret.yaml + +# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo +/.agents/ +/.codex/ +/AGENTS.md +# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/) +.claude-flow/ + +# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) — +# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay. +scripts/*.go diff --git a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs index dbd07c5..aa59e91 100644 --- a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs @@ -20,7 +20,7 @@ //! --help Print this message use solitaire_core::DrawMode; -use solitaire_data::solver::try_solve; +use solitaire_core::game_state::GameState; // Budget boundaries defining each tier. A seed belongs to the lowest tier // whose budget proves it Winnable. @@ -99,7 +99,7 @@ fn main() { if buckets[i].len() >= per_tier { continue; } - match try_solve(seed, draw_mode, move_budget, state_budget) { + match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) { Ok(Some(_)) => { buckets[i].push(seed); eprintln!( diff --git a/solitaire_assetgen/src/bin/gen_seeds.rs b/solitaire_assetgen/src/bin/gen_seeds.rs index 8708f57..12b4871 100644 --- a/solitaire_assetgen/src/bin/gen_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_seeds.rs @@ -18,7 +18,8 @@ //! --help Print this message use solitaire_core::DrawMode; -use solitaire_data::solver::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve}; +use solitaire_core::game_state::GameState; +use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET}; fn main() { let mut args = std::env::args().skip(1).peekable(); @@ -77,7 +78,7 @@ fn main() { while found.len() < count { tried += 1; if matches!( - try_solve( + GameState::solve_fresh_deal( seed, draw_mode, DEFAULT_SOLVE_MOVES_BUDGET, diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index bd624c7..39c99c6 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,12 +1,11 @@ use crate::error::MoveError; use crate::klondike_adapter::{ DrawMode, KlondikeAdapter, SavedInstruction, - 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::{Card, Game as _, Session, SessionConfig}; +use card_game::{Card, Game as _, Session, SessionConfig, SolveError}; use klondike::{ DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, @@ -22,10 +21,30 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card. /// - v3: session-backed save files using local `SavedInstruction` mirror types /// with u8 indices for enum variants. -/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with -/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are -/// auto-migrated on load via `AnyInstruction` transparent deserialization. -pub const GAME_STATE_SCHEMA_VERSION: u32 = 4; +/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum +/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated +/// on load via `AnyInstruction` transparent deserialization. +/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer +/// persisted. They are derived from the upstream `card_game`/`klondike` session +/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that +/// still carry those keys load fine — the extra fields are ignored. +pub const GAME_STATE_SCHEMA_VERSION: u32 = 5; + +/// Default move budget for a solvability check. Matches the winnable-deal retry +/// loop in the engine. +pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000; +/// Default unique-state budget for a solvability check. +pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000; + +/// Outcome of a solvability check ([`GameState::solve_first_move`]): +/// +/// * `Ok(Some(instruction))` — winnable; `instruction` is the first useful move +/// on a winning path (used by the hint system). +/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or the +/// game is already won so no next move exists). +/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded before +/// a verdict was reached. +pub type SolveOutcome = Result, SolveError>; /// Default value for `GameState::schema_version` when deserialising older /// save files that pre-date the field. @@ -84,11 +103,8 @@ pub enum GameMode { struct PersistedGameState { pub draw_mode: DrawMode, pub mode: GameMode, - pub score: i32, pub elapsed_seconds: u64, pub seed: u64, - pub undo_count: u32, - pub recycle_count: u32, pub take_from_foundation: bool, pub schema_version: u32, pub saved_moves: Vec, @@ -110,20 +126,19 @@ enum AnyInstruction { V3(SavedInstruction), } -/// Input struct that accepts both schema v3 and v4 `saved_moves` formats. +/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats. /// -/// `recycle_count` is intentionally absent: the value is rebuilt from the -/// instruction replay so that stale counts (from the pre-Phase-3 undo drift -/// bug) are corrected on load. Serde ignores the field in the JSON. +/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all +/// three are rebuilt by replaying the instruction history through the upstream +/// session stats. Older save files (v3/v4) still carry those keys; serde ignores +/// them. #[derive(Debug, Clone, Deserialize)] struct PersistedGameStateIn { pub draw_mode: DrawMode, #[serde(default)] pub mode: GameMode, - pub score: i32, pub elapsed_seconds: u64, pub seed: u64, - pub undo_count: u32, #[serde(default)] pub take_from_foundation: bool, #[serde(default = "schema_v1")] @@ -162,33 +177,25 @@ pub struct TestPileState { } /// Full state of an in-progress Klondike Solitaire game. +/// +/// Score, undo count, and recycle count are **not** stored here. They are +/// derived on demand from the upstream `card_game`/`klondike` session stats via +/// [`GameState::score`], [`GameState::undo_count`], and +/// [`GameState::recycle_count`]. The session is the single source of truth; the +/// −15 undo penalty is configured on the session ([`Self::session_config`]) and +/// applied by the upstream score formula. #[derive(Debug, Clone)] pub struct GameState { /// Top-level mode (Classic / Zen). pub mode: GameMode, - /// Current game score. Can be negative (undo penalties subtract from score). - pub score: i32, /// 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, - /// 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. - pub recycle_count: u32, /// When `true`, the player may move the top card of a foundation pile back /// onto a compatible tableau column. pub take_from_foundation: bool, pub(crate) session: Session, - /// Score recorded immediately before each instruction was applied. - /// Parallel to `session.history()` during live play; used by `undo()` to - /// correctly restore the pre-move score before applying the undo penalty. - /// Empty after a load (can't be reconstructed from history alone). - score_history: Vec, - /// Whether each entry in `session.history()` was a stock recycle. - /// Parallel to `session.history()`; rebuilt from replay on load so that - /// `undo()` correctly decrements `recycle_count` even across save/load cycles. - is_recycle_history: Vec, #[cfg(feature = "test-support")] /// Test pile overrides. Always `None` in production runtime code. pub test_pile_state: Option, @@ -198,14 +205,14 @@ impl PartialEq for GameState { fn eq(&self, other: &Self) -> bool { self.draw_mode() == other.draw_mode() && self.mode == other.mode - && self.score == other.score + && self.score() == other.score() && 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.undo_count == other.undo_count - && self.recycle_count == other.recycle_count + && self.undo_count() == other.undo_count() + && self.recycle_count() == other.recycle_count() && self.take_from_foundation == other.take_from_foundation && self.stock_cards() == other.stock_cards() && self.waste_cards() == other.waste_cards() @@ -228,11 +235,8 @@ impl Serialize for GameState { PersistedGameState { draw_mode: self.draw_mode(), mode: self.mode, - score: self.score, elapsed_seconds: self.elapsed_seconds, seed: self.seed, - undo_count: self.undo_count, - recycle_count: self.recycle_count, take_from_foundation: self.take_from_foundation, schema_version: GAME_STATE_SCHEMA_VERSION, saved_moves: self.saved_moves(), @@ -245,10 +249,10 @@ impl<'de> Deserialize<'de> for GameState { fn deserialize>(deserializer: D) -> Result { let persisted = PersistedGameStateIn::deserialize(deserializer)?; - // Accept v3 (legacy u8-index format, auto-migrated) and v4 (current, - // upstream named-variant serde). Reject everything else. + // Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream + // named-variant serde), and v5 (current, derived stats). Reject the rest. match persisted.schema_version { - 3 | 4 => {} + 3..=5 => {} v => { return Err(serde::de::Error::custom(format!( "unsupported GameState schema version {v}" @@ -258,30 +262,22 @@ impl<'de> Deserialize<'de> for GameState { let mut game = Self { mode: persisted.mode, - score: persisted.score, elapsed_seconds: persisted.elapsed_seconds, seed: persisted.seed, - 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. - recycle_count: 0, take_from_foundation: persisted.take_from_foundation, session: Self::new_session(persisted.seed, persisted.draw_mode), - // score_history cannot be faithfully rebuilt from the instruction - // history because live-play undo penalties are not recorded in - // saved_moves. Leave empty; undo() falls back to old behaviour for - // any move made before this load (see undo() for details). - score_history: Vec::new(), - // is_recycle_history IS rebuilt: recycle detection only needs the - // pre-instruction session state, which is available during replay. - is_recycle_history: Vec::new(), #[cfg(feature = "test-support")] test_pile_state: None, }; + // Replay the saved instruction history. The upstream session tracks + // score components and recycle_count as it processes each move, so the + // derived stats are correct once replay completes. `undo_count()` resets + // to 0 across save/load because undone moves are not part of the saved + // forward history. 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::V4 arrives directly from upstream serde (schema v4+). // AnyInstruction::V3 was serialised with u8 indices (schema v3) and is // converted here via the existing TryFrom impl. let instruction = match any { @@ -291,12 +287,6 @@ impl<'de> Deserialize<'de> for GameState { } }; - // Detect recycle BEFORE processing so that the pre-instruction - // session state (face-down stock) is still available. - let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock) - && game.stock_cards().is_empty() - && !game.waste_cards().is_empty(); - if !game .session .state() @@ -308,11 +298,6 @@ impl<'de> Deserialize<'de> for GameState { )); } game.session.process_instruction(instruction); - - game.is_recycle_history.push(is_recycle); - if is_recycle { - game.recycle_count = game.recycle_count.saturating_add(1); - } } Ok(game) @@ -329,15 +314,10 @@ impl GameState { pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self { Self { mode, - score: 0, elapsed_seconds: 0, seed, - undo_count: 0, - recycle_count: 0, take_from_foundation: true, session: Self::new_session(seed, draw_mode), - score_history: Vec::new(), - is_recycle_history: Vec::new(), #[cfg(feature = "test-support")] test_pile_state: None, } @@ -352,6 +332,43 @@ impl GameState { } } + /// Current game score, derived from the upstream session stats. + /// + /// The upstream score is a linear sum of move-type counts (foundation/ + /// tableau/flip deltas) plus `undos * undo_penalty` (−15 each). Floored at 0 + /// so the displayed score is never negative. Returns 0 in [`GameMode::Zen`], + /// where scoring is suppressed entirely. + /// + /// Note: the win-time bonus (`compute_time_bonus`) is layered on by the + /// engine's win-summary, not included here — this is the in-play base score. + pub fn score(&self) -> i32 { + if self.mode == GameMode::Zen { + return 0; + } + self.session + .state() + .score(self.session.stats(), self.session.config()) + .max(0) + } + + /// Number of times `undo()` has been successfully invoked this game, read + /// from the upstream session stats. + /// + /// Resets to 0 across a save/load cycle: only the forward instruction + /// history is persisted, so undone moves leave no trace to replay. + pub fn undo_count(&self) -> u32 { + self.session.stats().undos() + } + + /// Number of times the waste pile has been recycled back to stock this game, + /// read from the upstream session stats. + /// + /// This is a **cumulative** count — the upstream stat is not rolled back when + /// a recycle is undone, so it reflects total recycles ever performed. + pub fn recycle_count(&self) -> u32 { + self.session.stats().stats().recycle_count() + } + /// Total moves made this game (draws, recycles, and card moves), derived /// from the session's instruction history length. pub fn move_count(&self) -> u32 { @@ -395,7 +412,9 @@ impl GameState { fn session_config(draw_mode: DrawMode) -> SessionConfig { SessionConfig { inner: Self::replay_config(draw_mode), - undo_penalty: 0, + // The −15 WXP undo penalty is now applied by the upstream score + // formula (`undos * undo_penalty`) rather than by hand in `undo()`. + undo_penalty: -15, ..SessionConfig::default() } } @@ -602,6 +621,67 @@ impl GameState { state.move_count = Some(move_count); } + /// Test-support helper: perform `n` real undos so [`Self::undo_count`] + /// reports `n`. Each iteration draws a card then immediately undoes it, + /// leaving the board unchanged but advancing the upstream `undos` counter. + /// + /// Since `score`/`undo_count`/`recycle_count` are now derived from the + /// session stats rather than stored fields, tests drive the real session to + /// reach a desired stat instead of assigning the value directly. + #[cfg(feature = "test-support")] + pub fn force_test_undos(&mut self, n: u32) { + for _ in 0..n { + if self.draw().is_ok() { + let _ = self.undo(); + } + } + } + + /// Test-support helper: perform `n` real stock recycles so + /// [`Self::recycle_count`] reports `n`. Draws until the stock empties, then + /// draws once more to recycle, repeated `n` times. + #[cfg(feature = "test-support")] + pub fn force_test_recycles(&mut self, n: u32) { + for _ in 0..n { + let mut guard = 0; + while !self.stock_cards().is_empty() && guard < 200 { + guard += 1; + if self.draw().is_err() { + break; + } + } + // Stock now empty (waste full) — this draw recycles waste → stock. + let _ = self.draw(); + } + } + + /// Test-support helper: drive real moves until [`Self::score`] reaches at + /// least `target`, returning the resulting score. Prefers foundation moves + /// (+10 each) and falls back to the solver-priority move, so a modest target + /// is reached within a handful of moves on a typical deal. + #[cfg(feature = "test-support")] + pub fn force_test_score(&mut self, target: i32) -> i32 { + let mut guard = 0; + while self.score() < target && !self.is_won() && guard < 4000 { + guard += 1; + let instructions = self.possible_instructions(); + let next = instructions + .iter() + .copied() + .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_))) + .or_else(|| instructions.into_iter().next()); + match next { + Some(instruction) => { + if self.apply_instruction(instruction).is_err() { + break; + } + } + None => break, + } + } + self.score() + } + /// Test-support helper: override face-down stock cards returned by /// [`Self::stock_cards`]. #[cfg(feature = "test-support")] @@ -673,79 +753,6 @@ impl GameState { .ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into())) } - fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool { - let KlondikePile::Tableau(_) = from else { - return false; - }; - let pile = self.pile(from); - if pile.is_empty() { - return false; - } - pile.len() > count && !pile[pile.len() - count - 1].1 - } - - /// Returns `(score_delta, is_recycle)` for `instruction` given the *current* - /// game state. Must be called **before** the instruction is applied to the - /// session; the helper reads pre-instruction pile state from `self`. - fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) { - match instruction { - KlondikeInstruction::RotateStock => { - let is_recycle = - self.stock_cards().is_empty() && !self.waste_cards().is_empty(); - if is_recycle { - 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.mode, - ); - (penalty, true) - } else { - (0, false) - } - } - KlondikeInstruction::DstFoundation(dst_foundation) => { - let from = dst_foundation.src; - let to = KlondikePile::Foundation(dst_foundation.foundation); - let move_delta = - KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode); - // DstFoundation always moves exactly 1 card. - let flip_bonus = if self.will_flip_tableau_source(from, 1) { - KlondikeAdapter::score_for_flip_with_mode(self.mode) - } else { - 0 - }; - (move_delta + flip_bonus, false) - } - KlondikeInstruction::DstTableau(dst_tableau) => { - let (from, count) = match dst_tableau.src { - KlondikePileStack::Stock => (KlondikePile::Stock, 1), - KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1), - KlondikePileStack::Tableau(ts) => { - let face_up_count = self - .session - .state() - .state() - .state() - .tableau_face_up_cards(ts.tableau) - .len(); - let count = face_up_count.saturating_sub(ts.skip_cards as usize); - (KlondikePile::Tableau(ts.tableau), count) - } - }; - let to = KlondikePile::Tableau(dst_tableau.tableau); - let move_delta = - KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode); - let flip_bonus = if self.will_flip_tableau_source(from, count) { - KlondikeAdapter::score_for_flip_with_mode(self.mode) - } else { - 0 - }; - (move_delta + flip_bonus, false) - } - } - } - fn instruction_for_move( &self, from: KlondikePile, @@ -900,19 +907,10 @@ impl GameState { return Err(MoveError::StockEmpty); } - let (score_delta, is_recycle) = - self.pre_instruction_score_delta(KlondikeInstruction::RotateStock); - - self.score_history.push(self.score); - self.is_recycle_history.push(is_recycle); - + // The session tracks score components and recycle_count as it processes + // the instruction; no local bookkeeping required. self.session .process_instruction(KlondikeInstruction::RotateStock); - - if is_recycle { - self.recycle_count = self.recycle_count.saturating_add(1); - } - self.score = (self.score + score_delta).max(0); Ok(()) } @@ -950,8 +948,9 @@ impl GameState { /// instruction form — solver hints, auto-complete, replay, and the property /// tests. User drag-and-drop enters through [`Self::move_cards`], which is a /// thin adapter that converts pile coordinates to an instruction and - /// delegates here, so the move bookkeeping (rule validation, score history, - /// recycle accounting, undo snapshot) lives in exactly one place. + /// delegates here, so the move bookkeeping (rule validation, the undo + /// snapshot, and the session's score/recycle stats) lives in exactly one + /// place. /// /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// current position, or [`MoveError::GameAlreadyWon`] once the game is over. @@ -973,21 +972,17 @@ impl GameState { return Err(MoveError::RuleViolation("move violates rules".into())); } - let (score_delta, is_recycle) = self.pre_instruction_score_delta(instruction); - - self.score_history.push(self.score); - self.is_recycle_history.push(is_recycle); - + // The session records the move snapshot and updates score/recycle stats. self.session.process_instruction(instruction); - - if is_recycle { - self.recycle_count = self.recycle_count.saturating_add(1); - } - self.score = (self.score + score_delta).max(0); Ok(()) } - /// Restore the most recent undo snapshot and apply the undo score penalty (-15). + /// Restore the most recent undo snapshot. + /// + /// The −15 undo penalty is applied by the upstream score formula + /// (`undos * undo_penalty`), and the session increments its `undos` counter, + /// so this method only has to delegate to [`Session::undo`] after the mode + /// guards. See [`Self::score`] / [`Self::undo_count`] for the derived values. pub fn undo(&mut self) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); @@ -1001,23 +996,7 @@ impl GameState { return Err(MoveError::UndoStackEmpty); } - // Pop the pre-instruction score for the move being undone. Falls back - // to self.score (= old behaviour) when score_history is empty, which - // happens for moves made before a save/load cycle because undo - // penalties aren't reflected in the saved instruction history. - let pre_move_score = self.score_history.pop().unwrap_or(self.score); - let was_recycle = self.is_recycle_history.pop().unwrap_or(false); - self.session.undo(); - - if was_recycle { - self.recycle_count = self.recycle_count.saturating_sub(1); - } - // Apply the undo penalty to the pre-move score, not the post-move score. - // 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.undo_count = self.undo_count.saturating_add(1); Ok(()) } @@ -1118,11 +1097,6 @@ impl GameState { }) } - /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). - pub fn compute_time_bonus(&self) -> i32 { - scoring_time_bonus(self.elapsed_seconds) - } - /// Read-only access to the underlying [`card_game::Session`] for this deal. /// /// Exposes `session.history()` (deterministic replay) and `session.solve()` @@ -1132,6 +1106,56 @@ impl GameState { pub fn session(&self) -> &Session { &self.session } + + /// Solvability of the current position: the first useful move on a winning + /// path, `Ok(None)` if unwinnable (or already won), or `Err` if the solver + /// hit its budget before reaching a verdict. See [`SolveOutcome`]. + /// + /// Delegates the search to upstream [`card_game::Session::solve`] on a + /// solve-budgeted copy of the current board, then extracts the first + /// non-useless instruction from the returned solution. Backs the hint system + /// and the Play-by-seed verdict badge. + pub fn solve_first_move(&self, moves_budget: u64, states_budget: u64) -> SolveOutcome { + // An already-won game has no "next move"; report it as unwinnable so the + // winnable contract (`Some(_)` ⇒ a real move exists) holds. + if self.is_won() { + return Ok(None); + } + + let config = SessionConfig { + inner: KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation), + undo_penalty: 0, + solve_moves_budget: moves_budget, + solve_states_budget: states_budget, + }; + let session = Session::new(self.session.state().state().clone(), config); + + session.solve().map(|solution| { + solution.and_then(|solution| { + solution + .raw_solution() + .iter() + .map(|snapshot| *snapshot.instruction()) + .find(|instruction| !instruction.is_useless()) + }) + }) + } + + /// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`. + /// + /// Fresh-deal solving models standard Klondike rules, so the non-standard + /// take-from-foundation house rule stays disabled. Backs the + /// "Winnable deals only" retry loop. + pub fn solve_fresh_deal( + seed: u64, + draw_mode: DrawMode, + moves_budget: u64, + states_budget: u64, + ) -> SolveOutcome { + let mut game = Self::new(seed, draw_mode); + game.take_from_foundation = false; + game.solve_first_move(moves_budget, states_budget) + } } #[cfg(test)] @@ -1216,56 +1240,40 @@ mod tests { } #[test] - fn recycle_count_decrements_when_recycle_is_undone() { + fn recycle_count_is_cumulative_and_not_rolled_back_on_undo() { + // Upstream `KlondikeStats::recycle_count` counts every recycle ever + // performed; it is intentionally NOT decremented when a recycle is + // undone (the session restores the board but leaves the stat). This is + // the post-migration semantics: a cumulative count, not a net count. let mut game = game_at_first_recycle().expect("could not reach recycle"); - let count_after_recycle = game.recycle_count; - assert_eq!(count_after_recycle, 1, "first recycle should give count=1"); + assert_eq!(game.recycle_count(), 1, "first recycle should give count=1"); game.undo().expect("undo should succeed"); assert_eq!( - game.recycle_count, 0, - "recycle_count must decrement back to 0 after undoing the recycle", + game.recycle_count(), + 1, + "recycle_count is cumulative: undoing a recycle does not roll it back", ); } #[test] - fn score_recycle_penalty_is_reversed_on_undo() { - // Reach the second recycle (count=2, Draw-1) so there is a −100 penalty. - let mut game = game_at_first_recycle().expect("could not reach first recycle"); - - // Draw until stock is empty again so we can do a second recycle. - let mut second_recycle_done = false; - for _ in 0..200 { - if game.stock_cards().is_empty() && !game.waste_cards().is_empty() { - let score_before_second_recycle = game.score; - game.draw().expect("second recycle should succeed"); - assert_eq!(game.recycle_count, 2); - - // The second recycle in Draw-1 mode costs −100. - let expected_after = (score_before_second_recycle - 100).max(0); - assert_eq!( - game.score, expected_after, - "second Draw-1 recycle must apply −100 penalty", - ); - - // Undo: score should recover to (score_before_second_recycle − 15).max(0), - // NOT to (score_after_recycle − 15).max(0). - game.undo().expect("undo of second recycle should succeed"); - let expected_after_undo = (score_before_second_recycle - 15).max(0); - assert_eq!( - game.score, expected_after_undo, - "undoing a penalised recycle must reverse the recycle penalty \ - before applying the −15 undo penalty", - ); - assert_eq!( - game.recycle_count, 1, - "recycle_count must also be decremented on undo", - ); - second_recycle_done = true; - break; - } - let _ = game.draw(); + fn undo_applies_minus_15_penalty_via_upstream_score() { + // A foundation move scores +10 upstream; undoing it nets the move score + // back to 0 and adds the −15 undo penalty, which `score()` floors at 0. + let mut game = GameState::new(1, DrawMode::DrawOne); + // Find and play any scoring move, then undo it. + let scoring_move = game + .possible_instructions() + .into_iter() + .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_))); + if let Some(instruction) = scoring_move { + game.apply_instruction(instruction) + .expect("scoring move should apply"); + assert!(game.score() > 0, "a foundation move should raise the score"); + game.undo().expect("undo should succeed"); + assert_eq!(game.undo_count(), 1, "undo increments the upstream counter"); + // base score returns to 0, minus 15 undo penalty, floored at 0. + assert_eq!(game.score(), 0, "score floors at 0 after the undo penalty"); } - assert!(second_recycle_done, "could not reach second recycle in test"); } #[test] @@ -1298,4 +1306,66 @@ mod tests { ); assert!(game.move_cards(from, to, 1).is_err()); } + + // ── Solvability check (solve_first_move / solve_fresh_deal) ────────────── + + /// `SolveError` has no `PartialEq`, so compare the winnable verdict and the + /// extracted first move (both `Eq`) rather than the whole `Result`. + fn verdict_key(outcome: &SolveOutcome) -> (bool, Option) { + (outcome.is_err(), outcome.clone().ok().flatten()) + } + + #[test] + fn solve_fresh_deal_is_deterministic() { + let a = GameState::solve_fresh_deal( + 7, + DrawMode::DrawOne, + DEFAULT_SOLVE_MOVES_BUDGET, + DEFAULT_SOLVE_STATES_BUDGET, + ); + let b = GameState::solve_fresh_deal( + 7, + DrawMode::DrawOne, + DEFAULT_SOLVE_MOVES_BUDGET, + DEFAULT_SOLVE_STATES_BUDGET, + ); + assert_eq!(verdict_key(&a), verdict_key(&b)); + } + + #[test] + fn winnable_verdict_carries_a_first_move() { + // Contract: a first move is present iff the verdict is winnable. + let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 5_000); + let winnable = matches!(outcome, Ok(Some(_))); + let has_move = outcome.ok().flatten().is_some(); + assert_eq!(winnable, has_move); + } + + #[test] + fn solve_first_move_uses_live_game_state() { + let mut game = GameState::new(42, DrawMode::DrawOne); + game.draw().expect("draw must succeed"); + + let outcome = game.solve_first_move(5_000, 5_000); + let winnable = matches!(outcome, Ok(Some(_))); + let has_move = outcome.ok().flatten().is_some(); + assert_eq!(winnable, has_move); + } + + #[test] + fn zero_state_budget_is_inconclusive() { + let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 0); + assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); + } + + #[test] + fn budget_is_passed_through_not_clamped() { + // This seed is Inconclusive at 1k states but Winnable at 5k — proving the + // budget reaches the solver unchanged. + let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000); + let medium = + GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000); + assert!(easy.is_err()); + assert!(matches!(medium, Ok(Some(_)))); + } } diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index e364050..8fb40bf 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -16,8 +16,6 @@ use klondike::{ }; use serde::{Deserialize, Serialize}; -use crate::game_state::GameMode; - /// Whether cards are drawn one at a time or three at a time from the stock. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DrawMode { @@ -51,116 +49,6 @@ impl KlondikeAdapter { scoring: ScoringConfig::DEFAULT, } } - - // ── Scoring helpers ─────────────────────────────────────────────────── - - /// Score delta for a card move. - /// - /// Reads from [`ScoringConfig`] (WXP Standard values): - /// - Any pile → Foundation: +10 - /// - Waste → Tableau: +5 - /// - Foundation → Tableau: −15 - /// - All other moves: 0 - pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 { - let sc = ScoringConfig::DEFAULT; - match (from, to) { - (_, KlondikePile::Foundation(_)) => sc.move_to_foundation, - (KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau, - (KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation, - _ => 0, - } - } - - /// Score delta for exposing a face-down tableau card: +5. - pub fn score_for_flip() -> i32 { - ScoringConfig::DEFAULT.flip_up_bonus - } - - /// Score delta for undo: −15. - /// - /// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty` - /// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty - /// is applied here by `GameState` on every undo. - pub fn score_for_undo() -> i32 { - -15 - } - - /// Score delta for recycling waste → stock. - /// - /// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free). - /// WXP allows a fixed number of free recycles before charging a penalty, - /// which the upstream library cannot express with a single delta: - /// - /// | Mode | Free recycles | Penalty per extra recycle | - /// |---|---|---| - /// | Draw-1 | 1 | −100 | - /// | Draw-3 | 3 | −20 | - /// - /// **Design note:** recycling is *never* blocked — only penalised. - /// This is intentional: Draw-1 can be played indefinitely with the score - /// dropping toward zero after the first free recycle. A hard cap would - /// create unwinnable positions when the solver cannot find a path without - /// additional recycling. Zen mode suppresses the penalty entirely. - /// - /// `recycle_count` must be the new total **after** this recycle. - pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 { - if is_draw_three { - if recycle_count > 3 { -20 } else { 0 } - } else if recycle_count > 1 { - -100 - } else { - 0 - } - } - - /// Score delta for a card move, accounting for game mode. - /// - /// Returns 0 in [`GameMode::Zen`] (all scoring suppressed). - pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 { - if mode == GameMode::Zen { - 0 - } else { - Self::score_for_move(from, to) - } - } - - /// Score delta for exposing a face-down card, accounting for game mode. - /// - /// Returns 0 in [`GameMode::Zen`]. - pub fn score_for_flip_with_mode(mode: GameMode) -> i32 { - if mode == GameMode::Zen { - 0 - } else { - Self::score_for_flip() - } - } - - /// Compute the new score after an undo, accounting for game mode. - /// - /// In [`GameMode::Zen`] the score is always 0. Otherwise applies the - /// −15 undo penalty and clamps to 0 via [`Self::score_for_undo`]. - pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 { - if mode == GameMode::Zen { - 0 - } else { - (snapshot_score + Self::score_for_undo()).max(0) - } - } - - /// Score delta for recycling, accounting for game mode. - /// - /// Returns 0 in [`GameMode::Zen`]. - pub fn score_for_recycle_with_mode( - recycle_count: u32, - is_draw_three: bool, - mode: GameMode, - ) -> i32 { - if mode == GameMode::Zen { - 0 - } else { - Self::score_for_recycle(recycle_count, is_draw_three) - } - } } /// Convert a zero-based tableau index (0..=6) into [`Tableau`]. diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 136e0d6..acefd8f 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -12,9 +12,13 @@ pub mod klondike_adapter; // re-exported — they are only used internally (in `klondike_adapter.rs` and // when decoding instructions to piles in `instruction_to_piles`) and do not // appear in any public method signature. -pub use card_game::{Card, Session}; +pub use card_game::{Card, Session, SolveError}; pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; pub use klondike_adapter::DrawMode; +// Solvability check API (delegates to `card_game::Session::solve`); replaces the +// former `solitaire_data::solver` wrapper module. +pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome}; + #[cfg(test)] mod proptest_tests; diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index fc02b3d..8b52881 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -99,12 +99,6 @@ impl SyncProvider for Box { } } -pub mod solver; -pub use solver::{ - DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve, - try_solve_from_state, -}; - pub mod stats; pub use stats::{StatsExt, StatsSnapshot}; @@ -124,8 +118,8 @@ pub use achievements::{ pub mod progress; pub use progress::{ - PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path, - save_progress_to, xp_for_win, + PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from, + progress_file_path, save_progress_to, xp_breakdown, xp_for_win, }; pub mod weekly; @@ -172,8 +166,11 @@ pub use replay::{ ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay, replay_history_path, save_replay_history_to, }; +// `latest_replay_path` is still consumed by the engine's one-shot legacy +// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers +// outside `replay.rs` and were dropped from the public surface. #[allow(deprecated)] -pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; +pub use replay::latest_replay_path; #[cfg(not(target_arch = "wasm32"))] pub mod matomo_client; diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index d1c60df..e082fd1 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 { y * 10_000 + m * 100 + d } -/// XP awarded for winning a game. +/// Component breakdown of the XP awarded for a win. +/// +/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums +/// it for the total, and UI that displays the individual lines (the win-summary +/// modal) reads the parts from here so the breakdown can never drift from the +/// total. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct XpBreakdown { + /// Flat base XP granted for any win. + pub base: u64, + /// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0). + pub speed_bonus: u64, + /// Bonus for winning without using undo (25, else 0). + pub no_undo_bonus: u64, +} + +impl XpBreakdown { + /// Total XP awarded: `base + speed_bonus + no_undo_bonus`. + pub fn total(self) -> u64 { + self.base + self.speed_bonus + self.no_undo_bonus + } +} + +/// Component breakdown of the XP awarded for a win. /// /// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if /// the player did not use undo. -pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { - let base: u64 = 50; +pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown { let speed_bonus: u64 = if time_seconds >= 120 { 0 } else { @@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); scaled.max(10) }; - let no_undo_bonus: u64 = if used_undo { 0 } else { 25 }; - base + speed_bonus + no_undo_bonus + XpBreakdown { + base: 50, + speed_bonus, + no_undo_bonus: if used_undo { 0 } else { 25 }, + } +} + +/// XP awarded for winning a game. See [`xp_breakdown`] for the components. +pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { + xp_breakdown(time_seconds, used_undo).total() } /// Platform-specific default path for `progress.json`. diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 461ca5f..8923b06 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -200,7 +200,7 @@ pub struct Settings { #[serde(default = "default_time_bonus_multiplier")] pub time_bonus_multiplier: f32, /// When `true`, the engine rejects new-game deals the - /// [`solitaire_data::solver`] cannot prove winnable, retrying + /// the solver cannot prove winnable, retrying /// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before /// giving up and using the last tried seed. Off by default — /// the solver adds a few hundred milliseconds of latency on the diff --git a/solitaire_data/src/solver.rs b/solitaire_data/src/solver.rs deleted file mode 100644 index abfd721..0000000 --- a/solitaire_data/src/solver.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Klondike solvability check using upstream `card_game::Session::solve()`. -//! -//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the -//! Play-by-seed verdict badge, and the hint system (which wants the first -//! move on a winning path). All search is delegated to `card_game`; this -//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts -//! the first move from the returned solution. - -use card_game::{Session, SessionConfig, SolveError}; -use klondike::KlondikeInstruction; -use solitaire_core::DrawMode; -use solitaire_core::game_state::GameState; -use solitaire_core::klondike_adapter::KlondikeAdapter; - -/// Default move budget for a solve. Matches the winnable-deal retry loop. -pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000; -/// Default unique-state budget for a solve. -pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000; - -/// Outcome of a solvability check: -/// -/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a -/// winning path (used by the hint system). -/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or -/// the game is already won so no next move exists). -/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded -/// before a verdict was reached. -pub type SolveOutcome = Result, SolveError>; - -/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`. -/// -/// Fresh-deal solving models standard Klondike rules, so the non-standard -/// take-from-foundation house rule stays disabled here. -pub fn try_solve( - seed: u64, - draw_mode: DrawMode, - moves_budget: u64, - states_budget: u64, -) -> SolveOutcome { - let mut game = GameState::new(seed, draw_mode); - game.take_from_foundation = false; - try_solve_from_state(&game, moves_budget, states_budget) -} - -/// Solves from an existing in-progress [`GameState`], returning the first move -/// on a winning path when one exists. -pub fn try_solve_from_state( - state: &GameState, - moves_budget: u64, - states_budget: u64, -) -> SolveOutcome { - // An already-won game has no "next move"; report it as unwinnable so the - // winnable contract (`Some(_)` ⇒ a real move exists) holds. - if state.is_won() { - return Ok(None); - } - - let config = SessionConfig { - inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation), - undo_penalty: 0, - solve_moves_budget: moves_budget, - solve_states_budget: states_budget, - }; - let session = Session::new(state.session().state().state().clone(), config); - - session.solve().map(|solution| { - solution.and_then(|solution| { - solution - .raw_solution() - .iter() - .map(|snapshot| *snapshot.instruction()) - .find(|instruction| !instruction.is_useless()) - }) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `SolveError` has no `PartialEq`, so compare the winnable verdict and the - /// extracted first move (both `Eq`) rather than the whole `Result`. - fn verdict_key(outcome: &SolveOutcome) -> (bool, Option) { - (outcome.is_err(), outcome.clone().ok().flatten()) - } - - #[test] - fn try_solve_is_deterministic() { - let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET); - let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET); - assert_eq!(verdict_key(&a), verdict_key(&b)); - } - - #[test] - fn winnable_verdict_carries_a_first_move() { - // Contract: a first move is present iff the verdict is winnable. - let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000); - let winnable = matches!(outcome, Ok(Some(_))); - let has_move = outcome.ok().flatten().is_some(); - assert_eq!(winnable, has_move); - } - - #[test] - fn try_solve_from_state_uses_live_game_state() { - let mut game = GameState::new(42, DrawMode::DrawOne); - game.draw().expect("draw must succeed"); - - let outcome = try_solve_from_state(&game, 5_000, 5_000); - let winnable = matches!(outcome, Ok(Some(_))); - let has_move = outcome.ok().flatten().is_some(); - assert_eq!(winnable, has_move); - } - - #[test] - fn zero_state_budget_is_inconclusive() { - let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0); - assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); - } - - #[test] - fn budget_is_passed_through_not_clamped() { - // This seed is Inconclusive at 1k states but Winnable at 5k — proving - // the budget reaches the solver unchanged. - let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000); - let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000); - assert!(easy.is_err()); - assert!(matches!(medium, Ok(Some(_)))); - } - - #[test] - fn budget_above_five_thousand_is_not_clamped() { - let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000); - let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000); - assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states"); - assert!( - matches!(above_cap, Ok(Some(_))), - "seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this" - ); - } -} diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index aa0a3aa..d23a9d2 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -497,15 +497,15 @@ mod tests { /// replays all `saved_moves` to reconstruct every pile. /// /// A fresh-game test (zero moves) never exercises that replay path, so this - /// test plays several real moves — including an undo — before saving, then - /// asserts the full pile layout round-trips exactly. + /// test plays several real moves — including an undo — before saving. /// - /// `GameState::PartialEq` covers stock, waste, all four foundations, all - /// seven tableau columns, `score`, `move_count`, `undo_count`, and - /// `recycle_count`. Any breakage in the upstream serde or replay path - /// will cause at least one pile to disagree. + /// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count` + /// (they are derived from the replayed session stats), round-trip fidelity is + /// verified by **re-save idempotency**: reloading the save and serialising it + /// again must reproduce byte-identical JSON. `undo_count` deliberately resets + /// to 0 on load because only the forward instruction history is persisted. #[test] - fn game_state_v4_mid_game_round_trip() { + fn game_state_v5_mid_game_round_trip() { use solitaire_core::KlondikeInstruction; use solitaire_core::game_state::GameState; @@ -546,19 +546,40 @@ mod tests { save_game_state_to(&path, &gs).expect("save"); - // Verify the file contains the v4 schema marker (tolerates pretty-print whitespace). + // Verify the file carries the v5 schema marker. let json = fs::read_to_string(&path).expect("read json"); assert!( - json.contains("schema_version") && json.contains('4') && !json.contains(": 3"), - "saved file must use schema version 4", + json.contains("\"schema_version\"") && json.contains('5'), + "saved file must use schema version 5", ); let loaded = load_game_state_from(&path) .expect("a valid in-progress game must load without error"); + // The forward instruction history round-trips, so the reconstructed board + // re-serialises to byte-identical JSON. + let path_reload = gs_path("v5_mid_game_reload"); + let _ = fs::remove_file(&path_reload); + save_game_state_to(&path_reload, &loaded).expect("re-save loaded"); assert_eq!( - loaded, gs, - "all pile layouts and counters must be identical after schema-v4 round-trip", + fs::read_to_string(&path).expect("read original save"), + fs::read_to_string(&path_reload).expect("read re-saved"), + "re-saving the loaded game must reproduce the original save exactly", + ); + + // Derived board reads match the live game (move count + recycle count are + // both rebuilt from the replayed forward history). + assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips"); + assert_eq!( + loaded.recycle_count(), + gs.recycle_count(), + "recycle_count round-trips", + ); + // undo_count is intentionally not persisted: it resets to 0 on load. + assert_eq!( + loaded.undo_count(), + 0, + "undo_count resets across save/load under schema v5", ); } diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 4062c01..777dd63 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -176,9 +176,9 @@ fn evaluate_on_win( daily_challenge_streak: progress.0.daily_challenge_streak, last_win_score: ev.score, last_win_time_seconds: ev.time_seconds, - last_win_used_undo: game.0.undo_count > 0, + last_win_used_undo: game.0.undo_count() > 0, wall_clock_hour: Some(Local::now().hour()), - last_win_recycle_count: game.0.recycle_count, + last_win_recycle_count: game.0.recycle_count(), last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen, }; @@ -779,7 +779,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .undo_count = 1; + .force_test_undos(1); app.world_mut().write_message(GameWonEvent { score: 1000, diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 546e074..2c1573b 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -1487,6 +1487,7 @@ fn update_drag_shadow( drag: Res, layout: Option>, card_entities: Query<(&CardEntity, &Transform)>, + card_index: Res, mut shadow: Local>, ) { if drag.is_idle() { @@ -1503,9 +1504,9 @@ fn update_drag_shadow( // Find the world position of the first (top) dragged card. let top_pos = drag.cards.first().and_then(|first_card| { - card_entities - .iter() - .find(|(marker, _)| marker.card == *first_card) + card_index + .get(first_card) + .and_then(|entity| card_entities.get(entity).ok()) .map(|(_, t)| t.translation) }); diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 452b398..3e38495 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -44,7 +44,8 @@ use std::hash::{Hash, Hasher}; use bevy::prelude::*; use bevy::window::RequestRedraw; use solitaire_core::card::Card; -use solitaire_core::{Foundation, KlondikePile}; +use solitaire_core::KlondikePile; +use solitaire_core::klondike_adapter::foundation_from_slot; use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; @@ -645,16 +646,6 @@ fn pile_cards( } } -fn foundation_from_slot(slot: u8) -> Option { - match slot { - 0 => Some(Foundation::Foundation1), - 1 => Some(Foundation::Foundation2), - 2 => Some(Foundation::Foundation3), - 3 => Some(Foundation::Foundation4), - _ => None, - } -} - // --------------------------------------------------------------------------- // Unit tests (pure functions only — no Bevy world required) // --------------------------------------------------------------------------- diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index dc9f81a..620128e 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -15,9 +15,7 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::window::AppLifecycle; use solitaire_core::KlondikePile; use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; -use solitaire_data::solver::{ - DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve, -}; +use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET}; #[allow(deprecated)] use solitaire_data::latest_replay_path; use solitaire_data::{ @@ -318,7 +316,7 @@ fn seed_from_system_time() -> u64 { } /// Walks forward from `initial_seed` (incrementing by 1 with wrapping -/// arithmetic) until the [`solitaire_data::solver`] returns a verdict +/// arithmetic) until the [`GameState::solve_fresh_deal`] returns a verdict /// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`] /// attempts have elapsed. /// @@ -393,7 +391,7 @@ fn poll_pending_new_game_seed( pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 { let mut seed = initial_seed; for _ in 0..SOLVER_DEAL_RETRY_CAP { - match try_solve( + match GameState::solve_fresh_deal( seed, draw_mode, DEFAULT_SOLVE_MOVES_BUDGET, @@ -920,7 +918,7 @@ fn handle_move( changed.write(StateChangedEvent); if !was_won && game.0.is_won() { won.write(GameWonEvent { - score: game.0.score, + score: game.0.score(), time_seconds: game.0.elapsed_seconds, }); // Delete the saved state — a won game should not be resumed. @@ -1117,7 +1115,7 @@ fn check_no_moves( // Only spawn the overlay if one does not already exist, and no other // modal scrim is currently open (global ModalScrim guard). if game_over_screens.is_empty() && scrims.is_empty() { - spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref()); + spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref()); } } } diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 4dee52d..8fc5319 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -1818,7 +1818,7 @@ fn detect_score_change( score_q: Query>, mut commands: Commands, ) { - let current = game.0.score; + let current = game.0.score(); let delta = current - prev.0; prev.0 = current; if delta <= 0 { @@ -2275,7 +2275,7 @@ fn update_hud( **t = if is_zen { String::new() } else { - format!("Score: {}", g.score) + format!("Score: {}", g.score()) }; } if let Ok(mut t) = moves_q.single_mut() { @@ -2311,7 +2311,7 @@ fn update_hud( // --- Undo count --- if let Ok((mut t, mut color)) = undos_q.single_mut() { - let count = g.undo_count; + let count = g.undo_count(); if count == 0 { **t = String::new(); *color = TextColor(TEXT_PRIMARY); @@ -2325,8 +2325,8 @@ fn update_hud( // --- Recycle counter (both modes, hidden until first recycle) --- if let Ok(mut t) = recycles_q.single_mut() { - **t = if g.recycle_count > 0 { - format!("Recycles: {}", g.recycle_count) + **t = if g.recycle_count() > 0 { + format!("Recycles: {}", g.recycle_count()) } else { String::new() }; @@ -2763,9 +2763,9 @@ mod tests { #[test] fn score_reflects_game_state() { let mut app = headless_app(); - app.world_mut().resource_mut::().0.score = 750; + let score = app.world_mut().resource_mut::().0.force_test_score(20); app.update(); - assert_eq!(read_hud_text::(&mut app), "Score: 750"); + assert_eq!(read_hud_text::(&mut app), format!("Score: {score}")); } #[test] @@ -2795,7 +2795,6 @@ mod tests { let mut app = headless_app(); app.world_mut().resource_mut::().0 = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); - app.world_mut().resource_mut::().0.score = 999; app.update(); // Zen mode spec: "No score display" → text must be empty. assert_eq!(read_hud_text::(&mut app), ""); @@ -2916,7 +2915,7 @@ mod tests { fn challenge_hud_empty_when_no_daily_resource() { // No DailyChallengeResource inserted → HudChallenge must be empty. let mut app = headless_app(); - app.world_mut().resource_mut::().0.score = 1; // force change + app.world_mut().resource_mut::().set_changed(); app.update(); assert_eq!(read_hud_text::(&mut app), ""); } @@ -2931,7 +2930,7 @@ mod tests { target_score: None, max_time_secs: Some(300), }); - app.world_mut().resource_mut::().0.score = 1; // force change + app.world_mut().resource_mut::().set_changed(); app.update(); assert_eq!(read_hud_text::(&mut app), "Limit: 5:00"); } @@ -2946,7 +2945,7 @@ mod tests { target_score: Some(4000), max_time_secs: None, }); - app.world_mut().resource_mut::().0.score = 1; + app.world_mut().resource_mut::().set_changed(); app.update(); assert_eq!(read_hud_text::(&mut app), "Goal: 4000 pts"); } @@ -2984,7 +2983,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .undo_count = 3; + .force_test_undos(3); app.update(); assert_eq!(read_hud_text::(&mut app), "Undos: 3"); } @@ -3057,7 +3056,7 @@ mod tests { fn recycles_hud_shows_count_draw_three() { let mut app = headless_app(); let mut gs = GameState::new(42, DrawMode::DrawThree); - gs.recycle_count = 3; + gs.force_test_recycles(3); app.world_mut().resource_mut::().0 = gs; app.update(); assert_eq!(read_hud_text::(&mut app), "Recycles: 3"); @@ -3068,7 +3067,7 @@ mod tests { let mut app = headless_app(); // Draw-One with recycle_count > 0 must now show the counter too. let mut gs = GameState::new(42, DrawMode::DrawOne); - gs.recycle_count = 2; + gs.force_test_recycles(2); app.world_mut().resource_mut::().0 = gs; app.update(); assert_eq!(read_hud_text::(&mut app), "Recycles: 2"); @@ -3108,7 +3107,7 @@ mod tests { set_manual_time_step(&mut app, 0.0); // Initial state has score=0; bumping by 50 (the threshold) // is the smallest jump that triggers the floater. - app.world_mut().resource_mut::().0.score = 50; + app.world_mut().resource_mut::().0.force_test_score(50); app.update(); // One floater should now exist. @@ -3129,7 +3128,7 @@ mod tests { #[test] fn score_floater_despawns_after_full_lifetime() { let mut app = headless_app(); - app.world_mut().resource_mut::().0.score = 100; + app.world_mut().resource_mut::().0.force_test_score(50); app.update(); assert_eq!(count_with::(&mut app), 1); @@ -3155,7 +3154,7 @@ mod tests { let mut app = headless_app(); // +5 mirrors a single tableau-to-foundation move; well below // the 50-point threshold so the floater path stays dormant. - app.world_mut().resource_mut::().0.score = 5; + app.world_mut().resource_mut::().0.force_test_score(5); app.update(); assert_eq!( count_with::(&mut app), @@ -3231,7 +3230,7 @@ mod tests { ..Settings::default() })); // +100 would normally create both a ScorePulse and a ScoreFloater. - app.world_mut().resource_mut::().0.score = 100; + app.world_mut().resource_mut::().0.force_test_score(50); app.update(); assert_eq!( count_with::(&mut app), diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 5477b2c..1e876f9 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -33,7 +33,9 @@ use solitaire_core::game_state::GameState; use crate::auto_complete_plugin::AutoCompleteState; use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::{CardAnimation, MotionCurve}; -use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC}; +use crate::card_plugin::{ + CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, +}; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::events::{ DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent, @@ -93,8 +95,8 @@ pub struct HintSolverConfig { impl Default for HintSolverConfig { fn default() -> Self { Self { - moves_budget: solitaire_data::solver::DEFAULT_SOLVE_MOVES_BUDGET, - states_budget: solitaire_data::solver::DEFAULT_SOLVE_STATES_BUDGET, + moves_budget: solitaire_core::DEFAULT_SOLVE_MOVES_BUDGET, + states_budget: solitaire_core::DEFAULT_SOLVE_STATES_BUDGET, } } } @@ -121,6 +123,10 @@ impl Plugin for InputPlugin { .init_resource::() .init_resource::() .init_resource::() + // The drag systems resolve cards via `CardEntityIndex`; `CardPlugin` + // owns and rebuilds it, but init here too so `InputPlugin` is + // self-sufficient in tests (idempotent if already registered). + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -674,6 +680,7 @@ fn follow_drag( layout: Option>, tuning: Res, mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>, + card_index: Res, ) { // Skip if idle or if a touch drag is running. if drag.is_idle() || drag.active_touch_id.is_some() { @@ -704,9 +711,8 @@ fn follow_drag( // Elevate cards: push to DRAG_Z and dim slightly so the board // beneath stays readable. for (i, card) in drag.cards.iter().enumerate() { - if let Some((_, mut transform, mut sprite)) = card_transforms - .iter_mut() - .find(|(ce, _, _)| ce.card == *card) + if let Some(entity) = card_index.get(card) + && let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity) { transform.translation.z = dragged_card_z(i); sprite.color.set_alpha(0.85); @@ -719,9 +725,8 @@ fn follow_drag( let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac; for (i, card) in drag.cards.iter().enumerate() { - if let Some((_, mut transform, _)) = card_transforms - .iter_mut() - .find(|(ce, _, _)| ce.card == *card) + if let Some(entity) = card_index.get(card) + && let Ok((_, mut transform, _)) = card_transforms.get_mut(entity) { transform.translation.x = bottom_pos.x; transform.translation.y = bottom_pos.y + fan * i as f32; @@ -743,6 +748,7 @@ fn end_drag( mut changed: MessageWriter, mut commands: Commands, card_entities: Query<(Entity, &CardEntity, &Transform)>, + card_index: Res, ) { if paused.is_some_and(|p| p.0) { drag.clear(); @@ -830,9 +836,8 @@ fn end_drag( continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); - if let Some((entity, _, transform)) = card_entities - .iter() - .find(|(_, ce, _)| ce.card == *card) + if let Some(entity) = card_index.get(card) + && let Ok((_, _, transform)) = card_entities.get(entity) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; @@ -930,6 +935,7 @@ fn touch_follow_drag( layout: Option>, tuning: Res, mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>, + card_index: Res, ) { let Some(active_id) = drag.active_touch_id else { return; // Mouse drag or idle. @@ -957,9 +963,8 @@ fn touch_follow_drag( drag.committed = true; for (i, card) in drag.cards.iter().enumerate() { - if let Some((_, mut transform, mut sprite)) = card_transforms - .iter_mut() - .find(|(ce, _, _)| ce.card == *card) + if let Some(entity) = card_index.get(card) + && let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity) { transform.translation.z = dragged_card_z(i); sprite.color.set_alpha(0.85); @@ -971,9 +976,8 @@ fn touch_follow_drag( let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac; for (i, card) in drag.cards.iter().enumerate() { - if let Some((_, mut transform, _)) = card_transforms - .iter_mut() - .find(|(ce, _, _)| ce.card == *card) + if let Some(entity) = card_index.get(card) + && let Ok((_, mut transform, _)) = card_transforms.get_mut(entity) { transform.translation.x = bottom_pos.x; transform.translation.y = bottom_pos.y + fan * i as f32; @@ -998,6 +1002,7 @@ fn touch_end_drag( mut changed: MessageWriter, mut commands: Commands, card_entities: Query<(Entity, &CardEntity, &Transform)>, + card_index: Res, ) { let Some(active_id) = drag.active_touch_id else { return; // Mouse drag or idle. @@ -1070,9 +1075,8 @@ fn touch_end_drag( continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); - if let Some((entity, _, transform)) = card_entities - .iter() - .find(|(_, ce, _)| ce.card == *card) + if let Some(entity) = card_index.get(card) + && let Ok((_, _, transform)) = card_entities.get(entity) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index 63e5b90..1e9fac0 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -26,7 +26,6 @@ use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use solitaire_core::KlondikeInstruction; use solitaire_core::game_state::GameState; -use solitaire_data::solver::try_solve_from_state; use crate::card_plugin::CardEntity; use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent}; @@ -66,7 +65,7 @@ impl PendingHintTask { // Winnable (`Ok(Some)`) carries the first move on a winning path; // unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back // to the live-state heuristic so H always produces feedback. - match try_solve_from_state(&state, moves_budget, states_budget) { + match state.solve_first_move(moves_budget, states_budget) { Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move), Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic, } diff --git a/solitaire_engine/src/play_by_seed_plugin.rs b/solitaire_engine/src/play_by_seed_plugin.rs index 04cd7ca..35720a1 100644 --- a/solitaire_engine/src/play_by_seed_plugin.rs +++ b/solitaire_engine/src/play_by_seed_plugin.rs @@ -11,7 +11,7 @@ //! 3. `handle_text_input` appends decimal digits / handles Backspace while //! the modal is open, updating [`SeedInputBuffer`] each frame. //! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at -//! 60 Hz) of no input before spawning a [`try_solve`] task on +//! 60 Hz) of no input before spawning a [`GameState::solve_fresh_deal`] task on //! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task //! by resetting the resource. //! 5. `poll_solver_task` polls the in-flight task each frame and updates the @@ -24,9 +24,8 @@ use bevy::input::ButtonInput; use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use solitaire_core::DrawMode; -use solitaire_data::solver::{ - DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve, -}; +use solitaire_core::game_state::GameState; +use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome}; use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent}; use crate::font_plugin::FontResource; @@ -343,7 +342,7 @@ fn tick_debounce_and_spawn_solver_task( .as_ref() .map_or(DrawMode::DrawOne, |s| s.0.draw_mode); let task = AsyncComputeTaskPool::get().spawn(async move { - try_solve( + GameState::solve_fresh_deal( seed, draw_mode, DEFAULT_SOLVE_MOVES_BUDGET, diff --git a/solitaire_engine/src/progress_plugin.rs b/solitaire_engine/src/progress_plugin.rs index 993a01b..145ac51 100644 --- a/solitaire_engine/src/progress_plugin.rs +++ b/solitaire_engine/src/progress_plugin.rs @@ -88,7 +88,7 @@ fn award_xp_on_win( mut progress: ResMut, ) { for ev in wins.read() { - let used_undo = game.0.undo_count > 0; + let used_undo = game.0.undo_count() > 0; let amount = xp_for_win(ev.time_seconds, used_undo); let prev_level = progress.0.add_xp(amount); xp_awarded.write(XpAwardedEvent { amount }); @@ -151,7 +151,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .undo_count = 1; + .force_test_undos(1); app.world_mut().write_message(GameWonEvent { score: 500, diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index ff5a0b9..32dbe19 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -241,7 +241,7 @@ enum SettingsButton { ToggleTouchInputMode, /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// random Classic-mode deals are filtered through - /// [`solitaire_data::solver::try_solve`] until one is provably + /// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably /// winnable (or the retry cap is hit). Off by default. ToggleWinnableDealsOnly, /// Toggle the inverse of [`Settings::disable_smart_default_size`]. diff --git a/solitaire_engine/src/weekly_goals_plugin.rs b/solitaire_engine/src/weekly_goals_plugin.rs index 40ae69e..b40d93f 100644 --- a/solitaire_engine/src/weekly_goals_plugin.rs +++ b/solitaire_engine/src/weekly_goals_plugin.rs @@ -82,7 +82,7 @@ fn evaluate_weekly_goals( for ev in events.drain(..) { let ctx = WeeklyGoalContext { time_seconds: ev.time_seconds, - used_undo: game.0.undo_count > 0, + used_undo: game.0.undo_count() > 0, draw_mode: game.0.draw_mode(), }; for def in WEEKLY_GOALS { @@ -177,7 +177,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .undo_count = 1; + .force_test_undos(1); app.world_mut().write_message(GameWonEvent { score: 500, diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index d62ba08..8f337c4 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -90,28 +90,23 @@ pub struct WinSummaryPending { /// Builds a human-readable XP breakdown string for the win modal. /// -/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always -/// matches the total shown on the `XpAwardedEvent`. +/// Reads the components from `solitaire_data::xp_breakdown` — the single source +/// of truth shared with `xp_for_win` — so the breakdown can never drift from +/// the total shown on the `XpAwardedEvent`. /// /// Examples: /// - slow win, no undo → `"+50 base +25 no-undo"` /// - fast win, undo → `"+50 base +30 speed"` /// - fast win, no undo → `"+50 base +25 no-undo +30 speed"` fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String { - let speed_bonus: u64 = if time_seconds >= 120 { - 0 - } else { - let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); - scaled.max(10) - }; - let no_undo_bonus: u64 = if used_undo { 0 } else { 25 }; + let xp = solitaire_data::xp_breakdown(time_seconds, used_undo); - let mut parts = vec!["+50 base".to_string()]; - if no_undo_bonus > 0 { - parts.push("+25 no-undo".to_string()); + let mut parts = vec![format!("+{} base", xp.base)]; + if xp.no_undo_bonus > 0 { + parts.push(format!("+{} no-undo", xp.no_undo_bonus)); } - if speed_bonus > 0 { - parts.push(format!("+{speed_bonus} speed")); + if xp.speed_bonus > 0 { + parts.push(format!("+{} speed", xp.speed_bonus)); } parts.join(" ") } @@ -477,14 +472,14 @@ fn cache_win_data( None }; - let used_undo = game.0.undo_count > 0; + let used_undo = game.0.undo_count() > 0; pending.score = ev.score; pending.time_seconds = ev.time_seconds; pending.xp = 0; // reset; XP event follows pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo); pending.new_record = is_new_record; pending.challenge_level = challenge_level; - pending.undo_count = game.0.undo_count; + pending.undo_count = game.0.undo_count(); pending.mode = game.0.mode; if is_new_record { @@ -1592,7 +1587,7 @@ mod tests { { let mut game = app.world_mut().resource_mut::(); game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen); - game.0.undo_count = 2; + game.0.force_test_undos(2); } app.world_mut().write_message(GameWonEvent { diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index bf6527f..c69e11d 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -184,7 +184,7 @@ impl ReplayPlayer { StateSnapshot { step_idx: self.step_idx, total_steps: self.moves.len(), - score: self.game.score, + score: self.game.score(), move_count: self.game.move_count(), is_won: self.game.is_won(), stock: self @@ -487,12 +487,12 @@ impl SolitaireGame { !stock_empty || !waste_empty || !self.game.possible_instructions().is_empty() }; GameSnapshot { - score: self.game.score, + score: self.game.score(), 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_count: self.game.undo_count(), undo_stack_len: self.game.undo_stack_len(), stock: self .game @@ -1059,7 +1059,7 @@ mod tests { draw_mode, mode: GameMode::Classic, time_seconds: 120, - final_score: game.game.score, + final_score: game.game.score(), recorded_at, moves: exported_moves, };