refactor(core): complete card_game::Card migration across engine + wasm
Finish the half-applied Card refactor. solitaire_core::card::Card is now an alias for the opaque card_game::Card: suit()/rank() are methods, there is no id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors return Vec<(Card, bool)> where the bool is face-up. Card identity is now the Card value itself (via Eq/Hash), not a numeric u32: - CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards. - Drag/selection collections and the touch/keyboard selection setters use Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card. - replay_overlay and feedback/settle/deal animations updated accordingly. solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the desktop engine), and consumes the (Card, bool) pile tuples. test-support: TestPileState tableau overrides now carry a per-card face-up flag so tests can place face-down tableau cards. set_test_tableau_cards keeps its Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face takes Vec<(Card, bool)>. cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy --workspace --all-targets -- -D warnings is clean. Save/serde format unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-110
@@ -1,110 +1 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use card_game::{Rank, Suit};
|
||||
|
||||
/// A single playing card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||
pub id: u32,
|
||||
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||
pub suit: Suit,
|
||||
/// The card's rank (Ace through King).
|
||||
pub rank: Rank,
|
||||
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Creates a card with explicit face orientation.
|
||||
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a face-up card.
|
||||
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, true)
|
||||
}
|
||||
|
||||
/// Creates a face-down card.
|
||||
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rank_values_are_sequential() {
|
||||
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||
assert_eq!(r.value(), (i + 1) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_as_u8_matches_value() {
|
||||
for r in Rank::RANKS {
|
||||
assert_eq!(r as u8, r.value());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_checked_add_boundary() {
|
||||
assert_eq!(Rank::King.checked_add(1), None);
|
||||
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
||||
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
||||
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_checked_sub_boundary() {
|
||||
assert_eq!(Rank::Ace.checked_sub(1), None);
|
||||
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
||||
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
||||
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_suits_contains_all_four() {
|
||||
assert_eq!(Suit::SUITS.len(), 4);
|
||||
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
||||
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
||||
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
||||
assert!(Suit::SUITS.contains(&Suit::Spades));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert_ne!(
|
||||
suit.is_red(),
|
||||
suit.is_black(),
|
||||
"{suit:?} must be exactly one of red/black"
|
||||
);
|
||||
}
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_constructors_set_fields() {
|
||||
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
|
||||
assert_eq!(up.id, 10);
|
||||
assert_eq!(up.suit, Suit::Spades);
|
||||
assert_eq!(up.rank, Rank::Queen);
|
||||
assert!(up.face_up);
|
||||
|
||||
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
|
||||
assert_eq!(down.id, 11);
|
||||
assert_eq!(down.suit, Suit::Diamonds);
|
||||
assert_eq!(down.rank, Rank::King);
|
||||
assert!(!down.face_up);
|
||||
}
|
||||
}
|
||||
pub use card_game::{Card, Deck, Rank, Suit};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use crate::card::Card;
|
||||
use crate::error::MoveError;
|
||||
use crate::klondike_adapter::{
|
||||
DrawMode, KlondikeAdapter, SavedInstruction, card_from_kl,
|
||||
DrawMode, KlondikeAdapter, SavedInstruction,
|
||||
compute_time_bonus as scoring_time_bonus,
|
||||
foundation_from_slot as adapter_foundation_from_slot,
|
||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||
tableau_from_index as adapter_tableau_from_index,
|
||||
};
|
||||
use card_game::{Game as _, Session, SessionConfig};
|
||||
use card_game::{Card, Game as _, Session, SessionConfig};
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||
@@ -142,13 +141,15 @@ struct PersistedGameStateIn {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TestPileState {
|
||||
/// Override for face-down stock cards. `None` means "use session".
|
||||
pub stock: Option<Vec<crate::card::Card>>,
|
||||
pub stock: Option<Vec<Card>>,
|
||||
/// Override for face-up waste cards. `None` means "use session".
|
||||
pub waste: Option<Vec<crate::card::Card>>,
|
||||
pub waste: Option<Vec<Card>>,
|
||||
/// Per-tableau overrides. Missing keys fall back to the session.
|
||||
pub tableau: std::collections::HashMap<Tableau, Vec<crate::card::Card>>,
|
||||
/// Each entry carries its own face-up flag so tests can place face-down
|
||||
/// cards (e.g. an un-flipped tableau card).
|
||||
pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
|
||||
/// Per-foundation overrides. Missing keys fall back to the session.
|
||||
pub foundation: std::collections::HashMap<Foundation, Vec<crate::card::Card>>,
|
||||
pub foundation: std::collections::HashMap<Foundation, Vec<Card>>,
|
||||
}
|
||||
|
||||
/// Full state of an in-progress Klondike Solitaire game.
|
||||
@@ -417,55 +418,52 @@ impl GameState {
|
||||
self.session.history().len()
|
||||
}
|
||||
|
||||
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<Card> {
|
||||
cards
|
||||
.into_iter()
|
||||
.map(|mut card| {
|
||||
card.face_up = face_up;
|
||||
card
|
||||
})
|
||||
.collect()
|
||||
fn cards_with_face(
|
||||
cards: impl IntoIterator<Item = Card>,
|
||||
face_up: bool,
|
||||
) -> Vec<(Card, bool)> {
|
||||
cards.into_iter().map(|card| (card, face_up)).collect()
|
||||
}
|
||||
|
||||
pub fn stock_cards(&self) -> Vec<Card> {
|
||||
pub fn stock_cards(&self) -> Vec<(Card, bool)> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(ref cards) = state.stock
|
||||
{
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), false)).collect();
|
||||
}
|
||||
let state = self.session.state().state().state();
|
||||
Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false)
|
||||
Self::cards_with_face(state.stock().face_down().iter().cloned(), false)
|
||||
}
|
||||
|
||||
pub fn waste_cards(&self) -> Vec<Card> {
|
||||
pub fn waste_cards(&self) -> Vec<(Card, bool)> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(ref cards) = state.waste
|
||||
{
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), true)).collect();
|
||||
}
|
||||
let state = self.session.state().state().state();
|
||||
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
|
||||
Self::cards_with_face(state.stock().face_up().iter().cloned(), true)
|
||||
}
|
||||
|
||||
/// Returns the cards in the requested pile.
|
||||
/// Returns the cards in the requested pile as `(card, face_up)` tuples.
|
||||
///
|
||||
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
|
||||
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
|
||||
/// to read the face-down draw cards.
|
||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||
pub fn pile(&self, pile: KlondikePile) -> Vec<(Card, bool)> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
if let Some(ref cards) = state.waste {
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), true)).collect();
|
||||
}
|
||||
}
|
||||
KlondikePile::Foundation(f) => {
|
||||
if let Some(cards) = state.foundation.get(&f) {
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), true)).collect();
|
||||
}
|
||||
}
|
||||
KlondikePile::Tableau(t) => {
|
||||
@@ -485,21 +483,13 @@ impl GameState {
|
||||
Foundation::Foundation3 => state.foundation3(),
|
||||
Foundation::Foundation4 => state.foundation4(),
|
||||
};
|
||||
Self::cards_with_face(cards.iter().map(card_from_kl), true)
|
||||
Self::cards_with_face(cards.iter().cloned(), true)
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => {
|
||||
let mut cards = Self::cards_with_face(
|
||||
state
|
||||
.tableau_face_down_cards(tableau)
|
||||
.iter()
|
||||
.map(card_from_kl),
|
||||
false,
|
||||
);
|
||||
let mut cards =
|
||||
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
||||
cards.extend(Self::cards_with_face(
|
||||
state
|
||||
.tableau_face_up_cards(tableau)
|
||||
.iter()
|
||||
.map(card_from_kl),
|
||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||
true,
|
||||
));
|
||||
cards
|
||||
@@ -515,7 +505,7 @@ impl GameState {
|
||||
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
|
||||
}
|
||||
|
||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<(Card, bool)>, MoveError> {
|
||||
let foundation = Self::foundation_from_slot(slot)?;
|
||||
Ok(self.pile(KlondikePile::Foundation(foundation)))
|
||||
}
|
||||
@@ -560,8 +550,20 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific tableau column.
|
||||
///
|
||||
/// All provided cards are treated as face-up. Use
|
||||
/// [`Self::set_test_tableau_cards_with_face`] when a test needs to place
|
||||
/// face-down cards.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
|
||||
let with_face = cards.into_iter().map(|c| (c, true)).collect();
|
||||
self.set_test_tableau_cards_with_face(tableau, with_face);
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific tableau column,
|
||||
/// specifying each card's face-up flag (`true` = face-up).
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_tableau_cards_with_face(&mut self, tableau: Tableau, cards: Vec<(Card, bool)>) {
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
@@ -578,21 +580,15 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific pile.
|
||||
///
|
||||
/// For `KlondikePile::Stock`, all provided cards go to the face-down stock
|
||||
/// override. Use [`Self::set_test_waste_cards`] to override the waste pile
|
||||
/// separately.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
let mut stock = Vec::new();
|
||||
let mut waste = Vec::new();
|
||||
for card in cards {
|
||||
if card.face_up {
|
||||
waste.push(card);
|
||||
} else {
|
||||
stock.push(card);
|
||||
}
|
||||
}
|
||||
self.set_test_stock_cards(stock);
|
||||
self.set_test_waste_cards(waste);
|
||||
self.set_test_stock_cards(cards);
|
||||
}
|
||||
KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
|
||||
KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
|
||||
@@ -612,7 +608,7 @@ impl GameState {
|
||||
if pile.is_empty() {
|
||||
return false;
|
||||
}
|
||||
pile.len() > count && !pile[pile.len() - count - 1].face_up
|
||||
pile.len() > count && !pile[pile.len() - count - 1].1
|
||||
}
|
||||
|
||||
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
|
||||
@@ -962,24 +958,24 @@ impl GameState {
|
||||
.is_instruction_valid(&config, instruction)
|
||||
}
|
||||
|
||||
/// Returns the current pile containing `card_id`, if any.
|
||||
pub fn pile_containing_card(&self, card_id: u32) -> Option<KlondikePile> {
|
||||
if self.stock_cards().iter().any(|card| card.id == card_id)
|
||||
|| self.waste_cards().iter().any(|card| card.id == card_id)
|
||||
/// Returns the current pile containing `card`, if any.
|
||||
pub fn pile_containing_card(&self, card: Card) -> Option<KlondikePile> {
|
||||
if self.stock_cards().iter().any(|(c, _)| *c == card)
|
||||
|| self.waste_cards().iter().any(|(c, _)| *c == card)
|
||||
{
|
||||
return Some(KlondikePile::Stock);
|
||||
}
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = Self::foundation_from_slot(slot).ok()?;
|
||||
let pile = self.pile(KlondikePile::Foundation(foundation));
|
||||
if pile.iter().any(|card| card.id == card_id) {
|
||||
if pile.iter().any(|(c, _)| *c == card) {
|
||||
return Some(KlondikePile::Foundation(foundation));
|
||||
}
|
||||
}
|
||||
for index in 0..7_usize {
|
||||
let tableau = Self::tableau_from_index(index).ok()?;
|
||||
let pile = self.pile(KlondikePile::Tableau(tableau));
|
||||
if pile.iter().any(|card| card.id == card_id) {
|
||||
if pile.iter().any(|(c, _)| *c == card) {
|
||||
return Some(KlondikePile::Tableau(tableau));
|
||||
}
|
||||
}
|
||||
@@ -1039,7 +1035,7 @@ mod tests {
|
||||
|
||||
for _ in 0..MAX_STEPS {
|
||||
let moves = game.possible_instructions();
|
||||
if let Some((from, to, _count)) = moves.iter().copied().find(|(from, to, count)| {
|
||||
if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| {
|
||||
*count == 1
|
||||
&& matches!(from, KlondikePile::Foundation(_))
|
||||
&& matches!(to, KlondikePile::Tableau(_))
|
||||
@@ -1047,7 +1043,7 @@ mod tests {
|
||||
return Some((game, from, to));
|
||||
}
|
||||
|
||||
if let Some((from, to, count)) = moves.iter().copied().find(|(from, to, count)| {
|
||||
if let Some((from, to, count)) = moves.iter().cloned().find(|(from, to, count)| {
|
||||
*count == 1
|
||||
&& !matches!(from, KlondikePile::Foundation(_))
|
||||
&& matches!(to, KlondikePile::Foundation(_))
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||
|
||||
use card_game::Card as KlCard;
|
||||
use klondike::{
|
||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
||||
@@ -17,7 +16,6 @@ use klondike::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::card;
|
||||
use crate::game_state::GameMode;
|
||||
|
||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||
@@ -210,28 +208,6 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
|
||||
/// derived from suit and rank (0–51, Clubs-first ordering).
|
||||
///
|
||||
/// The id is consistent for the same logical card across all reconstructions.
|
||||
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
|
||||
let suit = kl_card.suit();
|
||||
let rank = kl_card.rank();
|
||||
let suit_index = match suit {
|
||||
card::Suit::Clubs => 0,
|
||||
card::Suit::Diamonds => 1,
|
||||
card::Suit::Hearts => 2,
|
||||
card::Suit::Spades => 3,
|
||||
};
|
||||
let id = suit_index * 13 + (rank.value() as u32 - 1);
|
||||
card::Card {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
|
||||
//
|
||||
// These types were introduced when upstream `klondike` had no serde feature.
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod klondike_adapter;
|
||||
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
|
||||
// re-exported — they are only used internally in `klondike_adapter.rs` and do
|
||||
// not appear in any public method signature.
|
||||
pub use card_game::Session;
|
||||
pub use card_game::{Card, Session};
|
||||
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
||||
pub use klondike_adapter::DrawMode;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use card_game::Game;
|
||||
use card_game::{Card, Game};
|
||||
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
@@ -14,13 +14,13 @@ use crate::klondike_adapter::{
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collect all card IDs across every pile in a fixed traversal order:
|
||||
/// Collect all cards across every pile in a fixed traversal order:
|
||||
/// stock → waste → foundations 1–4 → tableaux 1–7.
|
||||
///
|
||||
/// The order is deterministic for a given game state, so two calls on
|
||||
/// equivalent states produce identical Vec outputs — the right fingerprint
|
||||
/// for undo-reversibility checks.
|
||||
fn all_card_ids(game: &GameState) -> Vec<u32> {
|
||||
fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
let foundations = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
@@ -37,19 +37,19 @@ fn all_card_ids(game: &GameState) -> Vec<u32> {
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
|
||||
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
|
||||
ids.extend(game.waste_cards().iter().map(|c| c.id));
|
||||
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
|
||||
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
|
||||
for f in &foundations {
|
||||
ids.extend(
|
||||
cards.extend(
|
||||
game.pile(KlondikePile::Foundation(*f))
|
||||
.iter()
|
||||
.map(|c| c.id),
|
||||
.map(|(c, _)| c.clone()),
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
|
||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||||
}
|
||||
ids
|
||||
cards
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
|
||||
@@ -170,13 +170,12 @@ proptest! {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
|
||||
let mut ids = all_card_ids(&game);
|
||||
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let cards = all_cards(&game);
|
||||
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
|
||||
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
|
||||
prop_assert_eq!(
|
||||
ids.len(), 52,
|
||||
"duplicate card IDs found after dedup — a card was cloned"
|
||||
unique.len(), 52,
|
||||
"duplicate cards found after dedup — a card was cloned"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,8 +192,8 @@ proptest! {
|
||||
let a = GameState::new(seed, draw_mode);
|
||||
let b = GameState::new(seed, draw_mode);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&a),
|
||||
all_card_ids(&b),
|
||||
all_cards(&a),
|
||||
all_cards(&b),
|
||||
"same seed + draw_mode produced different deals",
|
||||
);
|
||||
}
|
||||
@@ -218,7 +217,7 @@ proptest! {
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
// Snapshot the state before the move.
|
||||
let before_ids = all_card_ids(&game);
|
||||
let before_ids = all_cards(&game);
|
||||
let before_move_count = game.move_count;
|
||||
|
||||
// Apply one move.
|
||||
@@ -232,7 +231,7 @@ proptest! {
|
||||
"undo must succeed immediately after a successful move",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&game),
|
||||
all_cards(&game),
|
||||
before_ids,
|
||||
"pile layout after undo differs from the pre-move snapshot",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user