feat(core): add pile, error, deck, rules, scoring modules with tests

Implements PileType/Pile, MoveError (thiserror), Deck with seeded shuffle,
deal_klondike layout, foundation/tableau placement rules, and Windows XP
Standard scoring — 41 tests, clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Solitaire Quest
2026-04-23 11:07:58 -07:00
parent fcf878b403
commit 17bbec054c
6 changed files with 465 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
use rand::{seq::SliceRandom, SeedableRng};
use rand::rngs::StdRng;
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const ALL_RANKS: [Rank; 13] = [
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,
];
/// A standard 52-card deck.
pub struct Deck {
pub cards: Vec<Card>,
}
impl Deck {
/// Creates an unshuffled deck with all 52 unique cards (id 051).
pub fn new() -> Self {
let mut cards = Vec::with_capacity(52);
let mut id = 0u32;
for &suit in &ALL_SUITS {
for &rank in &ALL_RANKS {
cards.push(Card { id, suit, rank, face_up: false });
id += 1;
}
}
Self { cards }
}
/// Shuffles the deck in-place using Fisher-Yates with a seeded `SmallRng`.
/// The same seed always produces the same order on any platform.
pub fn shuffle(&mut self, seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
self.cards.shuffle(&mut rng);
}
}
impl Default for Deck {
fn default() -> Self {
Self::new()
}
}
/// Deals a standard Klondike layout from a pre-shuffled deck.
///
/// Returns 7 tableau piles and the remaining stock pile.
/// Column `i` contains `i + 1` cards; only the top card is face-up.
/// Stock receives the remaining 24 cards, all face-down.
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
let mut cards = deck.cards.into_iter();
for (col, pile) in tableau.iter_mut().enumerate() {
for row in 0..=col {
let mut card = cards.next().expect("deck has 52 cards");
card.face_up = row == col;
pile.cards.push(card);
}
}
let mut stock = Pile::new(PileType::Stock);
stock.cards.extend(cards);
(tableau, stock)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deck_new_has_52_cards() {
assert_eq!(Deck::new().cards.len(), 52);
}
#[test]
fn deck_new_has_unique_ids() {
let deck = Deck::new();
let mut ids: Vec<u32> = deck.cards.iter().map(|c| c.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 52);
}
#[test]
fn deck_new_has_all_suits_and_ranks() {
let deck = Deck::new();
for suit in ALL_SUITS {
for rank in ALL_RANKS {
assert!(
deck.cards.iter().any(|c| c.suit == suit && c.rank == rank),
"missing {rank:?} {suit:?}"
);
}
}
}
#[test]
fn same_seed_produces_same_order() {
let mut d1 = Deck::new(); d1.shuffle(42);
let mut d2 = Deck::new(); d2.shuffle(42);
assert_eq!(d1.cards, d2.cards);
}
#[test]
fn different_seeds_produce_different_orders() {
let mut d1 = Deck::new(); d1.shuffle(1);
let mut d2 = Deck::new(); d2.shuffle(2);
assert_ne!(d1.cards, d2.cards);
}
#[test]
fn deal_klondike_correct_tableau_sizes() {
let mut deck = Deck::new(); deck.shuffle(0);
let (tableau, stock) = deal_klondike(deck);
for (i, pile) in tableau.iter().enumerate() {
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
}
assert_eq!(stock.cards.len(), 24);
}
#[test]
fn deal_klondike_top_cards_are_face_up() {
let mut deck = Deck::new(); deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
assert!(pile.cards.last().unwrap().face_up);
}
}
#[test]
fn deal_klondike_non_top_cards_are_face_down() {
let mut deck = Deck::new(); deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
assert!(!card.face_up);
}
}
}
#[test]
fn deal_klondike_stock_is_face_down() {
let mut deck = Deck::new(); deck.shuffle(0);
let (_, stock) = deal_klondike(deck);
assert!(stock.cards.iter().all(|c| !c.face_up));
}
#[test]
fn deal_klondike_all_52_cards_present() {
let mut deck = Deck::new(); deck.shuffle(99);
let (tableau, stock) = deal_klondike(deck);
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
for pile in &tableau { ids.extend(pile.cards.iter().map(|c| c.id)); }
ids.sort_unstable();
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
}
}
+36
View File
@@ -0,0 +1,36 @@
use thiserror::Error;
/// All reasons a game move can be rejected.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum MoveError {
#[error("invalid source pile")]
InvalidSource,
#[error("invalid destination pile")]
InvalidDestination,
#[error("source pile is empty")]
EmptySource,
#[error("move violates rules: {0}")]
RuleViolation(String),
#[error("undo stack is empty")]
UndoStackEmpty,
#[error("game is already won")]
GameAlreadyWon,
#[error("stock and waste are both empty")]
StockEmpty,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_violation_includes_message() {
let e = MoveError::RuleViolation("king only on empty".into());
assert!(e.to_string().contains("king only on empty"));
}
#[test]
fn undo_stack_empty_has_non_empty_message() {
assert!(!MoveError::UndoStackEmpty.to_string().is_empty());
}
}
+5
View File
@@ -1 +1,6 @@
pub mod card;
pub mod deck;
pub mod error;
pub mod pile;
pub mod rules;
pub mod scoring;
+70
View File
@@ -0,0 +1,70 @@
use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit};
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PileType {
/// The face-down draw pile.
Stock,
/// The face-up discard pile drawn to.
Waste,
/// One of the four suit-ordered foundation piles.
Foundation(Suit),
/// One of the seven tableau columns (06).
Tableau(usize),
}
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile {
pub pile_type: PileType,
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: PileType) -> Self {
Self { pile_type, cards: Vec::new() }
}
/// Returns a reference to the top (last) card, or `None` if empty.
pub fn top(&self) -> Option<&Card> {
self.cards.last()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(PileType::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(PileType::Waste);
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
pile.cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Two, face_up: true });
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(PileType::Waste);
assert!(pile.top().is_none());
}
#[test]
fn pile_type_foundation_uses_suit() {
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades));
}
#[test]
fn pile_type_tableau_uses_index() {
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
}
}
+122
View File
@@ -0,0 +1,122 @@
use crate::card::{Card, Suit};
use crate::pile::Pile;
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`.
///
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher.
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
if card.suit != suit {
return false;
}
match pile.cards.last() {
None => card.rank.value() == 1,
Some(top) => card.rank.value() == top.rank.value() + 1,
}
}
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
///
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
match pile.cards.last() {
None => card.rank.value() == 13,
Some(top) => {
card.rank.value() + 1 == top.rank.value()
&& card.suit.is_red() != top.suit.is_red()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
fn card(suit: Suit, rank: Rank) -> Card {
Card { id: 0, suit, rank, face_up: true }
}
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
Pile { pile_type, cards }
}
// Foundation tests
#[test]
fn foundation_ace_on_empty_is_valid() {
let c = card(Suit::Hearts, Rank::Ace);
let p = Pile::new(PileType::Foundation(Suit::Hearts));
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
}
#[test]
fn foundation_non_ace_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Two);
let p = Pile::new(PileType::Foundation(Suit::Hearts));
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
}
#[test]
fn foundation_two_on_ace_same_suit_is_valid() {
let c = card(Suit::Clubs, Rank::Two);
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
}
#[test]
fn foundation_wrong_suit_is_invalid() {
let c = card(Suit::Hearts, Rank::Ace);
let p = Pile::new(PileType::Foundation(Suit::Spades));
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
}
#[test]
fn foundation_skipping_rank_is_invalid() {
let c = card(Suit::Diamonds, Rank::Three);
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
}
// Tableau tests
#[test]
fn tableau_king_on_empty_is_valid() {
let c = card(Suit::Hearts, Rank::King);
let p = Pile::new(PileType::Tableau(0));
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_non_king_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Queen);
let p = Pile::new(PileType::Tableau(0));
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_red_on_black_one_lower_is_valid() {
let c = card(Suit::Hearts, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_same_color_is_invalid() {
let c = card(Suit::Clubs, Rank::Nine);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_wrong_rank_difference_is_invalid() {
let c = card(Suit::Hearts, Rank::Eight);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_black_on_red_one_lower_is_valid() {
let c = card(Suit::Clubs, Rank::Six);
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
assert!(can_place_on_tableau(&c, &p));
}
}
+73
View File
@@ -0,0 +1,73 @@
use crate::pile::PileType;
/// Score delta for moving cards from `from` to `to`.
///
/// Windows XP Standard scoring:
/// - +10 for any card reaching a foundation pile
/// - +5 for a waste → tableau move
/// - 0 for all other moves
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to {
PileType::Foundation(_) => 10,
PileType::Tableau(_) => {
if matches!(from, PileType::Waste) { 5 } else { 0 }
}
_ => 0,
}
}
/// Score penalty applied when the player uses undo: -15.
pub fn score_undo() -> i32 {
-15
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::Suit;
#[test]
fn move_to_foundation_scores_ten() {
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10);
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10);
}
#[test]
fn waste_to_tableau_scores_five() {
assert_eq!(score_move(&PileType::Waste, &PileType::Tableau(3)), 5);
}
#[test]
fn tableau_to_tableau_scores_zero() {
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Tableau(1)), 0);
}
#[test]
fn undo_penalty_is_negative_fifteen() {
assert_eq!(score_undo(), -15);
}
#[test]
fn time_bonus_at_100_seconds() {
assert_eq!(compute_time_bonus(100), 7000);
}
#[test]
fn time_bonus_at_zero_is_zero() {
assert_eq!(compute_time_bonus(0), 0);
}
#[test]
fn time_bonus_at_one_second() {
assert_eq!(compute_time_bonus(1), 700_000);
}
}