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
+97 -166
View File
@@ -370,10 +370,10 @@ pub fn emit_hint_visuals(
// Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from);
let top_card_id = source_cards.last().filter(|c| c.face_up).map(|c| c.id);
if let Some(card_id) = top_card_id {
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card_id == card_id {
if card_entity.card == card {
// Tint the card gold without replacing the Sprite (which would
// discard the image handle set by CardImageSet). Uses the
// design-system `STATE_WARNING` token so the source-card
@@ -390,7 +390,7 @@ pub fn emit_hint_visuals(
// Emit HintVisualEvent so the destination pile marker is also
// tinted gold for 2 s.
hint_visual.write(HintVisualEvent {
source_card_id: card_id,
source_card: card,
dest_pile: *to,
});
}
@@ -401,7 +401,7 @@ pub fn emit_hint_visuals(
// player keeps thinking in suit terms; otherwise fall back to "foundation".
let msg = match to {
KlondikePile::Foundation(_) => {
let claimed = game.pile(*to).first().map(|c| c.suit);
let claimed = game.pile(*to).first().map(|(c, _)| c.suit());
if let Some(suit) = claimed {
let suit_name = match suit {
Suit::Clubs => "Clubs",
@@ -687,10 +687,10 @@ fn follow_drag(
// Elevate cards: push to DRAG_Z and dim slightly so the board
// beneath stays readable.
for (i, &id) in drag.cards.iter().enumerate() {
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card_id == id)
.find(|(ce, _, _)| ce.card == *card)
{
transform.translation.z = dragged_card_z(i);
sprite.color.set_alpha(0.85);
@@ -702,10 +702,10 @@ fn follow_drag(
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, &id) in drag.cards.iter().enumerate() {
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card_id == id)
.find(|(ce, _, _)| ce.card == *card)
{
transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32;
@@ -807,15 +807,16 @@ fn end_drag(
// that fires below does not fight this tween.
let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() {
for &card_id in &drag.cards {
let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id)
for card in &drag.cards {
let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else {
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card_id == card_id)
.find(|(_, ce, _)| ce.card == *card)
{
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
@@ -939,10 +940,10 @@ fn touch_follow_drag(
drag.committed = true;
for (i, &id) in drag.cards.iter().enumerate() {
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card_id == id)
.find(|(ce, _, _)| ce.card == *card)
{
transform.translation.z = dragged_card_z(i);
sprite.color.set_alpha(0.85);
@@ -953,10 +954,10 @@ fn touch_follow_drag(
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, &id) in drag.cards.iter().enumerate() {
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card_id == id)
.find(|(ce, _, _)| ce.card == *card)
{
transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32;
@@ -1046,15 +1047,16 @@ fn touch_end_drag(
// feel identical.
let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() {
for &card_id in &drag.cards {
let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id)
for card in &drag.cards {
let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else {
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card_id == card_id)
.find(|(_, ce, _)| ce.card == *card)
{
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
@@ -1142,8 +1144,8 @@ fn card_position(
let base = layout.pile_positions[pile];
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 {
for (_, face_up) in pile_cards(game, pile).iter().take(stack_index) {
let step = if *face_up {
layout.tableau_fan_frac
} else {
layout.tableau_facedown_fan_frac
@@ -1170,7 +1172,7 @@ fn find_draggable_at(
cursor: Vec2,
game: &GameState,
layout: &Layout,
) -> Option<(KlondikePile, usize, Vec<u32>)> {
) -> Option<(KlondikePile, usize, Vec<Card>)> {
// Search order: waste, foundations, tableau. Stock is skipped (click-to-draw).
// Within a pile, we consider cards top-down because the visual top card is drawn last.
let piles = [
@@ -1199,8 +1201,8 @@ fn find_draggable_at(
// Iterate from topmost to bottommost so the first hit is the one
// visually on top.
for i in (0..pile_cards.len()).rev() {
let card = &pile_cards[i];
if !card.face_up {
let (_, face_up) = pile_cards[i];
if !face_up {
continue;
}
let pos = card_position(game, layout, &pile, i);
@@ -1222,8 +1224,8 @@ fn find_draggable_at(
}
(i, i + 1)
};
let ids: Vec<u32> = pile_cards[start..end].iter().map(|c| c.id).collect();
return Some((pile, start, ids));
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
return Some((pile, start, cards));
}
}
None
@@ -1302,7 +1304,7 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
///
/// Returns `None` if no legal move exists from the card's current location.
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.id)?;
let source = game.pile_containing_card(card.clone())?;
for foundation in foundations() {
let dest = KlondikePile::Foundation(foundation);
@@ -1361,7 +1363,7 @@ fn handle_double_click(
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut last_click: Local<HashMap<u32, f32>>,
mut last_click: Local<HashMap<Card, f32>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
) {
@@ -1382,27 +1384,27 @@ fn handle_double_click(
};
// The topmost card in the draggable run — used as the double-click key.
let Some(&top_card_id) = card_ids.last() else {
let Some(top_card) = card_ids.last() else {
return;
};
let top_index = stack_index + card_ids.len() - 1;
let pile_cards = pile_cards(&game.0, &pile);
let Some(top_card) = pile_cards.get(top_index) else {
let Some((pile_top_card, pile_top_face_up)) = pile_cards.get(top_index) else {
return;
};
if !top_card.face_up || top_card.id != top_card_id {
if !*pile_top_face_up || pile_top_card != top_card {
return;
}
let now = time.elapsed_secs();
let prev = last_click
.get(&top_card_id)
.get(top_card)
.copied()
.unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_CLICK_WINDOW {
// Double-click confirmed.
last_click.remove(&top_card_id);
last_click.remove(top_card);
// Priority 1: move the single top card (foundation preferred, then tableau).
if let Some(dest) = best_destination(top_card, &game.0) {
@@ -1418,7 +1420,7 @@ fn handle_double_click(
// stack (card_ids.len() > 1), try moving the whole stack to another
// tableau column.
if card_ids.len() > 1
&& let Some(bottom_card) = pile_cards.get(stack_index)
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
&& let Some((dest, count)) =
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
{
@@ -1445,7 +1447,7 @@ fn handle_double_click(
});
} else {
// Single click — record the time.
last_click.insert(top_card_id, now);
last_click.insert(top_card.clone(), now);
}
}
@@ -1513,7 +1515,7 @@ fn handle_double_tap(
}
// Uncommitted touch ended = pure tap.
let Some(&top_card_id) = drag.cards.last() else {
let Some(top_card) = drag.cards.last() else {
return;
};
let Some(ref tapped_pile) = drag.origin_pile else {
@@ -1524,10 +1526,12 @@ fn handle_double_tap(
return;
}
let Some(top_card) = pile_cards.iter().find(|c| c.id == top_card_id) else {
let Some((found_card, found_face_up)) =
pile_cards.iter().find(|(c, _)| c == top_card)
else {
return;
};
if !top_card.face_up {
if !*found_face_up {
return;
}
@@ -1561,9 +1565,9 @@ fn handle_double_tap(
// --- One-tap auto-move (original behaviour) ---
// Priority 1: move single top card.
if let Some(dest) = best_destination(top_card, &game.0) {
if let Some(dest) = best_destination(found_card, &game.0) {
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if ce.card_id == top_card_id {
if ce.card == *top_card {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight {
remaining: DOUBLE_TAP_FLASH_SECS,
@@ -1582,7 +1586,7 @@ fn handle_double_tap(
// Priority 2: move whole face-up stack to best tableau column.
if drag.cards.len() > 1 {
let stack_index = pile_cards.len() - drag.cards.len();
if let Some(bottom_card) = pile_cards.get(stack_index)
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
tapped_pile,
@@ -1591,7 +1595,7 @@ fn handle_double_tap(
)
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card_id) {
if drag.cards.contains(&ce.card) {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight {
remaining: DOUBLE_TAP_FLASH_SECS,
@@ -1659,7 +1663,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
// Pass 1 — foundation moves (highest priority, shown first).
for from in &sources {
let from_pile = pile_cards(game, from);
let Some(_card) = from_pile.last().filter(|c| c.face_up) else {
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue;
};
for foundation in foundations() {
@@ -1675,7 +1679,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
// repeat the same source card multiple times for different destinations).
for from in &sources {
let from_pile = pile_cards(game, from);
let Some(_card) = from_pile.last().filter(|c| c.face_up) else {
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue;
};
let already_has_foundation_hint = hints
@@ -1701,7 +1705,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
for foundation in foundations() {
let from = KlondikePile::Foundation(foundation);
let from_pile = pile_cards(game, &from);
let Some(_card) = from_pile.last().filter(|c| c.face_up) else {
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue;
};
for tableau in tableaus() {
@@ -1731,7 +1735,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
hints
}
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),
@@ -1912,29 +1916,14 @@ mod tests {
fn find_draggable_returns_run_when_picking_mid_stack() {
// Manually construct a tableau with three face-up cards all stacked.
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
},
Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 102,
suit: Suit::Clubs,
rank: Rank::Jack,
face_up: true,
},
],
vec![king, queen.clone(), jack.clone()],
);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -1948,36 +1937,26 @@ mod tests {
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(start, 1);
assert_eq!(ids, vec![101, 102]);
assert_eq!(ids, vec![queen, jack]);
}
#[test]
fn find_draggable_skips_non_top_waste_card() {
let mut game = GameState::new(1, DrawMode::DrawOne);
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
game.set_test_waste_cards(vec![
Card {
id: 200,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
},
Card {
id: 201,
suit: Suit::Hearts,
rank: Rank::Three,
face_up: true,
},
]);
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1.
// the visually top card (three_hearts), with count = 1.
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Stock);
assert_eq!(start, 1);
assert_eq!(ids, vec![201]);
assert_eq!(ids, vec![three_hearts]);
}
#[test]
@@ -2028,30 +2007,15 @@ mod tests {
#[test]
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameMode};
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
// Three waste cards; top (id=202) is rightmost in the fan.
game.set_test_waste_cards(vec![
Card {
id: 200,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
},
Card {
id: 201,
suit: Suit::Hearts,
rank: Rank::Three,
face_up: true,
},
Card {
id: 202,
suit: Suit::Clubs,
rank: Rank::Four,
face_up: true,
},
]);
// Three waste cards; top (four_clubs) is rightmost in the fan.
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let waste_base = layout.pile_positions[&KlondikePile::Stock];
@@ -2066,7 +2030,7 @@ mod tests {
);
let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock);
assert_eq!(ids, vec![202], "only the top card is draggable from waste");
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
}
#[test]
@@ -2102,6 +2066,7 @@ mod tests {
#[test]
fn best_destination_returns_none_when_no_legal_move() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
@@ -2109,12 +2074,7 @@ mod tests {
clear_test_piles(&mut game);
// A Two of Clubs with empty foundations and empty tableau has no destination.
let card = Card {
id: 400,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
};
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
assert!(best_destination(&card, &game).is_none());
}
@@ -2124,6 +2084,7 @@ mod tests {
#[test]
fn best_tableau_destination_for_stack_skips_source_pile() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
@@ -2132,24 +2093,11 @@ mod tests {
// Only tableau 0 has anything; every other column is empty.
// A King is the only card that can go on an empty tableau column.
// Source is Tableau(0), so the result must NOT be Tableau(0).
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 200,
suit: Suit::Hearts,
rank: Rank::King,
face_up: true,
}],
);
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
let bottom_card = Card {
id: 200,
suit: Suit::Hearts,
rank: Rank::King,
face_up: true,
};
let result = best_tableau_destination_for_stack(
&bottom_card,
&king,
&KlondikePile::Tableau(Tableau::Tableau1),
&game,
1,
@@ -2162,6 +2110,7 @@ mod tests {
#[test]
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
@@ -2169,24 +2118,11 @@ mod tests {
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
// All other piles are empty — no legal tableau target.
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 300,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
}],
);
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
let bottom_card = Card {
id: 300,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
};
let result = best_tableau_destination_for_stack(
&bottom_card,
&two_clubs,
&KlondikePile::Tableau(Tableau::Tableau1),
&game,
1,
@@ -2203,20 +2139,14 @@ mod tests {
#[test]
fn find_hint_finds_ace_to_foundation() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Place Ace of Clubs on top of tableau 0.
clear_test_piles(&mut game);
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 500,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
);
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
let hint = find_hint(&game);
assert!(hint.is_some(), "should find a hint");
@@ -2254,6 +2184,7 @@ mod tests {
/// are no other moves and the stock is non-empty.
#[test]
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
@@ -2261,12 +2192,7 @@ mod tests {
// move exists. Leave one card in the stock.
clear_test_piles(&mut game);
// Put one card back into the stock so "draw" is a valid suggestion.
game.set_test_stock_cards(vec![Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: false,
}]);
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
let hints = all_hints(&game);
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
@@ -2312,20 +2238,25 @@ mod tests {
/// gets a CardAnimation" — same coverage, new component.
#[test]
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
use solitaire_core::card::Deck as D;
use solitaire_core::card::{Card, Rank, Suit};
// Simulate a stack drag of two cards.
let dragged_ids: Vec<u32> = vec![10, 11];
let dragged_cards: Vec<Card> = vec![
Card::new(D::Deck1, Suit::Hearts, Rank::King),
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
];
let mut animated: Vec<u32> = Vec::new();
for &card_id in &dragged_ids {
// In `end_drag` we iterate `drag.cards` and look up each id in
// `card_entities`. The ids we would insert a `CardAnimation` on
let mut animated: Vec<Card> = Vec::new();
for card in &dragged_cards {
// In `end_drag` we iterate `drag.cards` and look up each card in
// `card_entities`. The cards we would insert a `CardAnimation` on
// must exactly match the dragged set.
animated.push(card_id);
animated.push(card.clone());
}
assert_eq!(
animated, dragged_ids,
"every card id in drag.cards must receive a CardAnimation on rejection"
animated, dragged_cards,
"every card in drag.cards must receive a CardAnimation on rejection"
);
}