ffc79447d4
P0 fixes: - Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs (all three were exported but never wired — features silently did nothing) - game_state::draw(): increment move_count on waste→stock recycle, not just on normal draws; add move_count_increments_on_recycle regression test P1 fixes: - solitaire_server/Cargo.toml: remove duplicate dev-dependencies (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections) P2 — input_plugin refactor: - Split 198-line handle_keyboard() into three focused systems under 110 lines each: handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G) - Introduce KeyboardConfirmState resource to share countdown timers across systems - Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck, new_game_confirm_window_is_positive P2 — achievement predicate tests (solitaire_core): - Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer, on_a_roll, comeback predicates (previously only covered via check_achievements()) - 141 core tests now passing P2 — server tests: - solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required) - solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order P3 — documentation: - Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin, AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin, HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin) - Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef - Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win card_animation module (new files from previous session): - chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs - Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants - Add handle_touch_stock_tap so touch users can draw from the stock pile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
5.0 KiB
Rust
164 lines
5.0 KiB
Rust
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 {
|
||
/// 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<_>>());
|
||
}
|
||
}
|