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:
@@ -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 0–51).
|
||||||
|
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<_>>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
|
pub mod deck;
|
||||||
|
pub mod error;
|
||||||
|
pub mod pile;
|
||||||
|
pub mod rules;
|
||||||
|
pub mod scoring;
|
||||||
|
|||||||
@@ -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 (0–6).
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user