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
+34 -19
View File
@@ -43,6 +43,7 @@ use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::card::Card;
use solitaire_core::{Foundation, KlondikePile};
use solitaire_data::AnimSpeed;
@@ -187,6 +188,20 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
}
/// Converts a `Card` to a `u32` seed suitable for deterministic per-card
/// jitter. Uses suit index × 13 + (rank value 1) to produce a stable 051
/// integer that survives changes to the internal `Card` representation.
fn card_to_id(card: &Card) -> u32 {
use solitaire_core::card::Suit;
let suit_index = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
suit_index * 13 + (card.rank().value() as u32 - 1)
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -245,16 +260,16 @@ fn start_shake_anim(
continue;
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
// Collect the cards that belong to the destination pile.
let dest_cards = pile_cards(&game.0, dest_pile);
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
if dest_card_ids.is_empty() {
if dest_card_set.is_empty() {
continue;
}
for (entity, card_marker, transform) in card_entities.iter() {
if dest_card_ids.contains(&card_marker.card_id) {
if dest_card_set.contains(&card_marker.card) {
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: transform.translation.x,
@@ -311,27 +326,27 @@ fn start_settle_anim(
card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands,
) {
// Build the list of card ids that should bounce this frame from every
// Build the list of cards that should bounce this frame from every
// queued request; multiple events can fire in the same frame (e.g. a move
// followed by a draw via keyboard accelerators).
let mut bounce_ids: Vec<u32> = Vec::new();
let mut bounce_ids: Vec<Card> = Vec::new();
for ev in moves.read() {
let pile = pile_cards(&game.0, &ev.to);
if !pile.is_empty() {
// The moved cards land on top — take the last `count` ids.
// The moved cards land on top — take the last `count` cards.
let n = ev.count.min(pile.len());
if n > 0 {
let start = pile.len() - n;
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
}
}
}
if draws.read().next().is_some()
&& let Some(top) = game.0.waste_cards().last()
&& let Some((top, _)) = game.0.waste_cards().last()
{
bounce_ids.push(top.id);
bounce_ids.push(top.clone());
}
if bounce_ids.is_empty() {
@@ -339,7 +354,7 @@ fn start_settle_anim(
}
for (entity, card_marker) in card_entities.iter() {
if bounce_ids.contains(&card_marker.card_id) {
if bounce_ids.contains(&card_marker.card) {
commands.entity(entity).insert(SettleAnim::default());
}
}
@@ -410,7 +425,7 @@ fn start_deal_anim(
// ±10 % jitter, deterministic per card id, so the deal feels organic
// without losing reproducibility (a given seed still produces the
// same per-card stagger pattern across runs).
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card)));
commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim {
@@ -524,13 +539,13 @@ fn start_foundation_flourish(
let pile_type = KlondikePile::Foundation(foundation);
// Top card of the completed foundation is the King.
let cards = game.0.pile(pile_type);
let Some(king_id) = cards.last().map(|c| c.id) else {
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id {
if card_marker.card == king_card {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
@@ -633,7 +648,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<solitaire_core::card::Card> {
) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -865,19 +880,19 @@ mod tests {
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
let card_id = app
let card = app
.world()
.resource::<GameStateResource>()
.0
.pile(dest_pile)
.last()
.map(|c| c.id)
.map(|(c, _)| c.clone())
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would
// Spawn a minimal CardEntity matching that card so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut()
.spawn((CardEntity { card_id }, Transform::default()));
.spawn((CardEntity { card }, Transform::default()));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()