From 862f7e4b4873d961e3b2fddf5b40f8720249681c Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 29 May 2026 17:42:30 -0700 Subject: [PATCH] chore(core): delete deck.rs and scoring.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deck.rs (193 lines) — Deck/deal_klondike replaced by Klondike::with_seed() - scoring.rs (152 lines) — scoring fns superseded by KlondikeAdapter; move compute_time_bonus to klondike_adapter.rs, update win_summary_plugin import - Remove rand dep from solitaire_core (only used by deck.rs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - solitaire_core/Cargo.toml | 1 - solitaire_core/src/deck.rs | 193 --------------------- solitaire_core/src/game_state.rs | 3 +- solitaire_core/src/klondike_adapter.rs | 12 +- solitaire_core/src/lib.rs | 2 - solitaire_core/src/scoring.rs | 152 ---------------- solitaire_engine/src/win_summary_plugin.rs | 2 +- 8 files changed, 12 insertions(+), 354 deletions(-) delete mode 100644 solitaire_core/src/deck.rs delete mode 100644 solitaire_core/src/scoring.rs diff --git a/Cargo.lock b/Cargo.lock index 040a1db..3e864ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7034,7 +7034,6 @@ version = "0.1.0" dependencies = [ "card_game", "klondike", - "rand 0.9.4", "serde", "thiserror 2.0.18", ] diff --git a/solitaire_core/Cargo.toml b/solitaire_core/Cargo.toml index afe36f0..5145fa4 100644 --- a/solitaire_core/Cargo.toml +++ b/solitaire_core/Cargo.toml @@ -7,6 +7,5 @@ edition.workspace = true [dependencies] serde = { workspace = true } thiserror = { workspace = true } -rand = { workspace = true } klondike = { workspace = true } card_game = { workspace = true } diff --git a/solitaire_core/src/deck.rs b/solitaire_core/src/deck.rs deleted file mode 100644 index c41cd26..0000000 --- a/solitaire_core/src/deck.rs +++ /dev/null @@ -1,193 +0,0 @@ -use crate::card::{Card, Rank, Suit}; -use crate::pile::{Pile, PileType}; -use rand::rngs::StdRng; -use rand::{SeedableRng, seq::SliceRandom}; - -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 { - /// All 52 cards in the deck, in deal order. - 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 `StdRng`. - /// 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) { - debug_assert_eq!( - deck.cards.len(), - 52, - "deal_klondike requires a full 52-card deck" - ); - let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i))); - // Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded. - let mut idx = 0usize; - - for (col, pile) in tableau.iter_mut().enumerate() { - for row in 0..=col { - let mut card = deck.cards[idx].clone(); - card.face_up = row == col; - pile.cards.push(card); - idx += 1; - } - } - - let mut stock = Pile::new(PileType::Stock); - stock.cards.extend(deck.cards.into_iter().skip(idx)); - (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/game_state.rs b/solitaire_core/src/game_state.rs index ca7b7ad..b64a448 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,8 +1,7 @@ use crate::card::{Card, Rank}; use crate::error::MoveError; -use crate::klondike_adapter::{card_from_kl, KlondikeAdapter, SavedInstruction}; +use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, 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, diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index e5792a7..378d950 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -237,8 +237,7 @@ pub fn card_to_kl(card: &crate::card::Card) -> KlCard { /// Convert a [`card_game::Card`] back to our [`crate::card::Card`], assigning /// a stable `id` derived from the suit and rank (0–51, Clubs-first ordering). /// -/// This id matches the id assigned in [`crate::deck::Deck::new`] and is -/// consistent for the same logical card across all reconstructions. +/// The id is consistent for the same logical card across all reconstructions. pub fn card_from_kl(card: &KlCard) -> crate::card::Card { let suit = suit_from_kl(card.suit()); let rank = rank_from_kl(card.rank()); @@ -518,3 +517,12 @@ impl TryFrom for KlondikeInstruction { }) } } + +/// 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 +} diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index a0bd417..2d3755c 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -1,9 +1,7 @@ pub mod achievement; pub mod card; -pub mod deck; pub mod error; pub mod game_state; pub mod klondike_adapter; pub mod pile; -pub mod scoring; pub mod solver; diff --git a/solitaire_core/src/scoring.rs b/solitaire_core/src/scoring.rs deleted file mode 100644 index 9d521e8..0000000 --- a/solitaire_core/src/scoring.rs +++ /dev/null @@ -1,152 +0,0 @@ -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 -/// - -15 for a foundation → tableau (take-from-foundation) move -/// - 0 for all other moves -/// -/// Note: the +5 flip bonus for exposing a face-down tableau card is applied -/// separately in `game_state::move_cards` because it depends on post-move state. -pub fn score_move(from: &PileType, to: &PileType) -> i32 { - match to { - PileType::Foundation(_) => 10, - PileType::Tableau(_) => match from { - PileType::Waste => 5, - PileType::Foundation(_) => -15, - _ => 0, - }, - _ => 0, - } -} - -/// Score penalty applied when the player uses undo: -15. -pub fn score_undo() -> i32 { - -15 -} - -/// Score bonus awarded when a face-down tableau card is flipped face-up: +5. -pub fn score_flip() -> i32 { - 5 -} - -/// Score penalty for recycling the waste pile back to stock. -/// -/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3). -/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3). -/// `recycle_count` is the new total count **after** this recycle. -pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 { - let (free, penalty) = if is_draw_three { - (3_u32, -20_i32) - } else { - (1_u32, -100_i32) - }; - if recycle_count > free { penalty } else { 0 } -} - -/// 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::*; - - #[test] - fn move_to_foundation_scores_ten() { - assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10); - assert_eq!( - score_move(&PileType::Tableau(0), &PileType::Foundation(0)), - 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); - } - - #[test] - fn foundation_to_tableau_penalises_fifteen() { - // Moving a card back off a foundation (take_from_foundation rule) costs -15. - assert_eq!( - score_move(&PileType::Foundation(0), &PileType::Tableau(0)), - -15 - ); - } - - #[test] - fn move_to_stock_or_waste_scores_zero() { - // These destinations are illegal moves in practice, but the function - // must not panic and should return 0. - assert_eq!(score_move(&PileType::Waste, &PileType::Stock), 0); - assert_eq!(score_move(&PileType::Waste, &PileType::Waste), 0); - } - - #[test] - fn time_bonus_is_capped_at_i32_max_for_huge_values() { - // Very short elapsed time would overflow without the .min() guard. - let bonus = compute_time_bonus(1); - assert!( - bonus >= 0, - "time bonus must be non-negative after u64→i32 cast" - ); - } - - #[test] - fn flip_bonus_is_five() { - assert_eq!(score_flip(), 5); - } - - #[test] - fn recycle_draw1_first_pass_free() { - assert_eq!(score_recycle(1, false), 0); - } - - #[test] - fn recycle_draw1_second_pass_penalised() { - assert_eq!(score_recycle(2, false), -100); - } - - #[test] - fn recycle_draw3_third_pass_free() { - assert_eq!(score_recycle(3, true), 0); - } - - #[test] - fn recycle_draw3_fourth_pass_penalised() { - assert_eq!(score_recycle(4, true), -20); - } -} diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index 4b21e8a..647fe8d 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -12,7 +12,7 @@ use bevy::prelude::*; use solitaire_core::game_state::GameMode; -use solitaire_core::scoring::compute_time_bonus; +use solitaire_core::klondike_adapter::compute_time_bonus; use solitaire_data::AnimSpeed; use crate::achievement_plugin::display_name_for;