diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs deleted file mode 100644 index fe6e753..0000000 --- a/solitaire_core/src/card.rs +++ /dev/null @@ -1,23 +0,0 @@ -pub use card_game::{Card, Deck, Rank, Suit}; - -/// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the -/// upstream `card_game::Card` bit-packing. -/// -/// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is -/// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King). -/// The deck id is intentionally ignored so the id depends only on the visible -/// face. -/// -/// This is the single source of truth shared by `CardEntity` numeric tracking, -/// deterministic per-card animation jitter, and the WASM replay layer — those -/// must agree byte-for-byte so replay snapshots are identical across the -/// desktop and browser builds. -pub fn card_to_id(card: &Card) -> u32 { - let suit_index: u32 = match card.suit() { - Suit::Clubs => 0, - Suit::Diamonds => 1, - Suit::Hearts => 2, - Suit::Spades => 3, - }; - suit_index * 13 + (card.rank() as u32 - 1) -} diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 702f431..8655f83 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -1,5 +1,4 @@ pub mod achievement; -pub mod card; pub mod error; pub mod game_state; pub mod klondike_adapter; @@ -12,7 +11,7 @@ pub mod klondike_adapter; // re-exported — they are only used internally (in `klondike_adapter.rs` and // when decoding instructions to piles in `instruction_to_piles`) and do not // appear in any public method signature. -pub use card_game::{Card, Session, SolveError}; +pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; // Solvability check API (delegates to `card_game::Session::solve`); replaces the diff --git a/solitaire_engine/src/assets/card_face_svg.rs b/solitaire_engine/src/assets/card_face_svg.rs index c45a955..5fd7223 100644 --- a/solitaire_engine/src/assets/card_face_svg.rs +++ b/solitaire_engine/src/assets/card_face_svg.rs @@ -22,7 +22,7 @@ //! red/black colour split. use bevy::math::UVec2; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; /// Target rasterisation size in pixels (2:3 aspect, half the default /// `SvgLoaderSettings` resolution). diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 55fae1b..68df902 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::{Deck, Rank, Suit}; + use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; fn headless_app() -> App { @@ -207,7 +207,7 @@ mod tests { } g.set_test_tableau_cards( Tableau::Tableau1, - vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], + vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], ); g.set_test_auto_completable(true); let expected = ( diff --git a/solitaire_engine/src/card_animation/interaction.rs b/solitaire_engine/src/card_animation/interaction.rs index b9bc4a8..7e18936 100644 --- a/solitaire_engine/src/card_animation/interaction.rs +++ b/solitaire_engine/src/card_animation/interaction.rs @@ -33,7 +33,7 @@ use std::collections::VecDeque; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use solitaire_core::card::Card; +use solitaire_core::Card; use super::animation::CardAnimation; use super::tuning::AnimationTuning; diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index b5caf43..ab89cfd 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -17,7 +17,7 @@ use bevy::prelude::*; use bevy::sprite::Anchor; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::{Card, Rank, Suit}; +use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; @@ -2472,7 +2472,7 @@ mod tests { use super::*; use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; - use solitaire_core::card::Deck; + use solitaire_core::Deck; /// Convenience constructor — all unit tests use Deck1. fn make_card(suit: Suit, rank: Rank) -> Card { diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index 0726979..8bd4987 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -34,7 +34,7 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{DrawStockConfig, game_state::GameState}; @@ -580,7 +580,7 @@ mod tests { // ----------------------------------------------------------------------- use crate::layout::compute_layout; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; /// Builds an `App` with `MinimalPlugins` and the overlay system diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 3bcd8bb..e8c7ada 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::{Card, Suit}; +use solitaire_core::{Card, Suit}; use solitaire_core::game_state::GameMode; use solitaire_data::AchievementRecord; use solitaire_sync::SyncResponse; diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 3e832dc..5ef2689 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -43,7 +43,7 @@ use std::hash::{Hash, Hasher}; use bevy::prelude::*; use bevy::window::RequestRedraw; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::KlondikePile; use solitaire_core::klondike_adapter::foundation_from_slot; use solitaire_data::AnimSpeed; @@ -189,10 +189,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 { (jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 % } -// Per-card jitter keys off the shared stable card id so it matches the -// numeric identity used elsewhere (and on the WASM replay side). -use solitaire_core::card::card_to_id; - // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -413,10 +409,14 @@ fn start_deal_anim( for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() { let final_pos = transform.translation; - // ±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_to_id(&card_marker.card))); + // ±10 % jitter, deterministic per card, so the deal feels organic + // without losing reproducibility (a given deal produces the same + // per-card stagger pattern across runs). The seed is a hash of the + // card's own identity — no separate numeric id needed. + let mut card_hasher = DefaultHasher::new(); + card_marker.card.hash(&mut card_hasher); + let per_card_stagger = + stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32)); commands.entity(entity).insert(( Transform::from_translation(stock_start.with_z(final_pos.z)), CardAnim { @@ -639,7 +639,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color { fn pile_cards( game: &solitaire_core::game_state::GameState, pile: &KlondikePile, -) -> Vec<(solitaire_core::card::Card, bool)> { +) -> Vec<(solitaire_core::Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -917,7 +917,7 @@ mod tests { .resource_mut::>() .write(FoundationCompletedEvent { slot: 0, - suit: solitaire_core::card::Suit::Spades, + suit: solitaire_core::Suit::Spades, }); app.update(); diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index d75cc7d..e822129 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -824,7 +824,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_cards: Vec = { + let drawn_cards: Vec = { let stock = game.0.stock_cards(); if stock.is_empty() { Vec::new() @@ -1013,7 +1013,7 @@ pub fn record_replay_on_win( } } -fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::card::Card, bool)> { +fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> { match pile { KlondikePile::Stock => game.waste_cards(), _ => game.pile(*pile), @@ -1391,7 +1391,7 @@ 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 @@ -1407,7 +1407,7 @@ mod tests { }); app.update(); - let after: Vec = app + let after: Vec = app .world() .resource::() .0 @@ -1649,7 +1649,7 @@ mod tests { #[test] fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut app = test_app(1); // Build a tableau with two face-up cards. { @@ -1706,7 +1706,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, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); for foundation in [ Foundation::Foundation1, @@ -1742,7 +1742,7 @@ mod tests { #[test] fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Empty stock and waste so draw is NOT available. @@ -1786,7 +1786,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, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); game.set_test_stock_cards(Vec::new()); @@ -1976,7 +1976,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, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let mut app = test_app(1); // Reset the world: clear stock + waste so a draw isn't possible, diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 631d511..c52c88f 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -9,7 +9,7 @@ use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Suit; +use solitaire_core::Suit; use solitaire_core::{DrawStockConfig, game_state::GameMode}; use crate::auto_complete_plugin::AutoCompleteState; diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 3874dfe..f2ea1d9 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -27,7 +27,7 @@ use bevy::window::PrimaryWindow; #[cfg(not(target_os = "android"))] use bevy::window::{MonitorSelection, WindowMode}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; -use solitaire_core::card::{Card, Suit}; +use solitaire_core::{Card, Suit}; use solitaire_core::game_state::GameState; use crate::auto_complete_plugin::AutoCompleteState; @@ -1953,8 +1953,8 @@ 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, DrawStockConfig::DrawOne); - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{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); @@ -1980,8 +1980,8 @@ mod tests { #[test] fn find_draggable_skips_non_top_waste_card() { let mut game = GameState::new(1, DrawStockConfig::DrawOne); - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; 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()]); @@ -2044,8 +2044,8 @@ 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::Deck as D; + use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameMode}; let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic); // Three waste cards; top (four_clubs) is rightmost in the fan. @@ -2103,8 +2103,8 @@ 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}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Clear everything except one card that has nowhere to go. @@ -2121,8 +2121,8 @@ 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}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); clear_test_piles(&mut game); @@ -2147,8 +2147,8 @@ 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}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); clear_test_piles(&mut game); @@ -2176,8 +2176,8 @@ mod tests { #[test] fn find_hint_finds_ace_to_foundation() { - use solitaire_core::card::Deck as D; - use solitaire_core::card::{Card, Rank, Suit}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Place Ace of Clubs on top of tableau 0. @@ -2220,8 +2220,8 @@ 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}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawStockConfig::DrawOne); // Remove all foundation, tableau, and waste cards so no pile-to-pile @@ -2273,8 +2273,8 @@ 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}; + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; // Simulate a stack drag of two cards. let dragged_cards: Vec = vec![ Card::new(D::Deck1, Suit::Hearts, Rank::King), diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index 9e6fae5..3e7c9bf 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -179,7 +179,7 @@ mod tests { use crate::events::HintVisualEvent; use crate::input_plugin::HintSolverConfig; use solitaire_core::{Foundation, KlondikePile, Tableau}; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal Bevy app exercising only the polling system diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 31faf3f..fb72dea 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -48,7 +48,7 @@ use bevy::math::Vec2; use bevy::prelude::*; use bevy::window::PrimaryWindow; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::game_state::GameState; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; @@ -113,9 +113,9 @@ pub enum RightClickRadialState { /// radial is built around single-card foundation/tableau /// shortcuts and that matches the right-click highlight set). count: usize, - /// Card ids that would be moved (bottom-to-top order). Length + /// Cards that would be moved (bottom-to-top order). Length /// always equals `count`. Currently always one element. - cards: Vec, + cards: Vec, /// Pre-computed `(destination, icon_anchor_world_pos)` pairs. /// /// Anchors are evenly spaced around a ring of radius @@ -359,7 +359,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> { } } -use solitaire_core::card::card_to_id; const fn foundations() -> [Foundation; 4] { [ @@ -500,7 +499,7 @@ fn radial_open_on_right_click( *state = RightClickRadialState::Active { source_pile, count: 1, - cards: vec![card_to_id(&card)], + cards: vec![card.clone()], legal_destinations, centre: world, hovered_index: None, @@ -573,7 +572,7 @@ fn radial_open_on_long_press( *state = RightClickRadialState::Active { source_pile, count: 1, - cards: vec![card_to_id(&card)], + cards: vec![card.clone()], legal_destinations, centre: world, hovered_index: None, @@ -796,7 +795,7 @@ mod tests { use super::*; use crate::layout::compute_layout; use bevy::ecs::message::Messages; - use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit}; + use solitaire_core::{Card as CoreCard, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the diff --git a/solitaire_engine/src/replay_overlay/format.rs b/solitaire_engine/src/replay_overlay/format.rs index 22ed3ee..d3e141b 100644 --- a/solitaire_engine/src/replay_overlay/format.rs +++ b/solitaire_engine/src/replay_overlay/format.rs @@ -1,7 +1,7 @@ use super::ReplayPlaybackState; use chrono::Datelike; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::{Card, Rank, Suit}; +use solitaire_core::{Card, Rank, Suit}; use solitaire_core::game_state::GameState; use solitaire_core::klondike_adapter::SavedKlondikePile; use solitaire_data::ReplayMove; diff --git a/solitaire_engine/src/replay_overlay/tests.rs b/solitaire_engine/src/replay_overlay/tests.rs index e22e845..32155ec 100644 --- a/solitaire_engine/src/replay_overlay/tests.rs +++ b/solitaire_engine/src/replay_overlay/tests.rs @@ -1,7 +1,7 @@ use super::*; use chrono::NaiveDate; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; use solitaire_data::{Replay, ReplayMove}; diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs index 109bebb..473aa38 100644 --- a/solitaire_engine/src/resources.rs +++ b/solitaire_engine/src/resources.rs @@ -7,7 +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::Card; use solitaire_core::game_state::GameState; /// Wraps the currently active `GameState`. Single source of truth for the in-progress game. diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index 6eb31ee..c87fdfe 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -38,7 +38,7 @@ use bevy::input::ButtonInput; use bevy::prelude::*; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Card; +use solitaire_core::Card; use solitaire_core::game_state::GameState; use crate::card_plugin::CardEntityIndex; @@ -534,7 +534,7 @@ fn handle_selection_keys( /// destination after a lift. Players who want a different column simply /// press the right-arrow key once or twice. pub(crate) fn legal_destinations_for( - _bottom: &solitaire_core::card::Card, + _bottom: &solitaire_core::Card, source: &KlondikePile, game: &GameState, stack_count: usize, @@ -579,7 +579,7 @@ 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, bool)]) -> usize { +fn face_up_run_len(cards: &[(solitaire_core::Card, bool)]) -> usize { let mut count = 0; for (_, face_up) in cards.iter().rev() { if *face_up { @@ -598,7 +598,7 @@ fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize { /// handler can attempt a foundation move first and fall through to a /// multi-card stack move rather than accepting a single-card tableau move. fn try_foundation_dest( - card: &solitaire_core::card::Card, + card: &solitaire_core::Card, game: &solitaire_core::game_state::GameState, ) -> Option { let source = game.pile_containing_card(card.clone())?; @@ -886,7 +886,7 @@ mod tests { #[test] fn face_up_run_len_all_face_up() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![ (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true), @@ -897,7 +897,7 @@ mod tests { #[test] fn face_up_run_len_mixed_stops_at_face_down() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![ (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), @@ -910,7 +910,7 @@ mod tests { #[test] fn face_up_run_len_top_card_face_down_is_zero() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![ (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), @@ -920,7 +920,7 @@ mod tests { #[test] fn face_up_run_len_single_face_up_card() { - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)]; assert_eq!(face_up_run_len(&cards), 1); } @@ -934,7 +934,7 @@ mod tests { // ----------------------------------------------------------------------- use bevy::ecs::message::Messages; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index b8c6f90..8aa375b 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -7,7 +7,7 @@ use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::{Foundation, KlondikePile, Tableau}; -use solitaire_core::card::Suit; +use solitaire_core::Suit; use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::hud_plugin::HudVisibility; @@ -520,7 +520,7 @@ fn sync_pile_marker_visibility( fn pile_cards( game: &solitaire_core::game_state::GameState, pile: &KlondikePile, -) -> Vec<(solitaire_core::card::Card, bool)> { +) -> Vec<(solitaire_core::Card, bool)> { match pile { KlondikePile::Stock => { let stock = game.stock_cards(); diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs index 86fde60..2e6d751 100644 --- a/solitaire_engine/src/theme/mod.rs +++ b/solitaire_engine/src/theme/mod.rs @@ -27,7 +27,7 @@ use bevy::reflect::TypePath; use serde::{Deserialize, Serialize}; use thiserror::Error; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; #[cfg(not(target_arch = "wasm32"))] pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index 6ffe8dc..f72e015 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -12,7 +12,7 @@ use bevy::asset::AssetEvent; use bevy::ecs::message::MessageReader; use bevy::math::UVec2; use bevy::prelude::*; -use solitaire_core::card::{Rank, Suit}; +use solitaire_core::{Rank, Suit}; use crate::assets::{ bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, diff --git a/solitaire_engine/src/touch_selection_plugin.rs b/solitaire_engine/src/touch_selection_plugin.rs index f26e854..d976f61 100644 --- a/solitaire_engine/src/touch_selection_plugin.rs +++ b/solitaire_engine/src/touch_selection_plugin.rs @@ -29,7 +29,7 @@ use bevy::ecs::message::MessageReader; use bevy::prelude::*; use solitaire_core::KlondikePile; -use solitaire_core::card::Card; +use solitaire_core::Card; use crate::card_plugin::CardEntity; use crate::events::StateChangedEvent; @@ -194,7 +194,7 @@ fn spawn_touch_highlight( mod tests { use super::*; use solitaire_core::Tableau; - use solitaire_core::card::{Card, Deck, Rank, Suit}; + use solitaire_core::{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. diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 05775b7..e85819f 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -21,7 +21,7 @@ use chrono::NaiveDate; use solitaire_core::{Foundation, KlondikePile, Tableau}; use serde::{Deserialize, Serialize}; -use solitaire_core::card::Suit; +use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::error::MoveError; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::klondike_adapter::{ @@ -77,9 +77,11 @@ pub struct StateSnapshot { /// means the card back is drawn; in that case `suit` and `rank` are /// still set (so the renderer doesn't need separate "unknown" data), /// just hidden visually. -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct CardSnapshot { - pub id: u32, + /// Stable per-card identity for the JS renderer (an opaque key). Serialises + /// as the upstream `Card`'s transparent integer value. + pub id: Card, /// `"clubs" | "diamonds" | "hearts" | "spades"`. pub suit: &'static str, /// 1-13, where 1 is Ace and 13 is King. @@ -87,14 +89,10 @@ pub struct CardSnapshot { pub face_up: bool, } -// Stable 0..=51 card identity, shared with the desktop engine via -// solitaire_core so replay snapshots are identical across platforms. -use solitaire_core::card::card_to_id; - -impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot { - fn from((card, face_up): &(solitaire_core::card::Card, bool)) -> Self { +impl From<&(Card, bool)> for CardSnapshot { + fn from((card, face_up): &(Card, bool)) -> Self { Self { - id: card_to_id(card), + id: card.clone(), suit: match card.suit() { Suit::Clubs => "clubs", Suit::Diamonds => "diamonds", @@ -318,9 +316,10 @@ pub enum DebugMove { pub struct DebugInvariantReport { pub state_ok: bool, pub total_cards_seen: usize, - pub duplicate_card_ids: Vec, - pub missing_card_ids: Vec, - pub out_of_range_card_ids: Vec, + /// Cards that appeared more than once across all piles. + pub duplicate_cards: Vec, + /// Cards from the full single-deck set that are absent from the board. + pub missing_cards: Vec, pub stock_has_face_up_cards: bool, pub waste_has_face_down_cards: bool, pub foundation_has_face_down_cards: bool, @@ -389,24 +388,15 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb game.pile(KlondikePile::Tableau(Tableau::Tableau7)), ]; - let mut seen = [false; 52]; - let mut duplicate_card_ids = Vec::new(); - let mut out_of_range_card_ids = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut duplicate_cards = Vec::new(); let mut total_cards_seen = 0_usize; - let mut feed = |cards: &[(solitaire_core::card::Card, bool)]| { + let mut feed = |cards: &[(Card, bool)]| { for (card, _) in cards { total_cards_seen += 1; - let id = card_to_id(card); - if id >= 52 { - out_of_range_card_ids.push(id); - continue; - } - let idx = id as usize; - if seen[idx] { - duplicate_card_ids.push(id); - } else { - seen[idx] = true; + if !seen.insert(card.clone()) { + duplicate_cards.push(card.clone()); } } }; @@ -420,9 +410,22 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb feed(pile); } - let missing_card_ids = (0_u32..52_u32) - .filter(|id| !seen[*id as usize]) - .collect::>(); + // Reference set: the full 52-card single deck, using whichever deck id the + // dealt cards carry. Any of those 52 not on the board is missing. + let deck = seen + .iter() + .next() + .map(|c| c.deck()) + .unwrap_or_else(|| Deck::new(0).expect("deck id 0 is valid")); + let mut missing_cards = Vec::new(); + for suit in Suit::SUITS { + for rank in Rank::RANKS { + let card = Card::new(deck, suit, rank); + if !seen.contains(&card) { + missing_cards.push(card); + } + } + } 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); @@ -444,9 +447,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty(); - let state_ok = duplicate_card_ids.is_empty() - && missing_card_ids.is_empty() - && out_of_range_card_ids.is_empty() + let state_ok = duplicate_cards.is_empty() + && missing_cards.is_empty() && !stock_has_face_up_cards && !waste_has_face_down_cards && !foundation_has_face_down_cards @@ -455,9 +457,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb DebugInvariantReport { state_ok, total_cards_seen, - duplicate_card_ids, - missing_card_ids, - out_of_range_card_ids, + duplicate_cards, + missing_cards, stock_has_face_up_cards, waste_has_face_down_cards, foundation_has_face_down_cards, @@ -893,6 +894,25 @@ mod tests { use std::collections::HashSet; use std::fmt::Write; + /// The JS card renderer reads `card.id` as an opaque identity key. After the + /// `card_to_id` removal, `CardSnapshot.id` is a `card_game::Card`, which is + /// `#[serde(transparent)]` over `NonZeroU8` — so it must still serialise as a + /// plain JSON number (the same `Serialize` impl `serde_wasm_bindgen` uses). + #[test] + fn card_snapshot_id_serialises_as_a_plain_number() { + let card = Card::new(Deck::new(0).unwrap(), Suit::Hearts, Rank::RANKS[0]); + let snap = CardSnapshot::from(&(card, true)); + let json = serde_json::to_value(&snap).expect("serialise CardSnapshot"); + assert!( + json["id"].is_number(), + "card.id must serialise as a JSON number for the JS opaque key, got {:?}", + json["id"] + ); + assert_eq!(json["suit"], "hearts"); + assert_eq!(json["rank"], 1); + assert_eq!(json["face_up"], true); + } + fn pick_move_index(moves: &[DebugMove]) -> Option { if moves.is_empty() { return None; @@ -933,7 +953,7 @@ mod tests { for card in cards { let _ = write!( key, - "{}:{}:{},", + "{:?}:{}:{},", card.id, card.rank, if card.face_up { 1 } else { 0 }