Files
Ferrous-Solitaire/solitaire_core/src/game_state.rs
T
funman300 372b6423d8 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) <noreply@anthropic.com>
2026-06-11 10:36:31 -07:00

1244 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::error::MoveError;
use crate::klondike_adapter::{
DrawMode, 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,
};
use card_game::{Card, Game as _, Session, SessionConfig};
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Save-file schema version for `GameState`. Increment when the on-disk
/// representation changes incompatibly so `load_game_state_from` can refuse
/// older formats and start the player on a fresh game.
///
/// 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.
/// - 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.
fn schema_v1() -> u32 {
1
}
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
/// catalog is drawn from. `Random` skips verification entirely and uses a
/// system-time seed — deals may or may not be winnable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum DifficultyLevel {
#[default]
Easy,
Medium,
Hard,
Expert,
Grandmaster,
/// Unverified system-time seed — may or may not be winnable.
Random,
}
impl DifficultyLevel {
/// Short human-readable label shown in the HUD and win summary.
pub fn label(self) -> &'static str {
match self {
Self::Easy => "Easy",
Self::Medium => "Medium",
Self::Hard => "Hard",
Self::Expert => "Expert",
Self::Grandmaster => "Grandmaster",
Self::Random => "Random",
}
}
}
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode {
#[default]
/// Standard Klondike rules with score and timer.
Classic,
/// No timer, no score display, ambient audio only.
Zen,
/// Fixed hard seeds, no undo, must win to advance.
Challenge,
/// Play as many games as possible within 10 minutes.
TimeAttack,
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
Difficulty(DifficultyLevel),
}
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
/// `KlondikeInstruction` serde, which produces named enum variants.
#[derive(Debug, Clone, Serialize)]
struct PersistedGameState {
pub draw_mode: DrawMode,
pub mode: GameMode,
pub elapsed_seconds: u64,
pub seed: u64,
pub take_from_foundation: bool,
pub schema_version: u32,
pub saved_moves: Vec<KlondikeInstruction>,
}
/// Transparent migration wrapper for deserialisation.
///
/// 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.
#[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn {
pub draw_mode: DrawMode,
#[serde(default)]
pub mode: GameMode,
pub elapsed_seconds: u64,
pub seed: u64,
#[serde(default)]
pub take_from_foundation: bool,
#[serde(default = "schema_v1")]
pub schema_version: u32,
pub saved_moves: Vec<AnyInstruction>,
}
#[cfg(feature = "test-support")]
/// Test-only override state that shadows the real session pile data.
///
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
/// first checks for an override here before falling back to the session.
/// This lets unit tests in `solitaire_engine` construct arbitrary board
/// configurations without needing to drive the full klondike session.
#[derive(Clone, Debug, Default)]
pub struct TestPileState {
/// Override for face-down stock cards. `None` means "use session".
pub stock: Option<Vec<Card>>,
/// Override for face-up waste cards. `None` means "use session".
pub waste: Option<Vec<Card>>,
/// Per-tableau overrides. Missing keys fall back to the session.
/// Each entry carries its own face-up flag so tests can place face-down
/// cards (e.g. an un-flipped tableau card).
pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
/// Per-foundation overrides. Missing keys fall back to the session.
pub foundation: std::collections::HashMap<Foundation, Vec<Card>>,
/// Override for the derived `move_count()`. `None` means "use session
/// history length".
pub move_count: Option<u32>,
/// Override for the derived `is_won()`. `None` means "use session win
/// state".
pub won: Option<bool>,
/// Override for the derived `is_auto_completable()`. `None` means "derive
/// from session state".
pub auto_completable: Option<bool>,
}
/// 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,
/// 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,
/// 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<Klondike>,
#[cfg(feature = "test-support")]
/// Test pile overrides. Always `None` in production runtime code.
pub test_pile_state: Option<TestPileState>,
}
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.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.take_from_foundation == other.take_from_foundation
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
&& (0..4_u8)
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
&& (0..7_usize).all(|index| {
let Ok(tableau) = Self::tableau_from_index(index) else {
return false;
};
self.pile(KlondikePile::Tableau(tableau))
== other.pile(KlondikePile::Tableau(tableau))
})
}
}
impl Eq for GameState {}
impl Serialize for GameState {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
PersistedGameState {
draw_mode: self.draw_mode(),
mode: self.mode,
elapsed_seconds: self.elapsed_seconds,
seed: self.seed,
take_from_foundation: self.take_from_foundation,
schema_version: GAME_STATE_SCHEMA_VERSION,
saved_moves: self.saved_moves(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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.
match persisted.schema_version {
3..=5 => {}
v => {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}"
)));
}
}
let mut game = Self {
mode: persisted.mode,
elapsed_seconds: persisted.elapsed_seconds,
seed: persisted.seed,
take_from_foundation: persisted.take_from_foundation,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[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::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)?
}
};
if !game
.session
.state()
.state()
.is_instruction_valid(&replay_config, instruction)
{
return Err(serde::de::Error::custom(
"saved instruction history is invalid for reconstructed session",
));
}
game.session.process_instruction(instruction);
}
Ok(game)
}
}
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 {
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 {
Self {
mode,
elapsed_seconds: 0,
seed,
take_from_foundation: true,
session: Self::new_session(seed, draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
}
}
/// 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,
}
}
/// 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 {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(count) = state.move_count
{
return count;
}
Self::u32_from_len(self.session.history().len())
}
/// True once all 52 cards are on the foundations. No further moves are
/// accepted. Derived from the session win state.
pub fn is_won(&self) -> bool {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(won) = state.won
{
return won;
}
self.check_win()
}
/// True when the game can be completed without further player input
/// (and is not already won). Derived from the session state.
pub fn is_auto_completable(&self) -> bool {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(auto) = state.auto_completable
{
return auto;
}
!self.check_win() && self.check_auto_complete()
}
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
}
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
SessionConfig {
inner: Self::replay_config(draw_mode),
// 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()
}
}
fn replay_config(draw_mode: DrawMode) -> 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
// later disables it; a restrictive replay config would reject it and
// corrupt the save.
KlondikeAdapter::config_for(draw_mode, true)
}
fn validation_config(&self) -> KlondikeConfig {
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
}
/// Collects the session instruction history as upstream types for schema v4
/// serialisation.
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
self.session
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Returns the deterministic instruction history for the current deal as
/// legacy mirror types.
///
/// 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<SavedInstruction> {
self.session
.history()
.iter()
.map(|snapshot| SavedInstruction::from(*snapshot.instruction()))
.collect()
}
fn u32_from_len(len: usize) -> u32 {
if len > u32::MAX as usize {
u32::MAX
} else {
len as u32
}
}
pub fn undo_stack_len(&self) -> usize {
self.session.history().len()
}
fn cards_with_face(
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect()
}
pub fn stock_cards(&self) -> Vec<(Card, bool)> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.stock
{
return cards.iter().map(|c| (c.clone(), false)).collect();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_down().iter().cloned(), false)
}
pub fn waste_cards(&self) -> Vec<(Card, bool)> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.waste
{
return cards.iter().map(|c| (c.clone(), true)).collect();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_up().iter().cloned(), true)
}
/// Returns the cards in the requested pile as `(card, face_up)` tuples.
///
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
/// to read the face-down draw cards.
pub fn pile(&self, pile: KlondikePile) -> Vec<(Card, bool)> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state {
match pile {
KlondikePile::Stock => {
if let Some(ref cards) = state.waste {
return cards.iter().map(|c| (c.clone(), true)).collect();
}
}
KlondikePile::Foundation(f) => {
if let Some(cards) = state.foundation.get(&f) {
return cards.iter().map(|c| (c.clone(), true)).collect();
}
}
KlondikePile::Tableau(t) => {
if let Some(cards) = state.tableau.get(&t) {
return cards.clone();
}
}
}
}
let state = self.session.state().state().state();
match pile {
KlondikePile::Stock => self.waste_cards(),
KlondikePile::Foundation(foundation) => {
let cards = match foundation {
Foundation::Foundation1 => state.foundation1(),
Foundation::Foundation2 => state.foundation2(),
Foundation::Foundation3 => state.foundation3(),
Foundation::Foundation4 => state.foundation4(),
};
Self::cards_with_face(cards.iter().cloned(), true)
}
KlondikePile::Tableau(tableau) => {
let mut cards =
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(),
true,
));
cards
}
}
}
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
}
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
}
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<(Card, bool)>, MoveError> {
let foundation = Self::foundation_from_slot(slot)?;
Ok(self.pile(KlondikePile::Foundation(foundation)))
}
/// Returns `true` when test-only pile overrides are active.
#[cfg(feature = "test-support")]
pub fn has_test_pile_overrides(&self) -> bool {
self.test_pile_state.is_some()
}
/// Returns `false` in production builds where test pile overrides are absent.
#[cfg(not(feature = "test-support"))]
pub const fn has_test_pile_overrides(&self) -> bool {
false
}
/// Test-support helper: clear all pile overrides so reads come from the
/// underlying klondike session again.
#[cfg(feature = "test-support")]
pub fn clear_test_pile_overrides(&mut self) {
self.test_pile_state = None;
}
/// Test-support helper: re-deal the current seed under a different draw
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
/// a specific mode use this instead of mutating a field.
#[cfg(feature = "test-support")]
pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) {
self.session = Self::new_session(self.seed, draw_mode);
}
/// Test-support helper: override the value returned by [`Self::is_won`]
/// without driving the session to a genuine win.
#[cfg(feature = "test-support")]
pub fn set_test_won(&mut self, won: bool) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.won = Some(won);
}
/// Test-support helper: override the value returned by
/// [`Self::is_auto_completable`].
#[cfg(feature = "test-support")]
pub fn set_test_auto_completable(&mut self, auto_completable: bool) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.auto_completable = Some(auto_completable);
}
/// Test-support helper: override the value returned by
/// [`Self::move_count`] without applying real moves.
#[cfg(feature = "test-support")]
pub fn set_test_move_count(&mut self, move_count: u32) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.move_count = Some(move_count);
}
/// Test-support helper: 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")]
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.stock = Some(cards);
}
/// Test-support helper: override face-up waste cards returned by
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
#[cfg(feature = "test-support")]
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.waste = Some(cards);
}
/// Test-support helper: override cards for a specific tableau column.
///
/// All provided cards are treated as face-up. Use
/// [`Self::set_test_tableau_cards_with_face`] when a test needs to place
/// face-down cards.
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
let with_face = cards.into_iter().map(|c| (c, true)).collect();
self.set_test_tableau_cards_with_face(tableau, with_face);
}
/// Test-support helper: override cards for a specific tableau column,
/// specifying each card's face-up flag (`true` = face-up).
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards_with_face(&mut self, tableau: Tableau, cards: Vec<(Card, bool)>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.tableau.insert(tableau, cards);
}
/// Test-support helper: override cards for a specific foundation pile.
#[cfg(feature = "test-support")]
pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.foundation.insert(foundation, cards);
}
/// Test-support helper: override cards for a specific pile.
///
/// For `KlondikePile::Stock`, all provided cards go to the face-down stock
/// override. Use [`Self::set_test_waste_cards`] to override the waste pile
/// separately.
#[cfg(feature = "test-support")]
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
match pile {
KlondikePile::Stock => {
self.set_test_stock_cards(cards);
}
KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
}
}
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
adapter_skip_cards_from_count(skip)
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
}
fn instruction_for_move(
&self,
from: KlondikePile,
to: KlondikePile,
count: usize,
) -> Result<KlondikeInstruction, MoveError> {
match (from, to) {
(_, KlondikePile::Stock) => Err(MoveError::InvalidDestination),
(KlondikePile::Stock, KlondikePile::Foundation(foundation)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(),
));
}
Ok(KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Stock,
foundation,
}))
}
(KlondikePile::Tableau(src), KlondikePile::Foundation(foundation)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(),
));
}
Ok(KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(src),
foundation,
}))
}
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
MoveError::RuleViolation("cannot move between foundation slots".into()),
),
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only the top waste card may be moved".into(),
));
}
Ok(KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: dst,
}))
}
(KlondikePile::Foundation(foundation), KlondikePile::Tableau(dst)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can return from foundation at a time".into(),
));
}
Ok(KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Foundation(foundation),
tableau: dst,
}))
}
(KlondikePile::Tableau(src), KlondikePile::Tableau(dst)) => {
let face_up_count = self
.session
.state()
.state()
.state()
.tableau_face_up_cards(src)
.len();
if count > face_up_count {
return Err(MoveError::RuleViolation(
"cannot move face-down card".into(),
));
}
let skip_cards = Self::skip_cards_from_usize(face_up_count - count)?;
Ok(KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Tableau(TableauStack {
tableau: src,
skip_cards,
}),
tableau: dst,
}))
}
}
}
/// Decodes an upstream [`KlondikeInstruction`] into the `(from, to, count)`
/// pile coordinates of `solitaire_core`'s own pile model, against the live
/// board. Returns `None` for no-op instructions (foundation→foundation, or a
/// tableau move of zero cards).
///
/// This is the single, canonical translation from the upstream instruction
/// move-currency to core's [`KlondikePile`] vocabulary. It lives in core
/// because decoding a tableau-run length requires upstream pile-stack types
/// (`KlondikePileStack`/`SkipCards`) that the engine and wasm crates do not
/// see — relocating it would duplicate this logic across both crates. The
/// two edges that genuinely need on-screen positions call it: the engine's
/// hint highlight (which pile to glow) and the wasm debug move list (pile
/// names + run length serialized to the browser harness).
///
/// Game logic — auto-complete, move application, the property tests — stays
/// in instruction space and never calls this; applying a move uses
/// [`Self::apply_instruction`].
pub fn instruction_to_piles(
&self,
instruction: KlondikeInstruction,
) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state();
match instruction {
KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
}
let source = match dst_foundation.src {
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
KlondikePile::Stock => KlondikePile::Stock,
KlondikePile::Foundation(_) => return None,
};
Some((
source,
KlondikePile::Foundation(dst_foundation.foundation),
1,
))
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count =
state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
}
(KlondikePile::Tableau(tableau_stack.tableau), count)
}
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
KlondikePileStack::Foundation(foundation) => {
(KlondikePile::Foundation(foundation), 1)
}
};
Some((source, KlondikePile::Tableau(dst_tableau.tableau), count))
}
}
}
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
pub fn draw(&mut self) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
let stock_empty = self.stock_cards().is_empty();
let waste_empty = self.waste_cards().is_empty();
if stock_empty && waste_empty {
return Err(MoveError::StockEmpty);
}
// The session tracks score components and recycle_count as it processes
// the instruction; no local bookkeeping required.
self.session
.process_instruction(KlondikeInstruction::RotateStock);
Ok(())
}
/// Move `count` cards from pile `from` to pile `to` using Klondike-native pile ids.
pub fn move_cards(
&mut self,
from: KlondikePile,
to: KlondikePile,
count: usize,
) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
if from == to {
return Err(MoveError::RuleViolation(
"source and destination must differ".into(),
));
}
let from_pile = self.pile(from);
if from_pile.is_empty() {
return Err(MoveError::EmptySource);
}
if count == 0 || count > from_pile.len() {
return Err(MoveError::RuleViolation("invalid card count".into()));
}
let instruction = self.instruction_for_move(from, to, count)?;
self.apply_instruction(instruction)
}
/// Apply an upstream [`KlondikeInstruction`] directly to the live session.
///
/// This is the native apply path for moves that already exist in
/// 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, 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.
pub fn apply_instruction(
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
let config = self.validation_config();
if !self
.session
.state()
.state()
.is_instruction_valid(&config, instruction)
{
return Err(MoveError::RuleViolation("move violates rules".into()));
}
// The session records the move snapshot and updates score/recycle stats.
self.session.process_instruction(instruction);
Ok(())
}
/// 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);
}
if self.mode == GameMode::Challenge {
return Err(MoveError::RuleViolation(
"undo is disabled in Challenge mode".into(),
));
}
if self.session.history().is_empty() {
return Err(MoveError::UndoStackEmpty);
}
self.session.undo();
Ok(())
}
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
pub fn check_win(&self) -> bool {
self.session.state().state().is_win()
}
/// Returns `true` when the game can be completed without further player input
/// (stock empty, waste empty, all tableau cards face-up).
pub fn check_auto_complete(&self) -> bool {
self.session.state().state().is_win_trivial()
}
/// Returns all currently valid moves as upstream [`KlondikeInstruction`]s,
/// ordered by the `klondike` solver's move priority.
///
/// This is the engine's move currency. Callers that need on-screen pile
/// positions — hint highlighting and the wasm debug move list — decode each
/// instruction with [`Self::instruction_to_piles`] at their UI edge.
pub fn possible_instructions(&self) -> Vec<KlondikeInstruction> {
if self.is_won() {
return Vec::new();
}
let config = self.validation_config();
self.session
.state()
.state()
.get_sorted_moves(&config)
.into_iter()
.collect()
}
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
if self.is_won() || from == to {
return false;
}
let from_pile = self.pile(*from);
if count == 0 || count > from_pile.len() {
return false;
}
let Ok(instruction) = self.instruction_for_move(*from, *to, count) else {
return false;
};
let config = self.validation_config();
self.session
.state()
.state()
.is_instruction_valid(&config, instruction)
}
/// Returns the current pile containing `card`, if any.
pub fn pile_containing_card(&self, card: Card) -> Option<KlondikePile> {
if self.stock_cards().iter().any(|(c, _)| *c == card)
|| self.waste_cards().iter().any(|(c, _)| *c == card)
{
return Some(KlondikePile::Stock);
}
for slot in 0..4_u8 {
let foundation = Self::foundation_from_slot(slot).ok()?;
let pile = self.pile(KlondikePile::Foundation(foundation));
if pile.iter().any(|(c, _)| *c == card) {
return Some(KlondikePile::Foundation(foundation));
}
}
for index in 0..7_usize {
let tableau = Self::tableau_from_index(index).ok()?;
let pile = self.pile(KlondikePile::Tableau(tableau));
if pile.iter().any(|(c, _)| *c == card) {
return Some(KlondikePile::Tableau(tableau));
}
}
None
}
/// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> {
if !self.is_auto_completable() || self.is_won() {
return None;
}
// A foundation-bound single-card move is exactly a `DstFoundation`
// instruction whose source is not itself a foundation. Match the
// instruction variant directly rather than projecting every candidate
// to `(from, to, count)` pile coordinates — auto-complete is pure game
// logic and never needs on-screen positions.
self.possible_instructions()
.into_iter()
.find_map(|instruction| match instruction {
KlondikeInstruction::DstFoundation(dst)
if !matches!(dst.src, KlondikePile::Foundation(_)) =>
{
Some((dst.src, KlondikePile::Foundation(dst.foundation)))
}
_ => None,
})
}
/// Read-only access to the underlying [`card_game::Session`] for this deal.
///
/// Exposes `session.history()` (deterministic replay) and `session.solve()`
/// (DFS solver) to crates outside `solitaire_core` without surfacing the
/// mutable field. Internal code that needs to mutate the session accesses
/// the `pub(crate)` field directly.
pub fn session(&self) -> &Session<Klondike> {
&self.session
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Resolve every legal instruction to its `(from, to, count)` piles for
/// tests that assert against pile positions. Mirrors what a UI edge does
/// via [`GameState::instruction_to_piles`].
fn legal_pile_moves(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
game.possible_instructions()
.into_iter()
.filter_map(|instruction| game.instruction_to_piles(instruction))
.collect()
}
fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> {
const MAX_SEED: u64 = 512;
const MAX_STEPS: usize = 160;
for seed in 1..=MAX_SEED {
let mut game = GameState::new(seed, DrawMode::DrawOne);
game.take_from_foundation = true;
for _ in 0..MAX_STEPS {
let moves = legal_pile_moves(&game);
if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| {
*count == 1
&& matches!(from, KlondikePile::Foundation(_))
&& matches!(to, KlondikePile::Tableau(_))
}) {
return Some((game, from, to));
}
if let Some((from, to, count)) = moves.iter().cloned().find(|(from, to, count)| {
*count == 1
&& !matches!(from, KlondikePile::Foundation(_))
&& matches!(to, KlondikePile::Foundation(_))
}) && game.move_cards(from, to, count).is_ok()
{
continue;
}
if (!game.stock_cards().is_empty() || !game.waste_cards().is_empty())
&& game.draw().is_ok()
{
continue;
}
if let Some((from, to, count)) = moves
.iter()
.copied()
.find(|(from, _, _)| !matches!(from, KlondikePile::Foundation(_)))
&& game.move_cards(from, to, count).is_ok()
{
continue;
}
break;
}
}
None
}
/// Drive a DrawOne game until a recycle is available, perform it, and return
/// the game. Returns `None` if no recycle position is found within the
/// iteration limit (shouldn't happen in practice).
fn game_at_first_recycle() -> Option<GameState> {
for seed in 1..=256_u64 {
let mut game = GameState::new(seed, DrawMode::DrawOne);
for _ in 0..200 {
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
// This draw will recycle.
game.draw().ok()?;
return Some(game);
}
let _ = game.draw();
}
}
None
}
#[test]
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");
assert_eq!(game.recycle_count(), 1, "first recycle should give count=1");
game.undo().expect("undo should succeed");
assert_eq!(
game.recycle_count(),
1,
"recycle_count is cumulative: undoing a recycle does not roll it back",
);
}
#[test]
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");
}
}
#[test]
fn take_from_foundation_allows_legal_return_move() {
let (mut game, from, to) = find_foundation_return_position()
.expect("expected to find a deterministic foundation->tableau return move");
game.take_from_foundation = true;
assert!(game.can_move_cards(&from, &to, 1));
assert!(
legal_pile_moves(&game)
.iter()
.any(|(f, t, c)| *f == from && *t == to && *c == 1)
);
assert!(game.move_cards(from, to, 1).is_ok());
}
#[test]
fn take_from_foundation_disabled_blocks_return_move_everywhere() {
let (mut game, from, to) = find_foundation_return_position()
.expect("expected to find a deterministic foundation->tableau return move");
game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1));
assert!(
legal_pile_moves(&game)
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(game.move_cards(from, to, 1).is_err());
}
}