Files
Ferrous-Solitaire/solitaire_core/src/game_state.rs
T
funman300 8f3689761d
Android Release / build-apk (push) Successful in 4m31s
fix(core): make take_from_foundation true by default across all clients
The flag was modelled as an opt-in non-standard rule but moving a card
off a foundation is in fact standard Klondike — disabling it is the
non-standard variant.

Changing the core default to true means every client (desktop, Android,
web) gets correct behaviour without each having to independently patch
the value after construction. Clients that expose a settings toggle
(desktop/Android) can still disable it through SettingsResource.

- game_state.rs: flip default from false → true in new_with_mode
- game_state.rs: rename/update take_from_foundation_disabled_by_default
  test to reflect the new intended default
- solitaire_wasm/lib.rs: remove now-redundant override in new()
  (from_saved keeps its override to fix old saves that serialised false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:44:04 -07:00

1539 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{HashMap, VecDeque};
use serde::{Deserialize, Serialize};
use crate::card::Card;
use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError;
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
const MAX_UNDO_STACK: usize = 64;
/// 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 (current): `Foundation(u8)` slot keys; claimed suit derived from the
/// bottom card of the pile.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
/// Default value for `GameState::schema_version` when deserialising older
/// save files that pre-date the field.
fn schema_v1() -> u32 { 1 }
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
/// that JSON (which requires string map keys) round-trips correctly.
mod pile_map_serde {
use std::collections::HashMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::pile::{Pile, PileType};
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect();
entries.sort_by_key(|(k, _)| *k);
entries.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<HashMap<PileType, Pile>, D::Error> {
let entries: Vec<(PileType, Pile)> = Vec::deserialize(d)?;
Ok(entries.into_iter().collect())
}
}
/// 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.
///
/// - `Classic`: standard Klondike scoring, undo allowed.
/// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play.
/// - `Challenge`: standard scoring, **undo disabled** (returns
/// `MoveError::RuleViolation`).
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
/// countdown around the session and auto-deals a fresh game on every win
/// (see `solitaire_engine::TimeAttackPlugin`).
/// - `Difficulty(DifficultyLevel)`: seed drawn from a pre-verified per-tier catalog
/// (or system-time for `Random`). Rules identical to Classic.
#[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),
}
/// Snapshot of game state used for undo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct StateSnapshot {
#[serde(with = "pile_map_serde")]
piles: HashMap<PileType, Pile>,
score: i32,
move_count: u32,
}
/// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState {
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
#[serde(with = "pile_map_serde")]
pub piles: HashMap<PileType, Pile>,
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
/// compatibility with older save files via `#[serde(default)]`.
#[serde(default)]
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 (all remaining cards are face-up and in order).
pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`.
pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game.
/// Used by the `comeback` achievement condition.
#[serde(default)]
pub recycle_count: u32,
/// When `true`, the player may move the top card of a foundation pile back
/// onto a compatible tableau column. Off by default — non-standard house rule.
#[serde(default)]
pub take_from_foundation: bool,
/// Save-file schema version. Defaults to `1` for older files that pre-date
/// the field. The loader refuses any value other than
/// [`GAME_STATE_SCHEMA_VERSION`].
#[serde(default = "schema_v1")]
pub schema_version: u32,
#[serde(skip)]
undo_stack: VecDeque<StateSnapshot>,
}
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 {
let mut deck = Deck::new();
deck.shuffle(seed);
let (tableau, stock) = deal_klondike(deck);
let mut piles: HashMap<PileType, Pile> = HashMap::new();
piles.insert(PileType::Stock, stock);
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
for slot in 0..4_u8 {
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
}
for (i, pile) in tableau.into_iter().enumerate() {
piles.insert(PileType::Tableau(i), pile);
}
Self {
piles,
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,
undo_stack: VecDeque::new(),
}
}
/// Number of snapshots currently on the undo stack.
pub fn undo_stack_len(&self) -> usize {
self.undo_stack.len()
}
fn take_snapshot(&self) -> StateSnapshot {
StateSnapshot {
piles: self.piles.clone(),
score: self.score,
move_count: self.move_count,
}
}
fn push_snapshot(&mut self) {
if self.undo_stack.len() >= MAX_UNDO_STACK {
self.undo_stack.pop_front(); // O(1)
}
self.undo_stack.push_back(self.take_snapshot());
}
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
/// Recycling is unlimited: `StockEmpty` is only returned when both stock and waste are empty.
pub fn draw(&mut self) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
}
let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len();
if stock_len == 0 {
let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len();
if waste_len == 0 {
return Err(MoveError::StockEmpty);
}
// Recycle: snapshot so undo can reverse it, then move waste back to stock face-down
self.push_snapshot();
let waste_cards: Vec<Card> = self.piles
.get_mut(&PileType::Waste)
.ok_or(MoveError::InvalidSource)?
.cards
.drain(..)
.collect();
let stock = self.piles.get_mut(&PileType::Stock).ok_or(MoveError::InvalidDestination)?;
for mut card in waste_cards.into_iter().rev() {
card.face_up = false;
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count = self.move_count.saturating_add(1);
return Ok(());
}
self.push_snapshot();
let draw_count = match self.draw_mode {
DrawMode::DrawOne => 1,
DrawMode::DrawThree => 3,
};
let available = stock_len.min(draw_count);
let drain_start = stock_len - available;
let drawn: Vec<Card> = self.piles
.get_mut(&PileType::Stock)
.ok_or(MoveError::InvalidSource)?
.cards
.drain(drain_start..)
.collect();
let waste = self.piles.get_mut(&PileType::Waste).ok_or(MoveError::InvalidDestination)?;
for mut card in drawn {
card.face_up = true;
waste.cards.push(card);
}
self.move_count = self.move_count.saturating_add(1);
Ok(())
}
/// Move `count` cards from pile `from` to pile `to`.
///
/// Returns `Err(MoveError)` if the move is illegal. On success, updates score,
/// flips the newly exposed source card if needed, and checks win/auto-complete.
pub fn move_cards(&mut self, from: PileType, to: PileType, 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()));
}
// Validate via scoped immutable borrows
let move_start = {
let from_pile = self.piles.get(&from).ok_or(MoveError::InvalidSource)?;
if from_pile.cards.is_empty() {
return Err(MoveError::EmptySource);
}
if count == 0 || count > from_pile.cards.len() {
return Err(MoveError::RuleViolation("invalid card count".into()));
}
let start = from_pile.cards.len() - count;
for card in &from_pile.cards[start..] {
if !card.face_up {
return Err(MoveError::RuleViolation("cannot move face-down card".into()));
}
}
let bottom_card = from_pile.cards[start].clone();
match &to {
PileType::Foundation(_) => {
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(),
));
}
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
if !can_place_on_foundation(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
}
}
PileType::Tableau(_) => {
if matches!(&from, PileType::Foundation(_)) {
if !self.take_from_foundation {
return Err(MoveError::RuleViolation(
"take-from-foundation rule is disabled".into(),
));
}
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can return from foundation at a time".into(),
));
}
}
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
if !can_place_on_tableau(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
}
// The previous check only validates that the *bottom* of the
// moved stack lands on the destination's top card. Without
// this guard, a player could lift an arbitrary multi-card
// selection from one column and drop it onto another whenever
// the bottom card happens to match — even if the cards
// above the bottom don't form a legal descending
// alternating-colour run.
if !is_valid_tableau_sequence(&from_pile.cards[start..]) {
return Err(MoveError::RuleViolation(
"moved cards must form a valid tableau run".into(),
));
}
}
_ => return Err(MoveError::InvalidDestination),
}
start
};
let score_delta = if self.mode == GameMode::Zen {
0
} else {
score_move(&from, &to)
};
self.push_snapshot();
// Execute move
let mut moved: Vec<Card> = self.piles
.get_mut(&from)
.ok_or(MoveError::InvalidSource)?
.cards
.split_off(move_start);
// Flip the newly exposed top card of the source pile
if let Some(top) = self.piles
.get_mut(&from)
.ok_or(MoveError::InvalidSource)?
.cards
.last_mut()
&& !top.face_up
{
top.face_up = true;
}
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
self.score = (self.score + score_delta).max(0);
self.move_count = self.move_count.saturating_add(1);
self.is_won = self.check_win();
if !self.is_won {
self.is_auto_completable = self.check_auto_complete();
}
Ok(())
}
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
/// Disabled in `GameMode::Challenge` — returns `MoveError::RuleViolation`.
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(),
));
}
let snapshot = self.undo_stack.pop_back().ok_or(MoveError::UndoStackEmpty)?;
self.piles = snapshot.piles;
self.score = if self.mode == GameMode::Zen {
0
} else {
(self.score + scoring_undo()).max(0)
};
self.move_count = snapshot.move_count;
self.is_won = false;
self.is_auto_completable = false;
self.undo_count = self.undo_count.saturating_add(1);
Ok(())
}
/// Returns `true` when all four foundation slots each contain a valid A→K
/// sequence of a single suit.
///
/// Counting 13 cards is not sufficient — a corrupt save could produce 13
/// arbitrary cards per pile and permanently lock the game via `GameAlreadyWon`.
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 Some(pile) = self.piles.get(&PileType::Foundation(slot)) else {
return false;
};
if pile.cards.len() != 13 {
return false;
}
let suit = pile.cards[0].suit;
pile.cards.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.
/// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved
// by draw() calls inside next_auto_complete_move / auto_complete_step).
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
return false;
}
(0..7).all(|i| {
self.piles
.get(&PileType::Tableau(i))
.is_some_and(|p| p.cards.iter().all(|c| c.face_up))
})
}
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
///
/// Does not include stock draws — callers check `piles[&PileType::Stock]` directly.
/// Every returned triple is guaranteed to succeed when passed to `move_cards`.
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
if self.is_won {
return Vec::new();
}
let mut moves = Vec::new();
// Waste top card → foundation or tableau
if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) {
for slot in 0..4_u8 {
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(waste_top, f)
{
moves.push((PileType::Waste, PileType::Foundation(slot), 1));
}
}
for dst in 0..7_usize {
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(waste_top, t)
{
moves.push((PileType::Waste, PileType::Tableau(dst), 1));
}
}
}
// Tableau sources
for src in 0..7_usize {
let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue };
if src_pile.cards.is_empty() {
continue;
}
let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count();
if run_len == 0 {
continue;
}
for count in 1..=run_len {
let seq_start = src_pile.cards.len() - count;
if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) {
continue;
}
let bottom = &src_pile.cards[seq_start];
if count == 1 {
for slot in 0..4_u8 {
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(bottom, f)
{
moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1));
}
}
}
for dst in 0..7_usize {
if dst == src {
continue;
}
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(bottom, t)
{
moves.push((PileType::Tableau(src), PileType::Tableau(dst), count));
}
}
}
}
// Foundation top → tableau (only when house rule is enabled)
if self.take_from_foundation {
for slot in 0..4_u8 {
let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue };
let Some(top) = f.cards.last() else { continue };
for dst in 0..7_usize {
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
&& can_place_on_tableau(top, t)
{
moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1));
}
}
}
}
moves
}
/// Returns the next `(from, to)` move that advances auto-complete, or
/// `None` if no such move exists (or `is_auto_completable` is not set).
///
/// Scans tableau piles 06 in order, returning the first top card that
/// can be placed on any foundation pile. The scan order ensures Aces are
/// resolved before higher ranks that depend on them.
///
/// # Precondition
///
/// This function is only called when `is_auto_completable` is `true`.
/// Auto-completability requires the waste pile to be empty, as enforced by
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
/// in this scan is intentional and correct: by the time this function is
/// reached, there are guaranteed to be no cards there to move.
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
if !self.is_auto_completable || self.is_won {
return None;
}
// Check waste top first — when stock is exhausted the waste may still
// contain cards that can go directly to a foundation.
let waste = PileType::Waste;
if let Some((card, slot)) = self.piles.get(&waste)
.and_then(|p| p.cards.last())
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{
let _ = card; // borrow ends here
return Some((waste, PileType::Foundation(slot)));
}
for i in 0..7 {
let tableau = PileType::Tableau(i);
if let Some(slot) = self.piles.get(&tableau)
.and_then(|p| p.cards.last())
.and_then(|c| self.foundation_slot_for(c))
{
return Some((tableau, PileType::Foundation(slot)));
}
}
None
}
/// Return the foundation slot index that `card` can legally move to, or
/// `None` if no such slot exists.
///
/// Prefers the slot already claiming this card's suit so Aces always land
/// in a consistent column. Falls back to an empty slot only for Aces.
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 {
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue };
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
}
} else if pile.claimed_suit() == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target = candidate.or_else(|| {
if card.rank.value() == 1 { empty_slot } else { None }
});
target.filter(|&slot| {
self.piles.get(&PileType::Foundation(slot))
.is_some_and(|p| can_place_on_foundation(card, p))
})
}
/// 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::*;
use crate::card::{Card, Rank, Suit};
fn new_game() -> GameState {
GameState::new(42, DrawMode::DrawOne)
}
// --- Initial state ---
#[test]
fn new_game_has_correct_tableau_sizes() {
let g = new_game();
let total: usize = (0..7).map(|i| g.piles[&PileType::Tableau(i)].cards.len()).sum();
assert_eq!(total, 28);
for i in 0..7 {
assert_eq!(g.piles[&PileType::Tableau(i)].cards.len(), i + 1);
}
}
#[test]
fn new_game_stock_has_24_cards() {
assert_eq!(new_game().piles[&PileType::Stock].cards.len(), 24);
}
#[test]
fn new_game_waste_is_empty() {
assert!(new_game().piles[&PileType::Waste].cards.is_empty());
}
#[test]
fn new_game_foundations_are_empty() {
let g = new_game();
for slot in 0..4_u8 {
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
}
}
#[test]
fn new_game_is_not_won() {
assert!(!new_game().is_won);
}
// --- Seeded reproducibility ---
#[test]
fn same_seed_produces_identical_layout() {
let g1 = GameState::new(12345, DrawMode::DrawOne);
let g2 = GameState::new(12345, DrawMode::DrawOne);
for i in 0..7 {
assert_eq!(
g1.piles[&PileType::Tableau(i)].cards,
g2.piles[&PileType::Tableau(i)].cards
);
}
assert_eq!(
g1.piles[&PileType::Stock].cards,
g2.piles[&PileType::Stock].cards
);
}
#[test]
fn different_seeds_produce_different_layouts() {
let g1 = GameState::new(1, DrawMode::DrawOne);
let g2 = GameState::new(2, DrawMode::DrawOne);
let t1: Vec<u32> = g1.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect();
let t2: Vec<u32> = g2.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect();
assert_ne!(t1, t2);
}
// --- Draw ---
#[test]
fn draw_one_moves_one_card_to_waste() {
let mut g = new_game();
let stock_before = g.piles[&PileType::Stock].cards.len();
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before - 1);
assert_eq!(g.piles[&PileType::Waste].cards.len(), 1);
}
#[test]
fn drawn_card_is_face_up() {
let mut g = new_game();
g.draw().unwrap();
assert!(g.piles[&PileType::Waste].cards.last().unwrap().face_up);
}
#[test]
fn draw_three_moves_up_to_three_cards() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Waste].cards.len(), 3);
assert_eq!(g.piles[&PileType::Stock].cards.len(), 21);
}
#[test]
fn draw_three_partial_draw_when_fewer_than_three_remain() {
let mut g = GameState::new(42, DrawMode::DrawThree);
// Replace the stock with exactly 2 cards so the draw is a partial batch.
let two_cards: Vec<Card> = g.piles[&PileType::Stock].cards[..2].to_vec();
g.piles.get_mut(&PileType::Stock).unwrap().cards = two_cards;
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2");
assert!(g.piles[&PileType::Stock].cards.is_empty());
}
#[test]
fn draw_three_all_drawn_cards_are_face_up() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.draw().unwrap();
assert!(
g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up),
"all drawn cards must be face-up in waste"
);
}
#[test]
fn draw_three_undo_returns_all_cards_to_stock() {
let mut g = GameState::new(42, DrawMode::DrawThree);
let stock_before = g.piles[&PileType::Stock].cards.len();
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Waste].cards.len(), 3);
g.undo().unwrap();
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before);
assert!(g.piles[&PileType::Waste].cards.is_empty());
}
#[test]
fn draw_three_recycle_restores_waste_to_stock_face_down() {
let mut g = GameState::new(42, DrawMode::DrawThree);
// Drain all 24 stock cards into waste via repeated draws.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
let waste_count = g.piles[&PileType::Waste].cards.len();
assert!(waste_count > 0);
// Recycle: drawing when stock is empty returns all waste cards to stock.
g.draw().unwrap();
assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count);
assert!(g.piles[&PileType::Waste].cards.is_empty());
assert!(
g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up),
"recycled cards must be face-down"
);
}
#[test]
fn draw_from_empty_stock_recycles_waste() {
let mut g = new_game();
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
let waste_count = g.piles[&PileType::Waste].cards.len();
assert!(waste_count > 0);
g.draw().unwrap(); // recycle
assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count);
assert!(g.piles[&PileType::Waste].cards.is_empty());
}
#[test]
fn recycle_count_increments_on_each_waste_recycle() {
let mut g = new_game();
assert_eq!(g.recycle_count, 0);
// Drain entire stock to waste.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // first recycle
assert_eq!(g.recycle_count, 1);
// Drain again and recycle a second time.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // second recycle
assert_eq!(g.recycle_count, 2);
}
#[test]
fn move_count_increments_on_recycle() {
let mut g = new_game();
// Drain stock to waste, recording how many draws it took.
let mut draws: u32 = 0;
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
draws += 1;
}
let before = g.move_count;
g.draw().unwrap(); // recycle
assert_eq!(
g.move_count,
before + 1,
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
);
}
#[test]
fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are
// simultaneously empty. Manually empty both, then verify the error.
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
assert_eq!(g.draw(), Err(MoveError::StockEmpty));
}
// --- Move validation ---
#[test]
fn move_zero_cards_returns_rule_violation() {
let mut g = new_game();
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 0);
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn move_to_stock_returns_invalid_destination() {
let mut g = new_game();
let result = g.move_cards(PileType::Tableau(0), PileType::Stock, 1);
assert_eq!(result, Err(MoveError::InvalidDestination));
}
#[test]
fn move_to_waste_returns_invalid_destination() {
let mut g = new_game();
let result = g.move_cards(PileType::Tableau(0), PileType::Waste, 1);
assert_eq!(result, Err(MoveError::InvalidDestination));
}
#[test]
fn move_same_source_and_dest_returns_rule_violation() {
let mut g = new_game();
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(0), 1);
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn move_face_down_card_returns_rule_violation() {
let mut g = new_game();
// Tableau(6) has 7 cards; card 0 is always face-down.
// Attempt to move 7 cards (the whole pile including face-down ones).
let result = g.move_cards(PileType::Tableau(6), PileType::Tableau(5), 7);
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn move_multiple_cards_to_foundation_returns_rule_violation() {
let mut g = new_game();
// Inject two face-up cards into tableau(0) so count=2 is a valid count.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
Card { id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true },
];
let result = g.move_cards(
PileType::Tableau(0),
PileType::Foundation(0),
2,
);
assert!(
matches!(result, Err(MoveError::RuleViolation(_))),
"moving 2 cards to foundation must be rejected"
);
}
#[test]
fn move_count_exceeding_pile_size_returns_rule_violation() {
let mut g = new_game();
// Tableau(0) has exactly 1 card; asking for 2 should fail.
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 2);
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn move_multi_card_sequence_tableau_to_tableau_succeeds() {
let mut g = new_game();
// Clear both piles and construct a known valid sequence.
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards = vec![
Card { id: 10, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 11, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 12, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
];
// Tableau(1) needs an Ace so we can check empty pile correctly — use a red King target.
let t1 = g.piles.get_mut(&PileType::Tableau(1)).unwrap();
t1.cards.clear(); // empty accepts a King
// Move the whole 3-card sequence to the empty pile.
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 3);
assert!(result.is_ok(), "valid multi-card move must succeed: {result:?}");
assert!(g.piles[&PileType::Tableau(0)].cards.is_empty());
assert_eq!(g.piles[&PileType::Tableau(1)].cards.len(), 3);
assert_eq!(g.move_count, 1);
}
// --- Win detection ---
#[test]
fn win_detection_all_foundations_complete() {
let mut g = new_game();
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
for (slot, suit) in suits.into_iter().enumerate() {
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
f.cards.clear();
for rank in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
] {
f.cards.push(Card { id: 0, suit, rank, face_up: true });
}
}
assert!(g.check_win());
}
#[test]
fn win_detection_incomplete_is_false() {
assert!(!new_game().check_win());
}
// --- Undo ---
#[test]
fn undo_empty_stack_returns_error() {
let mut g = new_game();
assert_eq!(g.undo(), Err(MoveError::UndoStackEmpty));
}
#[test]
fn undo_after_draw_restores_pile_sizes() {
let mut g = new_game();
let stock_before = g.piles[&PileType::Stock].cards.len();
let waste_before = g.piles[&PileType::Waste].cards.len();
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before);
assert_eq!(g.piles[&PileType::Waste].cards.len(), waste_before);
}
#[test]
fn undo_applies_score_penalty() {
let mut g = new_game();
let score_before = g.score;
g.draw().unwrap();
g.undo().unwrap();
let expected = (score_before + scoring_undo()).max(0);
assert_eq!(g.score, expected);
}
#[test]
fn undo_stack_capped_at_64() {
let mut g = new_game();
for _ in 0..70 {
let _ = g.draw();
}
assert!(g.undo_stack_len() <= 64);
}
#[test]
fn undo_count_increments_on_each_undo() {
let mut g = new_game();
g.draw().unwrap();
assert_eq!(g.undo_count, 0, "undo_count unchanged before calling undo");
g.undo().unwrap();
assert_eq!(g.undo_count, 1);
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.undo_count, 2);
}
#[test]
fn undo_count_saturates_at_max() {
let mut g = new_game();
g.undo_count = u32::MAX;
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX");
}
// --- Fields excluded from undo snapshot ---
#[test]
fn undo_does_not_roll_back_elapsed_seconds() {
// elapsed_seconds tracks wall time and must be monotonic; undo must never
// reduce it, otherwise the time-bonus calculation would be gamed.
let mut g = new_game();
g.elapsed_seconds = 120;
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged");
}
#[test]
fn undo_does_not_roll_back_recycle_count() {
// recycle_count is a lifetime counter used for the 'comeback' achievement;
// rolling it back on undo would make the condition unachievable after recycling.
let mut g = new_game();
// Drain stock and recycle to increment recycle_count.
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // recycle
assert_eq!(g.recycle_count, 1);
// Now draw one more card and undo it.
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.recycle_count, 1, "undo must leave recycle_count unchanged");
}
#[test]
fn undo_after_win_returns_game_already_won() {
let mut g = new_game();
g.is_won = true;
assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon));
}
// --- Scoring ---
#[test]
fn score_never_goes_below_zero() {
let mut g = new_game();
for _ in 0..5 {
g.draw().unwrap();
g.undo().unwrap();
}
assert!(g.score >= 0);
}
// --- GameMode: Zen ---
#[test]
fn zen_mode_score_stays_zero_after_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
g.draw().unwrap();
g.undo().unwrap();
assert_eq!(g.score, 0);
}
#[test]
fn zen_mode_field_persists_through_construction() {
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
assert_eq!(g.mode, GameMode::Zen);
assert_eq!(g.draw_mode, DrawMode::DrawThree);
}
// --- GameMode: Challenge ---
#[test]
fn challenge_mode_disables_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
g.draw().unwrap();
let result = g.undo();
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
#[test]
fn challenge_mode_still_allows_normal_moves() {
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
// Just verify the game initialises cleanly with Challenge mode.
assert_eq!(g.mode, GameMode::Challenge);
assert_eq!(g.score, 0);
}
#[test]
fn challenge_mode_scoring_applies_normally() {
// Challenge uses Classic scoring; only undo is disabled.
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
assert_eq!(g.score, 0);
// Note: Verifying score increases on actual moves would require
// hand-crafting a legal move from the dealt state. We rely on the
// fact that move_cards' score path is identical to Classic.
}
// --- GameMode: TimeAttack ---
#[test]
fn time_attack_mode_field_persists() {
let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack);
assert_eq!(g.mode, GameMode::TimeAttack);
}
#[test]
fn time_attack_allows_undo() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
g.draw().unwrap();
// TimeAttack does not disable undo — only Challenge does.
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
}
#[test]
fn time_attack_draw_three_combination() {
// TimeAttack + DrawThree is a valid combination; verify construction.
let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack);
assert_eq!(g.mode, GameMode::TimeAttack);
assert_eq!(g.draw_mode, DrawMode::DrawThree);
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
}
// --- Auto-complete ---
#[test]
fn auto_complete_false_when_stock_not_empty() {
assert!(!new_game().check_auto_complete());
}
#[test]
fn auto_complete_false_when_face_down_cards_remain() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Tableau(1) has a face-down card at index 0
assert!(!g.check_auto_complete());
}
#[test]
fn auto_complete_true_when_stock_empty_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during
// auto-complete steps. Only stock-not-empty and face-down tableau
// cards block the flag.
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
for i in 0..7 {
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
c.face_up = true;
}
}
assert!(g.check_auto_complete());
}
#[test]
fn auto_complete_true_when_all_prerequisites_met() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau and put a single face-up card — all face-up guard passes.
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
assert!(g.check_auto_complete());
}
// --- Time bonus ---
#[test]
fn time_bonus_zero_when_elapsed_is_zero() {
let mut g = new_game();
g.elapsed_seconds = 0;
assert_eq!(g.compute_time_bonus(), 0);
}
#[test]
fn time_bonus_at_100_seconds() {
let mut g = new_game();
g.elapsed_seconds = 100;
assert_eq!(g.compute_time_bonus(), 7000);
}
// --- EmptySource error path ---
#[test]
fn move_from_empty_pile_returns_empty_source() {
// Build a game state, clear a tableau pile entirely, then attempt to
// move from it. The source pile exists in `piles` (key is present) but
// contains no cards — exactly the code path that returns EmptySource.
let mut g = new_game();
// Tableau(0) starts with exactly 1 card; clear it to make the pile empty.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1);
assert_eq!(
result,
Err(MoveError::EmptySource),
"moving from an empty pile must return EmptySource"
);
}
// --- next_auto_complete_move ---
#[test]
fn next_auto_complete_move_returns_none_on_fresh_game() {
// A fresh game has stock and face-down cards — not auto-completable.
assert!(new_game().next_auto_complete_move().is_none());
}
#[test]
fn next_auto_complete_move_finds_ace_on_auto_completable_board() {
use crate::card::{Card, Rank};
let mut g = new_game();
// Clear stock and waste to satisfy auto-complete precondition.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau piles and put a single face-up Ace of Clubs
// into Tableau(0); all other piles empty.
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
g.is_auto_completable = true;
let mv = g.next_auto_complete_move().expect("should find a move");
assert_eq!(mv.0, PileType::Tableau(0));
// Slot 0 is the first empty foundation; the Ace lands there.
assert_eq!(mv.1, PileType::Foundation(0));
}
#[test]
fn next_auto_complete_move_returns_none_when_already_won() {
let mut g = new_game();
g.is_auto_completable = true;
g.is_won = true;
assert!(g.next_auto_complete_move().is_none());
}
// --- Slot-based foundation behaviour (refactor coverage) ---
/// Aces land in the first empty slot regardless of suit, and successive
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
#[test]
fn any_ace_lands_in_first_empty_foundation() {
let mut g = new_game();
// Clear stock/waste/tableau so we can hand-construct moves directly.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Place an Ace of Clubs on tableau 0; move it to slot 0.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
}
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
/// foundation slot, regardless of which slot index the pile occupies.
#[test]
fn claimed_suit_is_derived_from_bottom_card() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
assert_eq!(
g.piles[&PileType::Foundation(2)].claimed_suit(),
Some(Suit::Hearts)
);
}
/// Undoing the only card from a foundation slot drops the claimed suit;
/// the slot then accepts a different Ace.
#[test]
fn foundation_claim_drops_when_emptied_via_undo() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
g.undo().unwrap();
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
// A different Ace can now claim slot 0.
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.clear();
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
}
/// Successive Aces from the waste pile distribute across slots 0..=3 in
/// order — the player picks the slot, but `move_cards` accepts any
/// empty-slot placement for an Ace.
#[test]
fn multiple_aces_distribute_across_slots() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
let aces = [
(Suit::Clubs, 10),
(Suit::Diamonds, 11),
(Suit::Hearts, 12),
(Suit::Spades, 13),
];
for (slot, (suit, id)) in aces.iter().enumerate() {
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
});
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
}
for (slot, (suit, _)) in aces.iter().enumerate() {
assert_eq!(
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
Some(*suit),
"slot {slot} should claim {suit:?}",
);
}
}
/// Auto-complete prefers the foundation slot whose claimed suit matches
/// the candidate card's suit, even if an empty slot exists at a lower
/// index.
#[test]
fn next_auto_complete_move_picks_slot_with_matching_claim() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
});
// Tableau 0 holds the 2 of Hearts to play.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
});
g.is_auto_completable = true;
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
assert_eq!(mv.0, PileType::Tableau(0));
assert_eq!(
mv.1,
PileType::Foundation(1),
"must target the Hearts-claimed slot, not the empty slot 0",
);
}
fn setup_take_from_foundation_game() -> GameState {
let mut g = new_game();
// Clear the board so we control the layout exactly.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Foundation slot 0: A♠, 2♠ (top = 2♠)
let f = g.piles.get_mut(&PileType::Foundation(0)).unwrap();
f.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
f.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Two, face_up: true });
// Tableau 0: 3♥ face-up (2♠ can go on 3♥ — different colour, rank-1)
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 3, suit: Suit::Hearts, rank: Rank::Three, face_up: true,
});
g
}
#[test]
fn take_from_foundation_enabled_by_default() {
let g = setup_take_from_foundation_game();
assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)");
}
#[test]
fn take_from_foundation_blocked_when_disabled() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = false;
let err = g
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1)
.unwrap_err();
assert!(
matches!(err, MoveError::RuleViolation(_)),
"expected RuleViolation, got {err:?}",
);
}
#[test]
fn take_from_foundation_allowed_when_enabled() {
let mut g = setup_take_from_foundation_game();
// already true by default; explicit set confirms the behaviour holds
g.take_from_foundation = true;
g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap();
// Foundation slot 0 should now hold only the Ace.
assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1);
assert_eq!(g.piles[&PileType::Foundation(0)].cards[0].rank, Rank::Ace);
// The 2♠ should be on top of tableau 0 above the 3♥.
let t0 = &g.piles[&PileType::Tableau(0)].cards;
assert_eq!(t0.len(), 2);
assert_eq!(t0[1].rank, Rank::Two);
}
#[test]
fn take_from_foundation_rejects_illegal_tableau_placement() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = true;
// Tableau 1 is empty — only a King can go there; 2♠ is not a King.
let err = g
.move_cards(PileType::Foundation(0), PileType::Tableau(1), 1)
.unwrap_err();
assert!(matches!(err, MoveError::RuleViolation(_)));
}
#[test]
fn take_from_foundation_rejects_count_gt_1() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = true;
let err = g
.move_cards(PileType::Foundation(0), PileType::Tableau(0), 2)
.unwrap_err();
assert!(matches!(err, MoveError::RuleViolation(_)));
}
// --- possible_instructions ---
#[test]
fn possible_instructions_empty_when_won() {
let mut g = new_game();
g.is_won = true;
assert!(g.possible_instructions().is_empty());
}
#[test]
fn possible_instructions_includes_ace_to_foundation() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
});
let moves = g.possible_instructions();
assert!(
moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)),
"Ace must be moveable to empty foundation slot 0; got {moves:?}"
);
}
#[test]
fn possible_instructions_all_valid_on_fresh_game() {
// Every triple returned must actually succeed when applied to a clone of the state.
let g = new_game();
for (from, to, count) in g.possible_instructions() {
let mut clone = g.clone();
assert!(
clone.move_cards(from.clone(), to.clone(), count).is_ok(),
"instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed"
);
}
}
#[test]
fn possible_instructions_no_face_down_sources() {
let g = new_game();
for (from, _, count) in g.possible_instructions() {
if let PileType::Tableau(i) = from {
let pile = &g.piles[&PileType::Tableau(i)];
let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count();
assert!(
count <= run_len,
"count {count} exceeds face-up run {run_len} for Tableau({i})"
);
}
}
}
#[test]
fn possible_instructions_waste_top_included() {
let mut g = new_game();
// Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true,
});
let moves = g.possible_instructions();
// King goes on any of the 7 empty tableau piles
assert!(
(0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))),
"King on waste must be moveable to an empty tableau column"
);
}
}