Files
Ferrous-Solitaire/solitaire_core/src/game_state.rs
T
funman300 920f2c8597 refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:38:04 -07:00

1183 lines
46 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::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{
DrawMode, KlondikeAdapter, SavedInstruction, card_from_kl,
compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Game as _, Session, SessionConfig};
use klondike::{
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 (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;
/// 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 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<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 both schema v3 and v4 `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.
#[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")]
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<crate::card::Card>>,
/// Override for face-up waste cards. `None` means "use session".
pub waste: Option<Vec<crate::card::Card>>,
/// Per-tableau overrides. Missing keys fall back to the session.
pub tableau: std::collections::HashMap<Tableau, Vec<crate::card::Card>>,
/// Per-foundation overrides. Missing keys fall back to the session.
pub foundation: std::collections::HashMap<Foundation, Vec<crate::card::Card>>,
}
/// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone)]
pub struct GameState {
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen).
pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32,
/// Total moves made this game, including draws and stock recycles.
pub move_count: u32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64,
/// True once all 52 cards are on the foundations. No further moves are accepted.
pub is_won: bool,
/// True when the game can be completed without further input.
pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game.
pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game.
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<Klondike>,
/// 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<i32>,
/// 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<bool>,
#[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,
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(),
}
.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) and v4 (current,
// upstream named-variant serde). Reject everything else.
match persisted.schema_version {
3 | 4 => {}
v => {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}"
)));
}
}
let mut game = Self {
draw_mode: persisted.draw_mode,
mode: persisted.mode,
score: persisted.score,
move_count: 0,
elapsed_seconds: persisted.elapsed_seconds,
seed: persisted.seed,
is_won: false,
is_auto_completable: false,
undo_count: persisted.undo_count,
// Rebuilt from the replay loop below; persisted value may be stale
// due to the pre-Phase-3 undo drift bug.
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,
};
let replay_config = Self::replay_config(game.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)?
}
};
// 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()
.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);
game.is_recycle_history.push(is_recycle);
if is_recycle {
game.recycle_count = game.recycle_count.saturating_add(1);
}
}
game.move_count = Self::u32_from_len(game.session.history().len());
game.is_won = game.check_win();
game.is_auto_completable = !game.is_won && game.check_auto_complete();
Ok(game)
}
}
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 {
draw_mode,
mode,
score: 0,
move_count: 0,
elapsed_seconds: 0,
seed,
is_won: false,
is_auto_completable: false,
undo_count: 0,
recycle_count: 0,
take_from_foundation: true,
session: Self::new_session(seed, draw_mode),
score_history: Vec::new(),
is_recycle_history: Vec::new(),
#[cfg(feature = "test-support")]
test_pile_state: None,
}
}
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),
undo_penalty: 0,
..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> {
cards
.into_iter()
.map(|mut card| {
card.face_up = face_up;
card
})
.collect()
}
pub fn stock_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.stock
{
return cards.clone();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false)
}
pub fn waste_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.waste
{
return cards.clone();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
}
/// Returns the cards in the requested pile.
///
/// **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> {
#[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.clone();
}
}
KlondikePile::Foundation(f) => {
if let Some(cards) = state.foundation.get(&f) {
return cards.clone();
}
}
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().map(card_from_kl), true)
}
KlondikePile::Tableau(tableau) => {
let mut cards = Self::cards_with_face(
state
.tableau_face_down_cards(tableau)
.iter()
.map(card_from_kl),
false,
);
cards.extend(Self::cards_with_face(
state
.tableau_face_up_cards(tableau)
.iter()
.map(card_from_kl),
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>, 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: 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.
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
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.
#[cfg(feature = "test-support")]
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
match pile {
KlondikePile::Stock => {
let mut stock = Vec::new();
let mut waste = Vec::new();
for card in cards {
if card.face_up {
waste.push(card);
} else {
stock.push(card);
}
}
self.set_test_stock_cards(stock);
self.set_test_waste_cards(waste);
}
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 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].face_up
}
/// 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,
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,
}))
}
}
}
fn instruction_to_move(
&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);
}
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);
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);
self.move_count = Self::u32_from_len(self.session.history().len());
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)?;
let config = self.validation_config();
if !self
.session
.state()
.state()
.is_instruction_valid(&config, instruction)
{
return Err(MoveError::RuleViolation("move violates rules".into()));
}
let (score_delta, _) = self.pre_instruction_score_delta(instruction);
self.score_history.push(self.score);
self.is_recycle_history.push(false);
self.session.process_instruction(instruction);
self.score = (self.score + score_delta).max(0);
self.move_count = Self::u32_from_len(self.session.history().len());
self.is_won = self.check_win();
self.is_auto_completable = !self.is_won && self.check_auto_complete();
Ok(())
}
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
pub fn undo(&mut self) -> Result<(), MoveError> {
if self.is_won {
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);
}
// 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.move_count = Self::u32_from_len(self.session.history().len());
self.is_won = self.check_win();
self.is_auto_completable = !self.is_won && self.check_auto_complete();
self.undo_count = self.undo_count.saturating_add(1);
Ok(())
}
/// 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 `(from, to, count)` moves.
pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> {
if self.is_won {
return Vec::new();
}
let config = self.validation_config();
self.session
.state()
.state()
.get_sorted_moves(&config)
.into_iter()
.filter_map(|instruction| self.instruction_to_move(instruction))
.collect()
}
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
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_id`, if any.
pub fn pile_containing_card(&self, card_id: u32) -> Option<KlondikePile> {
if self.stock_cards().iter().any(|card| card.id == card_id)
|| self.waste_cards().iter().any(|card| card.id == card_id)
{
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(|card| card.id == card_id) {
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(|card| card.id == card_id) {
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;
}
self.possible_instructions()
.into_iter()
.find_map(|(from, to, count)| {
if count != 1 {
return None;
}
if matches!(from, KlondikePile::Foundation(_)) {
return None;
}
if matches!(to, KlondikePile::Foundation(_)) {
Some((from, to))
} else {
None
}
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
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()`
/// (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::*;
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 = game.possible_instructions();
if let Some((from, to, _count)) = moves.iter().copied().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().copied().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_decrements_when_recycle_is_undone() {
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");
game.undo().expect("undo should succeed");
assert_eq!(
game.recycle_count, 0,
"recycle_count must decrement back to 0 after undoing the recycle",
);
}
#[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();
}
assert!(second_recycle_done, "could not reach second recycle in test");
}
#[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!(
game.possible_instructions()
.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!(
game.possible_instructions()
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(game.move_cards(from, to, 1).is_err());
}
}