From 1438fd62651a5d57d6351ddcc55270870a326476 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 9 Jun 2026 17:45:34 -0700 Subject: [PATCH] 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; 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 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) --- solitaire_core/src/card.rs | 111 +----- solitaire_core/src/game_state.rs | 112 +++--- solitaire_core/src/klondike_adapter.rs | 24 -- solitaire_core/src/lib.rs | 2 +- solitaire_core/src/proptest_tests.rs | 37 +- solitaire_engine/src/auto_complete_plugin.rs | 9 +- .../src/card_animation/interaction.rs | 5 +- solitaire_engine/src/card_plugin.rs | 371 +++++++----------- solitaire_engine/src/cursor_plugin.rs | 23 +- solitaire_engine/src/events.rs | 14 +- solitaire_engine/src/feedback_anim_plugin.rs | 53 ++- solitaire_engine/src/game_plugin.rs | 103 ++--- solitaire_engine/src/hud_plugin.rs | 2 +- solitaire_engine/src/input_plugin.rs | 263 +++++-------- solitaire_engine/src/pending_hint.rs | 18 +- solitaire_engine/src/radial_menu.rs | 58 ++- solitaire_engine/src/replay_overlay/format.rs | 4 +- solitaire_engine/src/resources.rs | 5 +- solitaire_engine/src/selection_plugin.rs | 150 +++---- solitaire_engine/src/table_plugin.rs | 2 +- .../src/touch_selection_plugin.rs | 55 ++- solitaire_wasm/src/lib.rs | 50 ++- 22 files changed, 549 insertions(+), 922 deletions(-) diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs index 67af25e..f9c3a39 100644 --- a/solitaire_core/src/card.rs +++ b/solitaire_core/src/card.rs @@ -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}; diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index e3cebe4..107f649 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -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>, + pub stock: Option>, /// Override for face-up waste cards. `None` means "use session". - pub waste: Option>, + pub waste: Option>, /// Per-tableau overrides. Missing keys fall back to the session. - pub tableau: std::collections::HashMap>, + /// 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>, /// Per-foundation overrides. Missing keys fall back to the session. - pub foundation: std::collections::HashMap>, + pub foundation: std::collections::HashMap>, } /// 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, face_up: bool) -> Vec { - cards - .into_iter() - .map(|mut card| { - card.face_up = face_up; - card - }) - .collect() + fn cards_with_face( + cards: impl IntoIterator, + face_up: bool, + ) -> Vec<(Card, bool)> { + cards.into_iter().map(|card| (card, face_up)).collect() } - pub fn stock_cards(&self) -> Vec { + 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 { + 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 { + 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, MoveError> { + pub fn foundation_cards(&self, slot: u8) -> Result, 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) { + 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) { 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 { - 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 { + 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(_)) diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index ff67955..3db8362 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -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 { } } -/// 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. diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index bdee5ed..c323287 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -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; diff --git a/solitaire_core/src/proptest_tests.rs b/solitaire_core/src/proptest_tests.rs index 96b1424..b63ee75 100644 --- a/solitaire_core/src/proptest_tests.rs +++ b/solitaire_core/src/proptest_tests.rs @@ -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 { +fn all_cards(game: &GameState) -> Vec { let foundations = [ Foundation::Foundation1, Foundation::Foundation2, @@ -37,19 +37,19 @@ fn all_card_ids(game: &GameState) -> Vec { Tableau::Tableau7, ]; - let mut ids: Vec = game.stock_cards().iter().map(|c| c.id).collect(); - ids.extend(game.waste_cards().iter().map(|c| c.id)); + let mut cards: Vec = 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 { @@ -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 = 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", ); diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 2bc27cb..882e194 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -168,7 +168,7 @@ mod tests { use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; use solitaire_core::{Foundation, KlondikePile, Tableau}; - use solitaire_core::card::{Rank, Suit}; + use solitaire_core::card::{Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameState}; fn headless_app() -> App { @@ -207,12 +207,7 @@ mod tests { } g.set_test_tableau_cards( Tableau::Tableau1, - vec![solitaire_core::card::Card { - id: 7_001, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }], + vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); g.is_auto_completable = true; let expected = ( diff --git a/solitaire_engine/src/card_animation/interaction.rs b/solitaire_engine/src/card_animation/interaction.rs index 8d747c9..b9bc4a8 100644 --- a/solitaire_engine/src/card_animation/interaction.rs +++ b/solitaire_engine/src/card_animation/interaction.rs @@ -33,6 +33,7 @@ use std::collections::VecDeque; use bevy::prelude::*; use bevy::window::PrimaryWindow; +use solitaire_core::card::Card; use super::animation::CardAnimation; use super::tuning::AnimationTuning; @@ -210,12 +211,12 @@ pub(crate) fn apply_drag_visual( // Only lift cards that are in a *committed* drag. Pending drags (below // threshold) must stay at scale 1.0 to avoid visible premature lift. - let (dragged_ids, committed): (&[u32], bool) = drag + let (dragged_cards, committed): (&[Card], bool) = drag .as_ref() .map_or((&[], false), |d| (d.cards.as_slice(), d.committed)); for (_, card, mut transform) in &mut cards { - let is_active_drag = committed && dragged_ids.contains(&card.card_id); + let is_active_drag = committed && dragged_cards.contains(&card.card); let target_scale = if is_active_drag { drag_scale } else { 1.0 }; let current = transform.scale.x; let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 1e96371..dc0a75d 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -159,10 +159,10 @@ fn card_back_colour(selected_card_back: usize) -> Color { } } -/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`. -#[derive(Component, Debug, Clone, Copy)] +/// Marker component linking a Bevy entity to its `solitaire_core::Card`. +#[derive(Component, Debug, Clone)] pub struct CardEntity { - pub card_id: u32, + pub card: Card, } /// Marker for the text child inside a card entity. @@ -562,20 +562,21 @@ fn load_card_images(asset_server: Option>, mut commands: Comman /// available and falling back to a solid-colour sprite in tests. fn card_sprite( card: &Card, + face_up: bool, card_size: Vec2, back_colour: Color, card_images: Option<&CardImageSet>, selected_back: usize, ) -> Sprite { if let Some(set) = card_images { - let image = if card.face_up { - let suit_idx = match card.suit { + let image = if face_up { + let suit_idx = match card.suit() { Suit::Clubs => 0, Suit::Diamonds => 1, Suit::Hearts => 2, Suit::Spades => 3, }; - let rank_idx = match card.rank { + let rank_idx = match card.rank() { Rank::Ace => 0, Rank::Two => 1, Rank::Three => 2, @@ -613,7 +614,7 @@ fn card_sprite( // the suit glyph colour, applied by `text_colour`, not the face // background). Pre-Terminal this branch dispatched through a // separate `face_colour(card, color_blind)` helper. - let body_colour = if card.face_up { + let body_colour = if face_up { CARD_FACE_COLOUR } else { back_colour @@ -732,7 +733,7 @@ fn sync_cards( // top card's slide animation plays — it must never be visible to the player. // Without this, the buffer sits at waste_base uncovered during the animation // and its rank/suit peek behind the incoming card. - let waste_buffer_id: Option = { + let waste_buffer_id: Option = { let visible = match game.draw_mode { DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, @@ -741,10 +742,10 @@ fn sync_cards( (waste_cards.len() > visible) .then_some(waste_cards) .and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned()) - .map(|c| c.id) + .map(|(c, _face_up)| c) }; - // Map card_id -> (Entity, current_translation, anim_end) for in-place + // Map Card -> (Entity, current_translation, anim_end) for in-place // updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation` // is currently driving the card (e.g. a drag-rejection return tween). // @@ -755,19 +756,19 @@ fn sync_cards( // • end ≠ target → the game state has changed (e.g. a new game started // while the win-cascade was mid-flight); cancel the // stale `CardAnimation` and apply the new position. - let mut existing: HashMap)> = HashMap::new(); + let mut existing: HashMap)> = HashMap::new(); for (entity, marker, transform, anim) in entities.iter() { existing.insert( - marker.card_id, + marker.card.clone(), (entity, transform.translation, anim.map(|a| a.end)), ); } - let live_ids: HashSet = positions.iter().map(|(c, _, _)| c.id).collect(); + let live_ids: HashSet = positions.iter().map(|(c, _, _)| c.0.clone()).collect(); // Despawn any entity whose card is no longer tracked. - for (card_id, (entity, _, _)) in &existing { - if !live_ids.contains(card_id) { + for (card, (entity, _, _)) in &existing { + if !live_ids.contains(card) { commands.entity(*entity).despawn(); } } @@ -775,8 +776,8 @@ fn sync_cards( // For each card in the current state: spawn or update its entity, then // apply visibility. The waste buffer card is hidden so it cannot peek // behind the incoming top card during the draw slide animation. - for (card, position, z) in positions { - let entity = match existing.get(&card.id) { + for ((card, face_up), position, z) in positions { + let entity = match existing.get(&card) { Some(&(entity, cur, anim_end)) => { // If a CardAnimation is in flight, check whether its destination // still matches the game-state target. If the game moved the card @@ -794,6 +795,7 @@ fn sync_cards( &mut commands, entity, &card, + face_up, position, z, layout, @@ -812,6 +814,7 @@ fn sync_cards( None => spawn_card_entity( &mut commands, &card, + face_up, position, z, layout, @@ -823,7 +826,7 @@ fn sync_cards( font_handle, ), }; - let visibility = if waste_buffer_id == Some(card.id) { + let visibility = if waste_buffer_id.as_ref() == Some(&card) { Visibility::Hidden } else { Visibility::Inherited @@ -832,9 +835,9 @@ fn sync_cards( } } -/// Returns an ordered vec of (card, position, z) for every card in the game. -fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { - let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52); +/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game. +fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> { + let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52); let piles = [ (KlondikePile::Stock, true), (KlondikePile::Stock, false), @@ -914,7 +917,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { let mut y_offset = 0.0_f32; let rendered_len = cards[render_start..].len(); - for (slot, card) in cards[render_start..].iter().enumerate() { + for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() { let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { // When len > visible, slot 0 is a hidden buffer card kept at // x=0 to prevent a flash during the draw tween. When len ≤ @@ -928,9 +931,9 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { }; let pos = Vec2::new(base.x + x_offset, base.y + y_offset); let z = 1.0 + (slot as f32) * STACK_FAN_FRAC; - out.push((card.clone(), pos, z)); + out.push(((card.clone(), *face_up), pos, z)); if is_tableau { - let step = if card.face_up { + let step = if *face_up { layout.tableau_fan_frac } else { layout.tableau_facedown_fan_frac @@ -942,8 +945,8 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { out } -fn all_cards(game: &GameState) -> Vec { - let mut cards = Vec::with_capacity(52); +fn all_cards(game: &GameState) -> Vec<(Card, bool)> { + let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52); cards.extend(game.stock_cards()); cards.extend(game.waste_cards()); for foundation in [ @@ -972,6 +975,7 @@ fn all_cards(game: &GameState) -> Vec { fn spawn_card_entity( commands: &mut Commands, card: &Card, + face_up: bool, pos: Vec2, z: f32, layout: &Layout, @@ -984,6 +988,7 @@ fn spawn_card_entity( ) -> Entity { let sprite = card_sprite( card, + face_up, layout.card_size, back_colour, card_images, @@ -991,7 +996,7 @@ fn spawn_card_entity( ); let mut entity = commands.spawn(( - CardEntity { card_id: card.id }, + CardEntity { card: card.clone() }, sprite, Transform::from_xyz(pos.x, pos.y, z), Visibility::default(), @@ -1024,7 +1029,7 @@ fn spawn_card_entity( }, TextColor(text_colour(card, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), - label_visibility(card), + label_visibility(face_up), )); }); } @@ -1033,6 +1038,7 @@ fn spawn_card_entity( add_android_corner_label( b, card, + face_up, layout.card_size, color_blind, high_contrast, @@ -1048,6 +1054,7 @@ fn update_card_entity( commands: &mut Commands, entity: Entity, card: &Card, + face_up: bool, pos: Vec2, z: f32, layout: &Layout, @@ -1066,6 +1073,7 @@ fn update_card_entity( // Always refresh the visual appearance. commands.entity(entity).insert(card_sprite( card, + face_up, layout.card_size, back_colour, card_images, @@ -1126,7 +1134,7 @@ fn update_card_entity( }, TextColor(text_colour(card, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), - label_visibility(card), + label_visibility(face_up), )); }); } @@ -1135,6 +1143,7 @@ fn update_card_entity( add_android_corner_label( b, card, + face_up, layout.card_size, color_blind, high_contrast, @@ -1145,7 +1154,7 @@ fn update_card_entity( } fn label_for(card: &Card) -> String { - let rank = match card.rank { + let rank = match card.rank() { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", @@ -1160,7 +1169,7 @@ fn label_for(card: &Card) -> String { Rank::Queen => "Q", Rank::King => "K", }; - let suit = match card.suit { + let suit = match card.suit() { Suit::Clubs => "C", Suit::Diamonds => "D", Suit::Hearts => "H", @@ -1188,7 +1197,7 @@ fn label_for(card: &Card) -> String { /// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art /// and has no constant-fallback equivalent. fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color { - if card.suit.is_red() { + if card.suit().is_red() { if color_blind { // CBM lime wins — the colour-blind swap replaces the // red hue entirely, and the lime is already high- @@ -1206,8 +1215,8 @@ fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color { } } -fn label_visibility(card: &Card) -> Visibility { - if card.face_up { +fn label_visibility(face_up: bool) -> Visibility { + if face_up { Visibility::Inherited } else { Visibility::Hidden @@ -1217,7 +1226,7 @@ fn label_visibility(card: &Card) -> Visibility { /// Rank+suit string for the readability overlay on touch HUD layouts. /// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono). fn mobile_label_for(card: &Card) -> String { - let rank = match card.rank { + let rank = match card.rank() { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", @@ -1232,7 +1241,7 @@ fn mobile_label_for(card: &Card) -> String { Rank::Queen => "Q", Rank::King => "K", }; - let suit = match card.suit { + let suit = match card.suit() { Suit::Clubs => "♣", Suit::Diamonds => "♦", Suit::Hearts => "♥", @@ -1254,12 +1263,13 @@ fn mobile_label_for(card: &Card) -> String { fn add_android_corner_label( parent: &mut ChildSpawnerCommands, card: &Card, + face_up: bool, card_size: Vec2, color_blind: bool, high_contrast: bool, font_handle: Option<&Handle>, ) { - if !card.face_up { + if !face_up { return; } let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE; @@ -1309,7 +1319,7 @@ fn add_android_corner_label( // red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a) // rather than the near-white BLACK_SUIT_COLOUR designed for the dark // Terminal theme background. - let text_col = if card.suit.is_red() { + let text_col = if card.suit().is_red() { if color_blind { RED_SUIT_COLOUR_CBM } else if high_contrast { @@ -1355,9 +1365,9 @@ fn start_flip_anim( return; } - for CardFlippedEvent(card_id) in events.read() { + for CardFlippedEvent(flipped_card) in events.read() { for (entity, marker) in &card_entities { - if marker.card_id == *card_id { + if marker.card == *flipped_card { commands.entity(entity).insert(CardFlipAnim { timer: 0.0, phase: FlipPhase::ScalingDown, @@ -1394,7 +1404,7 @@ fn tick_flip_anim( transform.scale.x = 0.0; // Fire the reveal event exactly once, at the phase transition, // so the flip sound is synchronised with the visual face reveal. - reveal_events.write(CardFaceRevealedEvent(card_entity.card_id)); + reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone())); } } FlipPhase::ScalingUp => { @@ -1438,11 +1448,10 @@ fn update_drag_shadow( let card_h = layout.0.card_size.y; // Find the world position of the first (top) dragged card. - let first_id = drag.cards.first().copied(); - let top_pos = first_id.and_then(|id| { + let top_pos = drag.cards.first().and_then(|first_card| { card_entities .iter() - .find(|(marker, _)| marker.card_id == id) + .find(|(marker, _)| marker.card == *first_card) .map(|(_, t)| t.translation) }); @@ -1498,10 +1507,10 @@ fn update_card_shadows_on_drag( cards: Query<(&CardEntity, &Sprite, &Children), Without>, mut shadows: Query<(&mut Sprite, &mut Transform), With>, ) { - let dragged: HashSet = drag.cards.iter().copied().collect(); + let dragged: HashSet<&Card> = drag.cards.iter().collect(); for (card_entity, card_sprite, children) in cards.iter() { - let is_dragged = dragged.contains(&card_entity.card_id); + let is_dragged = dragged.contains(&card_entity.card); let (offset, padding, alpha) = card_shadow_params(is_dragged); let Some(card_size) = card_sprite.custom_size else { continue; @@ -1548,8 +1557,8 @@ fn tick_hint_highlight( } else { let is_face_up = all_cards(&game.0) .iter() - .find(|c| c.id == card_entity.card_id) - .is_some_and(|c| c.face_up); + .find(|(c, _face_up)| *c == card_entity.card) + .is_some_and(|(_, face_up)| *face_up); if is_face_up { CARD_FACE_COLOUR } else { @@ -1718,7 +1727,7 @@ fn handle_right_click( return; }; - let Some(source_pile) = game.0.pile_containing_card(card.id) else { + let Some(source_pile) = game.0.pile_containing_card(card.clone()) else { return; }; @@ -1766,10 +1775,10 @@ fn find_top_card_at( { continue; } - let card = all_cards(game) + let found = all_cards(game) .into_iter() - .find(|c| c.id == card_entity.card_id && c.face_up); - if let Some(card) = card { + .find(|(c, face_up)| *c == card_entity.card && *face_up); + if let Some((card, _)) = found { let z = transform.translation.z; if best.as_ref().is_none_or(|(bz, _)| z > *bz) { best = Some((z, card)); @@ -2236,13 +2245,13 @@ fn resize_cards_in_place( >, ) { let positions = card_positions(game, layout); - let pos_by_id: HashMap = positions + let pos_by_id: HashMap = positions .into_iter() - .map(|(c, p, z)| (c.id, (p, z))) + .map(|((c, _face_up), p, z)| (c, (p, z))) .collect(); for (entity, marker, mut sprite, mut transform) in entities.iter_mut() { - let Some(&(pos, z)) = pos_by_id.get(&marker.card_id) else { + let Some(&(pos, z)) = pos_by_id.get(&marker.card) else { continue; }; sprite.custom_size = Some(layout.card_size); @@ -2369,7 +2378,7 @@ fn update_tableau_fan_frac( game.0 .pile(solitaire_core::KlondikePile::Tableau(tableau)) .into_iter() - .filter(|c| c.face_up) + .filter(|(_, face_up)| *face_up) .count() }) .max() @@ -2408,6 +2417,12 @@ mod tests { use super::*; use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; + use solitaire_core::card::Deck; + + /// Convenience constructor — all unit tests use Deck1. + fn make_card(suit: Suit, rank: Rank) -> Card { + Card::new(Deck::Deck1, suit, rank) + } fn app() -> App { let mut app = App::new(); @@ -2421,58 +2436,28 @@ mod tests { #[test] fn label_for_ace_of_hearts_is_ah() { - let c = Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }; + let c = make_card(Suit::Hearts, Rank::Ace); assert_eq!(label_for(&c), "AH"); } #[test] fn label_for_ten_of_clubs_is_10c() { - let c = Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::Ten, - face_up: true, - }; + let c = make_card(Suit::Clubs, Rank::Ten); assert_eq!(label_for(&c), "10C"); } #[test] fn text_colour_is_red_for_hearts_and_diamonds() { - let h = Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }; - let d = Card { - id: 0, - suit: Suit::Diamonds, - rank: Rank::Ace, - face_up: true, - }; + let h = make_card(Suit::Hearts, Rank::Ace); + let d = make_card(Suit::Diamonds, Rank::Ace); assert_eq!(text_colour(&h, false, false), RED_SUIT_COLOUR); assert_eq!(text_colour(&d, false, false), RED_SUIT_COLOUR); } #[test] fn text_colour_is_near_white_for_clubs_and_spades() { - let c = Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }; - let s = Card { - id: 0, - suit: Suit::Spades, - rank: Rank::Ace, - face_up: true, - }; + let c = make_card(Suit::Clubs, Rank::Ace); + let s = make_card(Suit::Spades, Rank::Ace); assert_eq!(text_colour(&c, false, false), BLACK_SUIT_COLOUR); assert_eq!(text_colour(&s, false, false), BLACK_SUIT_COLOUR); } @@ -2540,8 +2525,8 @@ mod tests { for _ in 0..3 { let _ = g.draw(); } - let waste_ids: std::collections::HashSet = - g.waste_cards().iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.0.clone()).collect(); assert_eq!(waste_ids.len(), 3); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); @@ -2550,7 +2535,7 @@ mod tests { // Filter rendered positions to only waste cards (by card ID). let waste_rendered: Vec<_> = positions .iter() - .filter(|(card, _, _)| waste_ids.contains(&card.id)) + .filter(|(card, _, _)| waste_ids.contains(&card.0)) .collect(); // Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to // prevent the evicted card from flashing during the draw tween). @@ -2563,9 +2548,9 @@ mod tests { "at least the top waste card must be rendered" ); // The top (last) waste card must always be among the rendered cards. - let top_id = g.waste_cards().last().unwrap().id; + let top_id = g.waste_cards().last().unwrap().0.clone(); assert!( - waste_rendered.iter().any(|(c, _, _)| c.id == top_id), + waste_rendered.iter().any(|(c, _, _)| c.0 == top_id), "top waste card must be rendered" ); } @@ -2584,14 +2569,15 @@ mod tests { "need at least 3 waste cards for this test" ); - let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + waste_pile.iter().map(|c| c.0.clone()).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let mut waste_rendered: Vec<_> = positions .iter() - .filter(|(card, _, _)| waste_ids.contains(&card.id)) + .filter(|(card, _, _)| waste_ids.contains(&card.0)) .collect(); // Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to // prevent the evicted card from flashing during the draw tween). @@ -2616,8 +2602,8 @@ mod tests { ); } // Top card (rightmost by x) must be the last card in the waste pile. - let top_id = waste_pile.last().unwrap().id; - assert_eq!(waste_rendered.last().unwrap().0.id, top_id); + let top_id = waste_pile.last().unwrap().0.clone(); + assert_eq!(waste_rendered.last().unwrap().0.0, top_id); } #[test] @@ -2636,13 +2622,14 @@ mod tests { let count = waste_pile.len(); assert!(count >= 2, "need at least 2 waste cards"); - let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + waste_pile.iter().map(|c| c.0.clone()).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let mut waste_rendered: Vec<_> = positions .iter() - .filter(|(card, _, _)| waste_ids.contains(&card.id)) + .filter(|(card, _, _)| waste_ids.contains(&card.0)) .collect(); // All waste cards should be visible (no hidden buffer when len ≤ visible). assert_eq!( @@ -2673,13 +2660,13 @@ mod tests { for _ in 0..3 { let _ = g.draw(); } - let waste_ids: std::collections::HashSet = - g.waste_cards().iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.0.clone()).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let waste_rendered: Vec<_> = positions .iter() - .filter(|(card, _, _)| waste_ids.contains(&card.id)) + .filter(|(card, _, _)| waste_ids.contains(&card.0)) .collect(); // Buffer (slot 0) + top (slot 1) = 2 rendered waste cards. assert_eq!( @@ -2883,12 +2870,7 @@ mod tests { #[test] fn text_colour_color_blind_mode_swaps_red_suits_to_lime() { - let red_card = Card { - id: 0, - suit: Suit::Diamonds, - rank: Rank::Queen, - face_up: true, - }; + let red_card = make_card(Suit::Diamonds, Rank::Queen); let cbm_colour = text_colour(&red_card, true, false); assert_eq!( cbm_colour, RED_SUIT_COLOUR_CBM, @@ -2902,12 +2884,7 @@ mod tests { #[test] fn text_colour_color_blind_mode_does_not_change_dark_suits() { - let black_card = Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::Jack, - face_up: true, - }; + let black_card = make_card(Suit::Clubs, Rank::Jack); assert_eq!( text_colour(&black_card, true, false), BLACK_SUIT_COLOUR, @@ -2928,12 +2905,7 @@ mod tests { #[test] fn text_colour_high_contrast_boosts_red_suits_to_hc_red() { - let red_card = Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Five, - face_up: true, - }; + let red_card = make_card(Suit::Hearts, Rank::Five); assert_eq!( text_colour(&red_card, false, true), RED_SUIT_COLOUR_HC, @@ -2948,12 +2920,7 @@ mod tests { #[test] fn text_colour_high_contrast_boosts_black_suits_to_hc_white() { - let black_card = Card { - id: 0, - suit: Suit::Spades, - rank: Rank::Two, - face_up: true, - }; + let black_card = make_card(Suit::Spades, Rank::Two); assert_eq!( text_colour(&black_card, false, true), TEXT_PRIMARY_HC, @@ -2967,12 +2934,7 @@ mod tests { // the CBM lime is itself a high-luminance accent and the HC // boost would pick a different hue, defeating the purpose of // the colour-blind swap. - let red_card = Card { - id: 0, - suit: Suit::Diamonds, - rank: Rank::Ace, - face_up: true, - }; + let red_card = make_card(Suit::Diamonds, Rank::Ace); assert_eq!( text_colour(&red_card, true, true), RED_SUIT_COLOUR_CBM, @@ -2984,12 +2946,7 @@ mod tests { fn text_colour_high_contrast_alone_boosts_dark_suits_under_cbm() { // CBM doesn't touch the dark suits, so HC remains the only // source of variation for the dark row when both are on. - let black_card = Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::King, - face_up: true, - }; + let black_card = make_card(Suit::Clubs, Rank::King); assert_eq!( text_colour(&black_card, true, true), TEXT_PRIMARY_HC, @@ -3003,24 +2960,12 @@ mod tests { #[test] fn label_visibility_face_up_is_inherited() { - let card = Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }; - assert_eq!(label_visibility(&card), Visibility::Inherited); + assert_eq!(label_visibility(true), Visibility::Inherited); } #[test] fn label_visibility_face_down_is_hidden() { - let card = Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: false, - }; - assert_eq!(label_visibility(&card), Visibility::Hidden); + assert_eq!(label_visibility(false), Visibility::Hidden); } // ----------------------------------------------------------------------- @@ -3032,12 +2977,7 @@ mod tests { let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let letters = ["C", "D", "H", "S"]; for (suit, letter) in suits.iter().zip(letters.iter()) { - let card = Card { - id: 0, - suit: *suit, - rank: Rank::King, - face_up: true, - }; + let card = make_card(*suit, Rank::King); assert!( label_for(&card).ends_with(letter), "label for {suit:?} must end with '{letter}'" @@ -3047,12 +2987,7 @@ mod tests { #[test] fn label_for_face_cards_use_letter_prefix() { - let make = |rank| Card { - id: 0, - suit: Suit::Spades, - rank, - face_up: true, - }; + let make = |rank| make_card(Suit::Spades, rank); assert!(label_for(&make(Rank::Jack)).starts_with('J')); assert!(label_for(&make(Rank::Queen)).starts_with('Q')); assert!(label_for(&make(Rank::King)).starts_with('K')); @@ -3060,12 +2995,7 @@ mod tests { #[test] fn label_for_numeric_ranks_two_through_nine() { - let make = |rank| Card { - id: 0, - suit: Suit::Clubs, - rank, - face_up: true, - }; + let make = |rank| make_card(Suit::Clubs, rank); let expected = [ (Rank::Two, "2C"), (Rank::Three, "3C"), @@ -3091,12 +3021,7 @@ mod tests { ]; for (suit, rank, expected) in cases { - let card = Card { - id: 0, - suit, - rank, - face_up: true, - }; + let card = make_card(suit, rank); assert_eq!(mobile_label_for(&card), expected); } } @@ -3380,33 +3305,33 @@ mod tests { fn shadow_offset_increases_during_drag() { let mut app = app(); - // Pick any spawned card id and stage it in DragState. - let card_id: u32 = { + // Pick any spawned card and stage it in DragState. + let card: Card = { let mut q = app.world_mut().query::<&CardEntity>(); q.iter(app.world()) .next() .expect("fixture should spawn at least one CardEntity") - .card_id + .card.clone() }; - // Pick a *different* card id to act as the negative control — + // Pick a *different* card to act as the negative control — // its shadow must remain at the idle offset. - let other_id: u32 = { + let other_card: Card = { let mut q = app.world_mut().query::<&CardEntity>(); q.iter(app.world()) - .map(|c| c.card_id) - .find(|id| *id != card_id) + .map(|c| c.card.clone()) + .find(|c| *c != card) .expect("fixture should spawn more than one CardEntity") }; // Stage the drag and run one Update so `update_card_shadows_on_drag` // sees the new DragState. - app.world_mut().resource_mut::().cards = vec![card_id]; + app.world_mut().resource_mut::().cards = vec![card.clone()]; app.update(); - // Find the shadow whose parent's CardEntity matches `card_id`. - let dragged_shadow_offset = shadow_offset_for_card(&mut app, card_id); - let other_shadow_offset = shadow_offset_for_card(&mut app, other_id); + // Find the shadow whose parent's CardEntity matches `card`. + let dragged_shadow_offset = shadow_offset_for_card(&mut app, &card); + let other_shadow_offset = shadow_offset_for_card(&mut app, &other_card); let drag_off = CARD_SHADOW_OFFSET_DRAG; let idle_off = CARD_SHADOW_OFFSET_IDLE; @@ -3428,7 +3353,7 @@ mod tests { // offset on the next frame. app.world_mut().resource_mut::().clear(); app.update(); - let after_clear = shadow_offset_for_card(&mut app, card_id); + let after_clear = shadow_offset_for_card(&mut app, &card); assert!( (after_clear.x - idle_off.x).abs() < 1e-3 && (after_clear.y - idle_off.y).abs() < 1e-3, "shadow must snap back to idle offset after drag clears \ @@ -3436,18 +3361,18 @@ mod tests { ); } - /// Helper: given a `card_id`, returns the world-space offset (x, y) of + /// Helper: given a `card`, returns the world-space offset (x, y) of /// its `CardShadow` child relative to the parent card's origin. - fn shadow_offset_for_card(app: &mut App, card_id: u32) -> Vec2 { - // Map every CardEntity to its (Entity, card_id). + fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 { + // Map every CardEntity to its (Entity, card). let card_entity = { let mut q = app .world_mut() .query::<(bevy::prelude::Entity, &CardEntity)>(); q.iter(app.world()) - .find(|(_, c)| c.card_id == card_id) + .find(|(_, c)| c.card == *card) .map(|(e, _)| e) - .expect("card_id not found in spawned CardEntity set") + .expect("card not found in spawned CardEntity set") }; let mut q = app @@ -3458,7 +3383,7 @@ mod tests { return Vec2::new(transform.translation.x, transform.translation.y); } } - panic!("no CardShadow child found for card_id {card_id}"); + panic!("no CardShadow child found for card {card:?}"); } // ----------------------------------------------------------------------- @@ -3538,7 +3463,8 @@ mod tests { assert_eq!(stock_badge_text(&mut app), "24"); { let mut game = app.world_mut().resource_mut::(); - let mut stock = game.0.stock_cards(); + let mut stock: Vec = + game.0.stock_cards().into_iter().map(|(c, _)| c).collect(); let _ = stock.pop(); game.0.set_test_stock_cards(stock); } @@ -3592,15 +3518,11 @@ mod tests { let theme_back: Handle = images.add(bevy::image::Image::default()); set.theme_back = Some(theme_back.clone()); - let face_down = Card { - id: 0, - suit: Suit::Spades, - rank: Rank::Ace, - face_up: false, - }; + let face_down = make_card(Suit::Spades, Rank::Ace); // Pick a non-zero legacy back so we'd notice if it leaked through. let sprite = card_sprite( &face_down, + false, Vec2::new(80.0, 112.0), card_back_colour(2), Some(&set), @@ -3627,15 +3549,11 @@ mod tests { "fixture starts with no theme back" ); - let face_down = Card { - id: 0, - suit: Suit::Spades, - rank: Rank::Ace, - face_up: false, - }; + let face_down = make_card(Suit::Spades, Rank::Ace); for selected_back in 0..5 { let sprite = card_sprite( &face_down, + false, Vec2::new(80.0, 112.0), card_back_colour(selected_back), Some(&set), @@ -3804,12 +3722,7 @@ mod tests { #[test] fn text_colour_black_suits_are_near_white_not_red() { for suit in [Suit::Clubs, Suit::Spades] { - let card = Card { - id: 0, - suit, - rank: Rank::Ace, - face_up: true, - }; + let card = make_card(suit, Rank::Ace); let colour = text_colour(&card, false, false); assert_eq!( colour, BLACK_SUIT_COLOUR, @@ -3845,12 +3758,12 @@ mod tests { let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); - let waste_ids: std::collections::HashSet = - g.waste_cards().iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_zs: Vec = positions .iter() - .filter(|(c, _, _)| waste_ids.contains(&c.id)) + .filter(|(c, _, _)| waste_ids.contains(&c.0)) .map(|(_, _, z)| *z) .collect(); waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap()); @@ -3895,20 +3808,20 @@ mod tests { let stock_x = layout.pile_positions[&KlondikePile::Stock].x; - let waste_ids: std::collections::HashSet = - g.waste_cards().iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_positions: Vec<_> = card_positions(&g, &layout) .into_iter() - .filter(|(c, _, _)| waste_ids.contains(&c.id)) + .filter(|(c, _, _)| waste_ids.contains(&c.0)) .collect(); waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap()); let visible_count = waste_positions.len().min(3); for (card, pos, _) in waste_positions.iter().rev().take(visible_count) { assert!( pos.x >= stock_x - 1e-3, - "waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window", - card.id, + "waste card {:?} x {:.2} drifted left of stock origin {:.2} on portrait window", + card.0, pos.x, stock_x, ); @@ -3925,12 +3838,12 @@ mod tests { let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); - let waste_ids: std::collections::HashSet = - g.waste_cards().iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = + g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_zs: Vec = positions .iter() - .filter(|(c, _, _)| waste_ids.contains(&c.id)) + .filter(|(c, _, _)| waste_ids.contains(&c.0)) .map(|(_, _, z)| *z) .collect(); waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap()); @@ -3943,7 +3856,7 @@ mod tests { // Deduplicated length must equal pre-dedup length → all z distinct. let raw_count = positions .iter() - .filter(|(c, _, _)| waste_ids.contains(&c.id)) + .filter(|(c, _, _)| waste_ids.contains(&c.0)) .count(); assert_eq!( waste_zs.len(), diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index b7a2b45..c0f0038 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -34,6 +34,7 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; +use solitaire_core::card::Card; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{DrawMode, game_state::GameState}; @@ -185,7 +186,7 @@ fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> boo let base = layout.pile_positions[&pile]; for (i, card) in pile_cards.iter().enumerate().rev() { - if !card.face_up { + if !card.1 { continue; } // Only the topmost card is draggable on non-tableau piles. @@ -446,7 +447,7 @@ fn tableau_or_stack_pos( } } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { if matches!(pile, KlondikePile::Stock) { game.waste_cards() } else { @@ -579,7 +580,7 @@ mod tests { // ----------------------------------------------------------------------- use crate::layout::compute_layout; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::{GameMode, GameState}}; /// Builds an `App` with `MinimalPlugins` and the overlay system @@ -618,7 +619,7 @@ mod tests { game.0.set_test_waste_cards(vec![dragged.clone()]); } let mut drag = app.world_mut().resource_mut::(); - drag.cards = vec![dragged.id]; + drag.cards = vec![dragged]; drag.origin_pile = Some(KlondikePile::Stock); drag.committed = true; } @@ -632,19 +633,9 @@ mod tests { set_tableau_top( &mut game, 2, - Card { - id: 9101, - suit: Suit::Clubs, - rank: Rank::Six, - face_up: true, - }, + Card::new(Deck::Deck1, Suit::Clubs, Rank::Six), ); - let dragged = Card { - id: 9102, - suit: Suit::Spades, - rank: Rank::Five, - face_up: true, - }; + let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let mut app = overlay_test_app(game); begin_drag_with(&mut app, dragged); diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 5ad69a0..3bcd8bb 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -2,7 +2,7 @@ use bevy::prelude::Message; use solitaire_core::KlondikePile; -use solitaire_core::card::Suit; +use solitaire_core::card::{Card, Suit}; use solitaire_core::game_state::GameMode; use solitaire_data::AchievementRecord; use solitaire_sync::SyncResponse; @@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent { } /// Fired when a card's face-up state changes during gameplay. -#[derive(Message, Debug, Clone, Copy)] -pub struct CardFlippedEvent(pub u32); +#[derive(Message, Debug, Clone)] +pub struct CardFlippedEvent(pub Card); /// Fired by the flip animation at its midpoint — the instant the card face /// becomes visible (scale.x crosses zero and the phase switches to ScalingUp). @@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32); /// Audio systems should listen to this event rather than `CardFlippedEvent` /// so the flip sound is synchronised with the visual reveal, not the move /// that triggered the animation. -#[derive(Message, Debug, Clone, Copy)] -pub struct CardFaceRevealedEvent(pub u32); +#[derive(Message, Debug, Clone)] +pub struct CardFaceRevealedEvent(pub Card); /// Achievement unlocked notification carrying the full `AchievementRecord` for /// the newly unlocked achievement. Consumed by the toast renderer and any @@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent; /// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s). #[derive(Message, Debug, Clone)] pub struct HintVisualEvent { - /// The `Card::id` of the source card to be highlighted. - pub source_card_id: u32, + /// The source card to be highlighted. + pub source_card: Card, /// The destination pile whose `PileMarker` should be tinted gold. pub dest_pile: KlondikePile, } diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index c20cb1e..bcb2f24 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -43,6 +43,7 @@ use std::hash::{Hash, Hasher}; use bevy::prelude::*; use bevy::window::RequestRedraw; +use solitaire_core::card::Card; use solitaire_core::{Foundation, KlondikePile}; use solitaire_data::AnimSpeed; @@ -187,6 +188,20 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 { (jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 % } +/// Converts a `Card` to a `u32` seed suitable for deterministic per-card +/// jitter. Uses suit index × 13 + (rank value − 1) to produce a stable 0–51 +/// integer that survives changes to the internal `Card` representation. +fn card_to_id(card: &Card) -> u32 { + use solitaire_core::card::Suit; + let suit_index = match card.suit() { + Suit::Clubs => 0, + Suit::Diamonds => 1, + Suit::Hearts => 2, + Suit::Spades => 3, + }; + suit_index * 13 + (card.rank().value() as u32 - 1) +} + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -245,16 +260,16 @@ fn start_shake_anim( continue; } let dest_pile = &ev.to; - // Collect the card ids that belong to the destination pile. + // Collect the cards that belong to the destination pile. let dest_cards = pile_cards(&game.0, dest_pile); - let dest_card_ids: Vec = dest_cards.iter().map(|c| c.id).collect(); + let dest_card_set: Vec = dest_cards.iter().map(|(c, _)| c.clone()).collect(); - if dest_card_ids.is_empty() { + if dest_card_set.is_empty() { continue; } for (entity, card_marker, transform) in card_entities.iter() { - if dest_card_ids.contains(&card_marker.card_id) { + if dest_card_set.contains(&card_marker.card) { commands.entity(entity).insert(ShakeAnim { elapsed: 0.0, origin_x: transform.translation.x, @@ -311,27 +326,27 @@ fn start_settle_anim( card_entities: Query<(Entity, &CardEntity)>, mut commands: Commands, ) { - // Build the list of card ids that should bounce this frame from every + // Build the list of cards that should bounce this frame from every // queued request; multiple events can fire in the same frame (e.g. a move // followed by a draw via keyboard accelerators). - let mut bounce_ids: Vec = Vec::new(); + let mut bounce_ids: Vec = Vec::new(); for ev in moves.read() { let pile = pile_cards(&game.0, &ev.to); if !pile.is_empty() { - // The moved cards land on top — take the last `count` ids. + // The moved cards land on top — take the last `count` cards. let n = ev.count.min(pile.len()); if n > 0 { let start = pile.len() - n; - bounce_ids.extend(pile[start..].iter().map(|c| c.id)); + bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone())); } } } if draws.read().next().is_some() - && let Some(top) = game.0.waste_cards().last() + && let Some((top, _)) = game.0.waste_cards().last() { - bounce_ids.push(top.id); + bounce_ids.push(top.clone()); } if bounce_ids.is_empty() { @@ -339,7 +354,7 @@ fn start_settle_anim( } for (entity, card_marker) in card_entities.iter() { - if bounce_ids.contains(&card_marker.card_id) { + if bounce_ids.contains(&card_marker.card) { commands.entity(entity).insert(SettleAnim::default()); } } @@ -410,7 +425,7 @@ fn start_deal_anim( // ±10 % jitter, deterministic per card id, so the deal feels organic // without losing reproducibility (a given seed still produces the // same per-card stagger pattern across runs). - let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id)); + let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card))); commands.entity(entity).insert(( Transform::from_translation(stock_start.with_z(final_pos.z)), CardAnim { @@ -524,13 +539,13 @@ fn start_foundation_flourish( let pile_type = KlondikePile::Foundation(foundation); // Top card of the completed foundation is the King. let cards = game.0.pile(pile_type); - let Some(king_id) = cards.last().map(|c| c.id) else { + let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else { continue; }; // Tag the King's card entity. for (entity, card_marker) in card_entities.iter() { - if card_marker.card_id == king_id { + if card_marker.card == king_card { commands.entity(entity).insert(FoundationFlourish { foundation_slot: ev.slot, elapsed: 0.0, @@ -633,7 +648,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color { fn pile_cards( game: &solitaire_core::game_state::GameState, pile: &KlondikePile, -) -> Vec { +) -> Vec<(solitaire_core::card::Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -865,19 +880,19 @@ mod tests { // Pick a card from Tableau(0) so the event refers to a real pile. let dest_pile = KlondikePile::Tableau(Tableau::Tableau1); - let card_id = app + let card = app .world() .resource::() .0 .pile(dest_pile) .last() - .map(|c| c.id) + .map(|(c, _)| c.clone()) .expect("Tableau(0) should have at least one card in a fresh game"); - // Spawn a minimal CardEntity matching that id so the system would + // Spawn a minimal CardEntity matching that card so the system would // find it and insert ShakeAnim if the gate were absent. app.world_mut() - .spawn((CardEntity { card_id }, Transform::default())); + .spawn((CardEntity { card }, Transform::default())); app.world_mut() .resource_mut::>() diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index b760eba..0f3ed05 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -818,7 +818,7 @@ fn handle_draw( // so we can fire flip events after they land face-up in the waste. // Only relevant when stock is non-empty; a recycle moves waste back to // stock face-down, so no flip events are needed in that case. - let drawn_ids: Vec = { + let drawn_cards: Vec = { let stock = game.0.stock_cards(); if stock.is_empty() { Vec::new() @@ -829,15 +829,15 @@ fn handle_draw( }; let n = stock.len(); let take = n.min(draw_count); - stock[n - take..].iter().map(|c| c.id).collect() + stock[n - take..].iter().map(|c| c.0.clone()).collect() } }; match game.0.draw() { Ok(()) => { // Fire a flip event for each card that moved from stock to waste. - for id in drawn_ids { - flipped.write(CardFlippedEvent(id)); + for card in drawn_cards { + flipped.write(CardFlippedEvent(card)); } // Record the atomic player input. Whether the engine // resolves this to a draw or a waste→stock recycle is @@ -869,11 +869,11 @@ fn handle_move( // Identify the card that will be exposed (and may flip face-up) by the move. // It's the card just below the bottom of the moving stack in the source pile. let source_cards = pile_cards(&game.0, &ev.from); - let flip_candidate_id = { + let flip_candidate = { let n = source_cards.len(); if n > ev.count { let c = &source_cards[n - ev.count - 1]; - if !c.face_up { Some(c.id) } else { None } + if !c.1 { Some(c.0.clone()) } else { None } } else { None } @@ -889,12 +889,12 @@ fn handle_move( count: ev.count, }); // Fire flip event if the candidate card is now face-up. - if let Some(fid) = flip_candidate_id + if let Some(fcard) = flip_candidate && pile_cards(&game.0, &ev.from) .last() - .is_some_and(|c| c.id == fid && c.face_up) + .is_some_and(|c| c.0 == fcard && c.1) { - flipped.write(crate::events::CardFlippedEvent(fid)); + flipped.write(crate::events::CardFlippedEvent(fcard)); } // If this move landed on a foundation pile and that pile is // now complete (Ace → King, 13 cards), fire the per-suit @@ -905,7 +905,7 @@ fn handle_move( if let KlondikePile::Foundation(slot) = ev.to && let Some(slot) = foundation_slot(slot) && game.0.pile(ev.to).len() == 13 - && let Some(suit) = game.0.pile(ev.to).first().map(|c| c.suit) + && let Some(suit) = game.0.pile(ev.to).first().map(|c| c.0.suit()) { foundation_done.write(FoundationCompletedEvent { slot, suit }); } @@ -1007,7 +1007,7 @@ pub fn record_replay_on_win( } } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::card::Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -1385,13 +1385,13 @@ mod tests { #[test] fn new_game_request_reseeds() { let mut app = test_app(1); - let before: Vec = app + let before: Vec = app .world() .resource::() .0 .pile(KlondikePile::Tableau(Tableau::Tableau1)) .iter() - .map(|c| c.id) + .map(|c| c.0.clone()) .collect(); app.world_mut().write_message(NewGameRequestEvent { @@ -1401,13 +1401,13 @@ mod tests { }); app.update(); - let after: Vec = app + let after: Vec = app .world() .resource::() .0 .pile(KlondikePile::Tableau(Tableau::Tableau1)) .iter() - .map(|c| c.id) + .map(|c| c.0.clone()) .collect(); assert_ne!(before, after); } @@ -1643,7 +1643,7 @@ mod tests { #[test] fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() { - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let mut app = test_app(1); // Build a tableau with two face-up cards. { @@ -1651,28 +1651,13 @@ mod tests { gs.0.set_test_tableau_cards( Tableau::Tableau1, vec![ - Card { - id: 910, - suit: Suit::Clubs, - rank: Rank::King, - face_up: true, - }, - Card { - id: 911, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: true, - }, + Card::new(Deck::Deck1, Suit::Clubs, Rank::King), + Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), ], ); gs.0.set_test_tableau_cards( Tableau::Tableau2, - vec![Card { - id: 912, - suit: Suit::Spades, - rank: Rank::King, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)], ); } @@ -1715,7 +1700,7 @@ mod tests { // Klondike (unlimited recycles), even if the drawn card cannot be // immediately placed. The game is only stuck when both stock AND waste // are exhausted and no visible card can be moved. - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); for foundation in [ Foundation::Foundation1, @@ -1739,12 +1724,7 @@ mod tests { game.set_test_waste_cards(Vec::new()); let mut stock = Vec::new(); for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] { - stock.push(Card { - id: 100 + r as u32, - suit: Suit::Hearts, - rank: r, - face_up: false, - }); + stock.push(Card::new(Deck::Deck1, Suit::Hearts, r)); } game.set_test_stock_cards(stock); // Stock is non-empty, so drawing is always a valid move. @@ -1756,7 +1736,7 @@ mod tests { #[test] fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() { - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); // Empty stock and waste so draw is NOT available. @@ -1785,12 +1765,7 @@ mod tests { } game.set_test_tableau_cards( Tableau::Tableau1, - vec![Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); assert!( @@ -1805,7 +1780,7 @@ mod tests { // If the only legal move involves a face-up card that is NOT the top // card of its column the previous code would return false (softlock) // even though the player can still move that run. - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); game.set_test_stock_cards(Vec::new()); @@ -1836,28 +1811,13 @@ mod tests { game.set_test_tableau_cards( Tableau::Tableau1, vec![ - Card { - id: 10, - suit: Suit::Spades, - rank: Rank::Queen, - face_up: true, - }, - Card { - id: 11, - suit: Suit::Hearts, - rank: Rank::Jack, - face_up: true, - }, + Card::new(Deck::Deck1, Suit::Spades, Rank::Queen), + Card::new(Deck::Deck1, Suit::Hearts, Rank::Jack), ], ); game.set_test_tableau_cards( Tableau::Tableau2, - vec![Card { - id: 12, - suit: Suit::Diamonds, - rank: Rank::King, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)], ); assert!( @@ -2010,7 +1970,7 @@ mod tests { /// to have been a King. #[test] fn foundation_completed_event_does_not_fire_for_non_foundation_moves() { - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let mut app = test_app(1); // Reset the world: clear stock + waste so a draw isn't possible, @@ -2042,12 +2002,7 @@ mod tests { } gs.0.set_test_tableau_cards( Tableau::Tableau1, - vec![Card { - id: 7_000, - suit: Suit::Spades, - rank: Rank::King, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)], ); } diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index b430d2e..fe2fe58 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -2426,7 +2426,7 @@ fn foundation_selection_label( let claimed = game .pile(KlondikePile::Foundation(slot)) .first() - .map(|c| c.suit); + .map(|c| c.0.suit()); match claimed { Some(suit) => { let s = match suit { diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index d1272a0..266cf1e 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -370,10 +370,10 @@ pub fn emit_hint_visuals( // Find the top face-up card in the source pile and highlight it. let source_cards = pile_cards(game, from); - let top_card_id = source_cards.last().filter(|c| c.face_up).map(|c| c.id); - if let Some(card_id) = top_card_id { + let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone()); + if let Some(card) = top_card { for (entity, card_entity, mut sprite) in card_entities.iter_mut() { - if card_entity.card_id == card_id { + if card_entity.card == card { // Tint the card gold without replacing the Sprite (which would // discard the image handle set by CardImageSet). Uses the // design-system `STATE_WARNING` token so the source-card @@ -390,7 +390,7 @@ pub fn emit_hint_visuals( // Emit HintVisualEvent so the destination pile marker is also // tinted gold for 2 s. hint_visual.write(HintVisualEvent { - source_card_id: card_id, + source_card: card, dest_pile: *to, }); } @@ -401,7 +401,7 @@ pub fn emit_hint_visuals( // player keeps thinking in suit terms; otherwise fall back to "foundation". let msg = match to { KlondikePile::Foundation(_) => { - let claimed = game.pile(*to).first().map(|c| c.suit); + let claimed = game.pile(*to).first().map(|(c, _)| c.suit()); if let Some(suit) = claimed { let suit_name = match suit { Suit::Clubs => "Clubs", @@ -687,10 +687,10 @@ fn follow_drag( // Elevate cards: push to DRAG_Z and dim slightly so the board // beneath stays readable. - for (i, &id) in drag.cards.iter().enumerate() { + for (i, card) in drag.cards.iter().enumerate() { if let Some((_, mut transform, mut sprite)) = card_transforms .iter_mut() - .find(|(ce, _, _)| ce.card_id == id) + .find(|(ce, _, _)| ce.card == *card) { transform.translation.z = dragged_card_z(i); sprite.color.set_alpha(0.85); @@ -702,10 +702,10 @@ fn follow_drag( let bottom_pos = world + drag.cursor_offset; let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac; - for (i, &id) in drag.cards.iter().enumerate() { + for (i, card) in drag.cards.iter().enumerate() { if let Some((_, mut transform, _)) = card_transforms .iter_mut() - .find(|(ce, _, _)| ce.card_id == id) + .find(|(ce, _, _)| ce.card == *card) { transform.translation.x = bottom_pos.x; transform.translation.y = bottom_pos.y + fan * i as f32; @@ -807,15 +807,16 @@ fn end_drag( // that fires below does not fight this tween. let origin_cards = pile_cards(&game.0, &origin); if !origin_cards.is_empty() { - for &card_id in &drag.cards { - let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id) + for card in &drag.cards { + let Some(stack_index) = + origin_cards.iter().position(|(c, _)| c == card) else { continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); if let Some((entity, _, transform)) = card_entities .iter() - .find(|(_, ce, _)| ce.card_id == card_id) + .find(|(_, ce, _)| ce.card == *card) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; @@ -939,10 +940,10 @@ fn touch_follow_drag( drag.committed = true; - for (i, &id) in drag.cards.iter().enumerate() { + for (i, card) in drag.cards.iter().enumerate() { if let Some((_, mut transform, mut sprite)) = card_transforms .iter_mut() - .find(|(ce, _, _)| ce.card_id == id) + .find(|(ce, _, _)| ce.card == *card) { transform.translation.z = dragged_card_z(i); sprite.color.set_alpha(0.85); @@ -953,10 +954,10 @@ fn touch_follow_drag( let bottom_pos = world + drag.cursor_offset; let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac; - for (i, &id) in drag.cards.iter().enumerate() { + for (i, card) in drag.cards.iter().enumerate() { if let Some((_, mut transform, _)) = card_transforms .iter_mut() - .find(|(ce, _, _)| ce.card_id == id) + .find(|(ce, _, _)| ce.card == *card) { transform.translation.x = bottom_pos.x; transform.translation.y = bottom_pos.y + fan * i as f32; @@ -1046,15 +1047,16 @@ fn touch_end_drag( // feel identical. let origin_cards = pile_cards(&game.0, &origin); if !origin_cards.is_empty() { - for &card_id in &drag.cards { - let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id) + for card in &drag.cards { + let Some(stack_index) = + origin_cards.iter().position(|(c, _)| c == card) else { continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); if let Some((entity, _, transform)) = card_entities .iter() - .find(|(_, ce, _)| ce.card_id == card_id) + .find(|(_, ce, _)| ce.card == *card) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; @@ -1142,8 +1144,8 @@ fn card_position( let base = layout.pile_positions[pile]; if matches!(pile, KlondikePile::Tableau(_)) { let mut y_offset = 0.0_f32; - for card in pile_cards(game, pile).iter().take(stack_index) { - let step = if card.face_up { + for (_, face_up) in pile_cards(game, pile).iter().take(stack_index) { + let step = if *face_up { layout.tableau_fan_frac } else { layout.tableau_facedown_fan_frac @@ -1170,7 +1172,7 @@ fn find_draggable_at( cursor: Vec2, game: &GameState, layout: &Layout, -) -> Option<(KlondikePile, usize, Vec)> { +) -> Option<(KlondikePile, usize, Vec)> { // Search order: waste, foundations, tableau. Stock is skipped (click-to-draw). // Within a pile, we consider cards top-down because the visual top card is drawn last. let piles = [ @@ -1199,8 +1201,8 @@ fn find_draggable_at( // Iterate from topmost to bottommost so the first hit is the one // visually on top. for i in (0..pile_cards.len()).rev() { - let card = &pile_cards[i]; - if !card.face_up { + let (_, face_up) = pile_cards[i]; + if !face_up { continue; } let pos = card_position(game, layout, &pile, i); @@ -1222,8 +1224,8 @@ fn find_draggable_at( } (i, i + 1) }; - let ids: Vec = pile_cards[start..end].iter().map(|c| c.id).collect(); - return Some((pile, start, ids)); + let cards: Vec = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect(); + return Some((pile, start, cards)); } } None @@ -1302,7 +1304,7 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35; /// /// Returns `None` if no legal move exists from the card's current location. pub fn best_destination(card: &Card, game: &GameState) -> Option { - let source = game.pile_containing_card(card.id)?; + let source = game.pile_containing_card(card.clone())?; for foundation in foundations() { let dest = KlondikePile::Foundation(foundation); @@ -1361,7 +1363,7 @@ fn handle_double_click( cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Res, - mut last_click: Local>, + mut last_click: Local>, mut moves: MessageWriter, mut rejected: MessageWriter, ) { @@ -1382,27 +1384,27 @@ fn handle_double_click( }; // The topmost card in the draggable run — used as the double-click key. - let Some(&top_card_id) = card_ids.last() else { + let Some(top_card) = card_ids.last() else { return; }; let top_index = stack_index + card_ids.len() - 1; let pile_cards = pile_cards(&game.0, &pile); - let Some(top_card) = pile_cards.get(top_index) else { + let Some((pile_top_card, pile_top_face_up)) = pile_cards.get(top_index) else { return; }; - if !top_card.face_up || top_card.id != top_card_id { + if !*pile_top_face_up || pile_top_card != top_card { return; } let now = time.elapsed_secs(); let prev = last_click - .get(&top_card_id) + .get(top_card) .copied() .unwrap_or(f32::NEG_INFINITY); if now - prev <= DOUBLE_CLICK_WINDOW { // Double-click confirmed. - last_click.remove(&top_card_id); + last_click.remove(top_card); // Priority 1: move the single top card (foundation preferred, then tableau). if let Some(dest) = best_destination(top_card, &game.0) { @@ -1418,7 +1420,7 @@ fn handle_double_click( // stack (card_ids.len() > 1), try moving the whole stack to another // tableau column. if card_ids.len() > 1 - && let Some(bottom_card) = pile_cards.get(stack_index) + && let Some((bottom_card, _)) = pile_cards.get(stack_index) && let Some((dest, count)) = best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len()) { @@ -1445,7 +1447,7 @@ fn handle_double_click( }); } else { // Single click — record the time. - last_click.insert(top_card_id, now); + last_click.insert(top_card.clone(), now); } } @@ -1513,7 +1515,7 @@ fn handle_double_tap( } // Uncommitted touch ended = pure tap. - let Some(&top_card_id) = drag.cards.last() else { + let Some(top_card) = drag.cards.last() else { return; }; let Some(ref tapped_pile) = drag.origin_pile else { @@ -1524,10 +1526,12 @@ fn handle_double_tap( return; } - let Some(top_card) = pile_cards.iter().find(|c| c.id == top_card_id) else { + let Some((found_card, found_face_up)) = + pile_cards.iter().find(|(c, _)| c == top_card) + else { return; }; - if !top_card.face_up { + if !*found_face_up { return; } @@ -1561,9 +1565,9 @@ fn handle_double_tap( // --- One-tap auto-move (original behaviour) --- // Priority 1: move single top card. - if let Some(dest) = best_destination(top_card, &game.0) { + if let Some(dest) = best_destination(found_card, &game.0) { for (entity, ce, mut sprite) in card_sprites.iter_mut() { - if ce.card_id == top_card_id { + if ce.card == *top_card { sprite.color = STATE_SUCCESS; commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS, @@ -1582,7 +1586,7 @@ fn handle_double_tap( // Priority 2: move whole face-up stack to best tableau column. if drag.cards.len() > 1 { let stack_index = pile_cards.len() - drag.cards.len(); - if let Some(bottom_card) = pile_cards.get(stack_index) + if let Some((bottom_card, _)) = pile_cards.get(stack_index) && let Some((dest, count)) = best_tableau_destination_for_stack( bottom_card, tapped_pile, @@ -1591,7 +1595,7 @@ fn handle_double_tap( ) { for (entity, ce, mut sprite) in card_sprites.iter_mut() { - if drag.cards.contains(&ce.card_id) { + if drag.cards.contains(&ce.card) { sprite.color = STATE_SUCCESS; commands.entity(entity).insert(HintHighlight { remaining: DOUBLE_TAP_FLASH_SECS, @@ -1659,7 +1663,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize) // Pass 1 — foundation moves (highest priority, shown first). for from in &sources { let from_pile = pile_cards(game, from); - let Some(_card) = from_pile.last().filter(|c| c.face_up) else { + let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else { continue; }; for foundation in foundations() { @@ -1675,7 +1679,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize) // repeat the same source card multiple times for different destinations). for from in &sources { let from_pile = pile_cards(game, from); - let Some(_card) = from_pile.last().filter(|c| c.face_up) else { + let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else { continue; }; let already_has_foundation_hint = hints @@ -1701,7 +1705,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize) for foundation in foundations() { let from = KlondikePile::Foundation(foundation); let from_pile = pile_cards(game, &from); - let Some(_card) = from_pile.last().filter(|c| c.face_up) else { + let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else { continue; }; for tableau in tableaus() { @@ -1731,7 +1735,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize) hints } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -1912,29 +1916,14 @@ mod tests { fn find_draggable_returns_run_when_picking_mid_stack() { // Manually construct a tableau with three face-up cards all stacked. let mut game = GameState::new(1, DrawMode::DrawOne); + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; + let king = Card::new(D::Deck1, Suit::Spades, Rank::King); + let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); + let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); game.set_test_tableau_cards( Tableau::Tableau1, - vec![ - Card { - id: 100, - suit: Suit::Spades, - rank: Rank::King, - face_up: true, - }, - Card { - id: 101, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: true, - }, - Card { - id: 102, - suit: Suit::Clubs, - rank: Rank::Jack, - face_up: true, - }, - ], + vec![king, queen.clone(), jack.clone()], ); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); @@ -1948,36 +1937,26 @@ mod tests { let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(start, 1); - assert_eq!(ids, vec![101, 102]); + assert_eq!(ids, vec![queen, jack]); } #[test] fn find_draggable_skips_non_top_waste_card() { let mut game = GameState::new(1, DrawMode::DrawOne); + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; - game.set_test_waste_cards(vec![ - Card { - id: 200, - suit: Suit::Spades, - rank: Rank::Two, - face_up: true, - }, - Card { - id: 201, - suit: Suit::Hearts, - rank: Rank::Three, - face_up: true, - }, - ]); + let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); + let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); + game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); // Both cards in waste sit at the same (x, y). Clicking should pick - // the visually top card (id 201), with count = 1. + // the visually top card (three_hearts), with count = 1. let pos = card_position(&game, &layout, &KlondikePile::Stock, 0); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); assert_eq!(pile, KlondikePile::Stock); assert_eq!(start, 1); - assert_eq!(ids, vec![201]); + assert_eq!(ids, vec![three_hearts]); } #[test] @@ -2028,30 +2007,15 @@ mod tests { #[test] fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() { + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameMode}; let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic); - // Three waste cards; top (id=202) is rightmost in the fan. - game.set_test_waste_cards(vec![ - Card { - id: 200, - suit: Suit::Spades, - rank: Rank::Two, - face_up: true, - }, - Card { - id: 201, - suit: Suit::Hearts, - rank: Rank::Three, - face_up: true, - }, - Card { - id: 202, - suit: Suit::Clubs, - rank: Rank::Four, - face_up: true, - }, - ]); + // Three waste cards; top (four_clubs) is rightmost in the fan. + let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); + let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); + let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four); + game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let waste_base = layout.pile_positions[&KlondikePile::Stock]; @@ -2066,7 +2030,7 @@ mod tests { ); let (pile, _start, ids) = result.unwrap(); assert_eq!(pile, KlondikePile::Stock); - assert_eq!(ids, vec![202], "only the top card is draggable from waste"); + assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste"); } #[test] @@ -2102,6 +2066,7 @@ mod tests { #[test] fn best_destination_returns_none_when_no_legal_move() { + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); @@ -2109,12 +2074,7 @@ mod tests { clear_test_piles(&mut game); // A Two of Clubs with empty foundations and empty tableau has no destination. - let card = Card { - id: 400, - suit: Suit::Clubs, - rank: Rank::Two, - face_up: true, - }; + let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two); assert!(best_destination(&card, &game).is_none()); } @@ -2124,6 +2084,7 @@ mod tests { #[test] fn best_tableau_destination_for_stack_skips_source_pile() { + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); @@ -2132,24 +2093,11 @@ mod tests { // Only tableau 0 has anything; every other column is empty. // A King is the only card that can go on an empty tableau column. // Source is Tableau(0), so the result must NOT be Tableau(0). - game.set_test_tableau_cards( - Tableau::Tableau1, - vec![Card { - id: 200, - suit: Suit::Hearts, - rank: Rank::King, - face_up: true, - }], - ); + let king = Card::new(D::Deck1, Suit::Hearts, Rank::King); + game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]); - let bottom_card = Card { - id: 200, - suit: Suit::Hearts, - rank: Rank::King, - face_up: true, - }; let result = best_tableau_destination_for_stack( - &bottom_card, + &king, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1, @@ -2162,6 +2110,7 @@ mod tests { #[test] fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() { + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); @@ -2169,24 +2118,11 @@ mod tests { // Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King). // All other piles are empty — no legal tableau target. - game.set_test_tableau_cards( - Tableau::Tableau1, - vec![Card { - id: 300, - suit: Suit::Clubs, - rank: Rank::Two, - face_up: true, - }], - ); + let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two); + game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]); - let bottom_card = Card { - id: 300, - suit: Suit::Clubs, - rank: Rank::Two, - face_up: true, - }; let result = best_tableau_destination_for_stack( - &bottom_card, + &two_clubs, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1, @@ -2203,20 +2139,14 @@ mod tests { #[test] fn find_hint_finds_ace_to_foundation() { + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); // Place Ace of Clubs on top of tableau 0. clear_test_piles(&mut game); - game.set_test_tableau_cards( - Tableau::Tableau1, - vec![Card { - id: 500, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }], - ); + let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace); + game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]); let hint = find_hint(&game); assert!(hint.is_some(), "should find a hint"); @@ -2254,6 +2184,7 @@ mod tests { /// are no other moves and the stock is non-empty. #[test] fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() { + use solitaire_core::card::Deck as D; use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); @@ -2261,12 +2192,7 @@ mod tests { // move exists. Leave one card in the stock. clear_test_piles(&mut game); // Put one card back into the stock so "draw" is a valid suggestion. - game.set_test_stock_cards(vec![Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: false, - }]); + game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]); let hints = all_hints(&game); assert_eq!(hints.len(), 1, "exactly one hint: draw from stock"); @@ -2312,20 +2238,25 @@ mod tests { /// gets a CardAnimation" — same coverage, new component. #[test] fn rejected_drag_inserts_card_animation_on_each_dragged_card() { + use solitaire_core::card::Deck as D; + use solitaire_core::card::{Card, Rank, Suit}; // Simulate a stack drag of two cards. - let dragged_ids: Vec = vec![10, 11]; + let dragged_cards: Vec = vec![ + Card::new(D::Deck1, Suit::Hearts, Rank::King), + Card::new(D::Deck1, Suit::Spades, Rank::Queen), + ]; - let mut animated: Vec = Vec::new(); - for &card_id in &dragged_ids { - // In `end_drag` we iterate `drag.cards` and look up each id in - // `card_entities`. The ids we would insert a `CardAnimation` on + let mut animated: Vec = Vec::new(); + for card in &dragged_cards { + // In `end_drag` we iterate `drag.cards` and look up each card in + // `card_entities`. The cards we would insert a `CardAnimation` on // must exactly match the dragged set. - animated.push(card_id); + animated.push(card.clone()); } assert_eq!( - animated, dragged_ids, - "every card id in drag.cards must receive a CardAnimation on rejection" + animated, dragged_cards, + "every card in drag.cards must receive a CardAnimation on rejection" ); } diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index 35d5bc0..c753c9f 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -187,7 +187,7 @@ mod tests { use crate::events::HintVisualEvent; use crate::input_plugin::HintSolverConfig; use solitaire_core::{Foundation, Tableau}; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameState}; /// Build a minimal Bevy app exercising only the polling system @@ -264,13 +264,8 @@ mod tests { .zip(suits.iter()) { let mut cards = Vec::new(); - for (i, rank) in ranks_below_king.iter().enumerate() { - cards.push(Card { - id: (foundation as u32) * 13 + i as u32, - suit: *suit, - rank: *rank, - face_up: true, - }); + for rank in ranks_below_king.iter() { + cards.push(Card::new(Deck::Deck1, *suit, *rank)); } game.set_test_foundation_cards(foundation, cards); } @@ -285,12 +280,7 @@ mod tests { { game.set_test_tableau_cards( tableau, - vec![Card { - id: 100 + tableau as u32, - suit: *suit, - rank: Rank::King, - face_up: true, - }], + vec![Card::new(Deck::Deck1, *suit, Rank::King)], ); } game diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index d6300a5..0ba9827 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -304,7 +304,7 @@ pub fn find_top_face_up_card_at( let is_tableau = matches!(pile, KlondikePile::Tableau(_)); for i in (0..pile_cards.len()).rev() { let card = &pile_cards[i]; - if !card.face_up { + if !card.1 { continue; } // Only the top card is draggable on non-tableau piles. @@ -320,7 +320,7 @@ pub fn find_top_face_up_card_at( { continue; } - return Some((pile, card.clone())); + return Some((pile, card.0.clone())); } } None @@ -339,7 +339,7 @@ fn card_position( if matches!(pile, KlondikePile::Tableau(_)) { let mut y_offset = 0.0_f32; for card in pile_cards(game, pile).iter().take(stack_index) { - let step = if card.face_up { + let step = if card.1 { TABLEAU_FAN_FRAC } else { TABLEAU_FACEDOWN_FAN_FRAC @@ -352,13 +352,27 @@ fn card_position( } } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), } } +/// Maps a `card_game::Card` to a stable `u32` identity used by `CardEntity` +/// and systems that still track cards by numeric ID. +/// Encoding: `suit_index * 13 + (rank.value() - 1)`, range 0..=51. +fn card_to_id(card: &Card) -> u32 { + use solitaire_core::card::Suit; + let suit_index: u32 = match card.suit() { + Suit::Clubs => 0, + Suit::Diamonds => 1, + Suit::Hearts => 2, + Suit::Spades => 3, + }; + suit_index * 13 + (card.rank().value() as u32 - 1) +} + const fn foundations() -> [Foundation; 4] { [ Foundation::Foundation1, @@ -498,7 +512,7 @@ fn radial_open_on_right_click( *state = RightClickRadialState::Active { source_pile, count: 1, - cards: vec![card.id], + cards: vec![card_to_id(&card)], legal_destinations, centre: world, hovered_index: None, @@ -571,7 +585,7 @@ fn radial_open_on_long_press( *state = RightClickRadialState::Active { source_pile, count: 1, - cards: vec![card.id], + cards: vec![card_to_id(&card)], legal_destinations, centre: world, hovered_index: None, @@ -794,7 +808,7 @@ mod tests { use super::*; use crate::layout::compute_layout; use bevy::ecs::message::Messages; - use solitaire_core::card::{Card as CoreCard, Rank, Suit}; + use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameState}; /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the @@ -844,12 +858,7 @@ mod tests { // Ace of Clubs on Tableau(0). g.set_test_tableau_cards( Tableau::Tableau1, - vec![CoreCard { - id: 100, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }], + vec![CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); g } @@ -879,14 +888,9 @@ mod tests { ] { g.set_test_tableau_cards(tableau, Vec::new()); } - g.set_test_tableau_cards( + g.set_test_tableau_cards_with_face( Tableau::Tableau1, - vec![CoreCard { - id: 100, - suit: Suit::Spades, - rank: Rank::King, - face_up: false, - }], + vec![(CoreCard::new(Deck::Deck1, Suit::Spades, Rank::King), false)], ); g } @@ -979,12 +983,7 @@ mod tests { #[test] fn legal_destinations_for_ace_includes_only_first_empty_foundation() { let g = ace_only_state(); - let card = CoreCard { - id: 100, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }; + let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace); let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g); // Ace can be placed on every empty foundation. We only need @@ -999,12 +998,7 @@ mod tests { #[test] fn legal_destinations_excludes_source_pile() { let g = ace_only_state(); - let card = CoreCard { - id: 100, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: true, - }; + let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace); let dests = legal_destinations_for_card( &card, &KlondikePile::Foundation(Foundation::Foundation1), diff --git a/solitaire_engine/src/replay_overlay/format.rs b/solitaire_engine/src/replay_overlay/format.rs index 851356a..22ed3ee 100644 --- a/solitaire_engine/src/replay_overlay/format.rs +++ b/solitaire_engine/src/replay_overlay/format.rs @@ -236,9 +236,9 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str { /// Pure helper — compact 2-char card label (`rank + suit glyph`) for a /// known card, or `"--"` for an absent top card (empty pile). -pub(crate) fn format_card_short(card: Option<&Card>) -> String { +pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String { match card { - Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)), + Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())), None => "--".to_string(), } } diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs index 28ab0b6..109bebb 100644 --- a/solitaire_engine/src/resources.rs +++ b/solitaire_engine/src/resources.rs @@ -7,6 +7,7 @@ use bevy::math::Vec2; use bevy::prelude::Resource; use chrono::{DateTime, Utc}; use solitaire_core::KlondikePile; +use solitaire_core::card::Card; use solitaire_core::game_state::GameState; /// Wraps the currently active `GameState`. Single source of truth for the in-progress game. @@ -27,8 +28,8 @@ pub struct GameStateResource(pub GameState); /// This prevents accidental drags on quick taps, especially on touch screens. #[derive(Resource, Debug, Clone)] pub struct DragState { - /// IDs of the cards being dragged (bottom-to-top stacking order). - pub cards: Vec, + /// Cards being dragged (bottom-to-top stacking order). + pub cards: Vec, /// Pile the drag originated from. pub origin_pile: Option, /// World-space offset from the cursor/touch to the bottom card's centre. diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index 6073b54..2db9368 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -91,9 +91,9 @@ pub enum KeyboardDragState { /// Number of cards lifted (1 for waste / foundation, full face-up /// run length for a tableau column). count: usize, - /// Card ids being lifted, in the same bottom-to-top order + /// Cards being lifted, in the same bottom-to-top order /// `DragState.cards` expects. - cards: Vec, + cards: Vec, /// Pre-computed list of piles the lifted stack can legally be /// placed on. Always at least one entry while in this variant — /// if no legal destinations exist the state machine refuses to @@ -393,7 +393,7 @@ fn handle_selection_keys( KlondikePile::Tableau(Tableau::Tableau7), ]; all.into_iter() - .filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)) + .filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1)) .collect() }; @@ -424,7 +424,7 @@ fn handle_selection_keys( && let Some(ref pile) = selection.selected_pile { let selected_cards = pile_cards(&game.0, pile); - let Some(card) = selected_cards.last().filter(|c| c.face_up) else { + let Some((card, _)) = selected_cards.last().filter(|c| c.1) else { return; }; // Priority 1: foundation move (single card). @@ -441,7 +441,7 @@ fn handle_selection_keys( let run_len = face_up_run_len(&selected_cards); let bottom_card = selected_cards .get(selected_cards.len().saturating_sub(run_len)) - .cloned(); + .map(|(c, _)| c.clone()); if let Some(bottom) = bottom_card && let Some((dest, count)) = best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len) @@ -483,8 +483,9 @@ fn handle_selection_keys( 1 }; let start = source_cards.len().saturating_sub(count); - let lifted_cards: Vec = source_cards[start..].iter().map(|c| c.id).collect(); - let Some(bottom) = source_cards.get(start) else { + let lifted_cards: Vec = + source_cards[start..].iter().map(|(c, _)| c.clone()).collect(); + let Some((bottom, _)) = source_cards.get(start) else { return; }; let legal = legal_destinations_for(bottom, source, &game.0, count); @@ -574,10 +575,10 @@ pub(crate) fn legal_destinations_for( /// Walks backwards from the last element and stops at the first face-down card /// (or when the slice is exhausted). Returns at least `1` when the top card is /// face-up; returns `0` for an empty slice or when the top card is face-down. -fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize { +fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize { let mut count = 0; - for card in cards.iter().rev() { - if card.face_up { + for (_, face_up) in cards.iter().rev() { + if *face_up { count += 1; } else { break; @@ -596,7 +597,7 @@ fn try_foundation_dest( card: &solitaire_core::card::Card, game: &solitaire_core::game_state::GameState, ) -> Option { - let source = game.pile_containing_card(card.id)?; + let source = game.pile_containing_card(card.clone())?; for foundation in [ Foundation::Foundation1, Foundation::Foundation2, @@ -695,7 +696,7 @@ fn update_selection_highlight( spawn_highlight_on_card( &mut commands, &card_entities, - card.id, + &card, card_size, source_color, ); @@ -712,7 +713,7 @@ fn update_selection_highlight( spawn_highlight_on_card( &mut commands, &card_entities, - card.id, + &card, card_size, dest_color, ); @@ -723,10 +724,13 @@ fn update_selection_highlight( /// Returns the top face-up card on `pile`, or `None` if the pile is /// empty or its top card is face-down. fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option { - pile_cards(game, pile).last().filter(|c| c.face_up).cloned() + pile_cards(game, pile) + .last() + .filter(|(_, up)| *up) + .map(|(c, _)| c.clone()) } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -734,16 +738,16 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec { } /// Spawn a `SelectionHighlight` sprite as a child of the entity carrying -/// the matching `CardEntity::card_id`. No-op if no entity matches. +/// the matching `CardEntity::card`. No-op if no entity matches. fn spawn_highlight_on_card( commands: &mut Commands, card_entities: &Query<(Entity, &CardEntity)>, - card_id: u32, + card: &Card, card_size: Vec2, color: Color, ) { for (entity, card_entity) in card_entities { - if card_entity.card_id == card_id { + if card_entity.card == *card { commands.entity(entity).with_children(|b| { b.spawn(( SelectionHighlight, @@ -881,58 +885,23 @@ mod tests { #[test] fn face_up_run_len_all_face_up() { - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let cards = vec![ - Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::King, - face_up: true, - }, - Card { - id: 1, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: true, - }, - Card { - id: 2, - suit: Suit::Spades, - rank: Rank::Jack, - face_up: true, - }, + (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), + (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true), + (Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true), ]; assert_eq!(face_up_run_len(&cards), 3); } #[test] fn face_up_run_len_mixed_stops_at_face_down() { - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let cards = vec![ - Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::King, - face_up: false, - }, - Card { - id: 1, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: false, - }, - Card { - id: 2, - suit: Suit::Spades, - rank: Rank::Jack, - face_up: true, - }, - Card { - id: 3, - suit: Suit::Diamonds, - rank: Rank::Ten, - face_up: true, - }, + (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false), + (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), + (Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true), + (Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true), ]; // Only the top two cards are face-up. assert_eq!(face_up_run_len(&cards), 2); @@ -940,33 +909,18 @@ mod tests { #[test] fn face_up_run_len_top_card_face_down_is_zero() { - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; let cards = vec![ - Card { - id: 0, - suit: Suit::Clubs, - rank: Rank::King, - face_up: true, - }, - Card { - id: 1, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: false, - }, + (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), + (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), ]; assert_eq!(face_up_run_len(&cards), 0); } #[test] fn face_up_run_len_single_face_up_card() { - use solitaire_core::card::{Card, Rank, Suit}; - let cards = vec![Card { - id: 0, - suit: Suit::Hearts, - rank: Rank::Ace, - face_up: true, - }]; + use solitaire_core::card::{Card, Deck, Rank, Suit}; + let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)]; assert_eq!(face_up_run_len(&cards), 1); } @@ -979,7 +933,7 @@ mod tests { // ----------------------------------------------------------------------- use bevy::ecs::message::Messages; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameState}; /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no @@ -1031,30 +985,15 @@ mod tests { // Place test cards. g.set_test_tableau_cards( Tableau::Tableau1, - vec![Card { - id: 100, - suit: Suit::Clubs, - rank: Rank::Five, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)], ); g.set_test_tableau_cards( Tableau::Tableau2, - vec![Card { - id: 101, - suit: Suit::Hearts, - rank: Rank::Six, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)], ); g.set_test_tableau_cards( Tableau::Tableau3, - vec![Card { - id: 102, - suit: Suit::Diamonds, - rank: Rank::Six, - face_up: true, - }], + vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)], ); g } @@ -1150,7 +1089,7 @@ mod tests { } => { assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(count, 1); - assert_eq!(cards, vec![100]); + assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]); assert!( !legal_destinations.is_empty(), "lifted stack must have at least one legal destination" @@ -1162,7 +1101,10 @@ mod tests { // DragState must mirror the lifted cards and carry the keyboard sentinel. let drag = app.world().resource::(); - assert_eq!(drag.cards, vec![100]); + assert_eq!( + drag.cards, + vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)] + ); assert_eq!( drag.origin_pile, Some(KlondikePile::Tableau(Tableau::Tableau1)) @@ -1267,7 +1209,7 @@ mod tests { // keyboard sentinel. { let mut drag = app.world_mut().resource_mut::(); - drag.cards = vec![100]; + drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]; drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1)); drag.committed = true; drag.active_touch_id = None; diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index bd3fbfc..b8c6f90 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -520,7 +520,7 @@ fn sync_pile_marker_visibility( fn pile_cards( game: &solitaire_core::game_state::GameState, pile: &KlondikePile, -) -> Vec { +) -> Vec<(solitaire_core::card::Card, bool)> { match pile { KlondikePile::Stock => { let stock = game.stock_cards(); diff --git a/solitaire_engine/src/touch_selection_plugin.rs b/solitaire_engine/src/touch_selection_plugin.rs index 95201e4..f26e854 100644 --- a/solitaire_engine/src/touch_selection_plugin.rs +++ b/solitaire_engine/src/touch_selection_plugin.rs @@ -29,6 +29,7 @@ use bevy::ecs::message::MessageReader; use bevy::prelude::*; use solitaire_core::KlondikePile; +use solitaire_core::card::Card; use crate::card_plugin::CardEntity; use crate::events::StateChangedEvent; @@ -49,8 +50,8 @@ use crate::ui_theme::ACCENT_PRIMARY; /// card ids that will be moved (1 for a single card, multiple for a face-up run). #[derive(Resource, Debug, Default)] pub struct TouchSelectionState { - /// Currently selected source pile and the card ids to move (bottom-to-top). - pub selected: Option<(KlondikePile, Vec)>, + /// Currently selected source pile and the cards to move (bottom-to-top). + pub selected: Option<(KlondikePile, Vec)>, } impl TouchSelectionState { @@ -60,12 +61,12 @@ impl TouchSelectionState { } /// Takes the current selection, leaving `selected` as `None`. - pub fn take(&mut self) -> Option<(KlondikePile, Vec)> { + pub fn take(&mut self) -> Option<(KlondikePile, Vec)> { self.selected.take() } /// Sets the current selection. - pub fn set(&mut self, pile: KlondikePile, cards: Vec) { + pub fn set(&mut self, pile: KlondikePile, cards: Vec) { self.selected = Some((pile, cards)); } @@ -142,7 +143,7 @@ pub(crate) fn update_touch_selection_highlight( commands.entity(entity).despawn(); } - let Some((_, ref card_ids)) = selection.selected else { + let Some((_, ref cards)) = selection.selected else { return; }; let Some(layout) = layout else { @@ -154,8 +155,8 @@ pub(crate) fn update_touch_selection_highlight( // but highlighting the whole run gives the player clear confirmation // of how many cards are involved in the move. let card_size = layout.0.card_size; - for &card_id in card_ids { - spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size); + for card in cards { + spawn_touch_highlight(&mut commands, &card_entities, card, card_size); } } @@ -163,11 +164,11 @@ pub(crate) fn update_touch_selection_highlight( fn spawn_touch_highlight( commands: &mut Commands, card_entities: &Query<(Entity, &CardEntity)>, - card_id: u32, + card: &Card, card_size: Vec2, ) { for (entity, card_entity) in card_entities { - if card_entity.card_id == card_id { + if card_entity.card == *card { commands.entity(entity).with_children(|b| { b.spawn(( TouchSelectionHighlight, @@ -193,6 +194,17 @@ fn spawn_touch_highlight( mod tests { use super::*; use solitaire_core::Tableau; + use solitaire_core::card::{Card, Deck, Rank, Suit}; + + /// Three distinct test cards, used in place of the old `vec![1, 2, 3]` + /// numeric ids. Identity is now the `Card` value. + fn test_cards() -> [Card; 3] { + [ + Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace), + Card::new(Deck::Deck1, Suit::Hearts, Rank::Two), + Card::new(Deck::Deck1, Suit::Spades, Rank::Three), + ] + } #[test] fn selection_state_default_is_idle() { @@ -204,20 +216,24 @@ mod tests { #[test] fn set_and_take_roundtrip() { let mut state = TouchSelectionState::default(); - state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]); + let cards = test_cards().to_vec(); + state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone()); assert!(state.has_selection()); let taken = state.take(); assert!(taken.is_some()); - let (pile, cards) = taken.unwrap(); + let (pile, taken_cards) = taken.unwrap(); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); - assert_eq!(cards, vec![1, 2, 3]); + assert_eq!(taken_cards, cards); assert!(!state.has_selection()); } #[test] fn clear_removes_selection() { let mut state = TouchSelectionState::default(); - state.set(KlondikePile::Stock, vec![42]); + state.set( + KlondikePile::Stock, + vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)], + ); state.clear(); assert!(!state.has_selection()); } @@ -232,10 +248,17 @@ mod tests { #[test] fn set_overwrites_previous_selection() { let mut state = TouchSelectionState::default(); - state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]); - state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]); + state.set( + KlondikePile::Tableau(Tableau::Tableau1), + vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], + ); + let second = vec![ + Card::new(Deck::Deck1, Suit::Hearts, Rank::Seven), + Card::new(Deck::Deck1, Suit::Spades, Rank::Eight), + ]; + state.set(KlondikePile::Tableau(Tableau::Tableau4), second.clone()); let (pile, cards) = state.take().unwrap(); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4)); - assert_eq!(cards, vec![7, 8]); + assert_eq!(cards, second); } } diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 7b8b113..bb24d4a 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -87,18 +87,31 @@ pub struct CardSnapshot { pub face_up: bool, } -impl From<&solitaire_core::card::Card> for CardSnapshot { - fn from(c: &solitaire_core::card::Card) -> Self { +/// Stable 0..=51 identifier derived from suit and rank. Mirrors the desktop +/// engine's `card_to_id` so replay snapshots are identical across platforms — +/// `Card` itself carries no id field (suit + rank are unique within a deck). +fn card_to_id(card: &solitaire_core::card::Card) -> u32 { + let suit_index = match card.suit() { + Suit::Clubs => 0, + Suit::Diamonds => 1, + Suit::Hearts => 2, + Suit::Spades => 3, + }; + suit_index * 13 + (card.rank().value() as u32 - 1) +} + +impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot { + fn from((card, face_up): &(solitaire_core::card::Card, bool)) -> Self { Self { - id: c.id, - suit: match c.suit { + id: card_to_id(card), + suit: match card.suit() { Suit::Clubs => "clubs", Suit::Diamonds => "diamonds", Suit::Hearts => "hearts", Suit::Spades => "spades", }, - rank: c.rank.value(), - face_up: c.face_up, + rank: card.rank().value(), + face_up: *face_up, } } } @@ -389,16 +402,17 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb let mut out_of_range_card_ids = Vec::new(); let mut total_cards_seen = 0_usize; - let mut feed = |cards: &[solitaire_core::card::Card]| { - for card in cards { + let mut feed = |cards: &[(solitaire_core::card::Card, bool)]| { + for (card, _) in cards { total_cards_seen += 1; - if card.id >= 52 { - out_of_range_card_ids.push(card.id); + let id = card_to_id(card); + if id >= 52 { + out_of_range_card_ids.push(id); continue; } - let idx = card.id as usize; + let idx = id as usize; if seen[idx] { - duplicate_card_ids.push(card.id); + duplicate_card_ids.push(id); } else { seen[idx] = true; } @@ -418,16 +432,16 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb .filter(|id| !seen[*id as usize]) .collect::>(); - let stock_has_face_up_cards = stock.iter().any(|c| c.face_up); - let waste_has_face_down_cards = waste.iter().any(|c| !c.face_up); + let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up); + let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up); let foundation_has_face_down_cards = foundations .iter() - .any(|pile| pile.iter().any(|c| !c.face_up)); + .any(|pile| pile.iter().any(|(_, face_up)| !*face_up)); let tableau_visibility_violation = tableaus.iter().any(|pile| { let mut seen_face_up = false; - for card in pile { - if card.face_up { + for (_, face_up) in pile { + if *face_up { seen_face_up = true; } else if seen_face_up { return true; @@ -599,7 +613,7 @@ impl SolitaireGame { .pile(KlondikePile::Tableau(tableau)) .iter() .rev() - .take_while(|card| card.face_up) + .take_while(|(_, face_up)| *face_up) .count(); let skip = tableau_stack.skip_cards.0 as usize; let count = face_up_count.checked_sub(skip).ok_or_else(|| {