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
+29 -74
View File
@@ -818,7 +818,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_ids: Vec<u32> = {
let drawn_cards: Vec<solitaire_core::card::Card> = {
let stock = game.0.stock_cards();
if stock.is_empty() {
Vec::new()
@@ -829,15 +829,15 @@ fn handle_draw(
};
let n = stock.len();
let take = n.min(draw_count);
stock[n - take..].iter().map(|c| c.id).collect()
stock[n - take..].iter().map(|c| c.0.clone()).collect()
}
};
match game.0.draw() {
Ok(()) => {
// Fire a flip event for each card that moved from stock to waste.
for id in drawn_ids {
flipped.write(CardFlippedEvent(id));
for card in drawn_cards {
flipped.write(CardFlippedEvent(card));
}
// Record the atomic player input. Whether the engine
// resolves this to a draw or a waste→stock recycle is
@@ -869,11 +869,11 @@ fn handle_move(
// Identify the card that will be exposed (and may flip face-up) by the move.
// It's the card just below the bottom of the moving stack in the source pile.
let source_cards = pile_cards(&game.0, &ev.from);
let flip_candidate_id = {
let flip_candidate = {
let n = source_cards.len();
if n > ev.count {
let c = &source_cards[n - ev.count - 1];
if !c.face_up { Some(c.id) } else { None }
if !c.1 { Some(c.0.clone()) } else { None }
} else {
None
}
@@ -889,12 +889,12 @@ fn handle_move(
count: ev.count,
});
// Fire flip event if the candidate card is now face-up.
if let Some(fid) = flip_candidate_id
if let Some(fcard) = flip_candidate
&& pile_cards(&game.0, &ev.from)
.last()
.is_some_and(|c| c.id == fid && c.face_up)
.is_some_and(|c| c.0 == fcard && c.1)
{
flipped.write(crate::events::CardFlippedEvent(fid));
flipped.write(crate::events::CardFlippedEvent(fcard));
}
// If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit
@@ -905,7 +905,7 @@ fn handle_move(
if let KlondikePile::Foundation(slot) = ev.to
&& let Some(slot) = foundation_slot(slot)
&& game.0.pile(ev.to).len() == 13
&& let Some(suit) = game.0.pile(ev.to).first().map(|c| c.suit)
&& let Some(suit) = game.0.pile(ev.to).first().map(|c| c.0.suit())
{
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
@@ -1007,7 +1007,7 @@ pub fn record_replay_on_win(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -1385,13 +1385,13 @@ mod tests {
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app
let before: Vec<solitaire_core::card::Card> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.map(|c| c.0.clone())
.collect();
app.world_mut().write_message(NewGameRequestEvent {
@@ -1401,13 +1401,13 @@ mod tests {
});
app.update();
let after: Vec<u32> = app
let after: Vec<solitaire_core::card::Card> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.map(|c| c.0.clone())
.collect();
assert_ne!(before, after);
}
@@ -1643,7 +1643,7 @@ mod tests {
#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Build a tableau with two face-up cards.
{
@@ -1651,28 +1651,13 @@ mod tests {
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 910,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 911,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card::new(Deck::Deck1, Suit::Clubs, Rank::King),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen),
],
);
gs.0.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 912,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
);
}
@@ -1715,7 +1700,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, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for foundation in [
Foundation::Foundation1,
@@ -1739,12 +1724,7 @@ mod tests {
game.set_test_waste_cards(Vec::new());
let mut stock = Vec::new();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.push(Card {
id: 100 + r as u32,
suit: Suit::Hearts,
rank: r,
face_up: false,
});
stock.push(Card::new(Deck::Deck1, Suit::Hearts, r));
}
game.set_test_stock_cards(stock);
// Stock is non-empty, so drawing is always a valid move.
@@ -1756,7 +1736,7 @@ mod tests {
#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Empty stock and waste so draw is NOT available.
@@ -1785,12 +1765,7 @@ mod tests {
}
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
assert!(
@@ -1805,7 +1780,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, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
game.set_test_stock_cards(Vec::new());
@@ -1836,28 +1811,13 @@ mod tests {
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 10,
suit: Suit::Spades,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 11,
suit: Suit::Hearts,
rank: Rank::Jack,
face_up: true,
},
Card::new(Deck::Deck1, Suit::Spades, Rank::Queen),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Jack),
],
);
game.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 12,
suit: Suit::Diamonds,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
assert!(
@@ -2010,7 +1970,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, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible,
@@ -2042,12 +2002,7 @@ mod tests {
}
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 7_000,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
);
}