From f0832f3dfaca0b741acaf262e669631e1b59296f Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 10 Jun 2026 20:12:03 -0700 Subject: [PATCH 01/16] refactor: remove leftover redundancies after card_game migration Post-migration audit found the card_game/klondike migration essentially complete; these are the four small redundancies that remained: - core: delete dead GameState::compute_time_bonus (zero callers; engine uses the klondike_adapter free fn directly) - data: drop dead public re-exports load_latest_replay_from / save_latest_replay_to (no callers outside replay.rs); keep latest_replay_path (engine legacy migration still uses it) - data+engine: lift win-XP scoring into a shared XpBreakdown so the win-summary modal breakdown and xp_for_win share one source of truth instead of duplicating the speed/no-undo constants - engine: replace feedback_anim_plugin's private foundation_from_slot copy with the canonical klondike_adapter::foundation_from_slot cargo test --workspace + clippy -D warnings green. Co-Authored-By: Claude Opus 4.8 (1M context) --- solitaire_core/src/game_state.rs | 6 --- solitaire_data/src/lib.rs | 9 +++-- solitaire_data/src/progress.rs | 40 +++++++++++++++++--- solitaire_engine/src/feedback_anim_plugin.rs | 13 +------ solitaire_engine/src/win_summary_plugin.rs | 23 +++++------ 5 files changed, 52 insertions(+), 39 deletions(-) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index bd624c7..f5d8ee8 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,7 +1,6 @@ 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, @@ -1118,11 +1117,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()` diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index fc02b3d..d453ae1 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -124,8 +124,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 +172,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_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/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index d62ba08..ac3940a 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(" ") } From 9e3c6b06b075b997fe3a4eee32e8c50977dc05b1 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 10 Jun 2026 21:17:04 -0700 Subject: [PATCH 02/16] chore: gitignore local agent-tooling artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep Codex / claude-flow scaffolding (.agents/, .codex/, AGENTS.md) out of the repo — these are locally generated and not project sources. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 1cf071f..7d6486b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ 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 From 372b6423d87a0fe9bb1f267ab5a8d4aa6e24ba35 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 10:36:31 -0700 Subject: [PATCH 03/16] refactor(core): derive score/undo/recycle from upstream session stats Replace the bespoke WXP scoring engine with the upstream card_game/klondike session stats, eliminating duplicated state that could drift from the single source of truth. score()/undo_count()/recycle_count() now read session.stats(); the -15 undo penalty is configured as SessionConfig::undo_penalty and applied by the upstream score formula. Save schema bumped v4 -> v5 (the three counters are no longer persisted -- they are rebuilt by replaying the forward instruction history on load). - Remove GameState fields score, undo_count, recycle_count (#87) - Remove score_history / is_recycle_history undo journal (#86) - Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers, plus pre_instruction_score_delta / will_flip_tableau_source (#84) These three issues are a single atomic change: each removed field/helper is consumed by the same draw/apply_instruction/undo/serde/PartialEq paths, so they cannot compile or pass tests in isolation. Behaviour changes (intentional): the escalating recycle penalty and per-step score floor are gone (upstream linear scoring, floored once at 0); recycle_count is now cumulative; undo_count resets across save/load. Refs #84, #86, #87 Co-Authored-By: Claude Opus 4.8 (1M context) --- solitaire_core/src/game_state.rs | 394 +++++++++----------- solitaire_core/src/klondike_adapter.rs | 112 ------ solitaire_data/src/storage.rs | 45 ++- solitaire_engine/src/achievement_plugin.rs | 6 +- solitaire_engine/src/game_plugin.rs | 4 +- solitaire_engine/src/hud_plugin.rs | 35 +- solitaire_engine/src/progress_plugin.rs | 4 +- solitaire_engine/src/weekly_goals_plugin.rs | 4 +- solitaire_engine/src/win_summary_plugin.rs | 6 +- solitaire_wasm/src/lib.rs | 8 +- 10 files changed, 237 insertions(+), 381 deletions(-) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index f5d8ee8..e56e60c 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -21,10 +21,14 @@ 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 value for `GameState::schema_version` when deserialising older /// save files that pre-date the field. @@ -83,11 +87,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, @@ -109,20 +110,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")] @@ -161,33 +161,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, @@ -197,14 +189,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() @@ -227,11 +219,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(), @@ -244,10 +233,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}" @@ -257,30 +246,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 { @@ -290,12 +271,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() @@ -307,11 +282,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) @@ -328,15 +298,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, } @@ -351,6 +316,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 { @@ -394,7 +396,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() } } @@ -601,6 +605,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")] @@ -672,79 +737,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, @@ -899,19 +891,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(()) } @@ -949,8 +932,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. @@ -972,21 +956,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); @@ -1000,23 +980,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(()) } @@ -1210,56 +1174,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] 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_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/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index dc9f81a..b262d53 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -920,7 +920,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 +1117,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/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/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 ac3940a..8f337c4 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -472,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 { @@ -1587,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, }; From 424c8b2d5084e71df716ec96d18cbb0e756c80e8 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 11:05:24 -0700 Subject: [PATCH 04/16] =?UTF-8?q?perf(engine):=20route=20remaining=20drag?= =?UTF-8?q?=20card=E2=86=92entity=20lookups=20through=20CardEntityIndex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace O(n) `Query::iter().find()` card scans with O(1) `CardEntityIndex` lookups in the mouse and touch drag pipelines (`follow_drag`, `end_drag`, `touch_follow_drag`, `touch_end_drag`) and `update_drag_shadow` — 7 sites across 5 systems. Each ran per dragged card per frame during a drag. `InputPlugin` now defensively `init_resource::()` (idempotent; `CardPlugin` still owns and rebuilds it) so the plugin is self-sufficient in tests. The lone remaining card-keyed `.find` is a `#[cfg(test)]` world-query helper, which is the correct pattern there. Completes the CardEntityIndex migration started in ef1efdc. Co-Authored-By: Claude Opus 4.8 (1M context) --- solitaire_engine/src/card_plugin.rs | 7 +++-- solitaire_engine/src/input_plugin.rs | 42 +++++++++++++++------------- 2 files changed, 27 insertions(+), 22 deletions(-) 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/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 5477b2c..e3be4ac 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, @@ -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; From e841a7ab4fd0f6b6a19fe4ae3bf36a4c48a90148 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 15:04:47 -0700 Subject: [PATCH 05/16] refactor: delete solitaire_data::solver wrapper; solve via card_game directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the standalone solver wrapper module. Its thin shaping — build a solve-budgeted Session, run card_game::Session::solve(), extract the first useful move — moves onto the domain type in solitaire_core as GameState::solve_first_move() / GameState::solve_fresh_deal(), with the budget consts and the SolveOutcome alias re-exported from solitaire_core. Solving is deterministic, IO-free game logic, so core (which already owns GameState and exposes session().solve()) is its correct home; solitaire_data is the persistence/sync layer and never should have owned it. Consumers now call the core API directly: - engine: pending_hint (solve_first_move), game_plugin + play_by_seed_plugin (solve_fresh_deal), input_plugin (budget consts) - assetgen: gen_seeds + gen_difficulty_seeds (solve_fresh_deal) The solver tests move to solitaire_core. cargo test --workspace and clippy --workspace --all-targets -- -D warnings both green. Resolves the "delete the solver" directive — card_game provides the solver. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bin/gen_difficulty_seeds.rs | 4 +- solitaire_assetgen/src/bin/gen_seeds.rs | 5 +- solitaire_core/src/game_state.rs | 130 +++++++++++++++- solitaire_core/src/lib.rs | 6 +- solitaire_data/src/lib.rs | 6 - solitaire_data/src/settings.rs | 2 +- solitaire_data/src/solver.rs | 140 ------------------ solitaire_engine/src/game_plugin.rs | 8 +- solitaire_engine/src/input_plugin.rs | 4 +- solitaire_engine/src/pending_hint.rs | 3 +- solitaire_engine/src/play_by_seed_plugin.rs | 9 +- solitaire_engine/src/settings_plugin.rs | 2 +- 12 files changed, 151 insertions(+), 168 deletions(-) delete mode 100644 solitaire_data/src/solver.rs 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 e56e60c..39c99c6 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -5,7 +5,7 @@ use crate::klondike_adapter::{ 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, @@ -30,6 +30,22 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// 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. fn schema_v1() -> u32 { @@ -1090,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)] @@ -1240,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/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 d453ae1..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}; 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_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index b262d53..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, diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index e3be4ac..1e876f9 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -95,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, } } } 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/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`]. From f0871c03e8abcd8466def15054b0776db245da2e Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 15:10:18 -0700 Subject: [PATCH 06/16] chore: gitignore local helper scripts Ignore the local token-saving Go helpers under scripts/ (peek, cargoclip, testfail, diffclip, cratemap, sessionpack, etc.) via scripts/*.go. These are inspection-only dev tools, not committed. Tracked scripts/*.sh and *.md are unaffected. Replaces a broad, machine-local .git/info/exclude rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 7d6486b..fb255d1 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ deploy/*-auth-secret.yaml /.agents/ /.codex/ /AGENTS.md + +# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) — +# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay. +scripts/*.go From d0457811191f40b72009b613e21522f4bf289d05 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 15:12:18 -0700 Subject: [PATCH 07/16] chore: gitignore .claude-flow scratch dirs A claude-flow tool run left solitaire_engine/src/.claude-flow/. Ignore .claude-flow/ anywhere in the tree, matching the existing agent-tooling artifact rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index fb255d1..4d000c1 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ deploy/*-auth-secret.yaml /.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. From 5c992cbdca1b97405023e0f7ea844baeb9c99d55 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 16:01:11 -0700 Subject: [PATCH 08/16] refactor: replace local DrawMode with upstream klondike::DrawStockConfig (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawMode was a 1:1 mirror of klondike::DrawStockConfig (DrawOne/DrawThree). Delete it and use the upstream type everywhere; re-export DrawStockConfig from solitaire_core. config_for assigns draw_stock directly and draw_mode() returns session.config().inner.draw_stock. Serde is unchanged — DrawStockConfig serialises to the same "DrawOne"/"DrawThree" named variants, so persisted game_state.json / replay JSON stay byte-compatible (no migration). Field/method/variable names containing draw_mode are unchanged. 35 files, mechanical type swap across all crates. Implemented via a multi-agent workflow (core → per-crate consumers → verify). cargo test --workspace and clippy --workspace --all-targets -- -D warnings green. Closes #82 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bin/gen_difficulty_seeds.rs | 4 +- solitaire_assetgen/src/bin/gen_seeds.rs | 4 +- solitaire_core/src/game_state.rs | 47 ++++++++--------- solitaire_core/src/klondike_adapter.rs | 16 +----- solitaire_core/src/lib.rs | 3 +- solitaire_core/src/proptest_tests.rs | 7 ++- solitaire_data/src/replay.rs | 10 ++-- solitaire_data/src/settings.rs | 10 ++-- solitaire_data/src/stats.rs | 52 +++++++++---------- solitaire_data/src/storage.rs | 16 +++--- solitaire_data/src/weekly.rs | 10 ++-- solitaire_engine/src/achievement_plugin.rs | 8 +-- solitaire_engine/src/auto_complete_plugin.rs | 6 +-- solitaire_engine/src/card_plugin.rs | 48 ++++++++--------- solitaire_engine/src/challenge_plugin.rs | 6 +-- solitaire_engine/src/cursor_plugin.rs | 12 ++--- .../src/daily_challenge_plugin.rs | 8 +-- solitaire_engine/src/feedback_anim_plugin.rs | 8 +-- solitaire_engine/src/game_plugin.rs | 32 ++++++------ solitaire_engine/src/home_plugin.rs | 12 ++--- solitaire_engine/src/hud_plugin.rs | 24 ++++----- solitaire_engine/src/input_plugin.rs | 42 +++++++-------- solitaire_engine/src/pause_plugin.rs | 42 +++++++-------- solitaire_engine/src/pending_hint.rs | 4 +- solitaire_engine/src/play_by_seed_plugin.rs | 4 +- solitaire_engine/src/radial_menu.rs | 6 +-- solitaire_engine/src/replay_overlay/tests.rs | 8 +-- solitaire_engine/src/replay_playback.rs | 8 +-- solitaire_engine/src/selection_plugin.rs | 4 +- solitaire_engine/src/settings_plugin.rs | 12 ++--- solitaire_engine/src/stats_plugin.rs | 4 +- solitaire_engine/src/sync_plugin.rs | 4 +- solitaire_engine/src/time_attack_plugin.rs | 6 +-- solitaire_engine/src/win_summary_plugin.rs | 10 ++-- solitaire_wasm/src/lib.rs | 34 ++++++------ 35 files changed, 257 insertions(+), 274 deletions(-) diff --git a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs index aa59e91..b4d1844 100644 --- a/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_difficulty_seeds.rs @@ -19,7 +19,7 @@ //! --per-tier Seeds to emit per tier (default 40) //! --help Print this message -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; use solitaire_core::game_state::GameState; // Budget boundaries defining each tier. A seed belongs to the lowest tier @@ -74,7 +74,7 @@ fn main() { std::process::exit(1); } - let draw_mode = DrawMode::DrawOne; + let draw_mode = DrawStockConfig::DrawOne; let num_tiers = BUDGETS.len(); let mut buckets: Vec> = vec![Vec::with_capacity(per_tier); num_tiers]; let mut tried: u64 = 0; diff --git a/solitaire_assetgen/src/bin/gen_seeds.rs b/solitaire_assetgen/src/bin/gen_seeds.rs index 12b4871..17d719a 100644 --- a/solitaire_assetgen/src/bin/gen_seeds.rs +++ b/solitaire_assetgen/src/bin/gen_seeds.rs @@ -17,7 +17,7 @@ //! --count Number of Winnable seeds to emit (default 75) //! --help Print this message -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; use solitaire_core::game_state::GameState; use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET}; @@ -68,7 +68,7 @@ fn main() { std::process::exit(1); } - let draw_mode = DrawMode::DrawOne; + let draw_mode = DrawStockConfig::DrawOne; let mut found: Vec = Vec::with_capacity(count); let mut tried: u64 = 0; let mut seed = start; diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 39c99c6..4d0942b 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,6 +1,6 @@ use crate::error::MoveError; use crate::klondike_adapter::{ - DrawMode, KlondikeAdapter, SavedInstruction, + KlondikeAdapter, SavedInstruction, 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, @@ -101,7 +101,7 @@ pub enum GameMode { /// `KlondikeInstruction` serde, which produces named enum variants. #[derive(Debug, Clone, Serialize)] struct PersistedGameState { - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, pub mode: GameMode, pub elapsed_seconds: u64, pub seed: u64, @@ -134,7 +134,7 @@ enum AnyInstruction { /// them. #[derive(Debug, Clone, Deserialize)] struct PersistedGameStateIn { - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, #[serde(default)] pub mode: GameMode, pub elapsed_seconds: u64, @@ -306,12 +306,12 @@ impl<'de> Deserialize<'de> for GameState { impl GameState { /// Creates a new Classic-mode game dealt from the given seed and draw mode. - pub fn new(seed: u64, draw_mode: DrawMode) -> Self { + pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self { Self::new_with_mode(seed, draw_mode, GameMode::Classic) } /// Creates a new game with an explicit `GameMode`. - pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self { + pub fn new_with_mode(seed: u64, draw_mode: DrawStockConfig, mode: GameMode) -> Self { Self { mode, elapsed_seconds: 0, @@ -325,11 +325,8 @@ impl GameState { /// Whether the player draws one or three cards from the stock per turn. /// Derived from the underlying session config (set once at deal time). - pub fn draw_mode(&self) -> DrawMode { - match self.session.config().inner.draw_stock { - DrawStockConfig::DrawOne => DrawMode::DrawOne, - DrawStockConfig::DrawThree => DrawMode::DrawThree, - } + pub fn draw_mode(&self) -> DrawStockConfig { + self.session.config().inner.draw_stock } /// Current game score, derived from the upstream session stats. @@ -405,11 +402,11 @@ impl GameState { !self.check_win() && self.check_auto_complete() } - fn new_session(seed: u64, draw_mode: DrawMode) -> Session { + fn new_session(seed: u64, draw_mode: DrawStockConfig) -> Session { Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode)) } - fn session_config(draw_mode: DrawMode) -> SessionConfig { + fn session_config(draw_mode: DrawStockConfig) -> SessionConfig { SessionConfig { inner: Self::replay_config(draw_mode), // The −15 WXP undo penalty is now applied by the upstream score @@ -419,7 +416,7 @@ impl GameState { } } - fn replay_config(draw_mode: DrawMode) -> KlondikeConfig { + fn replay_config(draw_mode: DrawStockConfig) -> KlondikeConfig { // Always allow foundation returns during replay, regardless of the // player's current `take_from_foundation` setting. A move recorded // when the rule was enabled must replay correctly even if the player @@ -587,7 +584,7 @@ impl GameState { /// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need /// a specific mode use this instead of mutating a field. #[cfg(feature = "test-support")] - pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) { + pub fn set_test_draw_mode(&mut self, draw_mode: DrawStockConfig) { self.session = Self::new_session(self.seed, draw_mode); } @@ -1148,7 +1145,7 @@ impl GameState { /// "Winnable deals only" retry loop. pub fn solve_fresh_deal( seed: u64, - draw_mode: DrawMode, + draw_mode: DrawStockConfig, moves_budget: u64, states_budget: u64, ) -> SolveOutcome { @@ -1177,7 +1174,7 @@ mod tests { const MAX_STEPS: usize = 160; for seed in 1..=MAX_SEED { - let mut game = GameState::new(seed, DrawMode::DrawOne); + let mut game = GameState::new(seed, DrawStockConfig::DrawOne); game.take_from_foundation = true; for _ in 0..MAX_STEPS { @@ -1226,7 +1223,7 @@ mod tests { /// iteration limit (shouldn't happen in practice). fn game_at_first_recycle() -> Option { for seed in 1..=256_u64 { - let mut game = GameState::new(seed, DrawMode::DrawOne); + let mut game = GameState::new(seed, DrawStockConfig::DrawOne); for _ in 0..200 { if game.stock_cards().is_empty() && !game.waste_cards().is_empty() { // This draw will recycle. @@ -1259,7 +1256,7 @@ mod tests { 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); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Find and play any scoring move, then undo it. let scoring_move = game .possible_instructions() @@ -1319,13 +1316,13 @@ mod tests { fn solve_fresh_deal_is_deterministic() { let a = GameState::solve_fresh_deal( 7, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, ); let b = GameState::solve_fresh_deal( 7, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, ); @@ -1335,7 +1332,7 @@ mod tests { #[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 outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 5_000); let winnable = matches!(outcome, Ok(Some(_))); let has_move = outcome.ok().flatten().is_some(); assert_eq!(winnable, has_move); @@ -1343,7 +1340,7 @@ mod tests { #[test] fn solve_first_move_uses_live_game_state() { - let mut game = GameState::new(42, DrawMode::DrawOne); + let mut game = GameState::new(42, DrawStockConfig::DrawOne); game.draw().expect("draw must succeed"); let outcome = game.solve_first_move(5_000, 5_000); @@ -1354,7 +1351,7 @@ mod tests { #[test] fn zero_state_budget_is_inconclusive() { - let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 0); + let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 0); assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); } @@ -1362,9 +1359,9 @@ mod tests { 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 easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000); let medium = - GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000); + GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::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 8fb40bf..6c666dc 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -16,15 +16,6 @@ use klondike::{ }; use serde::{Deserialize, Serialize}; -/// 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 { - /// Draw one card from stock per turn. - DrawOne, - /// Draw three cards from stock per turn; only the top is playable. - DrawThree, -} - /// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. /// /// This type is intentionally zero-sized: it does not carry mutable runtime @@ -35,12 +26,9 @@ pub struct KlondikeAdapter; impl KlondikeAdapter { /// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting. - pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig { + pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig { KlondikeConfig { - draw_stock: match draw_mode { - DrawMode::DrawOne => DrawStockConfig::DrawOne, - DrawMode::DrawThree => DrawStockConfig::DrawThree, - }, + draw_stock: draw_mode, move_from_foundation: if take_from_foundation { MoveFromFoundationConfig::Allowed } else { diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index acefd8f..702f431 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -13,8 +13,7 @@ pub mod klondike_adapter; // when decoding instructions to piles in `instruction_to_piles`) and do not // appear in any public method signature. pub use card_game::{Card, Session, SolveError}; -pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; -pub use klondike_adapter::DrawMode; +pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; // Solvability check API (delegates to `card_game::Session::solve`); replaces the // former `solitaire_data::solver` wrapper module. diff --git a/solitaire_core/src/proptest_tests.rs b/solitaire_core/src/proptest_tests.rs index 596daa7..3ec7cc4 100644 --- a/solitaire_core/src/proptest_tests.rs +++ b/solitaire_core/src/proptest_tests.rs @@ -1,9 +1,8 @@ use card_game::{Card, Game}; -use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; +use klondike::{DrawStockConfig, Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; use proptest::prelude::*; use crate::game_state::GameState; -use crate::klondike_adapter::DrawMode; use crate::klondike_adapter::{ InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation, SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau, @@ -52,8 +51,8 @@ fn all_cards(game: &GameState) -> Vec { cards } -fn draw_mode_strategy() -> impl Strategy { - prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)] +fn draw_mode_strategy() -> impl Strategy { + prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)] } /// Apply a sequence of random actions to a game, silently ignoring errors. diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index 4b60b82..86d9480 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -26,7 +26,7 @@ use std::path::{Path, PathBuf}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; -use solitaire_core::{DrawMode, game_state::GameMode}; +use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::klondike_adapter::SavedKlondikePile; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; @@ -124,7 +124,7 @@ pub struct Replay { /// `GameState::new_with_mode(seed, draw_mode, mode)`. pub seed: u64, /// Draw mode the recorded game was played in. - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, /// Game mode the recorded game was played in. pub mode: GameMode, /// Total wall-clock seconds the win took. Used for the Stats UI @@ -180,7 +180,7 @@ impl Replay { /// latter directly when the upload task resolves. pub fn new( seed: u64, - draw_mode: DrawMode, + draw_mode: DrawStockConfig, mode: GameMode, time_seconds: u64, final_score: i32, @@ -453,7 +453,7 @@ mod tests { let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"); Replay::new( 12345, - DrawMode::DrawThree, + DrawStockConfig::DrawThree, GameMode::Classic, 134, 5_120, @@ -596,7 +596,7 @@ mod tests { let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"); Replay::new( id as u64, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 60, id, diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 8923b06..8e8b63b 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -9,7 +9,7 @@ use std::io; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use solitaire_core::{DrawMode, game_state::DifficultyLevel}; +use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel}; const SETTINGS_FILE_NAME: &str = "settings.json"; @@ -101,7 +101,7 @@ pub struct WindowGeometry { pub struct Settings { /// Draw mode selected for new games. #[serde(default = "default_draw_mode")] - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, /// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain. #[serde(default = "default_sfx_volume")] pub sfx_volume: f32, @@ -288,8 +288,8 @@ pub struct Settings { pub touch_input_mode: TouchInputMode, } -fn default_draw_mode() -> DrawMode { - DrawMode::DrawOne +fn default_draw_mode() -> DrawStockConfig { + DrawStockConfig::DrawOne } fn default_sfx_volume() -> f32 { @@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50; impl Default for Settings { fn default() -> Self { Self { - draw_mode: DrawMode::DrawOne, + draw_mode: DrawStockConfig::DrawOne, sfx_volume: default_sfx_volume(), music_volume: default_music_volume(), animation_speed: AnimSpeed::Normal, diff --git a/solitaire_data/src/stats.rs b/solitaire_data/src/stats.rs index ae16f60..6339235 100644 --- a/solitaire_data/src/stats.rs +++ b/solitaire_data/src/stats.rs @@ -2,10 +2,10 @@ //! //! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here. //! This module adds the [`StatsExt`] extension trait, which supplies the -//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`. +//! `update_on_win` method that depends on [`DrawStockConfig`] from `solitaire_core`. use chrono::Utc; -use solitaire_core::{DrawMode, game_state::GameMode}; +use solitaire_core::{DrawStockConfig, game_state::GameMode}; pub use solitaire_sync::StatsSnapshot; @@ -18,9 +18,9 @@ pub trait StatsExt { /// /// Tracks lifetime totals only — per-mode best scores and times are /// updated separately via [`StatsExt::update_per_mode_bests`] so the - /// long-standing call sites that only know about [`DrawMode`] keep + /// long-standing call sites that only know about [`DrawStockConfig`] keep /// compiling. - fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode); + fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig); /// Updates the per-mode best score and fastest-win-time fields for the /// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from @@ -37,7 +37,7 @@ pub trait StatsExt { } impl StatsExt for StatsSnapshot { - fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) { + fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) { let prev_wins = self.games_won; self.games_played += 1; self.games_won += 1; @@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot { }; match draw_mode { - DrawMode::DrawOne => self.draw_one_wins += 1, - DrawMode::DrawThree => self.draw_three_wins += 1, + DrawStockConfig::DrawOne => self.draw_one_wins += 1, + DrawStockConfig::DrawThree => self.draw_three_wins += 1, } self.last_modified = Utc::now(); @@ -135,7 +135,7 @@ mod tests { #[test] fn first_win_sets_all_fields() { let mut s = StatsSnapshot::default(); - s.update_on_win(1500, 120, &DrawMode::DrawOne); + s.update_on_win(1500, 120, &DrawStockConfig::DrawOne); assert_eq!(s.games_played, 1); assert_eq!(s.games_won, 1); assert_eq!(s.win_streak_current, 1); @@ -152,7 +152,7 @@ mod tests { fn streak_tracks_across_wins() { let mut s = StatsSnapshot::default(); for _ in 0..3 { - s.update_on_win(100, 60, &DrawMode::DrawOne); + s.update_on_win(100, 60, &DrawStockConfig::DrawOne); } assert_eq!(s.win_streak_current, 3); assert_eq!(s.win_streak_best, 3); @@ -161,8 +161,8 @@ mod tests { #[test] fn record_abandoned_resets_streak_and_increments_played() { let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawOne); + s.update_on_win(100, 60, &DrawStockConfig::DrawOne); + s.update_on_win(100, 60, &DrawStockConfig::DrawOne); assert_eq!(s.win_streak_current, 2); s.record_abandoned(); assert_eq!(s.games_played, 3); @@ -174,35 +174,35 @@ mod tests { #[test] fn fastest_win_takes_minimum() { let mut s = StatsSnapshot::default(); - s.update_on_win(100, 300, &DrawMode::DrawOne); - s.update_on_win(100, 120, &DrawMode::DrawOne); - s.update_on_win(100, 500, &DrawMode::DrawOne); + s.update_on_win(100, 300, &DrawStockConfig::DrawOne); + s.update_on_win(100, 120, &DrawStockConfig::DrawOne); + s.update_on_win(100, 500, &DrawStockConfig::DrawOne); assert_eq!(s.fastest_win_seconds, 120); } #[test] fn avg_time_is_correct_rolling_average() { let mut s = StatsSnapshot::default(); - s.update_on_win(100, 100, &DrawMode::DrawOne); - s.update_on_win(100, 200, &DrawMode::DrawOne); - s.update_on_win(100, 300, &DrawMode::DrawOne); + s.update_on_win(100, 100, &DrawStockConfig::DrawOne); + s.update_on_win(100, 200, &DrawStockConfig::DrawOne); + s.update_on_win(100, 300, &DrawStockConfig::DrawOne); assert_eq!(s.avg_time_seconds, 200); } #[test] fn best_score_updates_only_on_higher_score() { let mut s = StatsSnapshot::default(); - s.update_on_win(500, 60, &DrawMode::DrawOne); - s.update_on_win(300, 60, &DrawMode::DrawOne); + s.update_on_win(500, 60, &DrawStockConfig::DrawOne); + s.update_on_win(300, 60, &DrawStockConfig::DrawOne); assert_eq!(s.best_single_score, 500); - s.update_on_win(800, 60, &DrawMode::DrawOne); + s.update_on_win(800, 60, &DrawStockConfig::DrawOne); assert_eq!(s.best_single_score, 800); } #[test] fn negative_score_treated_as_zero() { let mut s = StatsSnapshot::default(); - s.update_on_win(-50, 60, &DrawMode::DrawOne); + s.update_on_win(-50, 60, &DrawStockConfig::DrawOne); assert_eq!(s.best_single_score, 0); assert_eq!(s.lifetime_score, 0); } @@ -210,8 +210,8 @@ mod tests { #[test] fn draw_three_wins_tracked_separately() { let mut s = StatsSnapshot::default(); - s.update_on_win(100, 60, &DrawMode::DrawOne); - s.update_on_win(100, 60, &DrawMode::DrawThree); + s.update_on_win(100, 60, &DrawStockConfig::DrawOne); + s.update_on_win(100, 60, &DrawStockConfig::DrawThree); assert_eq!(s.draw_one_wins, 1); assert_eq!(s.draw_three_wins, 1); } @@ -221,7 +221,7 @@ mod tests { let mut s = StatsSnapshot::default(); // Build a streak of 5. for _ in 0..5 { - s.update_on_win(100, 60, &DrawMode::DrawOne); + s.update_on_win(100, 60, &DrawStockConfig::DrawOne); } assert_eq!(s.win_streak_best, 5); // Lose (abandon), resetting current. @@ -229,7 +229,7 @@ mod tests { assert_eq!(s.win_streak_current, 0); assert_eq!(s.win_streak_best, 5, "best must survive the loss"); // Win once — current becomes 1, best must remain 5. - s.update_on_win(100, 60, &DrawMode::DrawOne); + s.update_on_win(100, 60, &DrawStockConfig::DrawOne); assert_eq!(s.win_streak_current, 1); assert_eq!( s.win_streak_best, 5, @@ -243,7 +243,7 @@ mod tests { lifetime_score: u64::MAX - 100, ..Default::default() }; - s.update_on_win(200, 60, &DrawMode::DrawOne); + s.update_on_win(200, 60, &DrawStockConfig::DrawOne); assert_eq!( s.lifetime_score, u64::MAX, diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index d23a9d2..a272043 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -279,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) { mod tests { use super::*; use crate::stats::{StatsExt, StatsSnapshot}; - use solitaire_core::DrawMode; + use solitaire_core::DrawStockConfig; use std::env; fn tmp_path(name: &str) -> PathBuf { @@ -292,7 +292,7 @@ mod tests { let _ = fs::remove_file(&path); let mut stats = StatsSnapshot::default(); - stats.update_on_win(1000, 180, &DrawMode::DrawOne); + stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne); save_stats_to(&path, &stats).expect("save"); let loaded = load_stats_from(&path); @@ -381,7 +381,7 @@ mod tests { let path = gs_path("round_trip"); let _ = fs::remove_file(&path); - let gs = GameState::new(12345, DrawMode::DrawOne); + let gs = GameState::new(12345, DrawStockConfig::DrawOne); save_game_state_to(&path, &gs).expect("save"); let loaded = load_game_state_from(&path).expect("load"); @@ -410,7 +410,7 @@ mod tests { let path = gs_path("won_skip"); let _ = fs::remove_file(&path); - let mut gs = GameState::new(99, DrawMode::DrawOne); + let mut gs = GameState::new(99, DrawStockConfig::DrawOne); gs.set_test_won(true); save_game_state_to(&path, &gs).expect("save should be no-op, not error"); assert!( @@ -423,7 +423,7 @@ mod tests { fn delete_game_state_removes_file() { use solitaire_core::game_state::GameState; let path = gs_path("delete"); - let gs = GameState::new(1, DrawMode::DrawOne); + let gs = GameState::new(1, DrawStockConfig::DrawOne); save_game_state_to(&path, &gs).expect("save"); assert!(path.exists()); delete_game_state_at(&path).expect("delete"); @@ -441,7 +441,7 @@ mod tests { fn save_game_state_is_atomic() { use solitaire_core::game_state::GameState; let path = gs_path("atomic"); - let gs = GameState::new(55, DrawMode::DrawThree); + let gs = GameState::new(55, DrawStockConfig::DrawThree); save_game_state_to(&path, &gs).expect("save"); let tmp = path.with_extension("json.tmp"); assert!(!tmp.exists(), ".tmp must be cleaned up after rename"); @@ -512,7 +512,7 @@ mod tests { let path = gs_path("v4_mid_game"); let _ = fs::remove_file(&path); - let mut gs = GameState::new(42, DrawMode::DrawOne); + let mut gs = GameState::new(42, DrawStockConfig::DrawOne); // Draw several times to populate the instruction history with // RotateStock entries and expose waste cards for further moves. @@ -619,7 +619,7 @@ mod tests { .expect("schema v3 must be accepted and migrated to v4"); // The loaded game should match a fresh game that had one draw applied. - let mut expected = GameState::new(42, DrawMode::DrawOne); + let mut expected = GameState::new(42, DrawStockConfig::DrawOne); expected.draw().expect("draw must succeed on a fresh game"); assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state"); } diff --git a/solitaire_data/src/weekly.rs b/solitaire_data/src/weekly.rs index 96254cc..f7f9933 100644 --- a/solitaire_data/src/weekly.rs +++ b/solitaire_data/src/weekly.rs @@ -4,7 +4,7 @@ //! increments matching counters in `PlayerProgress::weekly_goal_progress`. use chrono::{Datelike, NaiveDate}; -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; /// XP awarded each time a weekly goal is just completed. pub const WEEKLY_GOAL_XP: u64 = 75; @@ -36,7 +36,7 @@ pub struct WeeklyGoalDef { pub struct WeeklyGoalContext { pub time_seconds: u64, pub used_undo: bool, - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, } impl WeeklyGoalDef { @@ -47,7 +47,7 @@ impl WeeklyGoalDef { WeeklyGoalKind::WinGame => true, WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo, WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds, - WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree, + WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree, } } } @@ -106,7 +106,7 @@ mod tests { WeeklyGoalContext { time_seconds: time, used_undo: undo, - draw_mode: DrawMode::DrawOne, + draw_mode: DrawStockConfig::DrawOne, } } @@ -114,7 +114,7 @@ mod tests { WeeklyGoalContext { time_seconds: time, used_undo: false, - draw_mode: DrawMode::DrawThree, + draw_mode: DrawStockConfig::DrawThree, } } diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 777dd63..5d987f2 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -819,7 +819,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .set_test_draw_mode(solitaire_core::DrawMode::DrawThree); + .set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree); app.world_mut().write_message(GameWonEvent { score: 500, @@ -868,7 +868,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .set_test_draw_mode(solitaire_core::DrawMode::DrawThree); + .set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree); app.world_mut().write_message(GameWonEvent { score: 500, @@ -1393,7 +1393,7 @@ mod tests { use crate::replay_playback::ReplayPlaybackState; use chrono::NaiveDate; - use solitaire_core::{DrawMode, game_state::GameMode}; + use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_data::{Replay, ReplayMove}; /// Headless app variant that injects a default `ReplayPlaybackState` @@ -1409,7 +1409,7 @@ mod tests { fn dummy_replay() -> Replay { Replay::new( 1, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 10, 100, diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 5b50584..55fae1b 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -169,7 +169,7 @@ mod tests { use crate::table_plugin::TablePlugin; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Deck, Rank, Suit}; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { let mut app = App::new(); @@ -183,7 +183,7 @@ mod tests { } fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) { - let mut g = GameState::new(1, DrawMode::DrawOne); + let mut g = GameState::new(1, DrawStockConfig::DrawOne); g.set_test_stock_cards(Vec::new()); g.set_test_waste_cards(Vec::new()); for foundation in [ @@ -227,7 +227,7 @@ mod tests { #[test] fn detect_activates_when_auto_completable() { let mut app = headless_app(); - let mut g = GameState::new(42, DrawMode::DrawOne); + let mut g = GameState::new(42, DrawStockConfig::DrawOne); g.set_test_auto_completable(true); app.world_mut().resource_mut::().0 = g; app.world_mut().write_message(StateChangedEvent); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 2c1573b..b5caf43 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -18,7 +18,7 @@ use bevy::sprite::Anchor; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Rank, Suit}; -use solitaire_core::{DrawMode, game_state::GameState}; +use solitaire_core::{DrawStockConfig, game_state::GameState}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::card_animation::CardAnimation; @@ -789,8 +789,8 @@ fn sync_cards( // and its rank/suit peek behind the incoming card. let waste_buffer_id: Option = { let visible = match game.draw_mode() { - DrawMode::DrawOne => 1_usize, - DrawMode::DrawThree => 3_usize, + DrawStockConfig::DrawOne => 1_usize, + DrawStockConfig::DrawThree => 3_usize, }; let waste_cards = game.waste_cards(); (waste_cards.len() > visible) @@ -958,8 +958,8 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, // shows up to 3 fanned in X (matching the standard Klondike presentation). let render_start = if is_waste { let visible = match game.draw_mode() { - DrawMode::DrawOne => 1_usize, - DrawMode::DrawThree => 3_usize, + DrawStockConfig::DrawOne => 1_usize, + DrawStockConfig::DrawThree => 3_usize, }; // Render one extra card so that the card sliding off the waste // during a draw animation is still present in the world at z=0 @@ -972,7 +972,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, let mut y_offset = 0.0_f32; let rendered_len = cards[render_start..].len(); for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() { - let x_offset = if is_waste && matches!(game.draw_mode(), DrawMode::DrawThree) { + let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) { // When len > visible, slot 0 is a hidden buffer card kept at // x=0 to prevent a flash during the draw tween. When len ≤ // visible (small pile), every card is visible and should fan @@ -2566,7 +2566,7 @@ mod tests { #[test] fn card_positions_includes_all_52_cards_at_game_start() { // At game start waste is empty, so all 52 cards are across stock + tableau. - let g = GameState::new(42, solitaire_core::DrawMode::DrawOne); + let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); assert_eq!(positions.len(), 52); @@ -2574,8 +2574,8 @@ mod tests { #[test] fn waste_draw_one_only_renders_top_card() { - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawOne); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawOne); // Draw 3 cards so the waste pile has 3 cards. for _ in 0..3 { let _ = g.draw(); @@ -2612,8 +2612,8 @@ mod tests { #[test] fn waste_draw_three_renders_up_to_three_fanned_cards() { - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawThree); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawThree); // 5 draw() calls in Draw-Three mode accumulates multiple waste cards. for _ in 0..5 { let _ = g.draw(); @@ -2666,8 +2666,8 @@ mod tests { // Regression: slot.saturating_sub(1) always hid slot-0 even when the // pile was too small to have a buffer card, collapsing 2 visible cards // onto x=0 instead of fanning them. - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawThree); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawThree); // Draw exactly once — in Draw-Three mode with a full stock this gives // 3 waste cards (still ≤ visible=3, so no hidden buffer needed). let _ = g.draw(); @@ -2709,8 +2709,8 @@ mod tests { /// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap. #[test] fn waste_draw_one_buffer_card_at_same_xy_as_top() { - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawOne); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawOne); // Draw 3 times so the waste pile has 3 cards and the buffer exists. for _ in 0..3 { let _ = g.draw(); @@ -2740,7 +2740,7 @@ mod tests { #[test] fn card_positions_tableau_cards_are_fanned_downward() { - let g = GameState::new(42, solitaire_core::DrawMode::DrawOne); + let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -3083,7 +3083,7 @@ mod tests { #[test] fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { - let g = GameState::new(42, solitaire_core::DrawMode::DrawOne); + let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -3533,7 +3533,7 @@ mod tests { #[test] fn stock_card_count_helper_reads_zero_for_empty_stock() { - let g = GameState::new(42, solitaire_core::DrawMode::DrawOne); + let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); let mut g_empty_stock = g.clone(); g_empty_stock.set_test_stock_cards(Vec::new()); assert_eq!(stock_card_count(&g_empty_stock), 0); @@ -3805,8 +3805,8 @@ mod tests { #[test] fn waste_pile_cards_have_strictly_increasing_z() { - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawThree); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawThree); for _ in 0..5 { let _ = g.draw(); } @@ -3849,8 +3849,8 @@ mod tests { /// offsets or flips the fan direction is caught immediately. #[test] fn waste_cards_do_not_overlap_stock_column_on_portrait() { - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawThree); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawThree); for _ in 0..5 { let _ = g.draw(); } @@ -3885,8 +3885,8 @@ mod tests { #[test] fn waste_pile_draw_one_cards_have_distinct_z() { - use solitaire_core::DrawMode; - let mut g = GameState::new(42, DrawMode::DrawOne); + use solitaire_core::DrawStockConfig; + let mut g = GameState::new(42, DrawStockConfig::DrawOne); for _ in 0..3 { let _ = g.draw(); } diff --git a/solitaire_engine/src/challenge_plugin.rs b/solitaire_engine/src/challenge_plugin.rs index 887cdb3..e7bfce9 100644 --- a/solitaire_engine/src/challenge_plugin.rs +++ b/solitaire_engine/src/challenge_plugin.rs @@ -117,7 +117,7 @@ mod tests { use crate::game_plugin::GamePlugin; use crate::progress_plugin::ProgressPlugin; use crate::table_plugin::TablePlugin; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { let mut app = App::new(); @@ -135,7 +135,7 @@ mod tests { fn challenge_win_advances_index() { let mut app = headless_app(); app.world_mut().resource_mut::().0 = - GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge); + GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge); app.world_mut().write_message(GameWonEvent { score: 500, @@ -224,7 +224,7 @@ mod tests { .0 .challenge_index = 2; app.world_mut().resource_mut::().0 = - GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge); + GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge); app.world_mut().write_message(GameWonEvent { score: 500, diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 2b0ff86..0726979 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -36,7 +36,7 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use solitaire_core::card::Card; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::{DrawMode, game_state::GameState}; +use solitaire_core::{DrawStockConfig, game_state::GameState}; use crate::card_plugin::RightClickHighlight; use crate::layout::{Layout, LayoutResource}; @@ -437,7 +437,7 @@ fn tableau_or_stack_pos( base.x, base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), ) - } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree { + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { let pile_len = game.waste_cards().len(); let visible_start = pile_len.saturating_sub(3); let slot = index.saturating_sub(visible_start) as f32; @@ -563,9 +563,9 @@ mod tests { #[test] fn cursor_over_draggable_returns_false_for_empty_game() { use crate::layout::compute_layout; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // A cursor far off-screen should never hit anything. assert!(!cursor_over_draggable( @@ -581,7 +581,7 @@ mod tests { use crate::layout::compute_layout; use solitaire_core::card::{Card, Deck, Rank, Suit}; - use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; + use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; /// Builds an `App` with `MinimalPlugins` and the overlay system /// registered, plus the resources the system needs. Callers @@ -629,7 +629,7 @@ mod tests { // 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black) // — same colour family, illegal. Tableau(2) must NOT be // highlighted. - let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic); + let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic); set_tableau_top( &mut game, 2, diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 6da749a..d3ee1a2 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -362,7 +362,7 @@ mod tests { use crate::progress_plugin::ProgressPlugin; use crate::table_plugin::TablePlugin; #[allow(unused_imports)] - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { let mut app = App::new(); @@ -391,7 +391,7 @@ mod tests { // Replace the GameState with one whose seed matches the daily seed. app.world_mut().resource_mut::().0 = - GameState::new(daily_seed, DrawMode::DrawOne); + GameState::new(daily_seed, DrawStockConfig::DrawOne); app.world_mut().write_message(GameWonEvent { score: 500, @@ -419,7 +419,7 @@ mod tests { let daily_seed = app.world().resource::().seed; // Use a deliberately different seed. app.world_mut().resource_mut::().0 = - GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne); + GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne); app.world_mut().write_message(GameWonEvent { score: 500, @@ -442,7 +442,7 @@ mod tests { let mut app = headless_app(); let daily_seed = app.world().resource::().seed; app.world_mut().resource_mut::().0 = - GameState::new(daily_seed, DrawMode::DrawOne); + GameState::new(daily_seed, DrawStockConfig::DrawOne); app.world_mut().write_message(GameWonEvent { score: 500, diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 3e38495..3e832dc 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -846,13 +846,13 @@ mod tests { fn shake_anim_skipped_under_reduce_motion() { use bevy::ecs::message::Messages; use solitaire_core::Tableau; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_data::Settings; let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(FeedbackAnimPlugin); - app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne))); + app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.insert_resource(SettingsResource(Settings { reduce_motion_mode: true, ..Settings::default() @@ -900,13 +900,13 @@ mod tests { #[test] fn foundation_flourish_skipped_under_reduce_motion() { use bevy::ecs::message::Messages; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_data::Settings; let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(FeedbackAnimPlugin); - app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne))); + app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.insert_resource(SettingsResource(Settings { reduce_motion_mode: true, ..Settings::default() diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 620128e..d75cc7d 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -14,7 +14,7 @@ use bevy::prelude::*; 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_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET}; #[allow(deprecated)] use solitaire_data::latest_replay_path; @@ -157,12 +157,12 @@ impl Plugin for GamePlugin { .is_some_and(|g| g.move_count() > 0 && !g.is_won()); let (initial_state, pending_restore) = if prompt_worthy { ( - GameState::new(seed_from_system_time(), DrawMode::DrawOne), + GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne), saved, ) } else { ( - saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)), + saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)), None, ) }; @@ -388,7 +388,7 @@ fn poll_pending_new_game_seed( /// Pure helper extracted for testability — `new_game_with_solver_*` /// engine tests in the same file exercise this path. -pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 { +pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawStockConfig) -> u64 { let mut seed = initial_seed; for _ in 0..SOLVER_DEAL_RETRY_CAP { match GameState::solve_fresh_deal( @@ -830,8 +830,8 @@ fn handle_draw( Vec::new() } else { let draw_count = match game.0.draw_mode() { - DrawMode::DrawOne => 1_usize, - DrawMode::DrawThree => 3_usize, + DrawStockConfig::DrawOne => 1_usize, + DrawStockConfig::DrawThree => 3_usize, }; let n = stock.len(); let take = n.min(draw_count); @@ -1324,7 +1324,7 @@ mod tests { app.insert_resource(PendingRestoredGame(None)); // Override the system-time seed with a known value. app.world_mut().resource_mut::().0 = - GameState::new(seed, DrawMode::DrawOne); + GameState::new(seed, DrawStockConfig::DrawOne); app } @@ -1540,7 +1540,7 @@ mod tests { app.insert_resource(GameStatePath(Some(path.clone()))); // Override the seed so we can verify it was written. app.world_mut().resource_mut::().0 = - GameState::new(7654, DrawMode::DrawOne); + GameState::new(7654, DrawStockConfig::DrawOne); app.world_mut().write_message(AppExit::Success); app.update(); @@ -1559,7 +1559,7 @@ mod tests { let path = tmp_gs_path("new_game_delete"); // Pre-create a saved file. - save_game_state_to(&path, &GameState::new(1, DrawMode::DrawOne)).unwrap(); + save_game_state_to(&path, &GameState::new(1, DrawStockConfig::DrawOne)).unwrap(); assert!(path.exists()); let mut app = test_app(1); @@ -1693,7 +1693,7 @@ mod tests { fn has_legal_moves_returns_true_for_fresh_game() { // A fresh deal always has a non-empty stock (24 cards), so drawing // is always a legal move regardless of the initial face-up tableau cards. - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); assert!( has_legal_moves(&game), "fresh deal must contain at least one legal move" @@ -1707,7 +1707,7 @@ mod tests { // immediately placed. The game is only stuck when both stock AND waste // are exhausted and no visible card can be moved. use solitaire_core::card::{Card, Deck, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); for foundation in [ Foundation::Foundation1, Foundation::Foundation2, @@ -1743,7 +1743,7 @@ mod tests { #[test] fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() { use solitaire_core::card::{Card, Deck, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Empty stock and waste so draw is NOT available. game.set_test_stock_cards(Vec::new()); @@ -1787,7 +1787,7 @@ mod tests { // card of its column the previous code would return false (softlock) // even though the player can still move that run. use solitaire_core::card::{Card, Deck, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); game.set_test_stock_cards(Vec::new()); game.set_test_waste_cards(Vec::new()); @@ -2185,7 +2185,7 @@ mod tests { assert_eq!(loaded.seed, 7654, "seed must match the live game state"); assert_eq!( loaded.draw_mode, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, "draw_mode must be captured" ); assert_eq!( @@ -2326,7 +2326,7 @@ mod tests { "with solver toggle off, the requested seed must be honoured exactly" ); // Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte. - let expected = GameState::new(999, DrawMode::DrawOne); + let expected = GameState::new(999, DrawStockConfig::DrawOne); for tableau in [ Tableau::Tableau1, Tableau::Tableau2, @@ -2403,7 +2403,7 @@ mod tests { // // Seed 394 was previously Unwinnable under the old DFS; now it resolves // as Inconclusive, so the helper must accept it immediately. - let chosen = choose_winnable_seed(394, DrawMode::DrawOne); + let chosen = choose_winnable_seed(394, DrawStockConfig::DrawOne); assert_eq!( chosen, 394, "seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is" diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 01bbfc4..fd8d89f 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -16,7 +16,7 @@ use bevy::input::ButtonInput; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; -use solitaire_core::{DrawMode, game_state::DifficultyLevel}; +use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel}; use solitaire_data::save_settings_to; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; @@ -432,7 +432,7 @@ fn build_home_context<'a>( zen_best: stats.map_or(0, |s| s.0.zen_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), daily_today, - draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne), + draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne), font_res, difficulty_expanded, last_difficulty: settings.and_then(|s| s.0.last_difficulty), @@ -620,9 +620,9 @@ fn handle_home_draw_mode_buttons( return; }; let target = if want_one { - DrawMode::DrawOne + DrawStockConfig::DrawOne } else { - DrawMode::DrawThree + DrawStockConfig::DrawThree }; if settings.0.draw_mode == target { return; // already in this mode — avoid a redundant respawn. @@ -857,7 +857,7 @@ struct HomeContext<'a> { challenge_best: u32, daily_streak: u32, daily_today: Option, - draw_mode: DrawMode, + draw_mode: DrawStockConfig, font_res: Option<&'a FontResource>, /// Whether the difficulty section header is currently expanded. difficulty_expanded: bool, @@ -1038,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) ..default() }; - let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne); + let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne); parent .spawn(Node { diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 8fc5319..631d511 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -10,7 +10,7 @@ use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::Suit; -use solitaire_core::{DrawMode, game_state::GameMode}; +use solitaire_core::{DrawStockConfig, game_state::GameMode}; use crate::auto_complete_plugin::AutoCompleteState; #[cfg(not(target_arch = "wasm32"))] @@ -2284,8 +2284,8 @@ fn update_hud( if let Ok(mut t) = mode_q.single_mut() { **t = match g.mode { GameMode::Classic => match g.draw_mode() { - DrawMode::DrawOne => String::new(), - DrawMode::DrawThree => "Draw 3".to_string(), + DrawStockConfig::DrawOne => String::new(), + DrawStockConfig::DrawThree => "Draw 3".to_string(), }, GameMode::Zen => "ZEN".to_string(), GameMode::Challenge => "CHALLENGE".to_string(), @@ -2334,7 +2334,7 @@ fn update_hud( // --- Draw-cycle indicator (Draw-Three mode only) --- if let Ok(mut t) = draw_cycle_q.single_mut() { - **t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree { + **t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree { // Hide when not in Draw-Three or after the game is won. String::new() } else { @@ -2726,7 +2726,7 @@ mod tests { use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; use chrono::Local; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { let mut app = App::new(); @@ -2747,7 +2747,7 @@ mod tests { fn update_hud_runs_after_game_mutation_without_panic() { let mut app = headless_app(); app.world_mut().resource_mut::().0 = - GameState::new(42, DrawMode::DrawOne); + GameState::new(42, DrawStockConfig::DrawOne); app.update(); } @@ -2784,7 +2784,7 @@ mod tests { use solitaire_core::game_state::GameMode; let mut app = headless_app(); app.world_mut().resource_mut::().0 = - GameState::new_with_mode(42, DrawMode::DrawThree, GameMode::Classic); + GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic); app.update(); assert_eq!(read_hud_text::(&mut app), "Draw 3"); } @@ -2794,7 +2794,7 @@ mod tests { use solitaire_core::game_state::GameMode; let mut app = headless_app(); app.world_mut().resource_mut::().0 = - GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen); + GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen); app.update(); // Zen mode spec: "No score display" → text must be empty. assert_eq!(read_hud_text::(&mut app), ""); @@ -3037,7 +3037,7 @@ mod tests { let mut app = headless_app(); // Draw-One, no recycles yet — text must be empty. app.world_mut().resource_mut::().0 = - GameState::new(42, DrawMode::DrawOne); + GameState::new(42, DrawStockConfig::DrawOne); app.update(); assert_eq!(read_hud_text::(&mut app), ""); } @@ -3047,7 +3047,7 @@ mod tests { let mut app = headless_app(); // Draw-Three, no recycles yet — text must also be empty. app.world_mut().resource_mut::().0 = - GameState::new(42, DrawMode::DrawThree); + GameState::new(42, DrawStockConfig::DrawThree); app.update(); assert_eq!(read_hud_text::(&mut app), ""); } @@ -3055,7 +3055,7 @@ mod tests { #[test] fn recycles_hud_shows_count_draw_three() { let mut app = headless_app(); - let mut gs = GameState::new(42, DrawMode::DrawThree); + let mut gs = GameState::new(42, DrawStockConfig::DrawThree); gs.force_test_recycles(3); app.world_mut().resource_mut::().0 = gs; app.update(); @@ -3066,7 +3066,7 @@ mod tests { fn recycles_hud_shows_count_draw_one() { 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); + let mut gs = GameState::new(42, DrawStockConfig::DrawOne); gs.force_test_recycles(2); app.world_mut().resource_mut::().0 = gs; app.update(); diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 1e876f9..3874dfe 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -54,7 +54,7 @@ use crate::settings_plugin::SettingsResource; use crate::time_attack_plugin::TimeAttackResource; use crate::touch_selection_plugin::TouchSelectionState; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING}; -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; /// System-set labels used to anchor external systems relative to the touch /// drag pipeline without duplicating the internal chain ordering. @@ -1173,7 +1173,7 @@ fn card_position( y_offset -= layout.card_size.y * step; } Vec2::new(base.x, base.y + y_offset) - } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree { + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { // In Draw-Three mode the top 3 waste cards are fanned in X to match // card_plugin::card_positions(). Hit-testing must use the same offsets // so clicking the visually rightmost (top) card actually registers. @@ -1830,7 +1830,7 @@ mod tests { use super::*; use crate::layout::compute_layout; use solitaire_core::{Foundation, Tableau}; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; fn clear_test_piles(game: &mut GameState) { game.set_test_stock_cards(Vec::new()); @@ -1898,7 +1898,7 @@ mod tests { #[test] fn find_draggable_picks_top_of_tableau() { - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // In tableau 6, the visually topmost card is the last (face-up) one. @@ -1912,7 +1912,7 @@ mod tests { #[test] fn find_draggable_skips_face_down_cards() { - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at @@ -1934,7 +1934,7 @@ mod tests { // at 0.12 — so for any column with face-down cards above the // face-up bottom card, clicking the visible card face missed the // hit-test box and only the bottom strip of the card responded. - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card @@ -1952,7 +1952,7 @@ mod tests { #[test] fn find_draggable_returns_run_when_picking_mid_stack() { // Manually construct a tableau with three face-up cards all stacked. - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let king = Card::new(D::Deck1, Suit::Spades, Rank::King); @@ -1979,7 +1979,7 @@ mod tests { #[test] fn find_draggable_skips_non_top_waste_card() { - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); @@ -1998,7 +1998,7 @@ mod tests { #[test] fn find_drop_target_hits_empty_tableau_pile_marker() { - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Move all cards out of tableau 0 so its marker is the only drop area. let mut game = game; @@ -2015,7 +2015,7 @@ mod tests { #[test] fn find_drop_target_returns_none_for_origin() { - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)]; let target = find_drop_target( @@ -2029,7 +2029,7 @@ mod tests { #[test] fn pile_drop_rect_extends_for_tableau_with_cards() { - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Tableau 6 has 7 cards. let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game); @@ -2046,8 +2046,8 @@ mod tests { fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() { use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - use solitaire_core::{DrawMode, game_state::GameMode}; - let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic); + use solitaire_core::{DrawStockConfig, game_state::GameMode}; + let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic); // Three waste cards; top (four_clubs) is rightmost in the fan. let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); @@ -2072,7 +2072,7 @@ mod tests { #[test] fn find_draggable_returns_none_for_click_on_empty_pile() { - let mut game = GameState::new(42, DrawMode::DrawOne); + let mut game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Clear tableau 0 so it's an empty slot. game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); @@ -2086,7 +2086,7 @@ mod tests { #[test] fn pile_drop_rect_is_card_sized_for_non_tableau() { - let game = GameState::new(42, DrawMode::DrawOne); + let game = GameState::new(42, DrawStockConfig::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); for pile in [ KlondikePile::Stock, @@ -2105,7 +2105,7 @@ mod tests { fn best_destination_returns_none_when_no_legal_move() { use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Clear everything except one card that has nowhere to go. clear_test_piles(&mut game); @@ -2123,7 +2123,7 @@ mod tests { fn best_tableau_destination_for_stack_skips_source_pile() { use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); clear_test_piles(&mut game); @@ -2149,7 +2149,7 @@ mod tests { fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() { use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); clear_test_piles(&mut game); @@ -2178,7 +2178,7 @@ mod tests { fn find_hint_finds_ace_to_foundation() { use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Place Ace of Clubs on top of tableau 0. clear_test_piles(&mut game); @@ -2222,7 +2222,7 @@ mod tests { fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() { use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Remove all foundation, tableau, and waste cards so no pile-to-pile // move exists. Leave one card in the stock. @@ -2431,7 +2431,7 @@ mod tests { app.insert_resource(crate::layout::LayoutResource( crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), )); - app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne))); + app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); app.add_systems(Update, handle_keyboard_hint); // Simulate the H key being pressed this frame. diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index 4a38976..55a8307 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -21,7 +21,7 @@ //! active opens the overlay as normal. use bevy::prelude::*; -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; use solitaire_data::save_game_state_to; use crate::events::{ @@ -86,10 +86,10 @@ struct ForfeitConfirmButton; /// Returns the human-readable label for a draw mode. /// /// Used on the pause overlay draw-mode toggle button. -pub fn draw_mode_label(mode: DrawMode) -> &'static str { +pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str { match mode { - DrawMode::DrawOne => "Draw 1", - DrawMode::DrawThree => "Draw 3", + DrawStockConfig::DrawOne => "Draw 1", + DrawStockConfig::DrawThree => "Draw 3", } } @@ -273,9 +273,9 @@ fn handle_pause_draw_buttons( } let Some(mut settings) = settings else { return }; let new_mode = if pressed_one { - DrawMode::DrawOne + DrawStockConfig::DrawOne } else { - DrawMode::DrawThree + DrawStockConfig::DrawThree }; if settings.0.draw_mode == new_mode { return; @@ -477,7 +477,7 @@ fn spawn_pause_screen( commands: &mut Commands, level: Option, streak: Option, - draw_mode: Option, + draw_mode: Option, font_res: Option<&FontResource>, ) { spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| { @@ -516,7 +516,7 @@ fn spawn_pause_screen( /// `Tertiary` (recessed), giving an obvious selection state at a glance. fn spawn_draw_mode_row( parent: &mut ChildSpawnerCommands, - mode: DrawMode, + mode: DrawStockConfig, font_res: Option<&FontResource>, ) { let label_font = TextFont { @@ -530,8 +530,8 @@ fn spawn_draw_mode_row( ..default() }; let (one_variant, three_variant) = match mode { - DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary), - DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary), + DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary), + DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary), }; parent .spawn(Node { @@ -800,20 +800,20 @@ mod tests { #[test] fn draw_mode_label_draw_one() { - assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1"); + assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1"); } #[test] fn draw_mode_label_draw_three() { - assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3"); + assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3"); } /// Both variants are covered so the match is exhaustive — this test would - /// fail to compile if a new DrawMode variant were added without updating + /// fail to compile if a new DrawStockConfig variant were added without updating /// `draw_mode_label`. #[test] fn draw_mode_label_covers_all_variants() { - for mode in [DrawMode::DrawOne, DrawMode::DrawThree] { + for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] { let label = draw_mode_label(mode); assert!( !label.is_empty(), @@ -842,7 +842,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .draw_mode = DrawMode::DrawOne; + .draw_mode = DrawStockConfig::DrawOne; // Set paused so handle_pause_draw_toggle acts. app.world_mut().resource_mut::().0 = true; @@ -856,7 +856,7 @@ mod tests { let mode = &app.world().resource::().0.draw_mode; assert_eq!( *mode, - DrawMode::DrawThree, + DrawStockConfig::DrawThree, "pressing Draw 3 must set mode to DrawThree" ); @@ -869,7 +869,7 @@ mod tests { let mode2 = &app.world().resource::().0.draw_mode; assert_eq!( *mode2, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, "pressing Draw 1 must set mode to DrawOne" ); @@ -965,11 +965,11 @@ mod tests { /// Provides a fresh `GameStateResource` (not won) so the modal can /// open. `move_count` doesn't matter — the gate is just `!is_won`. fn forfeit_app() -> App { - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.init_resource::>(); - app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne))); + app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); app.update(); app } @@ -1020,11 +1020,11 @@ mod tests { /// hotkey was received but is currently a no-op. #[test] fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() { - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.init_resource::>(); - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); game.set_test_won(true); app.insert_resource(GameStateResource(game)); app.update(); diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index 1e9fac0..9e6fae5 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -180,7 +180,7 @@ mod tests { use crate::input_plugin::HintSolverConfig; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Deck, Rank, Suit}; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal Bevy app exercising only the polling system /// and the resources/messages it touches. @@ -209,7 +209,7 @@ mod tests { /// foundations hold A..Q for each suit, four Kings sit on /// tableau columns 0..3, stock and waste empty. fn near_finished_state() -> GameState { - let mut game = GameState::new(1, DrawMode::DrawOne); + let mut game = GameState::new(1, DrawStockConfig::DrawOne); game.set_test_stock_cards(Vec::new()); game.set_test_waste_cards(Vec::new()); for foundation in [ diff --git a/solitaire_engine/src/play_by_seed_plugin.rs b/solitaire_engine/src/play_by_seed_plugin.rs index 35720a1..2dff997 100644 --- a/solitaire_engine/src/play_by_seed_plugin.rs +++ b/solitaire_engine/src/play_by_seed_plugin.rs @@ -23,7 +23,7 @@ use bevy::input::ButtonInput; use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; use solitaire_core::game_state::GameState; use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome}; @@ -340,7 +340,7 @@ fn tick_debounce_and_spawn_solver_task( let draw_mode = settings .as_ref() - .map_or(DrawMode::DrawOne, |s| s.0.draw_mode); + .map_or(DrawStockConfig::DrawOne, |s| s.0.draw_mode); let task = AsyncComputeTaskPool::get().spawn(async move { GameState::solve_fresh_deal( seed, diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index ba02cca..31faf3f 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -797,7 +797,7 @@ mod tests { use crate::layout::compute_layout; use bevy::ecs::message::Messages; use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit}; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the /// resources / messages it depends on. No window, no camera — the @@ -820,7 +820,7 @@ mod tests { /// destination — Foundation(0) — under the standard rules /// (`can_place_on_foundation` accepts the Ace on an empty foundation). fn ace_only_state() -> GameState { - let mut g = GameState::new(0, DrawMode::DrawOne); + let mut g = GameState::new(0, DrawStockConfig::DrawOne); // Wipe everything. g.set_test_stock_cards(Vec::new()); g.set_test_waste_cards(Vec::new()); @@ -854,7 +854,7 @@ mod tests { /// Place a face-down King on Tableau(0). `find_top_face_up_card_at` /// must skip it. fn face_down_only_state() -> GameState { - let mut g = GameState::new(0, DrawMode::DrawOne); + let mut g = GameState::new(0, DrawStockConfig::DrawOne); g.set_test_stock_cards(Vec::new()); g.set_test_waste_cards(Vec::new()); for foundation in [ diff --git a/solitaire_engine/src/replay_overlay/tests.rs b/solitaire_engine/src/replay_overlay/tests.rs index cde2413..e22e845 100644 --- a/solitaire_engine/src/replay_overlay/tests.rs +++ b/solitaire_engine/src/replay_overlay/tests.rs @@ -2,7 +2,7 @@ use super::*; use chrono::NaiveDate; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Rank, Suit}; -use solitaire_core::{DrawMode, game_state::GameMode}; +use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; use solitaire_data::{Replay, ReplayMove}; @@ -13,7 +13,7 @@ use solitaire_data::{Replay, ReplayMove}; fn synthetic_replay(move_count: usize) -> Replay { Replay::new( 42, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 120, 1_000, @@ -2314,7 +2314,7 @@ fn format_suit_glyph_all_suits() { fn format_foundations_row_empty_board() { let game = solitaire_core::game_state::GameState::new_with_mode( 42, - solitaire_core::DrawMode::DrawOne, + solitaire_core::DrawStockConfig::DrawOne, solitaire_core::game_state::GameMode::Classic, ); assert_eq!(format_foundations_row(&game), "F: -- -- -- --"); @@ -2326,7 +2326,7 @@ fn format_foundations_row_empty_board() { fn format_stock_waste_row_initial_state() { let game = solitaire_core::game_state::GameState::new_with_mode( 42, - solitaire_core::DrawMode::DrawOne, + solitaire_core::DrawStockConfig::DrawOne, solitaire_core::game_state::GameMode::Classic, ); let text = format_stock_waste_row(&game); diff --git a/solitaire_engine/src/replay_playback.rs b/solitaire_engine/src/replay_playback.rs index ecca062..3a9f180 100644 --- a/solitaire_engine/src/replay_playback.rs +++ b/solitaire_engine/src/replay_playback.rs @@ -556,7 +556,7 @@ mod tests { use bevy::time::TimeUpdateStrategy; use chrono::NaiveDate; use solitaire_core::{KlondikePile, Tableau}; - use solitaire_core::{DrawMode, game_state::GameMode}; + use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; use std::time::Duration; @@ -598,7 +598,7 @@ mod tests { fn sample_replay_three_moves() -> Replay { Replay::new( 12345, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 60, 500, @@ -771,7 +771,7 @@ mod tests { let mut app = headless_app(); let one_move = Replay::new( 42, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 10, 100, @@ -880,7 +880,7 @@ mod tests { fn ten_draws_replay() -> Replay { Replay::new( 7, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 10, 100, diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index b84bd95..6eb31ee 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -935,7 +935,7 @@ mod tests { use bevy::ecs::message::Messages; use solitaire_core::card::{Card, Deck, Rank, Suit}; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no /// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` / @@ -968,7 +968,7 @@ mod tests { /// Ace first). It cannot go to an empty tableau (only Kings). /// Empty tableaus T3..T6 only accept Kings, so they are filtered out. fn deterministic_state() -> GameState { - let mut g = GameState::new(0, DrawMode::DrawOne); + let mut g = GameState::new(0, DrawStockConfig::DrawOne); // Clear stock, waste, all tableaus. g.set_test_stock_cards(Vec::new()); g.set_test_waste_cards(Vec::new()); diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 32dbe19..0688680 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use bevy::ui::{ComputedNode, UiGlobalTransform}; use bevy::window::{WindowMoved, WindowResized}; -use solitaire_core::DrawMode; +use solitaire_core::DrawStockConfig; use solitaire_data::{ AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme, @@ -1086,8 +1086,8 @@ fn handle_settings_buttons( } SettingsButton::ToggleDrawMode => { settings.0.draw_mode = match settings.0.draw_mode { - DrawMode::DrawOne => DrawMode::DrawThree, - DrawMode::DrawThree => DrawMode::DrawOne, + DrawStockConfig::DrawOne => DrawStockConfig::DrawThree, + DrawStockConfig::DrawThree => DrawStockConfig::DrawOne, }; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); @@ -1310,10 +1310,10 @@ fn handle_sync_buttons( } } -fn draw_mode_label(mode: &DrawMode) -> String { +fn draw_mode_label(mode: &DrawStockConfig) -> String { match mode { - DrawMode::DrawOne => "Draw 1".into(), - DrawMode::DrawThree => "Draw 3".into(), + DrawStockConfig::DrawOne => "Draw 1".into(), + DrawStockConfig::DrawThree => "Draw 3".into(), } } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 834a8b8..edbc3c9 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -1327,7 +1327,7 @@ mod tests { app.world_mut() .resource_mut::() .0 - .set_test_draw_mode(solitaire_core::DrawMode::DrawThree); + .set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree); app.world_mut().write_message(GameWonEvent { score: 500, @@ -1952,7 +1952,7 @@ mod tests { let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date"); let mut r = solitaire_data::Replay::new( 1, - solitaire_core::DrawMode::DrawOne, + solitaire_core::DrawStockConfig::DrawOne, solitaire_core::game_state::GameMode::Classic, time_seconds, 0, diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 64f7814..68a31b9 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -604,7 +604,7 @@ mod tests { /// would silently drop the link. #[test] fn upload_result_writes_share_url_into_replay_and_persists() { - use solitaire_core::{DrawMode, game_state::GameMode}; + use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_data::{ Replay, ReplayHistory, load_replay_history_from, save_replay_history_to, }; @@ -617,7 +617,7 @@ mod tests { // share_url — the upload-poll path must populate it. let initial = Replay::new( 42, - DrawMode::DrawOne, + DrawStockConfig::DrawOne, GameMode::Classic, 60, 500, diff --git a/solitaire_engine/src/time_attack_plugin.rs b/solitaire_engine/src/time_attack_plugin.rs index 97a8910..4c3c0cf 100644 --- a/solitaire_engine/src/time_attack_plugin.rs +++ b/solitaire_engine/src/time_attack_plugin.rs @@ -299,7 +299,7 @@ mod tests { use crate::game_plugin::GamePlugin; use crate::progress_plugin::ProgressPlugin; use crate::table_plugin::TablePlugin; - use solitaire_core::{DrawMode, game_state::GameState}; + use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { let mut app = App::new(); @@ -430,7 +430,7 @@ mod tests { }; // The current game must be in TimeAttack mode for auto-deal to fire. app.world_mut().resource_mut::().0 = - GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack); + GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack); app.world_mut().write_message(GameWonEvent { score: 500, @@ -454,7 +454,7 @@ mod tests { let mut app = headless_app(); // Default session is inactive. Game is TimeAttack mode — still no count. app.world_mut().resource_mut::().0 = - GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack); + GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack); 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 8f337c4..13fe48a 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -1205,7 +1205,7 @@ mod tests { .insert_resource(StatsResource(StatsSnapshot::default())) .insert_resource(GameStateResource(GameState::new( 0, - solitaire_core::DrawMode::DrawOne, + solitaire_core::DrawStockConfig::DrawOne, ))) .insert_resource(ProgressResource(PlayerProgress::default())); app.update(); @@ -1534,9 +1534,9 @@ mod tests { .challenge_index = 4; // Switch game mode to Challenge. { - use solitaire_core::DrawMode; + use solitaire_core::DrawStockConfig; app.world_mut().resource_mut::().0 = - GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge); + GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge); } app.world_mut().write_message(GameWonEvent { @@ -1580,13 +1580,13 @@ mod tests { /// mode-multiplier rows. #[test] fn cache_win_data_captures_undo_count_and_mode() { - use solitaire_core::DrawMode; + use solitaire_core::DrawStockConfig; let mut app = make_app(); // Set up a Zen-mode game with 2 undos used. { let mut game = app.world_mut().resource_mut::(); - game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen); + game.0 = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Zen); game.0.force_test_undos(2); } diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index c69e11d..05775b7 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -23,7 +23,7 @@ use solitaire_core::{Foundation, KlondikePile, Tableau}; use serde::{Deserialize, Serialize}; use solitaire_core::card::Suit; use solitaire_core::error::MoveError; -use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; +use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::klondike_adapter::{ SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index, }; @@ -48,7 +48,7 @@ pub struct Replay { #[serde(default)] pub schema_version: u32, pub seed: u64, - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, pub mode: GameMode, pub time_seconds: u64, pub final_score: i32, @@ -332,7 +332,7 @@ pub struct DebugInvariantReport { #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct DebugSnapshot { pub seed: u64, - pub draw_mode: DrawMode, + pub draw_mode: DrawStockConfig, pub mode: GameMode, pub state: GameSnapshot, pub legal_moves: Vec, @@ -726,9 +726,9 @@ impl SolitaireGame { #[cfg(feature = "console_error_panic_hook")] console_error_panic_hook::set_once(); let dm = if draw_three { - DrawMode::DrawThree + DrawStockConfig::DrawThree } else { - DrawMode::DrawOne + DrawStockConfig::DrawOne }; SolitaireGame { game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic), @@ -952,7 +952,7 @@ mod tests { key } - fn run_autonomous(seed: u64, draw_mode: DrawMode, max_steps: usize) -> DebugSnapshot { + fn run_autonomous(seed: u64, draw_mode: DrawStockConfig, max_steps: usize) -> DebugSnapshot { let mut game = SolitaireGame { game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic), }; @@ -983,7 +983,7 @@ mod tests { #[test] fn debug_snapshot_exposes_replayable_seed_and_history() { let seed = 42_u64; - let final_snapshot = run_autonomous(seed, DrawMode::DrawOne, 1500); + let final_snapshot = run_autonomous(seed, DrawStockConfig::DrawOne, 1500); assert_eq!(final_snapshot.seed, seed); assert!( !final_snapshot.state_json.is_empty(), @@ -1000,7 +1000,7 @@ mod tests { #[test] fn replay_moves_export_is_json_compatible_and_replayable() { let seed = 7_u64; - let draw_mode = DrawMode::DrawThree; + let draw_mode = DrawStockConfig::DrawThree; let mut game = SolitaireGame { game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic), }; @@ -1098,9 +1098,9 @@ mod tests { fn debug_api_autonomous_seed_batch_smoke() { for seed in 0_u64..128_u64 { let draw_mode = if seed % 2 == 0 { - DrawMode::DrawOne + DrawStockConfig::DrawOne } else { - DrawMode::DrawThree + DrawStockConfig::DrawThree }; let snapshot = run_autonomous(seed, draw_mode, 2000); assert_invariants(&snapshot, seed); @@ -1112,9 +1112,9 @@ mod tests { 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 + DrawStockConfig::DrawOne } else { - DrawMode::DrawThree + DrawStockConfig::DrawThree }; let snapshot = run_autonomous(seed, draw_mode, 3000); assert_invariants(&snapshot, seed); @@ -1125,7 +1125,7 @@ mod tests { fn serialize_from_saved_round_trip() { let seed = 55_u64; let mut game = SolitaireGame { - game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic), + game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic), }; // Advance a few moves so there is non-trivial state to round-trip. for _ in 0..20 { @@ -1160,7 +1160,7 @@ mod tests { fn undo_reverts_to_prior_state() { let seed = 99_u64; let mut game = SolitaireGame { - game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic), + game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic), }; let before_key = board_key(&game.debug_snapshot_native().state); @@ -1198,7 +1198,7 @@ mod tests { fn draw_one_advances_waste_by_one() { let seed = 1_u64; let mut game = SolitaireGame { - game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic), + game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic), }; let stock_before = game.game.stock_cards().len(); @@ -1224,7 +1224,7 @@ mod tests { fn draw_three_advances_waste_by_three() { let seed = 1_u64; let mut game = SolitaireGame { - game: GameState::new_with_mode(seed, DrawMode::DrawThree, GameMode::Classic), + game: GameState::new_with_mode(seed, DrawStockConfig::DrawThree, GameMode::Classic), }; let stock_before = game.game.stock_cards().len(); @@ -1254,7 +1254,7 @@ mod tests { fn debug_apply_move_json_stock_click_advances_waste() { let seed = 3_u64; let mut game = SolitaireGame { - game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic), + game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic), }; let waste_before = game.game.waste_cards().len(); From e0a858d4e8d7a313ef29038dec4cc88e64481f02 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 11 Jun 2026 19:33:47 -0700 Subject: [PATCH 09/16] refactor: remove card.rs / card_to_id; use card_game::Card directly (#83) card_to_id was a frankenstein 0..=51 id shim. Replace it with card_game::Card: - feedback_anim deal jitter now seeds off a hash of the Card itself - radial_menu RightClickRadialState.cards: Vec -> Vec - wasm CardSnapshot.id: u32 -> Card (serialises transparently as a plain JS number, the same opaque key the renderer already used; new test asserts the JSON id field is a number) - wasm DebugInvariantReport deck-completeness check reworked from a [bool;52] index into a HashSet + Card::new reference deck; the out-of-range check is dropped since a Card is always valid Delete card.rs entirely: the Card/Deck/Rank/Suit re-exports move to the crate root and the 69 `solitaire_core::card::` import paths flatten to `solitaire_core::`. The JS card.id is purely an opaque identity key (Map key / dataset.cardId, no arithmetic, card faces render from rank+suit), so the value change is safe. cargo test --workspace and clippy --workspace --all-targets -- -D warnings green. Closes #83 Co-Authored-By: Claude Opus 4.8 (1M context) --- solitaire_core/src/card.rs | 23 ----- solitaire_core/src/lib.rs | 3 +- solitaire_engine/src/assets/card_face_svg.rs | 2 +- solitaire_engine/src/auto_complete_plugin.rs | 4 +- .../src/card_animation/interaction.rs | 2 +- solitaire_engine/src/card_plugin.rs | 4 +- solitaire_engine/src/cursor_plugin.rs | 4 +- solitaire_engine/src/events.rs | 2 +- solitaire_engine/src/feedback_anim_plugin.rs | 22 ++--- solitaire_engine/src/game_plugin.rs | 18 ++-- solitaire_engine/src/hud_plugin.rs | 2 +- solitaire_engine/src/input_plugin.rs | 38 ++++---- solitaire_engine/src/pending_hint.rs | 2 +- solitaire_engine/src/radial_menu.rs | 13 ++- solitaire_engine/src/replay_overlay/format.rs | 2 +- solitaire_engine/src/replay_overlay/tests.rs | 2 +- solitaire_engine/src/resources.rs | 2 +- solitaire_engine/src/selection_plugin.rs | 18 ++-- solitaire_engine/src/table_plugin.rs | 4 +- solitaire_engine/src/theme/mod.rs | 2 +- solitaire_engine/src/theme/plugin.rs | 2 +- .../src/touch_selection_plugin.rs | 4 +- solitaire_wasm/src/lib.rs | 94 +++++++++++-------- 23 files changed, 132 insertions(+), 137 deletions(-) delete mode 100644 solitaire_core/src/card.rs diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs deleted file mode 100644 index fe6e753..0000000 --- a/solitaire_core/src/card.rs +++ /dev/null @@ -1,23 +0,0 @@ -pub use card_game::{Card, Deck, Rank, Suit}; - -/// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the -/// upstream `card_game::Card` bit-packing. -/// -/// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is -/// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King). -/// The deck id is intentionally ignored so the id depends only on the visible -/// face. -/// -/// This is the single source of truth shared by `CardEntity` numeric tracking, -/// deterministic per-card animation jitter, and the WASM replay layer — those -/// must agree byte-for-byte so replay snapshots are identical across the -/// desktop and browser builds. -pub fn card_to_id(card: &Card) -> u32 { - let suit_index: u32 = match card.suit() { - Suit::Clubs => 0, - Suit::Diamonds => 1, - Suit::Hearts => 2, - Suit::Spades => 3, - }; - suit_index * 13 + (card.rank() as u32 - 1) -} diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 702f431..8655f83 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -1,5 +1,4 @@ pub mod achievement; -pub mod card; pub mod error; pub mod game_state; pub mod klondike_adapter; @@ -12,7 +11,7 @@ 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, SolveError}; +pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; // Solvability check API (delegates to `card_game::Session::solve`); replaces the diff --git a/solitaire_engine/src/assets/card_face_svg.rs b/solitaire_engine/src/assets/card_face_svg.rs index c45a955..5fd7223 100644 --- a/solitaire_engine/src/assets/card_face_svg.rs +++ b/solitaire_engine/src/assets/card_face_svg.rs @@ -22,7 +22,7 @@ //! red/black colour split. use bevy::math::UVec2; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; /// Target rasterisation size in pixels (2:3 aspect, half the default /// `SvgLoaderSettings` resolution). diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 55fae1b..68df902 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -168,7 +168,7 @@ mod tests { use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; use solitaire_core::{Foundation, KlondikePile, Tableau}; - use solitaire_core::card::{Deck, Rank, Suit}; + use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { @@ -207,7 +207,7 @@ mod tests { } g.set_test_tableau_cards( Tableau::Tableau1, - vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], + vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); g.set_test_auto_completable(true); let expected = ( diff --git a/solitaire_engine/src/card_animation/interaction.rs b/solitaire_engine/src/card_animation/interaction.rs index b9bc4a8..7e18936 100644 --- a/solitaire_engine/src/card_animation/interaction.rs +++ b/solitaire_engine/src/card_animation/interaction.rs @@ -33,7 +33,7 @@ use std::collections::VecDeque; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use solitaire_core::card::Card; +use solitaire_core::Card; use super::animation::CardAnimation; use super::tuning::AnimationTuning; diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index b5caf43..ab89cfd 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -17,7 +17,7 @@ use bevy::prelude::*; use bevy::sprite::Anchor; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::{Card, Rank, Suit}; +use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; @@ -2472,7 +2472,7 @@ mod tests { use super::*; use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; - use solitaire_core::card::Deck; + use solitaire_core::Deck; /// Convenience constructor — all unit tests use Deck1. fn make_card(suit: Suit, rank: Rank) -> Card { diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 0726979..8bd4987 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -34,7 +34,7 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{DrawStockConfig, game_state::GameState}; @@ -580,7 +580,7 @@ mod tests { // ----------------------------------------------------------------------- use crate::layout::compute_layout; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; /// Builds an `App` with `MinimalPlugins` and the overlay system diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 3bcd8bb..e8c7ada 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -2,7 +2,7 @@ use bevy::prelude::Message; use solitaire_core::KlondikePile; -use solitaire_core::card::{Card, Suit}; +use solitaire_core::{Card, Suit}; use solitaire_core::game_state::GameMode; use solitaire_data::AchievementRecord; use solitaire_sync::SyncResponse; diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 3e832dc..5ef2689 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -43,7 +43,7 @@ use std::hash::{Hash, Hasher}; use bevy::prelude::*; use bevy::window::RequestRedraw; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::KlondikePile; use solitaire_core::klondike_adapter::foundation_from_slot; use solitaire_data::AnimSpeed; @@ -189,10 +189,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 { (jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 % } -// Per-card jitter keys off the shared stable card id so it matches the -// numeric identity used elsewhere (and on the WASM replay side). -use solitaire_core::card::card_to_id; - // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -413,10 +409,14 @@ fn start_deal_anim( for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() { let final_pos = transform.translation; - // ±10 % jitter, deterministic per card id, so the deal feels organic - // without losing reproducibility (a given seed still produces the - // same per-card stagger pattern across runs). - let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card))); + // ±10 % jitter, deterministic per card, so the deal feels organic + // without losing reproducibility (a given deal produces the same + // per-card stagger pattern across runs). The seed is a hash of the + // card's own identity — no separate numeric id needed. + let mut card_hasher = DefaultHasher::new(); + card_marker.card.hash(&mut card_hasher); + let per_card_stagger = + stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32)); commands.entity(entity).insert(( Transform::from_translation(stock_start.with_z(final_pos.z)), CardAnim { @@ -639,7 +639,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color { fn pile_cards( game: &solitaire_core::game_state::GameState, pile: &KlondikePile, -) -> Vec<(solitaire_core::card::Card, bool)> { +) -> Vec<(solitaire_core::Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -917,7 +917,7 @@ mod tests { .resource_mut::>() .write(FoundationCompletedEvent { slot: 0, - suit: solitaire_core::card::Suit::Spades, + suit: solitaire_core::Suit::Spades, }); app.update(); diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index d75cc7d..e822129 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -824,7 +824,7 @@ fn handle_draw( // so we can fire flip events after they land face-up in the waste. // Only relevant when stock is non-empty; a recycle moves waste back to // stock face-down, so no flip events are needed in that case. - let drawn_cards: Vec = { + let drawn_cards: Vec = { let stock = game.0.stock_cards(); if stock.is_empty() { Vec::new() @@ -1013,7 +1013,7 @@ pub fn record_replay_on_win( } } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::card::Card, bool)> { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -1391,7 +1391,7 @@ mod tests { #[test] fn new_game_request_reseeds() { let mut app = test_app(1); - let before: Vec = app + let before: Vec = app .world() .resource::() .0 @@ -1407,7 +1407,7 @@ mod tests { }); app.update(); - let after: Vec = app + let after: Vec = app .world() .resource::() .0 @@ -1649,7 +1649,7 @@ mod tests { #[test] fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut app = test_app(1); // Build a tableau with two face-up cards. { @@ -1706,7 +1706,7 @@ mod tests { // Klondike (unlimited recycles), even if the drawn card cannot be // immediately placed. The game is only stuck when both stock AND waste // are exhausted and no visible card can be moved. - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); for foundation in [ Foundation::Foundation1, @@ -1742,7 +1742,7 @@ mod tests { #[test] fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Empty stock and waste so draw is NOT available. @@ -1786,7 +1786,7 @@ mod tests { // If the only legal move involves a face-up card that is NOT the top // card of its column the previous code would return false (softlock) // even though the player can still move that run. - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); game.set_test_stock_cards(Vec::new()); @@ -1976,7 +1976,7 @@ mod tests { /// to have been a King. #[test] fn foundation_completed_event_does_not_fire_for_non_foundation_moves() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut app = test_app(1); // Reset the world: clear stock + waste so a draw isn't possible, diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 631d511..c52c88f 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -9,7 +9,7 @@ use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Suit; +use solitaire_core::Suit; use solitaire_core::{DrawStockConfig, game_state::GameMode}; use crate::auto_complete_plugin::AutoCompleteState; diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 3874dfe..f2ea1d9 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -27,7 +27,7 @@ use bevy::window::PrimaryWindow; #[cfg(not(target_os = "android"))] use bevy::window::{MonitorSelection, WindowMode}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; -use solitaire_core::card::{Card, Suit}; +use solitaire_core::{Card, Suit}; use solitaire_core::game_state::GameState; use crate::auto_complete_plugin::AutoCompleteState; @@ -1953,8 +1953,8 @@ mod tests { fn find_draggable_returns_run_when_picking_mid_stack() { // Manually construct a tableau with three face-up cards all stacked. let mut game = GameState::new(1, DrawStockConfig::DrawOne); - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let king = Card::new(D::Deck1, Suit::Spades, Rank::King); let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); @@ -1980,8 +1980,8 @@ mod tests { #[test] fn find_draggable_skips_non_top_waste_card() { let mut game = GameState::new(1, DrawStockConfig::DrawOne); - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]); @@ -2044,8 +2044,8 @@ mod tests { #[test] fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameMode}; let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic); // Three waste cards; top (four_clubs) is rightmost in the fan. @@ -2103,8 +2103,8 @@ mod tests { #[test] fn best_destination_returns_none_when_no_legal_move() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Clear everything except one card that has nowhere to go. @@ -2121,8 +2121,8 @@ mod tests { #[test] fn best_tableau_destination_for_stack_skips_source_pile() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); clear_test_piles(&mut game); @@ -2147,8 +2147,8 @@ mod tests { #[test] fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); clear_test_piles(&mut game); @@ -2176,8 +2176,8 @@ mod tests { #[test] fn find_hint_finds_ace_to_foundation() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Place Ace of Clubs on top of tableau 0. @@ -2220,8 +2220,8 @@ mod tests { /// are no other moves and the stock is non-empty. #[test] fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Remove all foundation, tableau, and waste cards so no pile-to-pile @@ -2273,8 +2273,8 @@ mod tests { /// gets a CardAnimation" — same coverage, new component. #[test] fn rejected_drag_inserts_card_animation_on_each_dragged_card() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; // Simulate a stack drag of two cards. let dragged_cards: Vec = vec![ Card::new(D::Deck1, Suit::Hearts, Rank::King), diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index 9e6fae5..3e7c9bf 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -179,7 +179,7 @@ mod tests { use crate::events::HintVisualEvent; use crate::input_plugin::HintSolverConfig; use solitaire_core::{Foundation, KlondikePile, Tableau}; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal Bevy app exercising only the polling system diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 31faf3f..fb72dea 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -48,7 +48,7 @@ use bevy::math::Vec2; use bevy::prelude::*; use bevy::window::PrimaryWindow; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::game_state::GameState; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; @@ -113,9 +113,9 @@ pub enum RightClickRadialState { /// radial is built around single-card foundation/tableau /// shortcuts and that matches the right-click highlight set). count: usize, - /// Card ids that would be moved (bottom-to-top order). Length + /// Cards that would be moved (bottom-to-top order). Length /// always equals `count`. Currently always one element. - cards: Vec, + cards: Vec, /// Pre-computed `(destination, icon_anchor_world_pos)` pairs. /// /// Anchors are evenly spaced around a ring of radius @@ -359,7 +359,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { } } -use solitaire_core::card::card_to_id; const fn foundations() -> [Foundation; 4] { [ @@ -500,7 +499,7 @@ fn radial_open_on_right_click( *state = RightClickRadialState::Active { source_pile, count: 1, - cards: vec![card_to_id(&card)], + cards: vec![card.clone()], legal_destinations, centre: world, hovered_index: None, @@ -573,7 +572,7 @@ fn radial_open_on_long_press( *state = RightClickRadialState::Active { source_pile, count: 1, - cards: vec![card_to_id(&card)], + cards: vec![card.clone()], legal_destinations, centre: world, hovered_index: None, @@ -796,7 +795,7 @@ mod tests { use super::*; use crate::layout::compute_layout; use bevy::ecs::message::Messages; - use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit}; + use solitaire_core::{Card as CoreCard, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the diff --git a/solitaire_engine/src/replay_overlay/format.rs b/solitaire_engine/src/replay_overlay/format.rs index 22ed3ee..d3e141b 100644 --- a/solitaire_engine/src/replay_overlay/format.rs +++ b/solitaire_engine/src/replay_overlay/format.rs @@ -1,7 +1,7 @@ use super::ReplayPlaybackState; use chrono::Datelike; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::{Card, Rank, Suit}; +use solitaire_core::{Card, Rank, Suit}; use solitaire_core::game_state::GameState; use solitaire_core::klondike_adapter::SavedKlondikePile; use solitaire_data::ReplayMove; diff --git a/solitaire_engine/src/replay_overlay/tests.rs b/solitaire_engine/src/replay_overlay/tests.rs index e22e845..32155ec 100644 --- a/solitaire_engine/src/replay_overlay/tests.rs +++ b/solitaire_engine/src/replay_overlay/tests.rs @@ -1,7 +1,7 @@ use super::*; use chrono::NaiveDate; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; use solitaire_data::{Replay, ReplayMove}; diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs index 109bebb..473aa38 100644 --- a/solitaire_engine/src/resources.rs +++ b/solitaire_engine/src/resources.rs @@ -7,7 +7,7 @@ use bevy::math::Vec2; use bevy::prelude::Resource; use chrono::{DateTime, Utc}; use solitaire_core::KlondikePile; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::game_state::GameState; /// Wraps the currently active `GameState`. Single source of truth for the in-progress game. diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index 6eb31ee..c87fdfe 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -38,7 +38,7 @@ use bevy::input::ButtonInput; use bevy::prelude::*; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::game_state::GameState; use crate::card_plugin::CardEntityIndex; @@ -534,7 +534,7 @@ fn handle_selection_keys( /// destination after a lift. Players who want a different column simply /// press the right-arrow key once or twice. pub(crate) fn legal_destinations_for( - _bottom: &solitaire_core::card::Card, + _bottom: &solitaire_core::Card, source: &KlondikePile, game: &GameState, stack_count: usize, @@ -579,7 +579,7 @@ pub(crate) fn legal_destinations_for( /// Walks backwards from the last element and stops at the first face-down card /// (or when the slice is exhausted). Returns at least `1` when the top card is /// face-up; returns `0` for an empty slice or when the top card is face-down. -fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize { +fn face_up_run_len(cards: &[(solitaire_core::Card, bool)]) -> usize { let mut count = 0; for (_, face_up) in cards.iter().rev() { if *face_up { @@ -598,7 +598,7 @@ fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize { /// handler can attempt a foundation move first and fall through to a /// multi-card stack move rather than accepting a single-card tableau move. fn try_foundation_dest( - card: &solitaire_core::card::Card, + card: &solitaire_core::Card, game: &solitaire_core::game_state::GameState, ) -> Option { let source = game.pile_containing_card(card.clone())?; @@ -886,7 +886,7 @@ mod tests { #[test] fn face_up_run_len_all_face_up() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![ (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true), @@ -897,7 +897,7 @@ mod tests { #[test] fn face_up_run_len_mixed_stops_at_face_down() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![ (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), @@ -910,7 +910,7 @@ mod tests { #[test] fn face_up_run_len_top_card_face_down_is_zero() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![ (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), @@ -920,7 +920,7 @@ mod tests { #[test] fn face_up_run_len_single_face_up_card() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)]; assert_eq!(face_up_run_len(&cards), 1); } @@ -934,7 +934,7 @@ mod tests { // ----------------------------------------------------------------------- use bevy::ecs::message::Messages; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index b8c6f90..8aa375b 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -7,7 +7,7 @@ use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Suit; +use solitaire_core::Suit; use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::hud_plugin::HudVisibility; @@ -520,7 +520,7 @@ fn sync_pile_marker_visibility( fn pile_cards( game: &solitaire_core::game_state::GameState, pile: &KlondikePile, -) -> Vec<(solitaire_core::card::Card, bool)> { +) -> Vec<(solitaire_core::Card, bool)> { match pile { KlondikePile::Stock => { let stock = game.stock_cards(); diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs index 86fde60..2e6d751 100644 --- a/solitaire_engine/src/theme/mod.rs +++ b/solitaire_engine/src/theme/mod.rs @@ -27,7 +27,7 @@ use bevy::reflect::TypePath; use serde::{Deserialize, Serialize}; use thiserror::Error; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; #[cfg(not(target_arch = "wasm32"))] pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index 6ffe8dc..f72e015 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -12,7 +12,7 @@ use bevy::asset::AssetEvent; use bevy::ecs::message::MessageReader; use bevy::math::UVec2; use bevy::prelude::*; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; use crate::assets::{ bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, diff --git a/solitaire_engine/src/touch_selection_plugin.rs b/solitaire_engine/src/touch_selection_plugin.rs index f26e854..d976f61 100644 --- a/solitaire_engine/src/touch_selection_plugin.rs +++ b/solitaire_engine/src/touch_selection_plugin.rs @@ -29,7 +29,7 @@ use bevy::ecs::message::MessageReader; use bevy::prelude::*; use solitaire_core::KlondikePile; -use solitaire_core::card::Card; +use solitaire_core::Card; use crate::card_plugin::CardEntity; use crate::events::StateChangedEvent; @@ -194,7 +194,7 @@ fn spawn_touch_highlight( mod tests { use super::*; use solitaire_core::Tableau; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; /// Three distinct test cards, used in place of the old `vec![1, 2, 3]` /// numeric ids. Identity is now the `Card` value. diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 05775b7..e85819f 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -21,7 +21,7 @@ use chrono::NaiveDate; use solitaire_core::{Foundation, KlondikePile, Tableau}; use serde::{Deserialize, Serialize}; -use solitaire_core::card::Suit; +use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::error::MoveError; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::klondike_adapter::{ @@ -77,9 +77,11 @@ 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, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct CardSnapshot { - pub id: u32, + /// Stable per-card identity for the JS renderer (an opaque key). Serialises + /// as the upstream `Card`'s transparent integer value. + pub id: Card, /// `"clubs" | "diamonds" | "hearts" | "spades"`. pub suit: &'static str, /// 1-13, where 1 is Ace and 13 is King. @@ -87,14 +89,10 @@ pub struct CardSnapshot { pub face_up: bool, } -// Stable 0..=51 card identity, shared with the desktop engine via -// solitaire_core so replay snapshots are identical across platforms. -use solitaire_core::card::card_to_id; - -impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot { - fn from((card, face_up): &(solitaire_core::card::Card, bool)) -> Self { +impl From<&(Card, bool)> for CardSnapshot { + fn from((card, face_up): &(Card, bool)) -> Self { Self { - id: card_to_id(card), + id: card.clone(), suit: match card.suit() { Suit::Clubs => "clubs", Suit::Diamonds => "diamonds", @@ -318,9 +316,10 @@ pub enum DebugMove { 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, + /// Cards that appeared more than once across all piles. + pub duplicate_cards: Vec, + /// Cards from the full single-deck set that are absent from the board. + pub missing_cards: Vec, pub stock_has_face_up_cards: bool, pub waste_has_face_down_cards: bool, pub foundation_has_face_down_cards: bool, @@ -389,24 +388,15 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb 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 seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut duplicate_cards = Vec::new(); let mut total_cards_seen = 0_usize; - let mut feed = |cards: &[(solitaire_core::card::Card, bool)]| { + let mut feed = |cards: &[(Card, bool)]| { for (card, _) in cards { total_cards_seen += 1; - let id = card_to_id(card); - if id >= 52 { - out_of_range_card_ids.push(id); - continue; - } - let idx = id as usize; - if seen[idx] { - duplicate_card_ids.push(id); - } else { - seen[idx] = true; + if !seen.insert(card.clone()) { + duplicate_cards.push(card.clone()); } } }; @@ -420,9 +410,22 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb feed(pile); } - let missing_card_ids = (0_u32..52_u32) - .filter(|id| !seen[*id as usize]) - .collect::>(); + // Reference set: the full 52-card single deck, using whichever deck id the + // dealt cards carry. Any of those 52 not on the board is missing. + let deck = seen + .iter() + .next() + .map(|c| c.deck()) + .unwrap_or_else(|| Deck::new(0).expect("deck id 0 is valid")); + let mut missing_cards = Vec::new(); + for suit in Suit::SUITS { + for rank in Rank::RANKS { + let card = Card::new(deck, suit, rank); + if !seen.contains(&card) { + missing_cards.push(card); + } + } + } let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up); let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up); @@ -444,9 +447,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb 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() + let state_ok = duplicate_cards.is_empty() + && missing_cards.is_empty() && !stock_has_face_up_cards && !waste_has_face_down_cards && !foundation_has_face_down_cards @@ -455,9 +457,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb DebugInvariantReport { state_ok, total_cards_seen, - duplicate_card_ids, - missing_card_ids, - out_of_range_card_ids, + duplicate_cards, + missing_cards, stock_has_face_up_cards, waste_has_face_down_cards, foundation_has_face_down_cards, @@ -893,6 +894,25 @@ mod tests { use std::collections::HashSet; use std::fmt::Write; + /// The JS card renderer reads `card.id` as an opaque identity key. After the + /// `card_to_id` removal, `CardSnapshot.id` is a `card_game::Card`, which is + /// `#[serde(transparent)]` over `NonZeroU8` — so it must still serialise as a + /// plain JSON number (the same `Serialize` impl `serde_wasm_bindgen` uses). + #[test] + fn card_snapshot_id_serialises_as_a_plain_number() { + let card = Card::new(Deck::new(0).unwrap(), Suit::Hearts, Rank::RANKS[0]); + let snap = CardSnapshot::from(&(card, true)); + let json = serde_json::to_value(&snap).expect("serialise CardSnapshot"); + assert!( + json["id"].is_number(), + "card.id must serialise as a JSON number for the JS opaque key, got {:?}", + json["id"] + ); + assert_eq!(json["suit"], "hearts"); + assert_eq!(json["rank"], 1); + assert_eq!(json["face_up"], true); + } + fn pick_move_index(moves: &[DebugMove]) -> Option { if moves.is_empty() { return None; @@ -933,7 +953,7 @@ mod tests { for card in cards { let _ = write!( key, - "{}:{}:{},", + "{:?}:{}:{},", card.id, card.rank, if card.face_up { 1 } else { 0 } From 9bbb57134f7b87ca7ec7c84936d0608a81021934 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 12 Jun 2026 12:43:47 -0700 Subject: [PATCH 10/16] refactor: persist replay/save moves as KlondikeInstruction, not pile coords (#89) Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack) are runtime-only and have no serde upstream. Per Rhys's guidance, the persistence layer now stores the moves (KlondikeInstruction) rather than board coordinates, decoding back to runtime pile positions on demand. Core / data: - game_state: instruction_history() -> Vec; add instruction_to_piles() and apply_instruction(); drop AnyInstruction. - klondike_adapter: delete the entire Saved* serde mirror section (SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/ KlondikePileStack/DstFoundation/DstTableau/SavedInstruction). - replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now Vec; REPLAY_SCHEMA_VERSION 2 -> 3. - storage: game_state save format v3 rejected (v4/v5 only). Engine / wasm consumers: - record via KlondikeInstruction (stock click = RotateStock). - playback decodes each instruction to (from, to, count) against the live state via instruction_to_piles, then fires the canonical event; undecodable instructions are skipped with a warning, never panic. - remove all use solitaire_data::ReplayMove and Saved* imports. Workspace check, clippy -D warnings, and the full test suite all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- solitaire_core/src/game_state.rs | 72 ++--- solitaire_core/src/klondike_adapter.rs | 266 +----------------- solitaire_core/src/proptest_tests.rs | 119 +------- solitaire_data/src/lib.rs | 2 +- solitaire_data/src/replay.rs | 115 ++++---- solitaire_data/src/storage.rs | 31 +- solitaire_engine/src/achievement_plugin.rs | 6 +- solitaire_engine/src/game_plugin.rs | 73 ++--- solitaire_engine/src/replay_overlay/format.rs | 50 ++-- solitaire_engine/src/replay_overlay/input.rs | 24 +- solitaire_engine/src/replay_overlay/tests.rs | 34 ++- solitaire_engine/src/replay_overlay/update.rs | 16 +- solitaire_engine/src/replay_playback.rs | 140 ++++----- solitaire_wasm/src/lib.rs | 173 +++--------- 14 files changed, 311 insertions(+), 810 deletions(-) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 4d0942b..4691d32 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,7 +1,6 @@ use crate::error::MoveError; use crate::klondike_adapter::{ - KlondikeAdapter, SavedInstruction, - foundation_from_slot as adapter_foundation_from_slot, + KlondikeAdapter, 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, }; @@ -19,11 +18,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// History: /// - v1: `Foundation(Suit)` keys. /// - 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. +/// - v3 (rejected): session-backed save files using local mirror types with u8 +/// indices for enum variants. No longer loadable — v3 files are discarded. /// - 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. +/// variants (e.g. `"Foundation1"` instead of `0`). /// - 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 @@ -110,28 +108,13 @@ struct PersistedGameState { pub saved_moves: Vec, } -/// Transparent migration wrapper for deserialisation. +/// Input struct that accepts schema v4 and v5 `saved_moves` formats. /// -/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that -/// fails (because the value uses u8 indices), falls back to `SavedInstruction` -/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via -/// the existing `TryFrom` impl. -/// -/// `SavedInstruction` remains `pub` in `klondike_adapter` because -/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it. -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -enum AnyInstruction { - V4(KlondikeInstruction), - V3(SavedInstruction), -} - -/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats. -/// -/// `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. +/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction` +/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are +/// intentionally absent: all three are rebuilt by replaying the instruction +/// history through the upstream session stats. Older v4 save files still carry +/// those keys; serde ignores them. #[derive(Debug, Clone, Deserialize)] struct PersistedGameStateIn { pub draw_mode: DrawStockConfig, @@ -143,7 +126,7 @@ struct PersistedGameStateIn { pub take_from_foundation: bool, #[serde(default = "schema_v1")] pub schema_version: u32, - pub saved_moves: Vec, + pub saved_moves: Vec, } #[cfg(feature = "test-support")] @@ -249,10 +232,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), v4 (upstream - // named-variant serde), and v5 (current, derived stats). Reject the rest. + // Accept v4 (upstream named-variant serde) and v5 (current, derived + // stats). v3 (legacy u8-index format) and all others are rejected. match persisted.schema_version { - 3..=5 => {} + 4 | 5 => {} v => { return Err(serde::de::Error::custom(format!( "unsupported GameState schema version {v}" @@ -276,17 +259,7 @@ impl<'de> Deserialize<'de> for GameState { // 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::V3 was serialised with u8 indices (schema v3) and is - // converted here via the existing TryFrom impl. - let instruction = match any { - AnyInstruction::V4(i) => i, - AnyInstruction::V3(s) => { - KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)? - } - }; - + for instruction in persisted.saved_moves { if !game .session .state() @@ -440,20 +413,17 @@ impl GameState { } /// Returns the deterministic instruction history for the current deal as - /// legacy mirror types. + /// upstream [`KlondikeInstruction`] values. /// /// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this - /// sequence is sufficient to replay the game state exactly. - /// - /// Returns [`SavedInstruction`] (u8-index mirror types) for backward - /// compatibility with the WASM replay layer and `solitaire_data::ReplayMove` - /// format. New code that does not need serde should prefer - /// `session().history()` directly. - pub fn instruction_history(&self) -> Vec { + /// sequence is sufficient to replay the game state exactly. Consumers + /// record these directly (they serialise via `KlondikeInstruction`'s + /// compact serde) and play them back via [`GameState::apply_instruction`]. + pub fn instruction_history(&self) -> Vec { self.session .history() .iter() - .map(|snapshot| SavedInstruction::from(*snapshot.instruction())) + .map(|snapshot| *snapshot.instruction()) .collect() } diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index 6c666dc..9114e96 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -10,11 +10,9 @@ //! (`card`, `pile`, etc.) remain free of upstream dependencies. use klondike::{ - DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction, - KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau, - TableauStack, + DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig, + SkipCards, Tableau, }; -use serde::{Deserialize, Serialize}; /// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. /// @@ -84,266 +82,6 @@ pub fn skip_cards_from_count(skip: usize) -> Option { } } -// ── Legacy serde mirror types (kept for backward compatibility) ─────────────── -// -// These types were introduced when upstream `klondike` had no serde feature. -// Mainline `klondike` now provides full serde support (with a hand-written -// compact `KlondikeInstruction` impl), and `GameState` serialises -// `saved_moves` directly as `Vec` (schema v4). -// -// The mirror types are retained for three reasons: -// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses -// `TryFrom for KlondikeInstruction` to parse old save -// files with u8 indices and replay them. -// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde -// type; changing it would break the on-disk replay format (schema v2). -// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that -// replay JSON is cross-compatible between the desktop and browser builds. -// -// These types should not be used for new serialisation concerns. If the -// ReplayMove format is ever bumped to a new schema, migrate those callers to -// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted. - -/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SavedTableau(pub u8); - -/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SavedFoundation(pub u8); - -/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SavedSkipCards(pub u8); - -/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SavedKlondikePile { - Tableau(SavedTableau), - Stock, - Foundation(SavedFoundation), -} - -/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SavedTableauStack { - pub tableau: SavedTableau, - pub skip_cards: SavedSkipCards, -} - -/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SavedKlondikePileStack { - Tableau(SavedTableauStack), - Stock, - Foundation(SavedFoundation), -} - -/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SavedDstFoundation { - pub src: SavedKlondikePile, - pub foundation: SavedFoundation, -} - -/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SavedDstTableau { - pub src: SavedKlondikePileStack, - pub tableau: SavedTableau, -} - -/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`]. -/// -/// Convert to/from the upstream type with: -/// ```ignore -/// let saved = SavedInstruction::from(instruction); -/// let instruction = KlondikeInstruction::try_from(saved)?; -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SavedInstruction { - DstFoundation(SavedDstFoundation), - DstTableau(SavedDstTableau), - RotateStock, -} - -/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value -/// and cannot be converted back to a [`klondike::KlondikeInstruction`]. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum InvalidSavedInstruction { - #[error("invalid tableau index {0} (expected 0–6)")] - Tableau(u8), - #[error("invalid foundation index {0} (expected 0–3)")] - Foundation(u8), - #[error("invalid skip_cards value {0} (expected 0–12)")] - SkipCards(u8), -} - -// ── From impls: KlondikeInstruction → Saved* ───────────────────────────────── - -impl From for SavedTableau { - fn from(t: Tableau) -> Self { - Self(t as u8) - } -} - -impl From for SavedFoundation { - fn from(f: Foundation) -> Self { - Self(f as u8) - } -} - -impl From for SavedSkipCards { - fn from(s: SkipCards) -> Self { - Self(s as u8) - } -} - -impl From for SavedKlondikePile { - fn from(p: KlondikePile) -> Self { - match p { - KlondikePile::Tableau(t) => Self::Tableau(t.into()), - KlondikePile::Stock => Self::Stock, - KlondikePile::Foundation(f) => Self::Foundation(f.into()), - } - } -} - -impl From for SavedTableauStack { - fn from(ts: TableauStack) -> Self { - Self { - tableau: ts.tableau.into(), - skip_cards: ts.skip_cards.into(), - } - } -} - -impl From for SavedKlondikePileStack { - fn from(ps: KlondikePileStack) -> Self { - match ps { - KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()), - KlondikePileStack::Stock => Self::Stock, - KlondikePileStack::Foundation(f) => Self::Foundation(f.into()), - } - } -} - -impl From for SavedDstFoundation { - fn from(df: DstFoundation) -> Self { - Self { - src: df.src.into(), - foundation: df.foundation.into(), - } - } -} - -impl From for SavedDstTableau { - fn from(dt: DstTableau) -> Self { - Self { - src: dt.src.into(), - tableau: dt.tableau.into(), - } - } -} - -impl From for SavedInstruction { - fn from(i: KlondikeInstruction) -> Self { - match i { - KlondikeInstruction::RotateStock => Self::RotateStock, - KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()), - KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()), - } - } -} - -// ── TryFrom impls: Saved* → KlondikeInstruction ────────────────────────────── - -impl TryFrom for Tableau { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedTableau) -> Result { - tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0)) - } -} - -impl TryFrom for Foundation { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedFoundation) -> Result { - foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0)) - } -} - -impl TryFrom for SkipCards { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedSkipCards) -> Result { - skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0)) - } -} - -impl TryFrom for KlondikePile { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedKlondikePile) -> Result { - Ok(match s { - SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?), - SavedKlondikePile::Stock => KlondikePile::Stock, - SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?), - }) - } -} - -impl TryFrom for TableauStack { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedTableauStack) -> Result { - Ok(TableauStack { - tableau: s.tableau.try_into()?, - skip_cards: s.skip_cards.try_into()?, - }) - } -} - -impl TryFrom for KlondikePileStack { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedKlondikePileStack) -> Result { - Ok(match s { - SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?), - SavedKlondikePileStack::Stock => KlondikePileStack::Stock, - SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?), - }) - } -} - -impl TryFrom for DstFoundation { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedDstFoundation) -> Result { - Ok(DstFoundation { - src: s.src.try_into()?, - foundation: s.foundation.try_into()?, - }) - } -} - -impl TryFrom for DstTableau { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedDstTableau) -> Result { - Ok(DstTableau { - src: s.src.try_into()?, - tableau: s.tableau.try_into()?, - }) - } -} - -impl TryFrom for KlondikeInstruction { - type Error = InvalidSavedInstruction; - fn try_from(s: SavedInstruction) -> Result { - Ok(match s { - SavedInstruction::RotateStock => KlondikeInstruction::RotateStock, - SavedInstruction::DstFoundation(df) => { - KlondikeInstruction::DstFoundation(df.try_into()?) - } - SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?), - }) - } -} - /// Time bonus added to the score on a win: `700_000 / elapsed_seconds`. /// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero. pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 { diff --git a/solitaire_core/src/proptest_tests.rs b/solitaire_core/src/proptest_tests.rs index 3ec7cc4..03417a5 100644 --- a/solitaire_core/src/proptest_tests.rs +++ b/solitaire_core/src/proptest_tests.rs @@ -1,13 +1,8 @@ use card_game::{Card, Game}; -use klondike::{DrawStockConfig, Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; +use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau}; use proptest::prelude::*; use crate::game_state::GameState; -use crate::klondike_adapter::{ - InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation, - SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau, - SavedTableauStack, -}; // --------------------------------------------------------------------------- // Shared helpers @@ -261,116 +256,4 @@ proptest! { } } - // ------------------------------------------------------------------------- - // SavedInstruction ↔ KlondikeInstruction round-trip - // ------------------------------------------------------------------------- - - /// Every valid `SavedInstruction` survives a round-trip through - /// `KlondikeInstruction::try_from(SavedInstruction::from(original))`. - /// - /// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`) - /// and all legal sub-field ranges: - /// - `SavedTableau`: 0–6 - /// - `SavedFoundation`: 0–3 - /// - `SavedSkipCards`: 0–12 - #[test] - fn saved_instruction_round_trip( - instruction in saved_instruction_strategy(), - ) { - let klondike = KlondikeInstruction::try_from(instruction); - prop_assert!( - klondike.is_ok(), - "TryFrom failed for valid SavedInstruction {instruction:?}: {:?}", - klondike.err(), - ); - let saved_again = SavedInstruction::from(klondike.expect("checked above")); - prop_assert_eq!( - saved_again, - instruction, - "round-trip produced a different SavedInstruction", - ); - } -} - -// --------------------------------------------------------------------------- -// Proptest strategies for SavedInstruction and its sub-types -// --------------------------------------------------------------------------- - -fn saved_tableau_strategy() -> impl Strategy { - (0u8..=6).prop_map(SavedTableau) -} - -fn saved_foundation_strategy() -> impl Strategy { - (0u8..=3).prop_map(SavedFoundation) -} - -fn saved_skip_cards_strategy() -> impl Strategy { - (0u8..=12).prop_map(SavedSkipCards) -} - -fn saved_klondike_pile_strategy() -> impl Strategy { - prop_oneof![ - saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau), - Just(SavedKlondikePile::Stock), - saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation), - ] -} - -fn saved_klondike_pile_stack_strategy() -> impl Strategy { - prop_oneof![ - (saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| { - SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards }) - }), - Just(SavedKlondikePileStack::Stock), - saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation), - ] -} - -fn saved_instruction_strategy() -> impl Strategy { - prop_oneof![ - Just(SavedInstruction::RotateStock), - (saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map( - |(src, foundation)| { - SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation }) - } - ), - (saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map( - |(src, tableau)| { - SavedInstruction::DstTableau(SavedDstTableau { src, tableau }) - } - ), - ] -} - -// --------------------------------------------------------------------------- -// Boundary error unit tests (exact out-of-range values) -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod saved_instruction_boundary_tests { - use super::*; - - #[test] - fn saved_tableau_7_is_invalid() { - let result = Tableau::try_from(SavedTableau(7)); - assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7))); - } - - #[test] - fn saved_tableau_255_is_invalid() { - let result = Tableau::try_from(SavedTableau(255)); - assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255))); - } - - #[test] - fn saved_foundation_4_is_invalid() { - let result = Foundation::try_from(SavedFoundation(4)); - assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4))); - } - - #[test] - fn saved_skip_cards_13_is_invalid() { - let result = SkipCards::try_from(SavedSkipCards(13)); - assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13))); - } } diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 8b52881..415597e 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -163,7 +163,7 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend}; pub mod replay; pub use replay::{ REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay, - ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, + ReplayHistory, 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 diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index 86d9480..4dad2d7 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -12,13 +12,22 @@ //! carries any other version so older replays are silently dropped instead //! of crashing the loader. //! -//! The recording is intentionally minimal — only [`ReplayMove`] entries -//! that successfully advanced the game. `Undo` is **not** recorded: a -//! replay represents the canonical path the player ultimately took to win, -//! so backed-out missteps simply do not appear in the move list. The -//! starting deal is not stored either — the [`seed`](Replay::seed) + +//! The recording is intentionally minimal — only the +//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that +//! successfully advanced the game. `Undo` is **not** recorded: a replay +//! represents the canonical path the player ultimately took to win, so +//! backed-out missteps simply do not appear in the move list. The starting +//! deal is not stored either — the [`seed`](Replay::seed) + //! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient //! for `GameState::new_with_mode` to rebuild the identical layout. +//! +//! Each recorded move is the player's atomic *input*, not its outcome. +//! `KlondikeInstruction::RotateStock` covers every click on the stock pile; +//! the engine resolves draw-vs-recycle deterministically from the current +//! stock state during playback, so the same input always produces the same +//! effect on the same starting deal. Runtime-only pile-position types are +//! never serialised — the instruction itself serialises via its compact +//! upstream serde representation. use std::fs; use std::io; @@ -26,8 +35,7 @@ use std::path::{Path, PathBuf}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; -use solitaire_core::{DrawStockConfig, game_state::GameMode}; -use solitaire_core::klondike_adapter::SavedKlondikePile; +use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode}; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; @@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 { /// seeing a broken one. /// /// History: -/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle` +/// - v1: initial release. The move type had separate `Draw` and `Recycle` /// variants which carried the *outcome* of a stock interaction rather /// than the player's atomic input. -/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick` -/// variant. The engine resolves draw-vs-recycle deterministically from -/// the current stock state, so the input alone is sufficient and the -/// replay model now stores atomic player inputs end-to-end. -pub const REPLAY_SCHEMA_VERSION: u32 = 2; +/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant. +/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves +/// are now stored directly as upstream +/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact +/// int serde); `StockClick` is now `RotateStock`. Pile-position types are +/// runtime-only and are never serialised. v1/v2 files fail to deserialise +/// and are discarded by the loader. +pub const REPLAY_SCHEMA_VERSION: u32 = 3; /// Default value for [`Replay::schema_version`] when deserialising files /// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`] @@ -81,32 +92,6 @@ fn schema_v0() -> u32 { 0 } -/// One atomic player input recorded during a winning game, in the order -/// it was applied to the live `GameState`. -/// -/// `Undo` is intentionally absent — see the module-level docs. -/// -/// The variants represent *inputs*, not outcomes. `StockClick` covers -/// every player click on the stock pile; the engine then resolves -/// draw-vs-recycle deterministically from the current state during both -/// recording and playback, so the same input always produces the same -/// effect on the same starting deal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ReplayMove { - /// A successful `move_cards(from, to, count)` call. - Move { - /// Source pile. - from: SavedKlondikePile, - /// Destination pile. - to: SavedKlondikePile, - /// Number of cards moved. - count: usize, - }, - /// A click on the stock pile. Resolves to a draw when stock is - /// non-empty and to a waste→stock recycle when stock is empty. - StockClick, -} - /// A complete recording of a single winning game. /// /// Replays are reconstructed by rebuilding a fresh @@ -134,9 +119,11 @@ pub struct Replay { pub final_score: i32, /// ISO-8601 date the win was recorded. pub recorded_at: NaiveDate, - /// Ordered move list. Each entry is what the player did, replayable - /// against a fresh `GameState` constructed from the seed. - pub moves: Vec, + /// Ordered move list. Each entry is the atomic + /// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player + /// issued, replayable against a fresh `GameState` constructed from the + /// seed via `GameState::apply_instruction`. + pub moves: Vec, /// Public share URL for this replay on the active sync backend, set /// by `sync_plugin::poll_replay_upload_result` when the upload /// task resolves. `None` when the player won on a local-only @@ -185,7 +172,7 @@ impl Replay { time_seconds: u64, final_score: i32, recorded_at: NaiveDate, - moves: Vec, + moves: Vec, ) -> Self { Self { schema_version: REPLAY_SCHEMA_VERSION, @@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) { #[allow(deprecated)] mod tests { use super::*; - use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau}; + use klondike::{ + DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau, + }; use std::env; fn tmp_path(name: &str) -> PathBuf { @@ -459,18 +448,16 @@ mod tests { 5_120, date, vec![ - ReplayMove::StockClick, - ReplayMove::Move { - from: SavedKlondikePile::Stock, - to: SavedKlondikePile::Tableau(SavedTableau(3)), - count: 1, - }, - ReplayMove::StockClick, - ReplayMove::Move { - from: SavedKlondikePile::Tableau(SavedTableau(3)), - to: SavedKlondikePile::Foundation(SavedFoundation(0)), - count: 1, - }, + KlondikeInstruction::RotateStock, + KlondikeInstruction::DstTableau(DstTableau { + src: KlondikePileStack::Stock, + tableau: Tableau::Tableau4, + }), + KlondikeInstruction::RotateStock, + KlondikeInstruction::DstFoundation(DstFoundation { + src: KlondikePile::Tableau(Tableau::Tableau4), + foundation: Foundation::Foundation1, + }), ], ) } @@ -601,7 +588,7 @@ mod tests { 60, id, date, - vec![ReplayMove::StockClick], + vec![KlondikeInstruction::RotateStock], ) } @@ -837,9 +824,11 @@ mod tests { let path = tmp_path("legacy_no_win_move_index"); let _ = fs::remove_file(&path); - // Hand-rolled minimal v2 replay JSON with no win_move_index field. - let v2_no_field = r#"{ - "schema_version": 2, + // Hand-rolled minimal current-schema replay JSON with no + // win_move_index field — the additive field must still default to None. + let no_field = format!( + r#"{{ + "schema_version": {schema}, "seed": 1, "draw_mode": "DrawOne", "mode": "Classic", @@ -847,8 +836,10 @@ mod tests { "final_score": 100, "recorded_at": "2026-05-02", "moves": [] - }"#; - fs::write(&path, v2_no_field).expect("write fixture"); + }}"#, + schema = REPLAY_SCHEMA_VERSION, + ); + fs::write(&path, no_field).expect("write fixture"); let loaded = load_latest_replay_from(&path).expect("load"); assert_eq!(loaded.win_move_index, None); diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index a272043..d734eda 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -583,24 +583,16 @@ mod tests { ); } - /// A schema v3 save (instruction history using u8 indices) must load - /// successfully and be transparently migrated to schema v4. - /// - /// This verifies the `AnyInstruction` untagged deserialization migration - /// path. v3 files with `RotateStock` (unit variant, format-identical in - /// v3 and v4) load correctly and report `schema_version == 4` after load. - /// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the - /// u8-to-named conversion for `DstFoundation` / `DstTableau` indices. + /// A schema v3 save (instruction history using the old u8-index mirror + /// types) is no longer loadable. The legacy migration path was dropped, + /// so any file claiming `schema_version: 3` must be rejected and the + /// player started on a fresh game. #[test] - fn game_state_v3_migrates_to_v4() { - use solitaire_core::game_state::GameState; - - let path = gs_path("v3_migrate"); + fn game_state_v3_is_rejected() { + let path = gs_path("v3_reject"); let _ = fs::remove_file(&path); // Hand-crafted schema v3 JSON: one RotateStock (draw) instruction. - // RotateStock serialises as the string "RotateStock" in both v3 and v4, - // so this exercises the schema version acceptance code path. let v3_json = r#"{ "draw_mode": "DrawOne", "mode": "Classic", @@ -615,13 +607,12 @@ mod tests { }"#; fs::write(&path, v3_json).expect("write v3 fixture"); - let loaded = load_game_state_from(&path) - .expect("schema v3 must be accepted and migrated to v4"); + assert!( + load_game_state_from(&path).is_none(), + "schema v3 must be rejected (no migration path)", + ); - // The loaded game should match a fresh game that had one draw applied. - let mut expected = GameState::new(42, DrawStockConfig::DrawOne); - expected.draw().expect("draw must succeed on a fresh game"); - assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state"); + let _ = fs::remove_file(&path); } /// Schema v2 stored raw pile arrays and undo snapshots (no instruction diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 5d987f2..7c7a882 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -1393,8 +1393,8 @@ mod tests { use crate::replay_playback::ReplayPlaybackState; use chrono::NaiveDate; - use solitaire_core::{DrawStockConfig, game_state::GameMode}; - use solitaire_data::{Replay, ReplayMove}; + use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode}; + use solitaire_data::Replay; /// Headless app variant that injects a default `ReplayPlaybackState` /// directly (no `ReplayPlaybackPlugin`) so we can drive the resource @@ -1414,7 +1414,7 @@ mod tests { 10, 100, NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), - vec![ReplayMove::StockClick], + vec![KlondikeInstruction::RotateStock], ) } diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index e822129..3ccdeab 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -15,11 +15,11 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::window::AppLifecycle; use solitaire_core::KlondikePile; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; -use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET}; +use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction}; #[allow(deprecated)] use solitaire_data::latest_replay_path; use solitaire_data::{ - Replay, ReplayMove, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at, + Replay, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path, save_game_state_to, }; @@ -105,18 +105,21 @@ pub struct RestoreContinueButton; #[derive(Component, Debug)] pub struct RestoreNewGameButton; -/// In-memory accumulator for [`ReplayMove`] entries during the current -/// game. Cleared on every new-game start; frozen into a [`Replay`] and -/// flushed to disk by [`record_replay_on_win`] when the player wins. +/// In-memory accumulator for [`KlondikeInstruction`] entries during the +/// current game. Cleared on every new-game start; frozen into a [`Replay`] +/// and flushed to disk by [`record_replay_on_win`] when the player wins. /// /// Recording captures only successful state-mutating events the player /// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is /// intentionally not recorded — see [`solitaire_data::replay`] for the -/// design rationale. +/// design rationale. Each entry is the atomic player input as a +/// [`KlondikeInstruction`] (a stock click is +/// [`KlondikeInstruction::RotateStock`]); pile-position types are +/// runtime-only and never persisted. #[derive(Resource, Debug, Default, Clone)] pub struct RecordingReplay { - /// Ordered list of moves applied so far this game. - pub moves: Vec, + /// Ordered list of instructions applied so far this game. + pub moves: Vec, } impl RecordingReplay { @@ -851,7 +854,7 @@ fn handle_draw( // the click happens — re-executing on the same starting // deal produces the same effect, so the input alone is // sufficient to recover the move on playback. - recording.moves.push(ReplayMove::StockClick); + recording.moves.push(KlondikeInstruction::RotateStock); changed.write(StateChangedEvent); } Err(e) => warn!("draw rejected: {e}"), @@ -889,11 +892,17 @@ fn handle_move( // Record the move in the in-flight replay buffer. Done // first so the entry is captured even if a subsequent // event-write or pile-lookup happens to bail out below. - recording.moves.push(ReplayMove::Move { - from: ev.from.into(), - to: ev.to.into(), - count: ev.count, - }); + // `move_cards` resolved the pile coordinates to a + // `KlondikeInstruction` and pushed it onto the session + // history; recover that exact instruction from the tail + // (no clone — the instruction is `Copy`). Pile-position + // types are runtime-only, so we persist the instruction + // rather than the (from, to, count) triple. + if let Some(instruction) = + game.0.session().history().last().map(|s| *s.instruction()) + { + recording.moves.push(instruction); + } // Fire flip event if the candidate card is now face-up. if let Some(fcard) = flip_candidate && pile_cards(&game.0, &ev.from) @@ -1301,7 +1310,6 @@ fn save_game_state_on_exit( mod tests { use super::*; use solitaire_core::{Foundation, KlondikePile, Tableau}; - use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; /// Build a minimal headless `App` with just `GamePlugin` installed. /// Disables persistence and overrides the seed so tests are deterministic @@ -2102,7 +2110,7 @@ mod tests { 1, "only the draw is recorded; the undo does not erase it nor add a new entry", ); - assert!(matches!(recording.moves[0], ReplayMove::StockClick)); + assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock)); } /// Starting a new game wipes the recording so the next deal begins @@ -2154,16 +2162,16 @@ mod tests { let mut app = test_app(7654); app.insert_resource(ReplayPath(Some(path.clone()))); - // Push two recorded moves manually so we can verify they survive - // the freeze/save round-trip without having to drive a real win. + // Push two recorded instructions manually so we can verify they + // survive the freeze/save round-trip without having to drive a + // real win. Both are `RotateStock` — the only instruction + // constructible without the runtime-only `klondike` pile-stack + // types (which the engine intentionally does not depend on); the + // round-trip shape is identical for any instruction variant. { let mut recording = app.world_mut().resource_mut::(); - recording.moves.push(ReplayMove::StockClick); - recording.moves.push(ReplayMove::Move { - from: SavedKlondikePile::Stock, - to: SavedKlondikePile::Tableau(SavedTableau(2)), - count: 1, - }); + recording.moves.push(KlondikeInstruction::RotateStock); + recording.moves.push(KlondikeInstruction::RotateStock); } // Fire the win event the engine emits when the last foundation @@ -2197,15 +2205,8 @@ mod tests { "time_seconds must come from the win event" ); assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip"); - assert!(matches!(loaded.moves[0], ReplayMove::StockClick)); - match &loaded.moves[1] { - ReplayMove::Move { from, to, count } => { - assert_eq!(*from, SavedKlondikePile::Stock); - assert_eq!(*to, SavedKlondikePile::Tableau(SavedTableau(2))); - assert_eq!(*count, 1); - } - other => panic!("second entry must be a Move, got {other:?}"), - } + assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock)); + assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock)); #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); @@ -2229,7 +2230,7 @@ mod tests { { let mut recording = app.world_mut().resource_mut::(); recording.moves.clear(); - recording.moves.push(ReplayMove::StockClick); + recording.moves.push(KlondikeInstruction::RotateStock); } app.world_mut().write_message(GameWonEvent { score: 100, @@ -2241,8 +2242,8 @@ mod tests { { let mut recording = app.world_mut().resource_mut::(); recording.moves.clear(); - recording.moves.push(ReplayMove::StockClick); - recording.moves.push(ReplayMove::StockClick); + recording.moves.push(KlondikeInstruction::RotateStock); + recording.moves.push(KlondikeInstruction::RotateStock); } app.world_mut().write_message(GameWonEvent { score: 200, diff --git a/solitaire_engine/src/replay_overlay/format.rs b/solitaire_engine/src/replay_overlay/format.rs index d3e141b..226f360 100644 --- a/solitaire_engine/src/replay_overlay/format.rs +++ b/solitaire_engine/src/replay_overlay/format.rs @@ -1,10 +1,8 @@ use super::ReplayPlaybackState; use chrono::Datelike; -use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::{Card, Rank, Suit}; use solitaire_core::game_state::GameState; -use solitaire_core::klondike_adapter::SavedKlondikePile; -use solitaire_data::ReplayMove; +use solitaire_core::{Card, Rank, Suit}; +use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; /// Pure helper — formats the `GAME #YYYY-DDD` caption for the given /// state. Returns `None` for `Inactive` / `Completed` (the replay is @@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String { } } -pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String { - KlondikePile::try_from(*p) - .map(|pile| format_pile(&pile)) - .unwrap_or_else(|_| "unknown pile".to_string()) -} - fn foundation_number(foundation: Foundation) -> u8 { match foundation { Foundation::Foundation1 => 1, @@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 { } } -/// Pure helper — formats a [`ReplayMove`] as the body of a -/// move-log row. `StockClick` reads as `"stock cycle"`; `Move` -/// reads as `"{from} → {to}"` using [`format_pile`] for both -/// endpoints. The `count` field is omitted from the row body — -/// at row scale it adds visual noise without meaningful +/// Pure helper — formats a [`KlondikeInstruction`] as the body of a +/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*` +/// instruction reads as `"{from} → {to}"` using [`format_pile`] for +/// each nameable endpoint. The card count is omitted from the row +/// body — at row scale it adds visual noise without meaningful /// information for the typical 1-card moves. -pub(crate) fn format_move_body(m: &ReplayMove) -> String { - match m { - ReplayMove::StockClick => "stock cycle".to_string(), - ReplayMove::Move { from, to, .. } => { +/// +/// The destination pile is always recoverable directly from the +/// instruction. The source pile is shown when it is statically +/// nameable (a `DstFoundation` carries a [`KlondikePile`] source); +/// a `DstTableau`'s source is the runtime-only `KlondikePileStack` +/// type — not re-exported across the `solitaire_core` boundary and so +/// not pattern-matchable here — so its row renders `"→ {to}"` without +/// a leading source label. Faithful full-coordinate decoding lives in +/// [`GameState::instruction_to_piles`] on the playback path; the +/// move-log is a display-only digest. +pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String { + match instruction { + KlondikeInstruction::RotateStock => "stock cycle".to_string(), + KlondikeInstruction::DstFoundation(dst) => { format!( "{} \u{2192} {}", - format_saved_pile(from), - format_saved_pile(to) + format_pile(&dst.src), + format_pile(&KlondikePile::Foundation(dst.foundation)) + ) + } + KlondikeInstruction::DstTableau(dst) => { + format!( + "\u{2192} {}", + format_pile(&KlondikePile::Tableau(dst.tableau)) ) } } diff --git a/solitaire_engine/src/replay_overlay/input.rs b/solitaire_engine/src/replay_overlay/input.rs index a7199ee..46ea0d1 100644 --- a/solitaire_engine/src/replay_overlay/input.rs +++ b/solitaire_engine/src/replay_overlay/input.rs @@ -8,6 +8,7 @@ use crate::replay_playback::{ ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback, stop_replay_playback, toggle_pause_replay_playback, }; +use crate::resources::GameStateResource; /// Per-arrow-key time-since-last-fire accumulators that drive the /// continuous-scrub repeat behaviour for held arrow keys. Each @@ -1033,6 +1034,7 @@ pub(crate) fn handle_pause_button( /// guard lives inside `step_replay_playback`. pub(crate) fn handle_step_button( mut state: ResMut, + game: Option>, mut moves_writer: MessageWriter, mut draws_writer: MessageWriter, buttons: Query<&Interaction, (With, Changed)>, @@ -1040,7 +1042,12 @@ pub(crate) fn handle_step_button( if !buttons.iter().any(|i| *i == Interaction::Pressed) { return; } - step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer); + step_replay_playback( + &mut state, + game.as_deref(), + &mut moves_writer, + &mut draws_writer, + ); } /// Repaints the Pause / Resume button's label whenever @@ -1112,6 +1119,7 @@ pub(crate) fn handle_pause_keyboard( pub(crate) fn handle_arrow_keyboard( keys: Option>>, time: Res