6496e130f3
Build and Deploy / build-and-push (push) Failing after 29s
- Delete rules.rs (228 lines) — move validation now handled by klondike engine - Delete SolverState DFS from solver.rs (~900 lines) — replaced by session.solve() - Rewrite GameState::new_with_mode() using Klondike::with_seed() (removes deck.rs dep) - Rewrite move_cards/draw/undo to use Session<Klondike> as move executor - Remove internal undo_stack (VecDeque<StateSnapshot>) — session owns history - Sync piles from KlondikeState after each move via sync_piles_from_session() - Update engine layer (game_plugin, input_plugin, card_plugin, etc.) to new API - Net: 821 insertions, 3872 deletions (-3051 lines) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1694 lines
57 KiB
Rust
1694 lines
57 KiB
Rust
use crate::card::{Card, Rank};
|
|
use crate::error::MoveError;
|
|
use crate::klondike_adapter::{card_from_kl, KlondikeAdapter, SavedInstruction};
|
|
use crate::pile::{Pile, PileType};
|
|
use crate::scoring::compute_time_bonus as scoring_time_bonus;
|
|
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};
|
|
use std::collections::HashMap;
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// Full state of an in-progress Klondike Solitaire game.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GameState {
|
|
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
|
|
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).
|
|
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 adapter: KlondikeAdapter,
|
|
pub(crate) session: Session<Klondike>,
|
|
}
|
|
|
|
impl PartialEq for GameState {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.piles == other.piles
|
|
&& 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
|
|
}
|
|
}
|
|
|
|
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 {
|
|
piles: HashMap::new(),
|
|
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,
|
|
adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation),
|
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
|
};
|
|
|
|
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.sync_piles_from_session();
|
|
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 {
|
|
let mut game = Self {
|
|
piles: HashMap::new(),
|
|
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,
|
|
adapter: KlondikeAdapter::new(draw_mode, true),
|
|
session: Self::new_session(seed, draw_mode),
|
|
};
|
|
game.sync_piles_from_session();
|
|
game
|
|
}
|
|
|
|
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::new(draw_mode, true)
|
|
.klondike_config()
|
|
.clone()
|
|
}
|
|
|
|
fn validation_config(&self) -> KlondikeConfig {
|
|
KlondikeAdapter::new(self.draw_mode, self.take_from_foundation)
|
|
.klondike_config()
|
|
.clone()
|
|
}
|
|
|
|
fn saved_moves(&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()
|
|
}
|
|
|
|
pub(crate) fn session(&self) -> &Session<Klondike> {
|
|
&self.session
|
|
}
|
|
|
|
pub(crate) fn sync_piles_from_session(&mut self) {
|
|
fn push_cards(
|
|
pile: &mut Pile,
|
|
cards: impl IntoIterator<Item = Card>,
|
|
face_up: bool,
|
|
) {
|
|
for mut card in cards {
|
|
card.face_up = face_up;
|
|
pile.cards.push(card);
|
|
}
|
|
}
|
|
|
|
let state = self.session.state().state().state();
|
|
let mut piles = HashMap::new();
|
|
|
|
let mut stock = Pile::new(PileType::Stock);
|
|
push_cards(
|
|
&mut stock,
|
|
state.stock().face_down().iter().map(card_from_kl),
|
|
false,
|
|
);
|
|
piles.insert(PileType::Stock, stock);
|
|
|
|
let mut waste = Pile::new(PileType::Waste);
|
|
push_cards(
|
|
&mut waste,
|
|
state.stock().face_up().iter().map(card_from_kl),
|
|
true,
|
|
);
|
|
piles.insert(PileType::Waste, waste);
|
|
|
|
for (slot, cards) in [
|
|
(0_u8, state.foundation1()),
|
|
(1_u8, state.foundation2()),
|
|
(2_u8, state.foundation3()),
|
|
(3_u8, state.foundation4()),
|
|
] {
|
|
let mut foundation = Pile::new(PileType::Foundation(slot));
|
|
push_cards(&mut foundation, cards.iter().map(card_from_kl), true);
|
|
piles.insert(PileType::Foundation(slot), foundation);
|
|
}
|
|
|
|
for (index, tableau) in [
|
|
Tableau::Tableau1,
|
|
Tableau::Tableau2,
|
|
Tableau::Tableau3,
|
|
Tableau::Tableau4,
|
|
Tableau::Tableau5,
|
|
Tableau::Tableau6,
|
|
Tableau::Tableau7,
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let mut pile = Pile::new(PileType::Tableau(index));
|
|
push_cards(
|
|
&mut pile,
|
|
state
|
|
.tableau_face_down_cards(tableau)
|
|
.iter()
|
|
.map(card_from_kl),
|
|
false,
|
|
);
|
|
push_cards(
|
|
&mut pile,
|
|
state.tableau_face_up_cards(tableau).iter().map(card_from_kl),
|
|
true,
|
|
);
|
|
piles.insert(PileType::Tableau(index), pile);
|
|
}
|
|
|
|
self.piles = piles;
|
|
}
|
|
|
|
fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
|
match index {
|
|
0 => Ok(Tableau::Tableau1),
|
|
1 => Ok(Tableau::Tableau2),
|
|
2 => Ok(Tableau::Tableau3),
|
|
3 => Ok(Tableau::Tableau4),
|
|
4 => Ok(Tableau::Tableau5),
|
|
5 => Ok(Tableau::Tableau6),
|
|
6 => Ok(Tableau::Tableau7),
|
|
_ => Err(MoveError::InvalidSource),
|
|
}
|
|
}
|
|
|
|
fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
|
match slot {
|
|
0 => Ok(Foundation::Foundation1),
|
|
1 => Ok(Foundation::Foundation2),
|
|
2 => Ok(Foundation::Foundation3),
|
|
3 => Ok(Foundation::Foundation4),
|
|
_ => Err(MoveError::InvalidDestination),
|
|
}
|
|
}
|
|
|
|
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
|
match skip {
|
|
0 => Ok(SkipCards::Skip0),
|
|
1 => Ok(SkipCards::Skip1),
|
|
2 => Ok(SkipCards::Skip2),
|
|
3 => Ok(SkipCards::Skip3),
|
|
4 => Ok(SkipCards::Skip4),
|
|
5 => Ok(SkipCards::Skip5),
|
|
6 => Ok(SkipCards::Skip6),
|
|
7 => Ok(SkipCards::Skip7),
|
|
8 => Ok(SkipCards::Skip8),
|
|
9 => Ok(SkipCards::Skip9),
|
|
10 => Ok(SkipCards::Skip10),
|
|
11 => Ok(SkipCards::Skip11),
|
|
12 => Ok(SkipCards::Skip12),
|
|
_ => Err(MoveError::RuleViolation("invalid tableau card count".into())),
|
|
}
|
|
}
|
|
|
|
fn will_flip_tableau_source(&self, from: PileType, count: usize) -> bool {
|
|
let PileType::Tableau(_) = from else {
|
|
return false;
|
|
};
|
|
let Some(pile) = self.piles.get(&from) else {
|
|
return false;
|
|
};
|
|
pile.cards.len() > count && !pile.cards[pile.cards.len() - count - 1].face_up
|
|
}
|
|
|
|
fn instruction_for_move(
|
|
&self,
|
|
from: PileType,
|
|
to: PileType,
|
|
count: usize,
|
|
) -> Result<KlondikeInstruction, MoveError> {
|
|
match (from, to) {
|
|
(_, PileType::Stock | PileType::Waste) => Err(MoveError::InvalidDestination),
|
|
(PileType::Stock, _) => Err(MoveError::InvalidSource),
|
|
(PileType::Waste, PileType::Foundation(slot)) => {
|
|
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: Self::foundation_from_slot(slot)?,
|
|
}))
|
|
}
|
|
(PileType::Tableau(src), PileType::Foundation(slot)) => {
|
|
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(Self::tableau_from_index(src)?),
|
|
foundation: Self::foundation_from_slot(slot)?,
|
|
}))
|
|
}
|
|
(PileType::Foundation(_), PileType::Foundation(_)) => Err(MoveError::RuleViolation(
|
|
"cannot move between foundation slots".into(),
|
|
)),
|
|
(PileType::Waste, PileType::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: Self::tableau_from_index(dst)?,
|
|
}))
|
|
}
|
|
(PileType::Foundation(slot), PileType::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(Self::foundation_from_slot(slot)?),
|
|
tableau: Self::tableau_from_index(dst)?,
|
|
}))
|
|
}
|
|
(PileType::Tableau(src), PileType::Tableau(dst)) => {
|
|
let src_tableau = Self::tableau_from_index(src)?;
|
|
let face_up_count = self
|
|
.session
|
|
.state()
|
|
.state()
|
|
.state()
|
|
.tableau_face_up_cards(src_tableau)
|
|
.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_tableau,
|
|
skip_cards,
|
|
}),
|
|
tableau: Self::tableau_from_index(dst)?,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn instruction_to_move(
|
|
&self,
|
|
instruction: KlondikeInstruction,
|
|
) -> Option<(PileType, PileType, usize)> {
|
|
let state = self.session.state().state().state();
|
|
match instruction {
|
|
KlondikeInstruction::RotateStock => None,
|
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
|
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
|
return None;
|
|
}
|
|
let source = match dst_foundation.src {
|
|
KlondikePile::Tableau(tableau) => PileType::Tableau(tableau as usize),
|
|
KlondikePile::Stock => PileType::Waste,
|
|
KlondikePile::Foundation(_) => return None,
|
|
};
|
|
Some((
|
|
source,
|
|
PileType::Foundation(dst_foundation.foundation as u8),
|
|
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;
|
|
}
|
|
(PileType::Tableau(tableau_stack.tableau as usize), count)
|
|
}
|
|
KlondikePileStack::Stock => (PileType::Waste, 1),
|
|
KlondikePileStack::Foundation(foundation) => {
|
|
(PileType::Foundation(foundation as u8), 1)
|
|
}
|
|
};
|
|
Some((source, PileType::Tableau(dst_tableau.tableau as usize), 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
|
|
.piles
|
|
.get(&PileType::Stock)
|
|
.is_none_or(|pile| pile.cards.is_empty());
|
|
let waste_empty = self
|
|
.piles
|
|
.get(&PileType::Waste)
|
|
.is_none_or(|pile| pile.cards.is_empty());
|
|
if stock_empty && waste_empty {
|
|
return Err(MoveError::StockEmpty);
|
|
}
|
|
|
|
let recycling = stock_empty && !waste_empty;
|
|
self.session.process_instruction(KlondikeInstruction::RotateStock);
|
|
self.sync_piles_from_session();
|
|
|
|
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`.
|
|
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(),
|
|
));
|
|
}
|
|
|
|
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 instruction = self.instruction_for_move(from.clone(), to.clone(), 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.adapter.score_for_move_with_mode(&from, &to, self.mode);
|
|
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
|
self.adapter.score_for_flip_with_mode(self.mode)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
self.session.process_instruction(instruction);
|
|
self.sync_piles_from_session();
|
|
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.sync_piles_from_session();
|
|
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 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.
|
|
pub fn check_auto_complete(&self) -> bool {
|
|
if self
|
|
.piles
|
|
.get(&PileType::Stock)
|
|
.is_none_or(|pile| !pile.cards.is_empty())
|
|
{
|
|
return false;
|
|
}
|
|
if self
|
|
.piles
|
|
.get(&PileType::Waste)
|
|
.is_none_or(|pile| !pile.cards.is_empty())
|
|
{
|
|
return false;
|
|
}
|
|
(0..7).all(|index| {
|
|
self.piles
|
|
.get(&PileType::Tableau(index))
|
|
.is_some_and(|pile| pile.cards.iter().all(|card| card.face_up))
|
|
})
|
|
}
|
|
|
|
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
|
|
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
|
|
if self.is_won {
|
|
return Vec::new();
|
|
}
|
|
|
|
let config = self.validation_config();
|
|
self.session
|
|
.state()
|
|
.state()
|
|
.possible_instructions(&config)
|
|
.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: &PileType, to: &PileType, count: usize) -> bool {
|
|
if self.is_won || from == to {
|
|
return false;
|
|
}
|
|
let Some(from_pile) = self.piles.get(from) else {
|
|
return false;
|
|
};
|
|
if count == 0 || count > from_pile.cards.len() {
|
|
return false;
|
|
}
|
|
let Ok(instruction) = self.instruction_for_move(from.clone(), to.clone(), 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<PileType> {
|
|
self.piles.iter().find_map(|(pile_type, pile)| {
|
|
pile.cards
|
|
.iter()
|
|
.any(|card| card.id == card_id)
|
|
.then(|| pile_type.clone())
|
|
})
|
|
}
|
|
|
|
/// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
|
|
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
|
if !self.is_auto_completable || self.is_won {
|
|
return None;
|
|
}
|
|
|
|
let waste = PileType::Waste;
|
|
if let Some(slot) = self
|
|
.piles
|
|
.get(&waste)
|
|
.and_then(|pile| pile.cards.last())
|
|
.and_then(|card| self.foundation_slot_for(card))
|
|
{
|
|
return Some((waste, PileType::Foundation(slot)));
|
|
}
|
|
|
|
for index in 0..7 {
|
|
let tableau = PileType::Tableau(index);
|
|
if let Some(slot) = self
|
|
.piles
|
|
.get(&tableau)
|
|
.and_then(|pile| pile.cards.last())
|
|
.and_then(|card| self.foundation_slot_for(card))
|
|
{
|
|
return Some((tableau, PileType::Foundation(slot)));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool {
|
|
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else {
|
|
return false;
|
|
};
|
|
match pile.cards.last() {
|
|
Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank),
|
|
None => card.rank == Rank::Ace,
|
|
}
|
|
}
|
|
|
|
fn foundation_slot_for(&self, card: &Card) -> Option<u8> {
|
|
let mut candidate = None;
|
|
let mut empty_slot = 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 == Rank::Ace {
|
|
empty_slot
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
target.filter(|&slot| self.can_place_on_foundation_slot(card, slot))
|
|
}
|
|
|
|
/// 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};
|
|
use crate::klondike_adapter::KlondikeAdapter;
|
|
|
|
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_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(_))));
|
|
}
|
|
|
|
// --- 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 + KlondikeAdapter::score_for_undo()).max(0);
|
|
assert_eq!(g.score, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_stack_len_matches_session_history() {
|
|
let mut g = new_game();
|
|
for _ in 0..70 {
|
|
let _ = g.draw();
|
|
}
|
|
assert_eq!(g.undo_stack_len(), g.move_count as usize);
|
|
}
|
|
|
|
#[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_blocked_when_waste_has_cards() {
|
|
// Waste must also be empty for auto-complete to engage. A non-empty
|
|
// waste pile — even with all tableau cards face-up and stock empty —
|
|
// must return false to prevent a deadlock where the waste top cannot
|
|
// reach a foundation directly.
|
|
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.
|
|
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
|
|
/// foundation slot, regardless of which slot index the pile occupies.
|
|
/// Undoing the only card from a foundation slot drops the claimed suit;
|
|
/// the slot then accepts a different Ace.
|
|
/// 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.
|
|
/// 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.
|
|
// --- 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_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})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Flip bonus (+5) ---
|
|
|
|
// --- Recycle penalty ---
|
|
|
|
#[test]
|
|
fn recycle_penalty_draw1_first_pass_free() {
|
|
let mut g = new_game(); // DrawOne
|
|
g.score = 200;
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap(); // first recycle — free
|
|
assert_eq!(g.recycle_count, 1);
|
|
assert_eq!(g.score, 200, "first recycle in Draw-1 must be free");
|
|
}
|
|
|
|
#[test]
|
|
fn recycle_penalty_draw1_second_pass_costs_100() {
|
|
let mut g = new_game(); // DrawOne
|
|
g.score = 200;
|
|
// First recycle (free)
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap();
|
|
// Second recycle (-100)
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap();
|
|
assert_eq!(g.recycle_count, 2);
|
|
assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100");
|
|
}
|
|
|
|
#[test]
|
|
fn recycle_penalty_draw3_three_passes_free() {
|
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
|
g.score = 200;
|
|
for _ in 0..3 {
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap();
|
|
}
|
|
assert_eq!(g.recycle_count, 3);
|
|
assert_eq!(g.score, 200, "first 3 recycles in Draw-3 must be free");
|
|
}
|
|
|
|
#[test]
|
|
fn recycle_penalty_draw3_fourth_pass_costs_20() {
|
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
|
g.score = 200;
|
|
for _ in 0..3 {
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap();
|
|
}
|
|
// Fourth recycle (-20)
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap();
|
|
assert_eq!(g.recycle_count, 4);
|
|
assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20");
|
|
}
|
|
|
|
#[test]
|
|
fn recycle_penalty_suppressed_in_zen_mode() {
|
|
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
|
// Two recycles — second would normally cost -100 in classic mode
|
|
for _ in 0..2 {
|
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
|
g.draw().unwrap();
|
|
}
|
|
g.draw().unwrap();
|
|
}
|
|
assert_eq!(g.recycle_count, 2);
|
|
assert_eq!(g.score, 0, "zen mode must suppress recycle penalty");
|
|
}
|
|
|
|
// --- P2: waste multi-card move must be rejected ---
|
|
|
|
#[test]
|
|
fn waste_multi_card_move_returns_rule_violation() {
|
|
let mut g = new_game();
|
|
g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![
|
|
Card {
|
|
id: 1,
|
|
suit: Suit::Hearts,
|
|
rank: Rank::Ace,
|
|
face_up: true,
|
|
},
|
|
Card {
|
|
id: 2,
|
|
suit: Suit::Spades,
|
|
rank: Rank::King,
|
|
face_up: true,
|
|
},
|
|
];
|
|
for i in 0..7 {
|
|
g.piles
|
|
.get_mut(&PileType::Tableau(i))
|
|
.unwrap()
|
|
.cards
|
|
.clear();
|
|
}
|
|
let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2);
|
|
assert!(
|
|
matches!(result, Err(MoveError::RuleViolation(_))),
|
|
"moving 2 cards from waste must be rejected"
|
|
);
|
|
}
|
|
|
|
// --- P3: foundation-to-foundation move must be rejected ---
|
|
|
|
#[test]
|
|
fn foundation_to_foundation_move_returns_rule_violation() {
|
|
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();
|
|
}
|
|
// Place Ace of Clubs on Foundation(0), leave Foundation(1) empty.
|
|
g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![Card {
|
|
id: 1,
|
|
suit: Suit::Clubs,
|
|
rank: Rank::Ace,
|
|
face_up: true,
|
|
}];
|
|
// Attempting to move Ace from Foundation(0) to Foundation(1) must fail.
|
|
let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1);
|
|
assert!(
|
|
matches!(result, Err(MoveError::RuleViolation(_))),
|
|
"moving between foundation slots must be rejected"
|
|
);
|
|
}
|
|
|
|
// --- P4: undo must not retain points from the undone move ---
|
|
|
|
}
|