920f2c8597
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root) - Delete solitaire_core::pile — no external users - Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode - Remove schema_version field from GameState (redundant — deserializer stamps it from the constant) - Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1183 lines
46 KiB
Rust
1183 lines
46 KiB
Rust
use crate::card::Card;
|
||
use crate::error::MoveError;
|
||
use crate::klondike_adapter::{
|
||
DrawMode, KlondikeAdapter, SavedInstruction, card_from_kl,
|
||
compute_time_bonus as scoring_time_bonus,
|
||
foundation_from_slot as adapter_foundation_from_slot,
|
||
skip_cards_from_count as adapter_skip_cards_from_count,
|
||
tableau_from_index as adapter_tableau_from_index,
|
||
};
|
||
use card_game::{Game as _, Session, SessionConfig};
|
||
use klondike::{
|
||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||
};
|
||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||
|
||
/// Save-file schema version for `GameState`. Increment when the on-disk
|
||
/// representation changes incompatibly so `load_game_state_from` can refuse
|
||
/// older formats and start the player on a fresh game.
|
||
///
|
||
/// History:
|
||
/// - v1: `Foundation(Suit)` keys.
|
||
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
||
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
||
/// with u8 indices for enum variants.
|
||
/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with
|
||
/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are
|
||
/// auto-migrated on load via `AnyInstruction` transparent deserialization.
|
||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4;
|
||
|
||
/// Default value for `GameState::schema_version` when deserialising older
|
||
/// save files that pre-date the field.
|
||
fn schema_v1() -> u32 {
|
||
1
|
||
}
|
||
|
||
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
|
||
/// catalog is drawn from. `Random` skips verification entirely and uses a
|
||
/// system-time seed — deals may or may not be winnable.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||
pub enum DifficultyLevel {
|
||
#[default]
|
||
Easy,
|
||
Medium,
|
||
Hard,
|
||
Expert,
|
||
Grandmaster,
|
||
/// Unverified system-time seed — may or may not be winnable.
|
||
Random,
|
||
}
|
||
|
||
impl DifficultyLevel {
|
||
/// Short human-readable label shown in the HUD and win summary.
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Easy => "Easy",
|
||
Self::Medium => "Medium",
|
||
Self::Hard => "Hard",
|
||
Self::Expert => "Expert",
|
||
Self::Grandmaster => "Grandmaster",
|
||
Self::Random => "Random",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
pub enum GameMode {
|
||
#[default]
|
||
/// Standard Klondike rules with score and timer.
|
||
Classic,
|
||
/// No timer, no score display, ambient audio only.
|
||
Zen,
|
||
/// Fixed hard seeds, no undo, must win to advance.
|
||
Challenge,
|
||
/// Play as many games as possible within 10 minutes.
|
||
TimeAttack,
|
||
/// Seed drawn from a difficulty-tiered catalog; rules identical to Classic.
|
||
Difficulty(DifficultyLevel),
|
||
}
|
||
|
||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
struct PersistedGameState {
|
||
pub draw_mode: DrawMode,
|
||
pub mode: GameMode,
|
||
pub score: i32,
|
||
pub elapsed_seconds: u64,
|
||
pub seed: u64,
|
||
pub undo_count: u32,
|
||
pub recycle_count: u32,
|
||
pub take_from_foundation: bool,
|
||
pub schema_version: u32,
|
||
pub saved_moves: Vec<KlondikeInstruction>,
|
||
}
|
||
|
||
/// Transparent migration wrapper for deserialisation.
|
||
///
|
||
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that
|
||
/// fails (because the value uses u8 indices), falls back to `SavedInstruction`
|
||
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via
|
||
/// the existing `TryFrom` impl.
|
||
///
|
||
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
|
||
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
#[serde(untagged)]
|
||
enum AnyInstruction {
|
||
V4(KlondikeInstruction),
|
||
V3(SavedInstruction),
|
||
}
|
||
|
||
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
|
||
///
|
||
/// `recycle_count` is intentionally absent: the value is rebuilt from the
|
||
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
|
||
/// bug) are corrected on load. Serde ignores the field in the JSON.
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
struct PersistedGameStateIn {
|
||
pub draw_mode: DrawMode,
|
||
#[serde(default)]
|
||
pub mode: GameMode,
|
||
pub score: i32,
|
||
pub elapsed_seconds: u64,
|
||
pub seed: u64,
|
||
pub undo_count: u32,
|
||
#[serde(default)]
|
||
pub take_from_foundation: bool,
|
||
#[serde(default = "schema_v1")]
|
||
pub schema_version: u32,
|
||
pub saved_moves: Vec<AnyInstruction>,
|
||
}
|
||
|
||
#[cfg(feature = "test-support")]
|
||
/// Test-only override state that shadows the real session pile data.
|
||
///
|
||
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
|
||
/// first checks for an override here before falling back to the session.
|
||
/// This lets unit tests in `solitaire_engine` construct arbitrary board
|
||
/// configurations without needing to drive the full klondike session.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct TestPileState {
|
||
/// Override for face-down stock cards. `None` means "use session".
|
||
pub stock: Option<Vec<crate::card::Card>>,
|
||
/// Override for face-up waste cards. `None` means "use session".
|
||
pub waste: Option<Vec<crate::card::Card>>,
|
||
/// Per-tableau overrides. Missing keys fall back to the session.
|
||
pub tableau: std::collections::HashMap<Tableau, Vec<crate::card::Card>>,
|
||
/// Per-foundation overrides. Missing keys fall back to the session.
|
||
pub foundation: std::collections::HashMap<Foundation, Vec<crate::card::Card>>,
|
||
}
|
||
|
||
/// Full state of an in-progress Klondike Solitaire game.
|
||
#[derive(Debug, Clone)]
|
||
pub struct GameState {
|
||
/// Whether the player draws one or three cards from the stock per turn.
|
||
pub draw_mode: DrawMode,
|
||
/// Top-level mode (Classic / Zen).
|
||
pub mode: GameMode,
|
||
/// Current game score. Can be negative (undo penalties subtract from score).
|
||
pub score: i32,
|
||
/// Total moves made this game, including draws and stock recycles.
|
||
pub move_count: u32,
|
||
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||
pub elapsed_seconds: u64,
|
||
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||
pub seed: u64,
|
||
/// True once all 52 cards are on the foundations. No further moves are accepted.
|
||
pub is_won: bool,
|
||
/// True when the game can be completed without further input.
|
||
pub is_auto_completable: bool,
|
||
/// Number of times `undo()` has been successfully invoked this game.
|
||
pub undo_count: u32,
|
||
/// Number of times the waste pile has been recycled back to stock this game.
|
||
pub recycle_count: u32,
|
||
/// When `true`, the player may move the top card of a foundation pile back
|
||
/// onto a compatible tableau column.
|
||
pub take_from_foundation: bool,
|
||
pub(crate) session: Session<Klondike>,
|
||
/// Score recorded immediately before each instruction was applied.
|
||
/// Parallel to `session.history()` during live play; used by `undo()` to
|
||
/// correctly restore the pre-move score before applying the undo penalty.
|
||
/// Empty after a load (can't be reconstructed from history alone).
|
||
score_history: Vec<i32>,
|
||
/// Whether each entry in `session.history()` was a stock recycle.
|
||
/// Parallel to `session.history()`; rebuilt from replay on load so that
|
||
/// `undo()` correctly decrements `recycle_count` even across save/load cycles.
|
||
is_recycle_history: Vec<bool>,
|
||
#[cfg(feature = "test-support")]
|
||
/// Test pile overrides. Always `None` in production runtime code.
|
||
pub test_pile_state: Option<TestPileState>,
|
||
}
|
||
|
||
impl PartialEq for GameState {
|
||
fn eq(&self, other: &Self) -> bool {
|
||
self.draw_mode == other.draw_mode
|
||
&& self.mode == other.mode
|
||
&& self.score == other.score
|
||
&& self.move_count == other.move_count
|
||
&& self.elapsed_seconds == other.elapsed_seconds
|
||
&& self.seed == other.seed
|
||
&& self.is_won == other.is_won
|
||
&& self.is_auto_completable == other.is_auto_completable
|
||
&& self.undo_count == other.undo_count
|
||
&& self.recycle_count == other.recycle_count
|
||
&& self.take_from_foundation == other.take_from_foundation
|
||
&& self.stock_cards() == other.stock_cards()
|
||
&& self.waste_cards() == other.waste_cards()
|
||
&& (0..4_u8)
|
||
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
|
||
&& (0..7_usize).all(|index| {
|
||
let Ok(tableau) = Self::tableau_from_index(index) else {
|
||
return false;
|
||
};
|
||
self.pile(KlondikePile::Tableau(tableau))
|
||
== other.pile(KlondikePile::Tableau(tableau))
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Eq for GameState {}
|
||
|
||
impl Serialize for GameState {
|
||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||
PersistedGameState {
|
||
draw_mode: self.draw_mode,
|
||
mode: self.mode,
|
||
score: self.score,
|
||
elapsed_seconds: self.elapsed_seconds,
|
||
seed: self.seed,
|
||
undo_count: self.undo_count,
|
||
recycle_count: self.recycle_count,
|
||
take_from_foundation: self.take_from_foundation,
|
||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||
saved_moves: self.saved_moves(),
|
||
}
|
||
.serialize(serializer)
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for GameState {
|
||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||
|
||
// Accept v3 (legacy u8-index format, auto-migrated) and v4 (current,
|
||
// upstream named-variant serde). Reject everything else.
|
||
match persisted.schema_version {
|
||
3 | 4 => {}
|
||
v => {
|
||
return Err(serde::de::Error::custom(format!(
|
||
"unsupported GameState schema version {v}"
|
||
)));
|
||
}
|
||
}
|
||
|
||
let mut game = Self {
|
||
draw_mode: persisted.draw_mode,
|
||
mode: persisted.mode,
|
||
score: persisted.score,
|
||
move_count: 0,
|
||
elapsed_seconds: persisted.elapsed_seconds,
|
||
seed: persisted.seed,
|
||
is_won: false,
|
||
is_auto_completable: false,
|
||
undo_count: persisted.undo_count,
|
||
// Rebuilt from the replay loop below; persisted value may be stale
|
||
// due to the pre-Phase-3 undo drift bug.
|
||
recycle_count: 0,
|
||
take_from_foundation: persisted.take_from_foundation,
|
||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||
// score_history cannot be faithfully rebuilt from the instruction
|
||
// history because live-play undo penalties are not recorded in
|
||
// saved_moves. Leave empty; undo() falls back to old behaviour for
|
||
// any move made before this load (see undo() for details).
|
||
score_history: Vec::new(),
|
||
// is_recycle_history IS rebuilt: recycle detection only needs the
|
||
// pre-instruction session state, which is available during replay.
|
||
is_recycle_history: Vec::new(),
|
||
#[cfg(feature = "test-support")]
|
||
test_pile_state: None,
|
||
};
|
||
|
||
let replay_config = Self::replay_config(game.draw_mode);
|
||
for any in persisted.saved_moves {
|
||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
|
||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
||
// converted here via the existing TryFrom impl.
|
||
let instruction = match any {
|
||
AnyInstruction::V4(i) => i,
|
||
AnyInstruction::V3(s) => {
|
||
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
|
||
}
|
||
};
|
||
|
||
// Detect recycle BEFORE processing so that the pre-instruction
|
||
// session state (face-down stock) is still available.
|
||
let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock)
|
||
&& game.stock_cards().is_empty()
|
||
&& !game.waste_cards().is_empty();
|
||
|
||
if !game
|
||
.session
|
||
.state()
|
||
.state()
|
||
.is_instruction_valid(&replay_config, instruction)
|
||
{
|
||
return Err(serde::de::Error::custom(
|
||
"saved instruction history is invalid for reconstructed session",
|
||
));
|
||
}
|
||
game.session.process_instruction(instruction);
|
||
|
||
game.is_recycle_history.push(is_recycle);
|
||
if is_recycle {
|
||
game.recycle_count = game.recycle_count.saturating_add(1);
|
||
}
|
||
}
|
||
|
||
game.move_count = Self::u32_from_len(game.session.history().len());
|
||
game.is_won = game.check_win();
|
||
game.is_auto_completable = !game.is_won && game.check_auto_complete();
|
||
Ok(game)
|
||
}
|
||
}
|
||
|
||
impl GameState {
|
||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
|
||
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
||
}
|
||
|
||
/// Creates a new game with an explicit `GameMode`.
|
||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
||
Self {
|
||
draw_mode,
|
||
mode,
|
||
score: 0,
|
||
move_count: 0,
|
||
elapsed_seconds: 0,
|
||
seed,
|
||
is_won: false,
|
||
is_auto_completable: false,
|
||
undo_count: 0,
|
||
recycle_count: 0,
|
||
take_from_foundation: true,
|
||
session: Self::new_session(seed, draw_mode),
|
||
score_history: Vec::new(),
|
||
is_recycle_history: Vec::new(),
|
||
#[cfg(feature = "test-support")]
|
||
test_pile_state: None,
|
||
}
|
||
}
|
||
|
||
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
|
||
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
|
||
}
|
||
|
||
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
|
||
SessionConfig {
|
||
inner: Self::replay_config(draw_mode),
|
||
undo_penalty: 0,
|
||
..SessionConfig::default()
|
||
}
|
||
}
|
||
|
||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
||
// Always allow foundation returns during replay, regardless of the
|
||
// player's current `take_from_foundation` setting. A move recorded
|
||
// when the rule was enabled must replay correctly even if the player
|
||
// later disables it; a restrictive replay config would reject it and
|
||
// corrupt the save.
|
||
KlondikeAdapter::config_for(draw_mode, true)
|
||
}
|
||
|
||
fn validation_config(&self) -> KlondikeConfig {
|
||
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||
}
|
||
|
||
/// Collects the session instruction history as upstream types for schema v4
|
||
/// serialisation.
|
||
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
||
self.session
|
||
.history()
|
||
.iter()
|
||
.map(|snapshot| *snapshot.instruction())
|
||
.collect()
|
||
}
|
||
|
||
/// Returns the deterministic instruction history for the current deal as
|
||
/// legacy mirror types.
|
||
///
|
||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||
/// sequence is sufficient to replay the game state exactly.
|
||
///
|
||
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward
|
||
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove`
|
||
/// format. New code that does not need serde should prefer
|
||
/// `session().history()` directly.
|
||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
||
self.session
|
||
.history()
|
||
.iter()
|
||
.map(|snapshot| SavedInstruction::from(*snapshot.instruction()))
|
||
.collect()
|
||
}
|
||
|
||
fn u32_from_len(len: usize) -> u32 {
|
||
if len > u32::MAX as usize {
|
||
u32::MAX
|
||
} else {
|
||
len as u32
|
||
}
|
||
}
|
||
|
||
pub fn undo_stack_len(&self) -> usize {
|
||
self.session.history().len()
|
||
}
|
||
|
||
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<Card> {
|
||
cards
|
||
.into_iter()
|
||
.map(|mut card| {
|
||
card.face_up = face_up;
|
||
card
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn stock_cards(&self) -> Vec<Card> {
|
||
#[cfg(feature = "test-support")]
|
||
if let Some(ref state) = self.test_pile_state
|
||
&& let Some(ref cards) = state.stock
|
||
{
|
||
return cards.clone();
|
||
}
|
||
let state = self.session.state().state().state();
|
||
Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false)
|
||
}
|
||
|
||
pub fn waste_cards(&self) -> Vec<Card> {
|
||
#[cfg(feature = "test-support")]
|
||
if let Some(ref state) = self.test_pile_state
|
||
&& let Some(ref cards) = state.waste
|
||
{
|
||
return cards.clone();
|
||
}
|
||
let state = self.session.state().state().state();
|
||
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
|
||
}
|
||
|
||
/// Returns the cards in the requested pile.
|
||
///
|
||
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
|
||
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
|
||
/// to read the face-down draw cards.
|
||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||
#[cfg(feature = "test-support")]
|
||
if let Some(ref state) = self.test_pile_state {
|
||
match pile {
|
||
KlondikePile::Stock => {
|
||
if let Some(ref cards) = state.waste {
|
||
return cards.clone();
|
||
}
|
||
}
|
||
KlondikePile::Foundation(f) => {
|
||
if let Some(cards) = state.foundation.get(&f) {
|
||
return cards.clone();
|
||
}
|
||
}
|
||
KlondikePile::Tableau(t) => {
|
||
if let Some(cards) = state.tableau.get(&t) {
|
||
return cards.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let state = self.session.state().state().state();
|
||
match pile {
|
||
KlondikePile::Stock => self.waste_cards(),
|
||
KlondikePile::Foundation(foundation) => {
|
||
let cards = match foundation {
|
||
Foundation::Foundation1 => state.foundation1(),
|
||
Foundation::Foundation2 => state.foundation2(),
|
||
Foundation::Foundation3 => state.foundation3(),
|
||
Foundation::Foundation4 => state.foundation4(),
|
||
};
|
||
Self::cards_with_face(cards.iter().map(card_from_kl), true)
|
||
}
|
||
KlondikePile::Tableau(tableau) => {
|
||
let mut cards = Self::cards_with_face(
|
||
state
|
||
.tableau_face_down_cards(tableau)
|
||
.iter()
|
||
.map(card_from_kl),
|
||
false,
|
||
);
|
||
cards.extend(Self::cards_with_face(
|
||
state
|
||
.tableau_face_up_cards(tableau)
|
||
.iter()
|
||
.map(card_from_kl),
|
||
true,
|
||
));
|
||
cards
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
||
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
|
||
}
|
||
|
||
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
||
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
|
||
}
|
||
|
||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
||
let foundation = Self::foundation_from_slot(slot)?;
|
||
Ok(self.pile(KlondikePile::Foundation(foundation)))
|
||
}
|
||
|
||
/// Returns `true` when test-only pile overrides are active.
|
||
#[cfg(feature = "test-support")]
|
||
pub fn has_test_pile_overrides(&self) -> bool {
|
||
self.test_pile_state.is_some()
|
||
}
|
||
|
||
/// Returns `false` in production builds where test pile overrides are absent.
|
||
#[cfg(not(feature = "test-support"))]
|
||
pub const fn has_test_pile_overrides(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Test-support helper: clear all pile overrides so reads come from the
|
||
/// underlying klondike session again.
|
||
#[cfg(feature = "test-support")]
|
||
pub fn clear_test_pile_overrides(&mut self) {
|
||
self.test_pile_state = None;
|
||
}
|
||
|
||
/// Test-support helper: override face-down stock cards returned by
|
||
/// [`Self::stock_cards`].
|
||
#[cfg(feature = "test-support")]
|
||
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
|
||
let state = self
|
||
.test_pile_state
|
||
.get_or_insert_with(TestPileState::default);
|
||
state.stock = Some(cards);
|
||
}
|
||
|
||
/// Test-support helper: override face-up waste cards returned by
|
||
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
|
||
#[cfg(feature = "test-support")]
|
||
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
|
||
let state = self
|
||
.test_pile_state
|
||
.get_or_insert_with(TestPileState::default);
|
||
state.waste = Some(cards);
|
||
}
|
||
|
||
/// Test-support helper: override cards for a specific tableau column.
|
||
#[cfg(feature = "test-support")]
|
||
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
|
||
let state = self
|
||
.test_pile_state
|
||
.get_or_insert_with(TestPileState::default);
|
||
state.tableau.insert(tableau, cards);
|
||
}
|
||
|
||
/// Test-support helper: override cards for a specific foundation pile.
|
||
#[cfg(feature = "test-support")]
|
||
pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
|
||
let state = self
|
||
.test_pile_state
|
||
.get_or_insert_with(TestPileState::default);
|
||
state.foundation.insert(foundation, cards);
|
||
}
|
||
|
||
/// Test-support helper: override cards for a specific pile.
|
||
#[cfg(feature = "test-support")]
|
||
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||
match pile {
|
||
KlondikePile::Stock => {
|
||
let mut stock = Vec::new();
|
||
let mut waste = Vec::new();
|
||
for card in cards {
|
||
if card.face_up {
|
||
waste.push(card);
|
||
} else {
|
||
stock.push(card);
|
||
}
|
||
}
|
||
self.set_test_stock_cards(stock);
|
||
self.set_test_waste_cards(waste);
|
||
}
|
||
KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
|
||
KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
|
||
}
|
||
}
|
||
|
||
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
||
adapter_skip_cards_from_count(skip)
|
||
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
||
}
|
||
|
||
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
||
let KlondikePile::Tableau(_) = from else {
|
||
return false;
|
||
};
|
||
let pile = self.pile(from);
|
||
if pile.is_empty() {
|
||
return false;
|
||
}
|
||
pile.len() > count && !pile[pile.len() - count - 1].face_up
|
||
}
|
||
|
||
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
|
||
/// game state. Must be called **before** the instruction is applied to the
|
||
/// session; the helper reads pre-instruction pile state from `self`.
|
||
fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) {
|
||
match instruction {
|
||
KlondikeInstruction::RotateStock => {
|
||
let is_recycle =
|
||
self.stock_cards().is_empty() && !self.waste_cards().is_empty();
|
||
if is_recycle {
|
||
let next_count = self.recycle_count.saturating_add(1);
|
||
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
|
||
next_count,
|
||
self.draw_mode == DrawMode::DrawThree,
|
||
self.mode,
|
||
);
|
||
(penalty, true)
|
||
} else {
|
||
(0, false)
|
||
}
|
||
}
|
||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||
let from = dst_foundation.src;
|
||
let to = KlondikePile::Foundation(dst_foundation.foundation);
|
||
let move_delta =
|
||
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||
// DstFoundation always moves exactly 1 card.
|
||
let flip_bonus = if self.will_flip_tableau_source(from, 1) {
|
||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||
} else {
|
||
0
|
||
};
|
||
(move_delta + flip_bonus, false)
|
||
}
|
||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||
let (from, count) = match dst_tableau.src {
|
||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||
KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1),
|
||
KlondikePileStack::Tableau(ts) => {
|
||
let face_up_count = self
|
||
.session
|
||
.state()
|
||
.state()
|
||
.state()
|
||
.tableau_face_up_cards(ts.tableau)
|
||
.len();
|
||
let count = face_up_count.saturating_sub(ts.skip_cards as usize);
|
||
(KlondikePile::Tableau(ts.tableau), count)
|
||
}
|
||
};
|
||
let to = KlondikePile::Tableau(dst_tableau.tableau);
|
||
let move_delta =
|
||
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||
} else {
|
||
0
|
||
};
|
||
(move_delta + flip_bonus, false)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn instruction_for_move(
|
||
&self,
|
||
from: KlondikePile,
|
||
to: KlondikePile,
|
||
count: usize,
|
||
) -> Result<KlondikeInstruction, MoveError> {
|
||
match (from, to) {
|
||
(_, KlondikePile::Stock) => Err(MoveError::InvalidDestination),
|
||
(KlondikePile::Stock, KlondikePile::Foundation(foundation)) => {
|
||
if count != 1 {
|
||
return Err(MoveError::RuleViolation(
|
||
"only one card can move to foundation at a time".into(),
|
||
));
|
||
}
|
||
Ok(KlondikeInstruction::DstFoundation(DstFoundation {
|
||
src: KlondikePile::Stock,
|
||
foundation,
|
||
}))
|
||
}
|
||
(KlondikePile::Tableau(src), KlondikePile::Foundation(foundation)) => {
|
||
if count != 1 {
|
||
return Err(MoveError::RuleViolation(
|
||
"only one card can move to foundation at a time".into(),
|
||
));
|
||
}
|
||
Ok(KlondikeInstruction::DstFoundation(DstFoundation {
|
||
src: KlondikePile::Tableau(src),
|
||
foundation,
|
||
}))
|
||
}
|
||
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
|
||
MoveError::RuleViolation("cannot move between foundation slots".into()),
|
||
),
|
||
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
|
||
if count != 1 {
|
||
return Err(MoveError::RuleViolation(
|
||
"only the top waste card may be moved".into(),
|
||
));
|
||
}
|
||
Ok(KlondikeInstruction::DstTableau(DstTableau {
|
||
src: KlondikePileStack::Stock,
|
||
tableau: dst,
|
||
}))
|
||
}
|
||
(KlondikePile::Foundation(foundation), KlondikePile::Tableau(dst)) => {
|
||
if count != 1 {
|
||
return Err(MoveError::RuleViolation(
|
||
"only one card can return from foundation at a time".into(),
|
||
));
|
||
}
|
||
Ok(KlondikeInstruction::DstTableau(DstTableau {
|
||
src: KlondikePileStack::Foundation(foundation),
|
||
tableau: dst,
|
||
}))
|
||
}
|
||
(KlondikePile::Tableau(src), KlondikePile::Tableau(dst)) => {
|
||
let face_up_count = self
|
||
.session
|
||
.state()
|
||
.state()
|
||
.state()
|
||
.tableau_face_up_cards(src)
|
||
.len();
|
||
if count > face_up_count {
|
||
return Err(MoveError::RuleViolation(
|
||
"cannot move face-down card".into(),
|
||
));
|
||
}
|
||
let skip_cards = Self::skip_cards_from_usize(face_up_count - count)?;
|
||
Ok(KlondikeInstruction::DstTableau(DstTableau {
|
||
src: KlondikePileStack::Tableau(TableauStack {
|
||
tableau: src,
|
||
skip_cards,
|
||
}),
|
||
tableau: dst,
|
||
}))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn instruction_to_move(
|
||
&self,
|
||
instruction: KlondikeInstruction,
|
||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||
let state = self.session.state().state().state();
|
||
match instruction {
|
||
KlondikeInstruction::RotateStock => {
|
||
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||
}
|
||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||
return None;
|
||
}
|
||
let source = match dst_foundation.src {
|
||
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
|
||
KlondikePile::Stock => KlondikePile::Stock,
|
||
KlondikePile::Foundation(_) => return None,
|
||
};
|
||
Some((
|
||
source,
|
||
KlondikePile::Foundation(dst_foundation.foundation),
|
||
1,
|
||
))
|
||
}
|
||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||
let (source, count) = match dst_tableau.src {
|
||
KlondikePileStack::Tableau(tableau_stack) => {
|
||
let face_up_count =
|
||
state.tableau_face_up_cards(tableau_stack.tableau).len();
|
||
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||
if count == 0 {
|
||
return None;
|
||
}
|
||
(KlondikePile::Tableau(tableau_stack.tableau), count)
|
||
}
|
||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||
KlondikePileStack::Foundation(foundation) => {
|
||
(KlondikePile::Foundation(foundation), 1)
|
||
}
|
||
};
|
||
Some((source, KlondikePile::Tableau(dst_tableau.tableau), count))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||
pub fn draw(&mut self) -> Result<(), MoveError> {
|
||
if self.is_won {
|
||
return Err(MoveError::GameAlreadyWon);
|
||
}
|
||
|
||
let stock_empty = self.stock_cards().is_empty();
|
||
let waste_empty = self.waste_cards().is_empty();
|
||
if stock_empty && waste_empty {
|
||
return Err(MoveError::StockEmpty);
|
||
}
|
||
|
||
let (score_delta, is_recycle) =
|
||
self.pre_instruction_score_delta(KlondikeInstruction::RotateStock);
|
||
|
||
self.score_history.push(self.score);
|
||
self.is_recycle_history.push(is_recycle);
|
||
|
||
self.session
|
||
.process_instruction(KlondikeInstruction::RotateStock);
|
||
|
||
if is_recycle {
|
||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||
}
|
||
self.score = (self.score + score_delta).max(0);
|
||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||
Ok(())
|
||
}
|
||
|
||
/// Move `count` cards from pile `from` to pile `to` using Klondike-native pile ids.
|
||
pub fn move_cards(
|
||
&mut self,
|
||
from: KlondikePile,
|
||
to: KlondikePile,
|
||
count: usize,
|
||
) -> Result<(), MoveError> {
|
||
if self.is_won {
|
||
return Err(MoveError::GameAlreadyWon);
|
||
}
|
||
if from == to {
|
||
return Err(MoveError::RuleViolation(
|
||
"source and destination must differ".into(),
|
||
));
|
||
}
|
||
|
||
let from_pile = self.pile(from);
|
||
if from_pile.is_empty() {
|
||
return Err(MoveError::EmptySource);
|
||
}
|
||
if count == 0 || count > from_pile.len() {
|
||
return Err(MoveError::RuleViolation("invalid card count".into()));
|
||
}
|
||
|
||
let instruction = self.instruction_for_move(from, to, count)?;
|
||
let config = self.validation_config();
|
||
if !self
|
||
.session
|
||
.state()
|
||
.state()
|
||
.is_instruction_valid(&config, instruction)
|
||
{
|
||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||
}
|
||
|
||
let (score_delta, _) = self.pre_instruction_score_delta(instruction);
|
||
|
||
self.score_history.push(self.score);
|
||
self.is_recycle_history.push(false);
|
||
|
||
self.session.process_instruction(instruction);
|
||
self.score = (self.score + score_delta).max(0);
|
||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||
self.is_won = self.check_win();
|
||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||
Ok(())
|
||
}
|
||
|
||
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
|
||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||
if self.is_won {
|
||
return Err(MoveError::GameAlreadyWon);
|
||
}
|
||
if self.mode == GameMode::Challenge {
|
||
return Err(MoveError::RuleViolation(
|
||
"undo is disabled in Challenge mode".into(),
|
||
));
|
||
}
|
||
if self.session.history().is_empty() {
|
||
return Err(MoveError::UndoStackEmpty);
|
||
}
|
||
|
||
// Pop the pre-instruction score for the move being undone. Falls back
|
||
// to self.score (= old behaviour) when score_history is empty, which
|
||
// happens for moves made before a save/load cycle because undo
|
||
// penalties aren't reflected in the saved instruction history.
|
||
let pre_move_score = self.score_history.pop().unwrap_or(self.score);
|
||
let was_recycle = self.is_recycle_history.pop().unwrap_or(false);
|
||
|
||
self.session.undo();
|
||
|
||
if was_recycle {
|
||
self.recycle_count = self.recycle_count.saturating_sub(1);
|
||
}
|
||
// Apply the undo penalty to the pre-move score, not the post-move score.
|
||
// This correctly reverses any recycle or move penalty that was applied
|
||
// before adding the −15 undo penalty.
|
||
self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode);
|
||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||
self.is_won = self.check_win();
|
||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||
self.undo_count = self.undo_count.saturating_add(1);
|
||
Ok(())
|
||
}
|
||
|
||
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
|
||
pub fn check_win(&self) -> bool {
|
||
self.session.state().state().is_win()
|
||
}
|
||
|
||
/// Returns `true` when the game can be completed without further player input
|
||
/// (stock empty, waste empty, all tableau cards face-up).
|
||
pub fn check_auto_complete(&self) -> bool {
|
||
self.session.state().state().is_win_trivial()
|
||
}
|
||
|
||
/// Returns all currently valid `(from, to, count)` moves.
|
||
pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
||
if self.is_won {
|
||
return Vec::new();
|
||
}
|
||
|
||
let config = self.validation_config();
|
||
self.session
|
||
.state()
|
||
.state()
|
||
.get_sorted_moves(&config)
|
||
.into_iter()
|
||
.filter_map(|instruction| self.instruction_to_move(instruction))
|
||
.collect()
|
||
}
|
||
|
||
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
||
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
|
||
if self.is_won || from == to {
|
||
return false;
|
||
}
|
||
let from_pile = self.pile(*from);
|
||
if count == 0 || count > from_pile.len() {
|
||
return false;
|
||
}
|
||
let Ok(instruction) = self.instruction_for_move(*from, *to, count) else {
|
||
return false;
|
||
};
|
||
let config = self.validation_config();
|
||
self.session
|
||
.state()
|
||
.state()
|
||
.is_instruction_valid(&config, instruction)
|
||
}
|
||
|
||
/// Returns the current pile containing `card_id`, if any.
|
||
pub fn pile_containing_card(&self, card_id: u32) -> Option<KlondikePile> {
|
||
if self.stock_cards().iter().any(|card| card.id == card_id)
|
||
|| self.waste_cards().iter().any(|card| card.id == card_id)
|
||
{
|
||
return Some(KlondikePile::Stock);
|
||
}
|
||
for slot in 0..4_u8 {
|
||
let foundation = Self::foundation_from_slot(slot).ok()?;
|
||
let pile = self.pile(KlondikePile::Foundation(foundation));
|
||
if pile.iter().any(|card| card.id == card_id) {
|
||
return Some(KlondikePile::Foundation(foundation));
|
||
}
|
||
}
|
||
for index in 0..7_usize {
|
||
let tableau = Self::tableau_from_index(index).ok()?;
|
||
let pile = self.pile(KlondikePile::Tableau(tableau));
|
||
if pile.iter().any(|card| card.id == card_id) {
|
||
return Some(KlondikePile::Tableau(tableau));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
|
||
pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> {
|
||
if !self.is_auto_completable || self.is_won {
|
||
return None;
|
||
}
|
||
|
||
self.possible_instructions()
|
||
.into_iter()
|
||
.find_map(|(from, to, count)| {
|
||
if count != 1 {
|
||
return None;
|
||
}
|
||
if matches!(from, KlondikePile::Foundation(_)) {
|
||
return None;
|
||
}
|
||
if matches!(to, KlondikePile::Foundation(_)) {
|
||
Some((from, to))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||
pub fn compute_time_bonus(&self) -> i32 {
|
||
scoring_time_bonus(self.elapsed_seconds)
|
||
}
|
||
|
||
/// Read-only access to the underlying [`card_game::Session`] for this deal.
|
||
///
|
||
/// Exposes `session.history()` (deterministic replay) and `session.solve()`
|
||
/// (DFS solver) to crates outside `solitaire_core` without surfacing the
|
||
/// mutable field. Internal code that needs to mutate the session accesses
|
||
/// the `pub(crate)` field directly.
|
||
pub fn session(&self) -> &Session<Klondike> {
|
||
&self.session
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> {
|
||
const MAX_SEED: u64 = 512;
|
||
const MAX_STEPS: usize = 160;
|
||
|
||
for seed in 1..=MAX_SEED {
|
||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
||
game.take_from_foundation = true;
|
||
|
||
for _ in 0..MAX_STEPS {
|
||
let moves = game.possible_instructions();
|
||
if let Some((from, to, _count)) = moves.iter().copied().find(|(from, to, count)| {
|
||
*count == 1
|
||
&& matches!(from, KlondikePile::Foundation(_))
|
||
&& matches!(to, KlondikePile::Tableau(_))
|
||
}) {
|
||
return Some((game, from, to));
|
||
}
|
||
|
||
if let Some((from, to, count)) = moves.iter().copied().find(|(from, to, count)| {
|
||
*count == 1
|
||
&& !matches!(from, KlondikePile::Foundation(_))
|
||
&& matches!(to, KlondikePile::Foundation(_))
|
||
}) && game.move_cards(from, to, count).is_ok()
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!game.stock_cards().is_empty() || !game.waste_cards().is_empty())
|
||
&& game.draw().is_ok()
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if let Some((from, to, count)) = moves
|
||
.iter()
|
||
.copied()
|
||
.find(|(from, _, _)| !matches!(from, KlondikePile::Foundation(_)))
|
||
&& game.move_cards(from, to, count).is_ok()
|
||
{
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Drive a DrawOne game until a recycle is available, perform it, and return
|
||
/// the game. Returns `None` if no recycle position is found within the
|
||
/// iteration limit (shouldn't happen in practice).
|
||
fn game_at_first_recycle() -> Option<GameState> {
|
||
for seed in 1..=256_u64 {
|
||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
||
for _ in 0..200 {
|
||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||
// This draw will recycle.
|
||
game.draw().ok()?;
|
||
return Some(game);
|
||
}
|
||
let _ = game.draw();
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[test]
|
||
fn recycle_count_decrements_when_recycle_is_undone() {
|
||
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||
let count_after_recycle = game.recycle_count;
|
||
assert_eq!(count_after_recycle, 1, "first recycle should give count=1");
|
||
game.undo().expect("undo should succeed");
|
||
assert_eq!(
|
||
game.recycle_count, 0,
|
||
"recycle_count must decrement back to 0 after undoing the recycle",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn score_recycle_penalty_is_reversed_on_undo() {
|
||
// Reach the second recycle (count=2, Draw-1) so there is a −100 penalty.
|
||
let mut game = game_at_first_recycle().expect("could not reach first recycle");
|
||
|
||
// Draw until stock is empty again so we can do a second recycle.
|
||
let mut second_recycle_done = false;
|
||
for _ in 0..200 {
|
||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||
let score_before_second_recycle = game.score;
|
||
game.draw().expect("second recycle should succeed");
|
||
assert_eq!(game.recycle_count, 2);
|
||
|
||
// The second recycle in Draw-1 mode costs −100.
|
||
let expected_after = (score_before_second_recycle - 100).max(0);
|
||
assert_eq!(
|
||
game.score, expected_after,
|
||
"second Draw-1 recycle must apply −100 penalty",
|
||
);
|
||
|
||
// Undo: score should recover to (score_before_second_recycle − 15).max(0),
|
||
// NOT to (score_after_recycle − 15).max(0).
|
||
game.undo().expect("undo of second recycle should succeed");
|
||
let expected_after_undo = (score_before_second_recycle - 15).max(0);
|
||
assert_eq!(
|
||
game.score, expected_after_undo,
|
||
"undoing a penalised recycle must reverse the recycle penalty \
|
||
before applying the −15 undo penalty",
|
||
);
|
||
assert_eq!(
|
||
game.recycle_count, 1,
|
||
"recycle_count must also be decremented on undo",
|
||
);
|
||
second_recycle_done = true;
|
||
break;
|
||
}
|
||
let _ = game.draw();
|
||
}
|
||
assert!(second_recycle_done, "could not reach second recycle in test");
|
||
}
|
||
|
||
#[test]
|
||
fn take_from_foundation_allows_legal_return_move() {
|
||
let (mut game, from, to) = find_foundation_return_position()
|
||
.expect("expected to find a deterministic foundation->tableau return move");
|
||
|
||
game.take_from_foundation = true;
|
||
assert!(game.can_move_cards(&from, &to, 1));
|
||
assert!(
|
||
game.possible_instructions()
|
||
.iter()
|
||
.any(|(f, t, c)| *f == from && *t == to && *c == 1)
|
||
);
|
||
assert!(game.move_cards(from, to, 1).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn take_from_foundation_disabled_blocks_return_move_everywhere() {
|
||
let (mut game, from, to) = find_foundation_return_position()
|
||
.expect("expected to find a deterministic foundation->tableau return move");
|
||
|
||
game.take_from_foundation = false;
|
||
assert!(!game.can_move_cards(&from, &to, 1));
|
||
assert!(
|
||
game.possible_instructions()
|
||
.iter()
|
||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
||
|| !matches!(t, KlondikePile::Tableau(_)))
|
||
);
|
||
assert!(game.move_cards(from, to, 1).is_err());
|
||
}
|
||
}
|