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 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