refactor: remove card.rs / card_to_id; use card_game::Card directly (#83)

card_to_id was a frankenstein 0..=51 id shim. Replace it with card_game::Card:
- feedback_anim deal jitter now seeds off a hash of the Card itself
- radial_menu RightClickRadialState.cards: Vec<u32> -> Vec<Card>
- wasm CardSnapshot.id: u32 -> Card (serialises transparently as a plain JS
  number, the same opaque key the renderer already used; new test asserts the
  JSON id field is a number)
- wasm DebugInvariantReport deck-completeness check reworked from a [bool;52]
  index into a HashSet<Card> + Card::new reference deck; the out-of-range check
  is dropped since a Card is always valid

Delete card.rs entirely: the Card/Deck/Rank/Suit re-exports move to the crate
root and the 69 `solitaire_core::card::` import paths flatten to `solitaire_core::`.

The JS card.id is purely an opaque identity key (Map key / dataset.cardId, no
arithmetic, card faces render from rank+suit), so the value change is safe.
cargo test --workspace and clippy --workspace --all-targets -- -D warnings green.

Closes #83

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-11 19:33:47 -07:00
parent 5c992cbdca
commit e0a858d4e8
23 changed files with 132 additions and 137 deletions
-23
View File
@@ -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)
}
+1 -2
View File
@@ -1,5 +1,4 @@
pub mod achievement; pub mod achievement;
pub mod card;
pub mod error; pub mod error;
pub mod game_state; pub mod game_state;
pub mod klondike_adapter; pub mod klondike_adapter;
@@ -12,7 +11,7 @@ pub mod klondike_adapter;
// re-exported — they are only used internally (in `klondike_adapter.rs` and // re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not // when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature. // 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}; pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
// Solvability check API (delegates to `card_game::Session::solve`); replaces the // Solvability check API (delegates to `card_game::Session::solve`); replaces the
+1 -1
View File
@@ -22,7 +22,7 @@
//! red/black colour split. //! red/black colour split.
use bevy::math::UVec2; 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 /// Target rasterisation size in pixels (2:3 aspect, half the default
/// `SvgLoaderSettings` resolution). /// `SvgLoaderSettings` resolution).
+2 -2
View File
@@ -168,7 +168,7 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau}; 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}; use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App { fn headless_app() -> App {
@@ -207,7 +207,7 @@ mod tests {
} }
g.set_test_tableau_cards( g.set_test_tableau_cards(
Tableau::Tableau1, 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); g.set_test_auto_completable(true);
let expected = ( let expected = (
@@ -33,7 +33,7 @@ use std::collections::VecDeque;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::card::Card; use solitaire_core::Card;
use super::animation::CardAnimation; use super::animation::CardAnimation;
use super::tuning::AnimationTuning; use super::tuning::AnimationTuning;
+2 -2
View File
@@ -17,7 +17,7 @@ use bevy::prelude::*;
use bevy::sprite::Anchor; use bevy::sprite::Anchor;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau}; 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 solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
@@ -2472,7 +2472,7 @@ mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::card::Deck; use solitaire_core::Deck;
/// Convenience constructor — all unit tests use Deck1. /// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card { fn make_card(suit: Suit, rank: Rank) -> Card {
+2 -2
View File
@@ -34,7 +34,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Card; use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
@@ -580,7 +580,7 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
use crate::layout::compute_layout; 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}}; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
/// Builds an `App` with `MinimalPlugins` and the overlay system /// Builds an `App` with `MinimalPlugins` and the overlay system
+1 -1
View File
@@ -2,7 +2,7 @@
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::card::{Card, Suit}; use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse; use solitaire_sync::SyncResponse;
+11 -11
View File
@@ -43,7 +43,7 @@ use std::hash::{Hash, Hasher};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw; use bevy::window::RequestRedraw;
use solitaire_core::card::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot; use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed; 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 % (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 // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -413,10 +409,14 @@ fn start_deal_anim(
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() { for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
let final_pos = transform.translation; let final_pos = transform.translation;
// ±10 % jitter, deterministic per card id, so the deal feels organic // ±10 % jitter, deterministic per card, so the deal feels organic
// without losing reproducibility (a given seed still produces the // without losing reproducibility (a given deal produces the same
// same per-card stagger pattern across runs). // per-card stagger pattern across runs). The seed is a hash of the
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card))); // 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(( commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)), Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim { CardAnim {
@@ -639,7 +639,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
fn pile_cards( fn pile_cards(
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
pile: &KlondikePile, pile: &KlondikePile,
) -> Vec<(solitaire_core::card::Card, bool)> { ) -> Vec<(solitaire_core::Card, bool)> {
match pile { match pile {
KlondikePile::Stock => game.waste_cards(), KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile), _ => game.pile(*pile),
@@ -917,7 +917,7 @@ mod tests {
.resource_mut::<Messages<FoundationCompletedEvent>>() .resource_mut::<Messages<FoundationCompletedEvent>>()
.write(FoundationCompletedEvent { .write(FoundationCompletedEvent {
slot: 0, slot: 0,
suit: solitaire_core::card::Suit::Spades, suit: solitaire_core::Suit::Spades,
}); });
app.update(); app.update();
+9 -9
View File
@@ -824,7 +824,7 @@ fn handle_draw(
// so we can fire flip events after they land face-up in the waste. // 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 // 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. // stock face-down, so no flip events are needed in that case.
let drawn_cards: Vec<solitaire_core::card::Card> = { let drawn_cards: Vec<solitaire_core::Card> = {
let stock = game.0.stock_cards(); let stock = game.0.stock_cards();
if stock.is_empty() { if stock.is_empty() {
Vec::new() 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 { match pile {
KlondikePile::Stock => game.waste_cards(), KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile), _ => game.pile(*pile),
@@ -1391,7 +1391,7 @@ mod tests {
#[test] #[test]
fn new_game_request_reseeds() { fn new_game_request_reseeds() {
let mut app = test_app(1); let mut app = test_app(1);
let before: Vec<solitaire_core::card::Card> = app let before: Vec<solitaire_core::Card> = app
.world() .world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
.0 .0
@@ -1407,7 +1407,7 @@ mod tests {
}); });
app.update(); app.update();
let after: Vec<solitaire_core::card::Card> = app let after: Vec<solitaire_core::Card> = app
.world() .world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
.0 .0
@@ -1649,7 +1649,7 @@ mod tests {
#[test] #[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() { 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); let mut app = test_app(1);
// Build a tableau with two face-up cards. // Build a tableau with two face-up cards.
{ {
@@ -1706,7 +1706,7 @@ mod tests {
// Klondike (unlimited recycles), even if the drawn card cannot be // Klondike (unlimited recycles), even if the drawn card cannot be
// immediately placed. The game is only stuck when both stock AND waste // immediately placed. The game is only stuck when both stock AND waste
// are exhausted and no visible card can be moved. // 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); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
for foundation in [ for foundation in [
Foundation::Foundation1, Foundation::Foundation1,
@@ -1742,7 +1742,7 @@ mod tests {
#[test] #[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() { 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); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Empty stock and waste so draw is NOT available. // 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 // 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) // card of its column the previous code would return false (softlock)
// even though the player can still move that run. // 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); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_stock_cards(Vec::new()); game.set_test_stock_cards(Vec::new());
@@ -1976,7 +1976,7 @@ mod tests {
/// to have been a King. /// to have been a King.
#[test] #[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() { 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); let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible, // Reset the world: clear stock + waste so a draw isn't possible,
+1 -1
View File
@@ -9,7 +9,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit; use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
+19 -19
View File
@@ -27,7 +27,7 @@ use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode}; use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; 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 solitaire_core::game_state::GameState;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -1953,8 +1953,8 @@ mod tests {
fn find_draggable_returns_run_when_picking_mid_stack() { fn find_draggable_returns_run_when_picking_mid_stack() {
// Manually construct a tableau with three face-up cards all stacked. // Manually construct a tableau with three face-up cards all stacked.
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let king = Card::new(D::Deck1, Suit::Spades, Rank::King); let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
@@ -1980,8 +1980,8 @@ mod tests {
#[test] #[test]
fn find_draggable_skips_non_top_waste_card() { fn find_draggable_skips_non_top_waste_card() {
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]); game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
@@ -2044,8 +2044,8 @@ mod tests {
#[test] #[test]
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() { fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic); let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
// Three waste cards; top (four_clubs) is rightmost in the fan. // Three waste cards; top (four_clubs) is rightmost in the fan.
@@ -2103,8 +2103,8 @@ mod tests {
#[test] #[test]
fn best_destination_returns_none_when_no_legal_move() { fn best_destination_returns_none_when_no_legal_move() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Clear everything except one card that has nowhere to go. // Clear everything except one card that has nowhere to go.
@@ -2121,8 +2121,8 @@ mod tests {
#[test] #[test]
fn best_tableau_destination_for_stack_skips_source_pile() { fn best_tableau_destination_for_stack_skips_source_pile() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
clear_test_piles(&mut game); clear_test_piles(&mut game);
@@ -2147,8 +2147,8 @@ mod tests {
#[test] #[test]
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() { fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
clear_test_piles(&mut game); clear_test_piles(&mut game);
@@ -2176,8 +2176,8 @@ mod tests {
#[test] #[test]
fn find_hint_finds_ace_to_foundation() { fn find_hint_finds_ace_to_foundation() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Place Ace of Clubs on top of tableau 0. // 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. /// are no other moves and the stock is non-empty.
#[test] #[test]
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() { fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne); let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Remove all foundation, tableau, and waste cards so no pile-to-pile // 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. /// gets a CardAnimation" — same coverage, new component.
#[test] #[test]
fn rejected_drag_inserts_card_animation_on_each_dragged_card() { fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
use solitaire_core::card::Deck as D; use solitaire_core::Deck as D;
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
// Simulate a stack drag of two cards. // Simulate a stack drag of two cards.
let dragged_cards: Vec<Card> = vec![ let dragged_cards: Vec<Card> = vec![
Card::new(D::Deck1, Suit::Hearts, Rank::King), Card::new(D::Deck1, Suit::Hearts, Rank::King),
+1 -1
View File
@@ -179,7 +179,7 @@ mod tests {
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau}; 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}; use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
+6 -7
View File
@@ -48,7 +48,7 @@ use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
@@ -113,9 +113,9 @@ pub enum RightClickRadialState {
/// radial is built around single-card foundation/tableau /// radial is built around single-card foundation/tableau
/// shortcuts and that matches the right-click highlight set). /// shortcuts and that matches the right-click highlight set).
count: usize, 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. /// always equals `count`. Currently always one element.
cards: Vec<u32>, cards: Vec<Card>,
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs. /// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
/// ///
/// Anchors are evenly spaced around a ring of radius /// 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] { const fn foundations() -> [Foundation; 4] {
[ [
@@ -500,7 +499,7 @@ fn radial_open_on_right_click(
*state = RightClickRadialState::Active { *state = RightClickRadialState::Active {
source_pile, source_pile,
count: 1, count: 1,
cards: vec![card_to_id(&card)], cards: vec![card.clone()],
legal_destinations, legal_destinations,
centre: world, centre: world,
hovered_index: None, hovered_index: None,
@@ -573,7 +572,7 @@ fn radial_open_on_long_press(
*state = RightClickRadialState::Active { *state = RightClickRadialState::Active {
source_pile, source_pile,
count: 1, count: 1,
cards: vec![card_to_id(&card)], cards: vec![card.clone()],
legal_destinations, legal_destinations,
centre: world, centre: world,
hovered_index: None, hovered_index: None,
@@ -796,7 +795,7 @@ mod tests {
use super::*; use super::*;
use crate::layout::compute_layout; use crate::layout::compute_layout;
use bevy::ecs::message::Messages; 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}; use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the /// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
@@ -1,7 +1,7 @@
use super::ReplayPlaybackState; use super::ReplayPlaybackState;
use chrono::Datelike; use chrono::Datelike;
use solitaire_core::{Foundation, KlondikePile, Tableau}; 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::game_state::GameState;
use solitaire_core::klondike_adapter::SavedKlondikePile; use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_data::ReplayMove; use solitaire_data::ReplayMove;
+1 -1
View File
@@ -1,7 +1,7 @@
use super::*; use super::*;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau}; 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::{DrawStockConfig, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::{Replay, ReplayMove};
+1 -1
View File
@@ -7,7 +7,7 @@ use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::card::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+9 -9
View File
@@ -38,7 +38,7 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use crate::card_plugin::CardEntityIndex; use crate::card_plugin::CardEntityIndex;
@@ -534,7 +534,7 @@ fn handle_selection_keys(
/// destination after a lift. Players who want a different column simply /// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice. /// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for( pub(crate) fn legal_destinations_for(
_bottom: &solitaire_core::card::Card, _bottom: &solitaire_core::Card,
source: &KlondikePile, source: &KlondikePile,
game: &GameState, game: &GameState,
stack_count: usize, 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 /// 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 /// (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. /// 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; let mut count = 0;
for (_, face_up) in cards.iter().rev() { for (_, face_up) in cards.iter().rev() {
if *face_up { 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 /// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move. /// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest( fn try_foundation_dest(
card: &solitaire_core::card::Card, card: &solitaire_core::Card,
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
) -> Option<KlondikePile> { ) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
@@ -886,7 +886,7 @@ mod tests {
#[test] #[test]
fn face_up_run_len_all_face_up() { 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![ let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
@@ -897,7 +897,7 @@ mod tests {
#[test] #[test]
fn face_up_run_len_mixed_stops_at_face_down() { 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![ let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false), (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
@@ -910,7 +910,7 @@ mod tests {
#[test] #[test]
fn face_up_run_len_top_card_face_down_is_zero() { 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![ let cards = vec![
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true), (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false), (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
@@ -920,7 +920,7 @@ mod tests {
#[test] #[test]
fn face_up_run_len_single_face_up_card() { 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)]; let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
assert_eq!(face_up_run_len(&cards), 1); assert_eq!(face_up_run_len(&cards), 1);
} }
@@ -934,7 +934,7 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
use bevy::ecs::message::Messages; 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}; use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
+2 -2
View File
@@ -7,7 +7,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit; use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility; use crate::hud_plugin::HudVisibility;
@@ -520,7 +520,7 @@ fn sync_pile_marker_visibility(
fn pile_cards( fn pile_cards(
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
pile: &KlondikePile, pile: &KlondikePile,
) -> Vec<(solitaire_core::card::Card, bool)> { ) -> Vec<(solitaire_core::Card, bool)> {
match pile { match pile {
KlondikePile::Stock => { KlondikePile::Stock => {
let stock = game.stock_cards(); let stock = game.stock_cards();
+1 -1
View File
@@ -27,7 +27,7 @@ use bevy::reflect::TypePath;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::{Rank, Suit};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
+1 -1
View File
@@ -12,7 +12,7 @@ use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::math::UVec2; use bevy::math::UVec2;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::{Rank, Suit};
use crate::assets::{ use crate::assets::{
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
@@ -29,7 +29,7 @@
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::card::Card; use solitaire_core::Card;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
@@ -194,7 +194,7 @@ fn spawn_touch_highlight(
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::Tableau; 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]` /// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
/// numeric ids. Identity is now the `Card` value. /// numeric ids. Identity is now the `Card` value.
+57 -37
View File
@@ -21,7 +21,7 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError; use solitaire_core::error::MoveError;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::klondike_adapter::{ 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 /// means the card back is drawn; in that case `suit` and `rank` are
/// still set (so the renderer doesn't need separate "unknown" data), /// still set (so the renderer doesn't need separate "unknown" data),
/// just hidden visually. /// just hidden visually.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CardSnapshot { 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"`. /// `"clubs" | "diamonds" | "hearts" | "spades"`.
pub suit: &'static str, pub suit: &'static str,
/// 1-13, where 1 is Ace and 13 is King. /// 1-13, where 1 is Ace and 13 is King.
@@ -87,14 +89,10 @@ pub struct CardSnapshot {
pub face_up: bool, pub face_up: bool,
} }
// Stable 0..=51 card identity, shared with the desktop engine via impl From<&(Card, bool)> for CardSnapshot {
// solitaire_core so replay snapshots are identical across platforms. fn from((card, face_up): &(Card, bool)) -> Self {
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 {
Self { Self {
id: card_to_id(card), id: card.clone(),
suit: match card.suit() { suit: match card.suit() {
Suit::Clubs => "clubs", Suit::Clubs => "clubs",
Suit::Diamonds => "diamonds", Suit::Diamonds => "diamonds",
@@ -318,9 +316,10 @@ pub enum DebugMove {
pub struct DebugInvariantReport { pub struct DebugInvariantReport {
pub state_ok: bool, pub state_ok: bool,
pub total_cards_seen: usize, pub total_cards_seen: usize,
pub duplicate_card_ids: Vec<u32>, /// Cards that appeared more than once across all piles.
pub missing_card_ids: Vec<u32>, pub duplicate_cards: Vec<Card>,
pub out_of_range_card_ids: Vec<u32>, /// Cards from the full single-deck set that are absent from the board.
pub missing_cards: Vec<Card>,
pub stock_has_face_up_cards: bool, pub stock_has_face_up_cards: bool,
pub waste_has_face_down_cards: bool, pub waste_has_face_down_cards: bool,
pub foundation_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)), game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
]; ];
let mut seen = [false; 52]; let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
let mut duplicate_card_ids = Vec::new(); let mut duplicate_cards = Vec::new();
let mut out_of_range_card_ids = Vec::new();
let mut total_cards_seen = 0_usize; 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 { for (card, _) in cards {
total_cards_seen += 1; total_cards_seen += 1;
let id = card_to_id(card); if !seen.insert(card.clone()) {
if id >= 52 { duplicate_cards.push(card.clone());
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;
} }
} }
}; };
@@ -420,9 +410,22 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
feed(pile); feed(pile);
} }
let missing_card_ids = (0_u32..52_u32) // Reference set: the full 52-card single deck, using whichever deck id the
.filter(|id| !seen[*id as usize]) // dealt cards carry. Any of those 52 not on the board is missing.
.collect::<Vec<_>>(); 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 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 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 soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_card_ids.is_empty() let state_ok = duplicate_cards.is_empty()
&& missing_card_ids.is_empty() && missing_cards.is_empty()
&& out_of_range_card_ids.is_empty()
&& !stock_has_face_up_cards && !stock_has_face_up_cards
&& !waste_has_face_down_cards && !waste_has_face_down_cards
&& !foundation_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 { DebugInvariantReport {
state_ok, state_ok,
total_cards_seen, total_cards_seen,
duplicate_card_ids, duplicate_cards,
missing_card_ids, missing_cards,
out_of_range_card_ids,
stock_has_face_up_cards, stock_has_face_up_cards,
waste_has_face_down_cards, waste_has_face_down_cards,
foundation_has_face_down_cards, foundation_has_face_down_cards,
@@ -893,6 +894,25 @@ mod tests {
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Write; 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<usize> { fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
if moves.is_empty() { if moves.is_empty() {
return None; return None;
@@ -933,7 +953,7 @@ mod tests {
for card in cards { for card in cards {
let _ = write!( let _ = write!(
key, key,
"{}:{}:{},", "{:?}:{}:{},",
card.id, card.id,
card.rank, card.rank,
if card.face_up { 1 } else { 0 } if card.face_up { 1 } else { 0 }