07044f439c
Test / test (pull_request) Successful in 36m38s
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>
1815 lines
73 KiB
Rust
1815 lines
73 KiB
Rust
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);
|
||
}
|
||
}
|