chore(core): delete deck.rs and scoring.rs
Build and Deploy / build-and-push (push) Failing after 1m18s
Build and Deploy / build-and-push (push) Failing after 1m18s
- 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>
This commit is contained in:
Generated
-1
@@ -7034,7 +7034,6 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"card_game",
|
"card_game",
|
||||||
"klondike",
|
"klondike",
|
||||||
"rand 0.9.4",
|
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
rand = { workspace = true }
|
|
||||||
klondike = { workspace = true }
|
klondike = { workspace = true }
|
||||||
card_game = { workspace = true }
|
card_game = { workspace = true }
|
||||||
|
|||||||
@@ -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<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 `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<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<_>>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
use crate::card::{Card, Rank};
|
use crate::card::{Card, Rank};
|
||||||
use crate::error::MoveError;
|
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::pile::{Pile, PileType};
|
||||||
use crate::scoring::compute_time_bonus as scoring_time_bonus;
|
|
||||||
use card_game::{Game, Session, SessionConfig};
|
use card_game::{Game, Session, SessionConfig};
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||||
|
|||||||
@@ -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
|
/// 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).
|
/// 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
|
/// The id is consistent for the same logical card across all reconstructions.
|
||||||
/// consistent for the same logical card across all reconstructions.
|
|
||||||
pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
|
pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
|
||||||
let suit = suit_from_kl(card.suit());
|
let suit = suit_from_kl(card.suit());
|
||||||
let rank = rank_from_kl(card.rank());
|
let rank = rank_from_kl(card.rank());
|
||||||
@@ -518,3 +517,12 @@ impl TryFrom<SavedInstruction> 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod deck;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod klondike_adapter;
|
pub mod klondike_adapter;
|
||||||
pub mod pile;
|
pub mod pile;
|
||||||
pub mod scoring;
|
|
||||||
pub mod solver;
|
pub mod solver;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::GameMode;
|
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 solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
|
|||||||
Reference in New Issue
Block a user