refactor(core): complete card_game::Card migration across engine + wasm
Build and Deploy / build-and-push (push) Failing after 1m2s
Web E2E / web-e2e (push) Failing after 3m19s

Finish the half-applied Card refactor. solitaire_core::card::Card is now an
alias for the opaque card_game::Card: suit()/rank() are methods, there is no
id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors
return Vec<(Card, bool)> where the bool is face-up.

Card identity is now the Card value itself (via Eq/Hash), not a numeric u32:
- CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards.
- Drag/selection collections and the touch/keyboard selection setters use
  Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card.
- replay_overlay and feedback/settle/deal animations updated accordingly.

solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the
desktop engine), and consumes the (Card, bool) pile tuples.

test-support: TestPileState tableau overrides now carry a per-card face-up flag
so tests can place face-down tableau cards. set_test_tableau_cards keeps its
Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face
takes Vec<(Card, bool)>.

cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy
--workspace --all-targets -- -D warnings is clean. Save/serde format unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-09 17:45:34 -07:00
parent 920f2c8597
commit 1438fd6265
22 changed files with 549 additions and 922 deletions
+26 -32
View File
@@ -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<Card> {
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),