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
+46 -104
View File
@@ -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<u32>,
cards: Vec<Card>,
/// 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<u32> = source_cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = source_cards.get(start) else {
let lifted_cards: Vec<Card> =
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<KlondikePile> {
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<Card> {
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<Card> {
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<Card> {
}
/// 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::<DragState>();
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::<DragState>();
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;