Files
Ferrous-Solitaire/solitaire_core/src/game_state.rs
T
funman300 07044f439c
Test / test (pull_request) Successful in 36m38s
fix(core): save files store the deal via upstream serializers (schema v6)
GameState's save serde had the same latent flaw PR #170 removed from
replays: v5 persisted seed + saved_moves and re-dealt the board from the
seed on load, so any RNG or upstream upgrade that shifts the seed->deal
mapping would invalidate every in-progress save (graceful error, but the
player loses their game). v6 persists the existing SessionRecording
payload (upstream card_game session serde: config + dealt board +
instructions) instead; seed stays as presentation metadata only.

- v4/v5 files still load through the legacy seed path and are rewritten
  as v6 on the next save; v6 files load from the recording and replay
  with per-instruction validation, so tampered or corrupt files surface
  an error rather than a silently wrong board
- saved_moves() removed (dead once serialisation reads recording())
- tests: v6 round-trip via storage, seed-corruption immunity, v5 legacy
  load, missing-recording rejection, invalid-history rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:21:01 -07:00

1815 lines
73 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::{
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,
};
use card_game::{Card, Game as _, Session, SessionConfig, SolveError};
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 (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`).
/// - v5: `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.
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream
/// `card_game` session serialisation carrying the dealt board explicitly.
/// Loading no longer re-deals from `seed`, so in-progress saves survive
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
/// class that PR #170 fixed for replays). v4/v5 files still load through
/// the legacy seed path and are rewritten as v6 on next save.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
/// 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<Option<KlondikeInstruction>, SolveError>;
/// 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 v6 serialisation. `recording` is the upstream
/// session serialisation (config + dealt board + instruction list).
#[derive(Debug, Clone, Serialize)]
struct PersistedGameState {
pub draw_mode: DrawStockConfig,
pub mode: GameMode,
pub elapsed_seconds: u64,
pub seed: u64,
pub take_from_foundation: bool,
pub schema_version: u32,
pub recording: SessionRecording,
}
/// Input struct that accepts schema v4, v5, and v6 save formats.
///
/// v6 files carry `recording` (the upstream session serialisation, deal
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
/// `seed`. `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,
#[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,
/// v4/v5 only. Replayed against a seed-dealt board.
#[serde(default)]
pub saved_moves: Vec<KlondikeInstruction>,
/// v6 only. Carries the dealt board explicitly.
#[serde(default)]
pub recording: Option<SessionRecording>,
}
#[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,
recording: self.recording(),
}
.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 v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
// saves — loadable while the seed→deal mapping they were written
// under still holds; rewritten as v6 on the next save). v3 (legacy
// u8-index format) and all others are rejected.
let (mut game, instructions) = match persisted.schema_version {
6 => {
let Some(recording) = persisted.recording else {
return Err(serde::de::Error::custom(
"v6 save file is missing the session recording",
));
};
// The dealt board and session config come from the recording
// itself; `seed` is presentation metadata only.
Self::from_recording(&recording, persisted.seed, persisted.mode)
}
4 | 5 => (
Self {
mode: persisted.mode,
elapsed_seconds: 0,
seed: persisted.seed,
take_from_foundation: true,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
},
persisted.saved_moves,
),
v => {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}"
)));
}
};
game.mode = persisted.mode;
game.elapsed_seconds = persisted.elapsed_seconds;
game.take_from_foundation = persisted.take_from_foundation;
// Replay the saved instruction history with validation — a tampered
// or corrupt file must surface an error, not a silently wrong board.
// 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 instruction in instructions {
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)
}
}
/// Self-contained recording of one deal plus every instruction applied to it.
///
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
/// format is `{config, initial_state, instructions}` — the dealt board is
/// stored **explicitly**, so playback never depends on the seed→deal mapping
/// staying stable across RNG or upstream-crate upgrades. This is the payload
/// replays must persist; a bare seed is only sufficient for the exact build
/// that recorded it.
#[derive(Debug, Clone)]
pub struct SessionRecording(Session<Klondike>);
impl SessionRecording {
/// Builds a recording by dealing a fresh board from `seed` and
/// force-applying `instructions` **without validation**.
///
/// Fixture/test aid only — production recordings come from
/// [`GameState::recording`], whose history is valid by construction.
/// Invalid instructions are absorbed by the upstream session's
/// `Option`-based pile pops rather than rejected, so a recording built
/// here may not replay cleanly through
/// [`GameState::apply_instruction`]'s validation.
pub fn from_instructions_unchecked(
seed: u64,
draw_mode: DrawStockConfig,
instructions: impl IntoIterator<Item = KlondikeInstruction>,
) -> Self {
let mut session = GameState::new_session(seed, draw_mode);
for instruction in instructions {
session.process_instruction(instruction);
}
Self(session)
}
/// The dealt board the recording starts from (before any instruction).
fn initial_state(&self) -> &Klondike {
self.0
.history()
.first()
.map(|snapshot| snapshot.state())
.unwrap_or_else(|| self.0.state().state())
}
/// Ordered instruction list, replayable via
/// [`GameState::apply_instruction`] against the game returned by
/// [`GameState::from_recording`].
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
self.0
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Number of recorded instructions.
pub fn len(&self) -> usize {
self.0.history().len()
}
/// `true` when no instructions have been recorded.
pub fn is_empty(&self) -> bool {
self.0.history().is_empty()
}
}
impl PartialEq for SessionRecording {
fn eq(&self, other: &Self) -> bool {
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
}
}
impl Eq for SessionRecording {}
impl Serialize for SessionRecording {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SessionRecording {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Session::deserialize(deserializer).map(Self)
}
}
impl GameState {
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
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: DrawStockConfig, 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,
}
}
/// Snapshot of the live session for replay persistence: the dealt board
/// plus the forward instruction history (undone moves are absent — the
/// session pops them). Serialise the returned [`SessionRecording`] with
/// the upstream `card_game` serializers; rebuild playback with
/// [`Self::from_recording`].
pub fn recording(&self) -> SessionRecording {
SessionRecording(self.session.clone())
}
/// Rebuilds the initial-deal game plus the ordered instruction list from
/// a [`SessionRecording`].
///
/// The board and the session config (including draw mode) come from the
/// recording itself, so playback is independent of the current build's
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
/// alongside the recording by the replay file. Step through the returned
/// instructions with [`Self::apply_instruction`], which re-validates each
/// one and fails gracefully on corrupt input.
pub fn from_recording(
recording: &SessionRecording,
seed: u64,
mode: GameMode,
) -> (Self, Vec<KlondikeInstruction>) {
let game = Self {
mode,
elapsed_seconds: 0,
seed,
take_from_foundation: true,
session: Session::new(
recording.initial_state().clone(),
recording.0.config().clone(),
),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
(game, recording.instructions())
}
/// 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) -> DrawStockConfig {
self.session.config().inner.draw_stock
}
/// 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: DrawStockConfig) -> Session<Klondike> {
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
}
fn session_config(draw_mode: DrawStockConfig) -> 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: 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
// 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)
}
/// Returns the deterministic instruction history for the current deal as
/// upstream [`KlondikeInstruction`] values.
///
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
/// 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<KlondikeInstruction> {
self.session
.history()
.iter()
.map(|snapshot| *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: DrawStockConfig) {
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.
///
/// Recycling is deliberately unlimited in every draw mode: extra passes
/// are discouraged through the upstream score penalty, never rejected
/// with a [`MoveError`]. The difficulty seed catalog and the
/// winnable-deal solver are verified under this rule — see the
/// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core`
/// section) before considering a hard pass limit.
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.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
}
/// 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())
})
})
}
/// Full winning line from the current position:
///
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
/// [`GameState::apply_instruction`] reaches a won game.
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
///
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
/// copy of the board like [`GameState::solve_first_move`], but returns the
/// whole path instead of the first move, compacted with
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
/// back to an already-seen state — the raw DFS trace is full of them).
///
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
/// are additionally stripped when the remaining sequence still replays to
/// a win; if stripping one would break a later move's preconditions the
/// unstripped cleaned line is returned instead, so the replay contract
/// above always holds. `clean_solution` is quadratic-ish in line length —
/// callers should run this off the UI thread with modest budgets.
pub fn winning_line(
&self,
moves_budget: u64,
states_budget: u64,
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
if self.is_won() {
return Ok(None);
}
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
let config = SessionConfig {
inner: inner.clone(),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let start = self.session.state().state().clone();
let session = Session::new(start.clone(), config);
let Some(solution) = session.solve()? else {
return Ok(None);
};
let cleaned: Vec<KlondikeInstruction> = solution
.clean_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect();
let filtered: Vec<KlondikeInstruction> = cleaned
.iter()
.copied()
.filter(|instruction| !instruction.is_useless())
.collect();
if line_replays_to_win(start, &inner, &filtered) {
Ok(Some(filtered))
} else {
Ok(Some(cleaned))
}
}
/// 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: DrawStockConfig,
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)
}
}
/// `true` when applying `line` in order from `start` is legal at every step
/// and ends in a won game. Pure replay check backing
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
/// win" contract.
fn line_replays_to_win(
mut state: Klondike,
config: &KlondikeConfig,
line: &[KlondikeInstruction],
) -> bool {
let mut stats = <Klondike as card_game::Game>::Stats::default();
for &instruction in line {
if !state.is_instruction_valid(config, instruction) {
return false;
}
state.process_instruction(&mut stats, config, instruction);
}
state.is_win()
}
#[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, DrawStockConfig::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, DrawStockConfig::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 draw_one_recycling_is_unlimited_by_design() {
// Rules decision (Gitea #117): stock recycling is unlimited in every
// draw mode. Extra passes are discouraged via the upstream score
// penalty, never blocked with a MoveError. The difficulty seed
// catalog and the winnable-deal solver are verified under this rule,
// so a hard pass limit must not be introduced casually — see the
// "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`).
let mut game = game_at_first_recycle().expect("could not reach recycle");
// ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes.
for _ in 0..2_000 {
if game.recycle_count() >= 10 {
break;
}
game.draw()
.expect("unlimited recycling: draw must never fail on pass count");
}
assert!(
game.recycle_count() >= 10,
"expected at least 10 recycles within the draw budget, got {}",
game.recycle_count(),
);
}
#[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, DrawStockConfig::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());
}
// ── 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<KlondikeInstruction>) {
(outcome.is_err(), outcome.clone().ok().flatten())
}
#[test]
fn solve_fresh_deal_is_deterministic() {
let a = GameState::solve_fresh_deal(
7,
DrawStockConfig::DrawOne,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
);
let b = GameState::solve_fresh_deal(
7,
DrawStockConfig::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, 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);
}
#[test]
fn solve_first_move_uses_live_game_state() {
let mut game = GameState::new(42, DrawStockConfig::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, DrawStockConfig::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
// ── Full winning line (winning_line) ──────────────────────────────────
#[test]
fn winning_line_replays_to_a_won_game() {
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
// (take-from-foundation off) keep the search space identical to
// that baseline. The returned line must apply cleanly through the
// normal instruction pipeline and end in a win — the whole point
// of the API contract.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("this seed must not exhaust a 5k budget")
.expect("this seed must be winnable");
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
for (i, instruction) in line.iter().enumerate() {
game.apply_instruction(*instruction)
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
}
assert!(game.is_won(), "line must end in a won game");
}
#[test]
fn winning_line_contains_no_useless_moves() {
// The foundation→foundation strip must survive the replay check on
// this seed; a line shown to the player should never shuffle
// between foundations. Same proven-winnable seed and standard
// rules as `winning_line_replays_to_a_won_game`.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("budget")
.expect("winnable");
assert!(
!line.iter().any(KlondikeInstruction::is_useless),
"filtered line must not contain foundation→foundation moves"
);
}
#[test]
fn winning_line_is_inconclusive_when_budget_exhausted() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let outcome = game.winning_line(5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn winning_line_matches_first_move_verdict() {
// The two solver entry points must agree on winnability for the
// same position and budgets (both are deterministic DFS).
let game = GameState::new(42, DrawStockConfig::DrawOne);
let first = game.solve_first_move(5_000, 5_000);
let line = game.winning_line(5_000, 5_000);
assert_eq!(
matches!(first, Ok(Some(_))),
matches!(line, Ok(Some(_))),
"solve_first_move and winning_line disagree on winnability"
);
}
#[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,
DrawStockConfig::DrawOne,
1_000,
1_000,
);
let medium = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
5_000,
5_000,
);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
/// Play a few real moves on a fresh deal and return the game.
fn game_with_some_moves(seed: u64) -> GameState {
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
for _ in 0..40 {
let instructions = game.possible_instructions();
let applied = instructions
.into_iter()
.find(|i| game.clone().apply_instruction(*i).is_ok())
.and_then(|i| game.apply_instruction(i).ok());
if applied.is_none() && game.draw().is_err() {
break;
}
}
assert!(
!game.instruction_history().is_empty(),
"test needs at least one recorded move"
);
game
}
#[test]
fn recording_round_trips_through_upstream_serde() {
let game = game_with_some_moves(51);
let recording = game.recording();
let json = serde_json::to_string(&recording).expect("serialize recording");
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
assert_eq!(recording, restored);
assert_eq!(recording.instructions(), game.instruction_history());
}
#[test]
fn from_recording_replays_to_identical_board_without_seed_dealing() {
let game = game_with_some_moves(145);
let recording = game.recording();
let json = serde_json::to_string(&recording).expect("serialize recording");
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
// Deliberately pass a DIFFERENT seed: the board must come from the
// recording, proving playback no longer depends on seed→deal mapping.
let (mut replayed, instructions) =
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
assert_eq!(replayed.draw_mode(), game.draw_mode());
for instruction in instructions {
replayed
.apply_instruction(instruction)
.expect("recorded instruction must replay cleanly");
}
for pile in [KlondikePile::Stock]
.into_iter()
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
{
assert_eq!(
replayed.pile(pile),
game.pile(pile),
"pile {pile:?} differs"
);
}
assert_eq!(replayed.waste_cards(), game.waste_cards());
assert_eq!(replayed.move_count(), game.move_count());
}
/// v6 save files carry the deal in the recording; the `seed` field is
/// presentation metadata. Corrupting it must not change the loaded board.
#[test]
fn v6_save_load_ignores_seed_for_dealing() {
let game = game_with_some_moves(51);
let mut value = serde_json::to_value(&game).expect("serialise");
assert_eq!(value["schema_version"], 6);
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
let loaded: GameState =
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.waste_cards(), game.waste_cards());
for index in 0..7 {
let t = GameState::tableau_from_index(index).expect("tableau index");
assert_eq!(
loaded.pile(KlondikePile::Tableau(t)),
game.pile(KlondikePile::Tableau(t)),
"tableau {index} differs"
);
}
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.score(), game.score());
}
/// Legacy v5 files (seed + saved_moves, no recording) must still load
/// through the seed-dealt path until players resave as v6.
#[test]
fn v5_legacy_save_still_loads() {
let game = game_with_some_moves(145);
let v5 = serde_json::json!({
"draw_mode": game.draw_mode(),
"mode": game.mode,
"elapsed_seconds": 12,
"seed": game.seed,
"take_from_foundation": true,
"schema_version": 5,
"saved_moves": game.instruction_history(),
});
let loaded: GameState =
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.elapsed_seconds, 12);
}
/// A v6 file without the recording payload is malformed and must be
/// rejected with an error rather than silently re-dealt from the seed.
#[test]
fn v6_save_without_recording_is_rejected() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let mut value = serde_json::to_value(&game).expect("serialise");
value.as_object_mut().expect("object").remove("recording");
let err = serde_json::from_value::<GameState>(value)
.expect_err("v6 save without recording must fail");
assert!(err.to_string().contains("recording"), "got: {err}");
}
/// A recording whose instruction list is invalid for its own deal must
/// be rejected on load — tampered or corrupt files surface an error,
/// never a silently wrong board.
#[test]
fn v6_save_with_invalid_history_is_rejected() {
use klondike::{DstFoundation, Foundation, KlondikePile};
// A foundation move from an empty tableau is never legal on a fresh
// deal's first instruction slot for this source shape.
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(Foundation::Foundation1),
foundation: Foundation::Foundation2,
});
let recording =
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
let v6 = serde_json::json!({
"draw_mode": DrawStockConfig::DrawOne,
"mode": GameMode::Classic,
"elapsed_seconds": 0,
"seed": 7,
"take_from_foundation": true,
"schema_version": 6,
"recording": recording,
});
let err =
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
assert!(err.to_string().contains("invalid"), "got: {err}");
}
#[test]
fn from_recording_of_fresh_deal_returns_empty_instructions() {
let game = GameState::new(7, DrawStockConfig::DrawThree);
let recording = game.recording();
assert!(recording.is_empty());
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
assert!(instructions.is_empty());
assert_eq!(replayed.stock_cards(), game.stock_cards());
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
}
}