Files
Ferrous-Solitaire/solitaire_core/src/game_state.rs
T
funman300 baf524ec75 fix(web): rebuild Bevy canvas WASM; add SolitaireGame interactive API
Grey screen fix (canvas_bg.wasm):
- Rebuilt Bevy WASM from refactored solitaire_core that removes the
  per-game KlondikeAdapter field from GameState. The old binary was
  built with wasm-opt -Oz; the large adapter allocation pattern appears
  to trigger an over-aggressive wasm-opt optimisation that corrupts
  Bevy's render pipeline, causing a permanent grey screen on /play.
- build_wasm.sh: change wasm-opt -Oz → -O2. Speed-optimised level avoids
  the size-focused transforms that miscompile Bevy's deep render stacks.

solitaire_core refactoring:
- game_state.rs: remove adapter: KlondikeAdapter field; use static
  KlondikeAdapter::config_for() instead of a per-instance allocation.
  Gate test_pile_state behind #[cfg(feature = "test-support")] so
  production builds carry no test-only heap state.
  Add instruction_history() public accessor (delegates to saved_moves()).
- card.rs: add Card::new(), face_up(), face_down() const constructors
  for more ergonomic test and wasm code.
- pile.rs, solver.rs: cargo fmt.

solitaire_wasm interactive API:
- lib.rs: add SolitaireGame wasm-bindgen struct with draw(), move_cards(),
  undo(), auto_complete_step(), serialize(), from_saved() — the full
  player-action surface used by game.js.
  Add DebugSnapshot, DebugMove, DebugInvariantReport structs and
  debug_snapshot(), debug_legal_moves(), debug_apply_move_json()
  methods for e2e test automation (window.__FERROUS_DEBUG__ bridge).
  Add replay_moves() to export the current game as a Replay v2 payload.
- solitaire_wasm.js + solitaire_wasm_bg.wasm: rebuilt with new API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:20 -07:00

963 lines
35 KiB
Rust

use crate::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{
KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Game, Session, SessionConfig};
use klondike::{
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Save-file schema version for `GameState`. Increment when the on-disk
/// representation changes incompatibly so `load_game_state_from` can refuse
/// older formats and start the player on a fresh game.
///
/// History:
/// - v1: `Foundation(Suit)` keys.
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
/// - v3 (current): session-backed save files store replayable instruction
/// history instead of raw piles + undo snapshots.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 3;
/// Default value for `GameState::schema_version` when deserialising older
/// save files that pre-date the field.
fn schema_v1() -> u32 {
1
}
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// 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),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedGameState {
pub draw_mode: DrawMode,
#[serde(default)]
pub mode: GameMode,
pub score: i32,
pub elapsed_seconds: u64,
pub seed: u64,
pub undo_count: u32,
#[serde(default)]
pub recycle_count: u32,
#[serde(default)]
pub take_from_foundation: bool,
#[serde(default = "schema_v1")]
pub schema_version: u32,
pub saved_moves: Vec<SavedInstruction>,
}
#[cfg(feature = "test-support")]
/// Test-only override state that shadows the real session pile data.
///
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
/// first checks for an override here before falling back to the session.
/// This lets unit tests in `solitaire_engine` construct arbitrary board
/// configurations without needing to drive the full klondike session.
#[derive(Clone, Debug, Default)]
pub struct TestPileState {
/// Override for face-down stock cards. `None` means "use session".
pub stock: Option<Vec<crate::card::Card>>,
/// Override for face-up waste cards. `None` means "use session".
pub waste: Option<Vec<crate::card::Card>>,
/// Per-tableau overrides. Missing keys fall back to the session.
pub tableau: std::collections::HashMap<Tableau, Vec<crate::card::Card>>,
/// Per-foundation overrides. Missing keys fall back to the session.
pub foundation: std::collections::HashMap<Foundation, Vec<crate::card::Card>>,
}
/// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone)]
pub struct GameState {
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen).
pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32,
/// Total moves made this game, including draws and stock recycles.
pub move_count: u32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64,
/// True once all 52 cards are on the foundations. No further moves are accepted.
pub is_won: bool,
/// True when the game can be completed without further input.
pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game.
pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game.
pub recycle_count: u32,
/// When `true`, the player may move the top card of a foundation pile back
/// onto a compatible tableau column.
pub take_from_foundation: bool,
/// Save-file schema version.
pub schema_version: u32,
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.schema_version == other.schema_version
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
&& (0..4_u8)
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
&& (0..7_usize).all(|index| {
let Ok(tableau) = Self::tableau_from_index(index) else {
return false;
};
self.pile(KlondikePile::Tableau(tableau))
== other.pile(KlondikePile::Tableau(tableau))
})
}
}
impl Eq for GameState {}
impl Serialize for GameState {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
PersistedGameState {
draw_mode: self.draw_mode,
mode: self.mode,
score: self.score,
elapsed_seconds: self.elapsed_seconds,
seed: self.seed,
undo_count: self.undo_count,
recycle_count: self.recycle_count,
take_from_foundation: self.take_from_foundation,
schema_version: self.schema_version,
saved_moves: self.saved_moves(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameState::deserialize(deserializer)?;
if persisted.schema_version != GAME_STATE_SCHEMA_VERSION {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {}",
persisted.schema_version
)));
}
let mut game = Self {
draw_mode: persisted.draw_mode,
mode: persisted.mode,
score: persisted.score,
move_count: 0,
elapsed_seconds: persisted.elapsed_seconds,
seed: persisted.seed,
is_won: false,
is_auto_completable: false,
undo_count: persisted.undo_count,
recycle_count: persisted.recycle_count,
take_from_foundation: persisted.take_from_foundation,
schema_version: persisted.schema_version,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
let replay_config = Self::replay_config(game.draw_mode);
for saved in persisted.saved_moves {
let instruction =
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
if !game
.session
.state()
.state()
.is_instruction_valid(&replay_config, instruction)
{
return Err(serde::de::Error::custom(
"saved instruction history is invalid for reconstructed session",
));
}
game.session.process_instruction(instruction);
}
game.move_count = Self::u32_from_len(game.session.history().len());
game.is_won = game.check_win();
game.is_auto_completable = !game.is_won && game.check_auto_complete();
Ok(game)
}
}
impl GameState {
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
}
/// Creates a new game with an explicit `GameMode`.
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
Self {
draw_mode,
mode,
score: 0,
move_count: 0,
elapsed_seconds: 0,
seed,
is_won: false,
is_auto_completable: false,
undo_count: 0,
recycle_count: 0,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
session: Self::new_session(seed, draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
}
}
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
}
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
SessionConfig {
inner: Self::replay_config(draw_mode),
undo_penalty: 0,
..SessionConfig::default()
}
}
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
KlondikeAdapter::config_for(draw_mode, true)
}
fn validation_config(&self) -> KlondikeConfig {
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
}
fn saved_moves(&self) -> Vec<SavedInstruction> {
self.session
.history()
.iter()
.map(|snapshot| SavedInstruction::from(*snapshot.instruction()))
.collect()
}
/// Returns the deterministic instruction history for the current deal.
///
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
/// sequence is sufficient to replay the game state exactly.
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
self.saved_moves()
}
fn u32_from_len(len: usize) -> u32 {
if len > u32::MAX as usize {
u32::MAX
} else {
len as u32
}
}
pub fn undo_stack_len(&self) -> usize {
self.session.history().len()
}
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<Card> {
cards
.into_iter()
.map(|mut card| {
card.face_up = face_up;
card
})
.collect()
}
pub fn stock_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.stock
{
return cards.clone();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false)
}
pub fn waste_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.waste
{
return cards.clone();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
}
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state {
match pile {
KlondikePile::Stock => {
if let Some(ref cards) = state.waste {
return cards.clone();
}
}
KlondikePile::Foundation(f) => {
if let Some(cards) = state.foundation.get(&f) {
return cards.clone();
}
}
KlondikePile::Tableau(t) => {
if let Some(cards) = state.tableau.get(&t) {
return cards.clone();
}
}
}
}
let state = self.session.state().state().state();
match pile {
KlondikePile::Stock => self.waste_cards(),
KlondikePile::Foundation(foundation) => {
let cards = match foundation {
Foundation::Foundation1 => state.foundation1(),
Foundation::Foundation2 => state.foundation2(),
Foundation::Foundation3 => state.foundation3(),
Foundation::Foundation4 => state.foundation4(),
};
Self::cards_with_face(cards.iter().map(card_from_kl), true)
}
KlondikePile::Tableau(tableau) => {
let mut cards = Self::cards_with_face(
state
.tableau_face_down_cards(tableau)
.iter()
.map(card_from_kl),
false,
);
cards.extend(Self::cards_with_face(
state
.tableau_face_up_cards(tableau)
.iter()
.map(card_from_kl),
true,
));
cards
}
}
}
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
}
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
}
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
let foundation = Self::foundation_from_slot(slot)?;
Ok(self.pile(KlondikePile::Foundation(foundation)))
}
/// Returns `true` when test-only pile overrides are active.
#[cfg(feature = "test-support")]
pub fn has_test_pile_overrides(&self) -> bool {
self.test_pile_state.is_some()
}
/// Returns `false` in production builds where test pile overrides are absent.
#[cfg(not(feature = "test-support"))]
pub const fn has_test_pile_overrides(&self) -> bool {
false
}
/// Test-support helper: clear all pile overrides so reads come from the
/// underlying klondike session again.
#[cfg(feature = "test-support")]
pub fn clear_test_pile_overrides(&mut self) {
self.test_pile_state = None;
}
/// Test-support helper: override face-down stock cards returned by
/// [`Self::stock_cards`].
#[cfg(feature = "test-support")]
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.stock = Some(cards);
}
/// Test-support helper: override face-up waste cards returned by
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
#[cfg(feature = "test-support")]
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.waste = Some(cards);
}
/// Test-support helper: override cards for a specific tableau column.
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.tableau.insert(tableau, cards);
}
/// Test-support helper: override cards for a specific foundation pile.
#[cfg(feature = "test-support")]
pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.foundation.insert(foundation, cards);
}
/// Test-support helper: override cards for a specific pile.
#[cfg(feature = "test-support")]
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
match pile {
KlondikePile::Stock => {
let mut stock = Vec::new();
let mut waste = Vec::new();
for card in cards {
if card.face_up {
waste.push(card);
} else {
stock.push(card);
}
}
self.set_test_stock_cards(stock);
self.set_test_waste_cards(waste);
}
KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
}
}
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
adapter_skip_cards_from_count(skip)
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
}
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
let KlondikePile::Tableau(_) = from else {
return false;
};
let pile = self.pile(from);
if pile.is_empty() {
return false;
}
pile.len() > count && !pile[pile.len() - count - 1].face_up
}
fn instruction_for_move(
&self,
from: KlondikePile,
to: KlondikePile,
count: usize,
) -> Result<KlondikeInstruction, MoveError> {
match (from, to) {
(_, KlondikePile::Stock) => Err(MoveError::InvalidDestination),
(KlondikePile::Stock, KlondikePile::Foundation(foundation)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(),
));
}
Ok(KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Stock,
foundation,
}))
}
(KlondikePile::Tableau(src), KlondikePile::Foundation(foundation)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(),
));
}
Ok(KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(src),
foundation,
}))
}
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
MoveError::RuleViolation("cannot move between foundation slots".into()),
),
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only the top waste card may be moved".into(),
));
}
Ok(KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: dst,
}))
}
(KlondikePile::Foundation(foundation), KlondikePile::Tableau(dst)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can return from foundation at a time".into(),
));
}
Ok(KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Foundation(foundation),
tableau: dst,
}))
}
(KlondikePile::Tableau(src), KlondikePile::Tableau(dst)) => {
let face_up_count = self
.session
.state()
.state()
.state()
.tableau_face_up_cards(src)
.len();
if count > face_up_count {
return Err(MoveError::RuleViolation(
"cannot move face-down card".into(),
));
}
let skip_cards = Self::skip_cards_from_usize(face_up_count - count)?;
Ok(KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Tableau(TableauStack {
tableau: src,
skip_cards,
}),
tableau: dst,
}))
}
}
}
fn instruction_to_move(
&self,
instruction: KlondikeInstruction,
) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state();
match instruction {
KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
}
let source = match dst_foundation.src {
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
KlondikePile::Stock => KlondikePile::Stock,
KlondikePile::Foundation(_) => return None,
};
Some((
source,
KlondikePile::Foundation(dst_foundation.foundation),
1,
))
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count =
state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
}
(KlondikePile::Tableau(tableau_stack.tableau), count)
}
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
KlondikePileStack::Foundation(foundation) => {
(KlondikePile::Foundation(foundation), 1)
}
};
Some((source, KlondikePile::Tableau(dst_tableau.tableau), count))
}
}
}
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
pub fn draw(&mut self) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
}
let stock_empty = self.stock_cards().is_empty();
let waste_empty = self.waste_cards().is_empty();
if stock_empty && waste_empty {
return Err(MoveError::StockEmpty);
}
let recycling = stock_empty && !waste_empty;
self.session
.process_instruction(KlondikeInstruction::RotateStock);
if recycling {
self.recycle_count = self.recycle_count.saturating_add(1);
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
self.recycle_count,
self.draw_mode == DrawMode::DrawThree,
self.mode,
);
self.score = (self.score + penalty).max(0);
}
self.move_count = Self::u32_from_len(self.session.history().len());
Ok(())
}
/// Move `count` cards from pile `from` to pile `to` using Klondike-native pile ids.
pub fn move_cards(
&mut self,
from: KlondikePile,
to: KlondikePile,
count: usize,
) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
}
if from == to {
return Err(MoveError::RuleViolation(
"source and destination must differ".into(),
));
}
let from_pile = self.pile(from);
if from_pile.is_empty() {
return Err(MoveError::EmptySource);
}
if count == 0 || count > from_pile.len() {
return Err(MoveError::RuleViolation("invalid card count".into()));
}
let instruction = self.instruction_for_move(from, to, count)?;
let config = self.validation_config();
if !self
.session
.state()
.state()
.is_instruction_valid(&config, instruction)
{
return Err(MoveError::RuleViolation("move violates rules".into()));
}
let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
let flip_bonus = if self.will_flip_tableau_source(from, count) {
KlondikeAdapter::score_for_flip_with_mode(self.mode)
} else {
0
};
self.session.process_instruction(instruction);
self.score = (self.score + score_delta + flip_bonus).max(0);
self.move_count = Self::u32_from_len(self.session.history().len());
self.is_won = self.check_win();
self.is_auto_completable = !self.is_won && self.check_auto_complete();
Ok(())
}
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
pub fn undo(&mut self) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
}
if self.mode == GameMode::Challenge {
return Err(MoveError::RuleViolation(
"undo is disabled in Challenge mode".into(),
));
}
if self.session.history().is_empty() {
return Err(MoveError::UndoStackEmpty);
}
let snapshot_score = self.score;
self.session.undo();
self.score = KlondikeAdapter::apply_undo_score(snapshot_score, self.mode);
self.move_count = Self::u32_from_len(self.session.history().len());
self.is_won = self.check_win();
self.is_auto_completable = !self.is_won && self.check_auto_complete();
self.undo_count = self.undo_count.saturating_add(1);
Ok(())
}
/// Returns `true` when all four foundation slots each contain a valid A→K sequence.
pub fn check_win(&self) -> bool {
(0..4_u8).all(|slot| self.is_valid_foundation_pile(slot))
}
fn is_valid_foundation_pile(&self, slot: u8) -> bool {
let Ok(pile) = self.foundation_cards(slot) else {
return false;
};
if pile.len() != 13 {
return false;
}
let suit = pile[0].suit;
pile.iter()
.enumerate()
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
}
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
pub fn check_auto_complete(&self) -> bool {
if !self.stock_cards().is_empty() {
return false;
}
if !self.waste_cards().is_empty() {
return false;
}
(0..7).all(|index| {
Self::tableau_from_index(index)
.ok()
.map(|tableau| {
self.pile(KlondikePile::Tableau(tableau))
.iter()
.all(|card| card.face_up)
})
.unwrap_or(false)
})
}
/// Returns all currently valid `(from, to, count)` moves.
pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> {
if self.is_won {
return Vec::new();
}
let config = self.validation_config();
self.session
.state()
.state()
.get_sorted_moves(&config)
.into_iter()
.filter_map(|instruction| self.instruction_to_move(instruction))
.collect()
}
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
if self.is_won || from == to {
return false;
}
let from_pile = self.pile(*from);
if count == 0 || count > from_pile.len() {
return false;
}
let Ok(instruction) = self.instruction_for_move(*from, *to, count) else {
return false;
};
let config = self.validation_config();
self.session
.state()
.state()
.is_instruction_valid(&config, instruction)
}
/// Returns the current pile containing `card_id`, if any.
pub fn pile_containing_card(&self, card_id: u32) -> Option<KlondikePile> {
if self.stock_cards().iter().any(|card| card.id == card_id)
|| self.waste_cards().iter().any(|card| card.id == card_id)
{
return Some(KlondikePile::Stock);
}
for slot in 0..4_u8 {
let foundation = Self::foundation_from_slot(slot).ok()?;
let pile = self.pile(KlondikePile::Foundation(foundation));
if pile.iter().any(|card| card.id == card_id) {
return Some(KlondikePile::Foundation(foundation));
}
}
for index in 0..7_usize {
let tableau = Self::tableau_from_index(index).ok()?;
let pile = self.pile(KlondikePile::Tableau(tableau));
if pile.iter().any(|card| card.id == card_id) {
return Some(KlondikePile::Tableau(tableau));
}
}
None
}
/// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> {
if !self.is_auto_completable || self.is_won {
return None;
}
self.possible_instructions()
.into_iter()
.find_map(|(from, to, count)| {
if count != 1 {
return None;
}
if matches!(from, KlondikePile::Foundation(_)) {
return None;
}
if matches!(to, KlondikePile::Foundation(_)) {
Some((from, to))
} else {
None
}
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
pub fn compute_time_bonus(&self) -> i32 {
scoring_time_bonus(self.elapsed_seconds)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> {
const MAX_SEED: u64 = 512;
const MAX_STEPS: usize = 160;
for seed in 1..=MAX_SEED {
let mut game = GameState::new(seed, DrawMode::DrawOne);
game.take_from_foundation = true;
for _ in 0..MAX_STEPS {
let moves = game.possible_instructions();
if let Some((from, to, _count)) = moves.iter().copied().find(|(from, to, count)| {
*count == 1
&& matches!(from, KlondikePile::Foundation(_))
&& matches!(to, KlondikePile::Tableau(_))
}) {
return Some((game, from, to));
}
if let Some((from, to, count)) = moves.iter().copied().find(|(from, to, count)| {
*count == 1
&& !matches!(from, KlondikePile::Foundation(_))
&& matches!(to, KlondikePile::Foundation(_))
}) && game.move_cards(from, to, count).is_ok()
{
continue;
}
if (!game.stock_cards().is_empty() || !game.waste_cards().is_empty())
&& game.draw().is_ok()
{
continue;
}
if let Some((from, to, count)) = moves
.iter()
.copied()
.find(|(from, _, _)| !matches!(from, KlondikePile::Foundation(_)))
&& game.move_cards(from, to, count).is_ok()
{
continue;
}
break;
}
}
None
}
#[test]
fn take_from_foundation_allows_legal_return_move() {
let (mut game, from, to) = find_foundation_return_position()
.expect("expected to find a deterministic foundation->tableau return move");
game.take_from_foundation = true;
assert!(game.can_move_cards(&from, &to, 1));
assert!(
game.possible_instructions()
.iter()
.any(|(f, t, c)| *f == from && *t == to && *c == 1)
);
assert!(game.move_cards(from, to, 1).is_ok());
}
#[test]
fn take_from_foundation_disabled_blocks_return_move_everywhere() {
let (mut game, from, to) = find_foundation_return_position()
.expect("expected to find a deterministic foundation->tableau return move");
game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1));
assert!(
game.possible_instructions()
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(game.move_cards(from, to, 1).is_err());
}
}