diff --git a/solitaire_core/src/deck.rs b/solitaire_core/src/deck.rs new file mode 100644 index 0000000..8d2e391 --- /dev/null +++ b/solitaire_core/src/deck.rs @@ -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, +} + +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 = 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 = 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::>()); + } +} diff --git a/solitaire_core/src/error.rs b/solitaire_core/src/error.rs new file mode 100644 index 0000000..6566a87 --- /dev/null +++ b/solitaire_core/src/error.rs @@ -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()); + } +} diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index b4ba26a..79d71b0 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -1 +1,6 @@ pub mod card; +pub mod deck; +pub mod error; +pub mod pile; +pub mod rules; +pub mod scoring; diff --git a/solitaire_core/src/pile.rs b/solitaire_core/src/pile.rs new file mode 100644 index 0000000..de3c51f --- /dev/null +++ b/solitaire_core/src/pile.rs @@ -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, +} + +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)); + } +} diff --git a/solitaire_core/src/rules.rs b/solitaire_core/src/rules.rs new file mode 100644 index 0000000..b82a963 --- /dev/null +++ b/solitaire_core/src/rules.rs @@ -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) -> 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)); + } +} diff --git a/solitaire_core/src/scoring.rs b/solitaire_core/src/scoring.rs new file mode 100644 index 0000000..2598f02 --- /dev/null +++ b/solitaire_core/src/scoring.rs @@ -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); + } +}