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
+142 -229
View File
@@ -159,10 +159,10 @@ fn card_back_colour(selected_card_back: usize) -> Color {
}
}
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
#[derive(Component, Debug, Clone, Copy)]
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
#[derive(Component, Debug, Clone)]
pub struct CardEntity {
pub card_id: u32,
pub card: Card,
}
/// Marker for the text child inside a card entity.
@@ -562,20 +562,21 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
/// available and falling back to a solid-colour sprite in tests.
fn card_sprite(
card: &Card,
face_up: bool,
card_size: Vec2,
back_colour: Color,
card_images: Option<&CardImageSet>,
selected_back: usize,
) -> Sprite {
if let Some(set) = card_images {
let image = if card.face_up {
let suit_idx = match card.suit {
let image = if face_up {
let suit_idx = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match card.rank {
let rank_idx = match card.rank() {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
@@ -613,7 +614,7 @@ fn card_sprite(
// the suit glyph colour, applied by `text_colour`, not the face
// background). Pre-Terminal this branch dispatched through a
// separate `face_colour(card, color_blind)` helper.
let body_colour = if card.face_up {
let body_colour = if face_up {
CARD_FACE_COLOUR
} else {
back_colour
@@ -732,7 +733,7 @@ fn sync_cards(
// top card's slide animation plays — it must never be visible to the player.
// Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<u32> = {
let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
@@ -741,10 +742,10 @@ fn sync_cards(
(waste_cards.len() > visible)
.then_some(waste_cards)
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
.map(|c| c.id)
.map(|(c, _face_up)| c)
};
// Map card_id -> (Entity, current_translation, anim_end) for in-place
// Map Card -> (Entity, current_translation, anim_end) for in-place
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
// is currently driving the card (e.g. a drag-rejection return tween).
//
@@ -755,19 +756,19 @@ fn sync_cards(
// • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position.
let mut existing: HashMap<u32, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() {
existing.insert(
marker.card_id,
marker.card.clone(),
(entity, transform.translation, anim.map(|a| a.end)),
);
}
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
// Despawn any entity whose card is no longer tracked.
for (card_id, (entity, _, _)) in &existing {
if !live_ids.contains(card_id) {
for (card, (entity, _, _)) in &existing {
if !live_ids.contains(card) {
commands.entity(*entity).despawn();
}
}
@@ -775,8 +776,8 @@ fn sync_cards(
// For each card in the current state: spawn or update its entity, then
// apply visibility. The waste buffer card is hidden so it cannot peek
// behind the incoming top card during the draw slide animation.
for (card, position, z) in positions {
let entity = match existing.get(&card.id) {
for ((card, face_up), position, z) in positions {
let entity = match existing.get(&card) {
Some(&(entity, cur, anim_end)) => {
// If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card
@@ -794,6 +795,7 @@ fn sync_cards(
&mut commands,
entity,
&card,
face_up,
position,
z,
layout,
@@ -812,6 +814,7 @@ fn sync_cards(
None => spawn_card_entity(
&mut commands,
&card,
face_up,
position,
z,
layout,
@@ -823,7 +826,7 @@ fn sync_cards(
font_handle,
),
};
let visibility = if waste_buffer_id == Some(card.id) {
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
Visibility::Hidden
} else {
Visibility::Inherited
@@ -832,9 +835,9 @@ fn sync_cards(
}
}
/// Returns an ordered vec of (card, position, z) for every card in the game.
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
let piles = [
(KlondikePile::Stock, true),
(KlondikePile::Stock, false),
@@ -914,7 +917,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, card) in cards[render_start..].iter().enumerate() {
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
@@ -928,9 +931,9 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
};
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
out.push((card.clone(), pos, z));
out.push(((card.clone(), *face_up), pos, z));
if is_tableau {
let step = if card.face_up {
let step = if *face_up {
layout.tableau_fan_frac
} else {
layout.tableau_facedown_fan_frac
@@ -942,8 +945,8 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
out
}
fn all_cards(game: &GameState) -> Vec<Card> {
let mut cards = Vec::with_capacity(52);
fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
cards.extend(game.stock_cards());
cards.extend(game.waste_cards());
for foundation in [
@@ -972,6 +975,7 @@ fn all_cards(game: &GameState) -> Vec<Card> {
fn spawn_card_entity(
commands: &mut Commands,
card: &Card,
face_up: bool,
pos: Vec2,
z: f32,
layout: &Layout,
@@ -984,6 +988,7 @@ fn spawn_card_entity(
) -> Entity {
let sprite = card_sprite(
card,
face_up,
layout.card_size,
back_colour,
card_images,
@@ -991,7 +996,7 @@ fn spawn_card_entity(
);
let mut entity = commands.spawn((
CardEntity { card_id: card.id },
CardEntity { card: card.clone() },
sprite,
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
@@ -1024,7 +1029,7 @@ fn spawn_card_entity(
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(card),
label_visibility(face_up),
));
});
}
@@ -1033,6 +1038,7 @@ fn spawn_card_entity(
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
@@ -1048,6 +1054,7 @@ fn update_card_entity(
commands: &mut Commands,
entity: Entity,
card: &Card,
face_up: bool,
pos: Vec2,
z: f32,
layout: &Layout,
@@ -1066,6 +1073,7 @@ fn update_card_entity(
// Always refresh the visual appearance.
commands.entity(entity).insert(card_sprite(
card,
face_up,
layout.card_size,
back_colour,
card_images,
@@ -1126,7 +1134,7 @@ fn update_card_entity(
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(card),
label_visibility(face_up),
));
});
}
@@ -1135,6 +1143,7 @@ fn update_card_entity(
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
@@ -1145,7 +1154,7 @@ fn update_card_entity(
}
fn label_for(card: &Card) -> String {
let rank = match card.rank {
let rank = match card.rank() {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
@@ -1160,7 +1169,7 @@ fn label_for(card: &Card) -> String {
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit {
let suit = match card.suit() {
Suit::Clubs => "C",
Suit::Diamonds => "D",
Suit::Hearts => "H",
@@ -1188,7 +1197,7 @@ fn label_for(card: &Card) -> String {
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
/// and has no constant-fallback equivalent.
fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
if card.suit.is_red() {
if card.suit().is_red() {
if color_blind {
// CBM lime wins — the colour-blind swap replaces the
// red hue entirely, and the lime is already high-
@@ -1206,8 +1215,8 @@ fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
}
}
fn label_visibility(card: &Card) -> Visibility {
if card.face_up {
fn label_visibility(face_up: bool) -> Visibility {
if face_up {
Visibility::Inherited
} else {
Visibility::Hidden
@@ -1217,7 +1226,7 @@ fn label_visibility(card: &Card) -> Visibility {
/// Rank+suit string for the readability overlay on touch HUD layouts.
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660U+2666, covered by FiraMono).
fn mobile_label_for(card: &Card) -> String {
let rank = match card.rank {
let rank = match card.rank() {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
@@ -1232,7 +1241,7 @@ fn mobile_label_for(card: &Card) -> String {
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit {
let suit = match card.suit() {
Suit::Clubs => "",
Suit::Diamonds => "",
Suit::Hearts => "",
@@ -1254,12 +1263,13 @@ fn mobile_label_for(card: &Card) -> String {
fn add_android_corner_label(
parent: &mut ChildSpawnerCommands,
card: &Card,
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
font_handle: Option<&Handle<Font>>,
) {
if !card.face_up {
if !face_up {
return;
}
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
@@ -1309,7 +1319,7 @@ fn add_android_corner_label(
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
// Terminal theme background.
let text_col = if card.suit.is_red() {
let text_col = if card.suit().is_red() {
if color_blind {
RED_SUIT_COLOUR_CBM
} else if high_contrast {
@@ -1355,9 +1365,9 @@ fn start_flip_anim(
return;
}
for CardFlippedEvent(card_id) in events.read() {
for CardFlippedEvent(flipped_card) in events.read() {
for (entity, marker) in &card_entities {
if marker.card_id == *card_id {
if marker.card == *flipped_card {
commands.entity(entity).insert(CardFlipAnim {
timer: 0.0,
phase: FlipPhase::ScalingDown,
@@ -1394,7 +1404,7 @@ fn tick_flip_anim(
transform.scale.x = 0.0;
// Fire the reveal event exactly once, at the phase transition,
// so the flip sound is synchronised with the visual face reveal.
reveal_events.write(CardFaceRevealedEvent(card_entity.card_id));
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
}
}
FlipPhase::ScalingUp => {
@@ -1438,11 +1448,10 @@ fn update_drag_shadow(
let card_h = layout.0.card_size.y;
// Find the world position of the first (top) dragged card.
let first_id = drag.cards.first().copied();
let top_pos = first_id.and_then(|id| {
let top_pos = drag.cards.first().and_then(|first_card| {
card_entities
.iter()
.find(|(marker, _)| marker.card_id == id)
.find(|(marker, _)| marker.card == *first_card)
.map(|(_, t)| t.translation)
});
@@ -1498,10 +1507,10 @@ fn update_card_shadows_on_drag(
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
) {
let dragged: HashSet<u32> = drag.cards.iter().copied().collect();
let dragged: HashSet<&Card> = drag.cards.iter().collect();
for (card_entity, card_sprite, children) in cards.iter() {
let is_dragged = dragged.contains(&card_entity.card_id);
let is_dragged = dragged.contains(&card_entity.card);
let (offset, padding, alpha) = card_shadow_params(is_dragged);
let Some(card_size) = card_sprite.custom_size else {
continue;
@@ -1548,8 +1557,8 @@ fn tick_hint_highlight(
} else {
let is_face_up = all_cards(&game.0)
.iter()
.find(|c| c.id == card_entity.card_id)
.is_some_and(|c| c.face_up);
.find(|(c, _face_up)| *c == card_entity.card)
.is_some_and(|(_, face_up)| *face_up);
if is_face_up {
CARD_FACE_COLOUR
} else {
@@ -1718,7 +1727,7 @@ fn handle_right_click(
return;
};
let Some(source_pile) = game.0.pile_containing_card(card.id) else {
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
return;
};
@@ -1766,10 +1775,10 @@ fn find_top_card_at(
{
continue;
}
let card = all_cards(game)
let found = all_cards(game)
.into_iter()
.find(|c| c.id == card_entity.card_id && c.face_up);
if let Some(card) = card {
.find(|(c, face_up)| *c == card_entity.card && *face_up);
if let Some((card, _)) = found {
let z = transform.translation.z;
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
best = Some((z, card));
@@ -2236,13 +2245,13 @@ fn resize_cards_in_place(
>,
) {
let positions = card_positions(game, layout);
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
.into_iter()
.map(|(c, p, z)| (c.id, (p, z)))
.map(|((c, _face_up), p, z)| (c, (p, z)))
.collect();
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
let Some(&(pos, z)) = pos_by_id.get(&marker.card_id) else {
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
continue;
};
sprite.custom_size = Some(layout.card_size);
@@ -2369,7 +2378,7 @@ fn update_tableau_fan_frac(
game.0
.pile(solitaire_core::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.filter(|(_, face_up)| *face_up)
.count()
})
.max()
@@ -2408,6 +2417,12 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::card::Deck;
/// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card {
Card::new(Deck::Deck1, suit, rank)
}
fn app() -> App {
let mut app = App::new();
@@ -2421,58 +2436,28 @@ mod tests {
#[test]
fn label_for_ace_of_hearts_is_ah() {
let c = Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
};
let c = make_card(Suit::Hearts, Rank::Ace);
assert_eq!(label_for(&c), "AH");
}
#[test]
fn label_for_ten_of_clubs_is_10c() {
let c = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Ten,
face_up: true,
};
let c = make_card(Suit::Clubs, Rank::Ten);
assert_eq!(label_for(&c), "10C");
}
#[test]
fn text_colour_is_red_for_hearts_and_diamonds() {
let h = Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
};
let d = Card {
id: 0,
suit: Suit::Diamonds,
rank: Rank::Ace,
face_up: true,
};
let h = make_card(Suit::Hearts, Rank::Ace);
let d = make_card(Suit::Diamonds, Rank::Ace);
assert_eq!(text_colour(&h, false, false), RED_SUIT_COLOUR);
assert_eq!(text_colour(&d, false, false), RED_SUIT_COLOUR);
}
#[test]
fn text_colour_is_near_white_for_clubs_and_spades() {
let c = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let s = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: true,
};
let c = make_card(Suit::Clubs, Rank::Ace);
let s = make_card(Suit::Spades, Rank::Ace);
assert_eq!(text_colour(&c, false, false), BLACK_SUIT_COLOUR);
assert_eq!(text_colour(&s, false, false), BLACK_SUIT_COLOUR);
}
@@ -2540,8 +2525,8 @@ mod tests {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -2550,7 +2535,7 @@ mod tests {
// Filter rendered positions to only waste cards (by card ID).
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
@@ -2563,9 +2548,9 @@ mod tests {
"at least the top waste card must be rendered"
);
// The top (last) waste card must always be among the rendered cards.
let top_id = g.waste_cards().last().unwrap().id;
let top_id = g.waste_cards().last().unwrap().0.clone();
assert!(
waste_rendered.iter().any(|(c, _, _)| c.id == top_id),
waste_rendered.iter().any(|(c, _, _)| c.0 == top_id),
"top waste card must be rendered"
);
}
@@ -2584,14 +2569,15 @@ mod tests {
"need at least 3 waste cards for this test"
);
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to
// prevent the evicted card from flashing during the draw tween).
@@ -2616,8 +2602,8 @@ mod tests {
);
}
// Top card (rightmost by x) must be the last card in the waste pile.
let top_id = waste_pile.last().unwrap().id;
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
let top_id = waste_pile.last().unwrap().0.clone();
assert_eq!(waste_rendered.last().unwrap().0.0, top_id);
}
#[test]
@@ -2636,13 +2622,14 @@ mod tests {
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// All waste cards should be visible (no hidden buffer when len ≤ visible).
assert_eq!(
@@ -2673,13 +2660,13 @@ mod tests {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.filter(|(card, _, _)| waste_ids.contains(&card.0))
.collect();
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
assert_eq!(
@@ -2883,12 +2870,7 @@ mod tests {
#[test]
fn text_colour_color_blind_mode_swaps_red_suits_to_lime() {
let red_card = Card {
id: 0,
suit: Suit::Diamonds,
rank: Rank::Queen,
face_up: true,
};
let red_card = make_card(Suit::Diamonds, Rank::Queen);
let cbm_colour = text_colour(&red_card, true, false);
assert_eq!(
cbm_colour, RED_SUIT_COLOUR_CBM,
@@ -2902,12 +2884,7 @@ mod tests {
#[test]
fn text_colour_color_blind_mode_does_not_change_dark_suits() {
let black_card = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Jack,
face_up: true,
};
let black_card = make_card(Suit::Clubs, Rank::Jack);
assert_eq!(
text_colour(&black_card, true, false),
BLACK_SUIT_COLOUR,
@@ -2928,12 +2905,7 @@ mod tests {
#[test]
fn text_colour_high_contrast_boosts_red_suits_to_hc_red() {
let red_card = Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Five,
face_up: true,
};
let red_card = make_card(Suit::Hearts, Rank::Five);
assert_eq!(
text_colour(&red_card, false, true),
RED_SUIT_COLOUR_HC,
@@ -2948,12 +2920,7 @@ mod tests {
#[test]
fn text_colour_high_contrast_boosts_black_suits_to_hc_white() {
let black_card = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
};
let black_card = make_card(Suit::Spades, Rank::Two);
assert_eq!(
text_colour(&black_card, false, true),
TEXT_PRIMARY_HC,
@@ -2967,12 +2934,7 @@ mod tests {
// the CBM lime is itself a high-luminance accent and the HC
// boost would pick a different hue, defeating the purpose of
// the colour-blind swap.
let red_card = Card {
id: 0,
suit: Suit::Diamonds,
rank: Rank::Ace,
face_up: true,
};
let red_card = make_card(Suit::Diamonds, Rank::Ace);
assert_eq!(
text_colour(&red_card, true, true),
RED_SUIT_COLOUR_CBM,
@@ -2984,12 +2946,7 @@ mod tests {
fn text_colour_high_contrast_alone_boosts_dark_suits_under_cbm() {
// CBM doesn't touch the dark suits, so HC remains the only
// source of variation for the dark row when both are on.
let black_card = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
};
let black_card = make_card(Suit::Clubs, Rank::King);
assert_eq!(
text_colour(&black_card, true, true),
TEXT_PRIMARY_HC,
@@ -3003,24 +2960,12 @@ mod tests {
#[test]
fn label_visibility_face_up_is_inherited() {
let card = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
assert_eq!(label_visibility(&card), Visibility::Inherited);
assert_eq!(label_visibility(true), Visibility::Inherited);
}
#[test]
fn label_visibility_face_down_is_hidden() {
let card = Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: false,
};
assert_eq!(label_visibility(&card), Visibility::Hidden);
assert_eq!(label_visibility(false), Visibility::Hidden);
}
// -----------------------------------------------------------------------
@@ -3032,12 +2977,7 @@ mod tests {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let letters = ["C", "D", "H", "S"];
for (suit, letter) in suits.iter().zip(letters.iter()) {
let card = Card {
id: 0,
suit: *suit,
rank: Rank::King,
face_up: true,
};
let card = make_card(*suit, Rank::King);
assert!(
label_for(&card).ends_with(letter),
"label for {suit:?} must end with '{letter}'"
@@ -3047,12 +2987,7 @@ mod tests {
#[test]
fn label_for_face_cards_use_letter_prefix() {
let make = |rank| Card {
id: 0,
suit: Suit::Spades,
rank,
face_up: true,
};
let make = |rank| make_card(Suit::Spades, rank);
assert!(label_for(&make(Rank::Jack)).starts_with('J'));
assert!(label_for(&make(Rank::Queen)).starts_with('Q'));
assert!(label_for(&make(Rank::King)).starts_with('K'));
@@ -3060,12 +2995,7 @@ mod tests {
#[test]
fn label_for_numeric_ranks_two_through_nine() {
let make = |rank| Card {
id: 0,
suit: Suit::Clubs,
rank,
face_up: true,
};
let make = |rank| make_card(Suit::Clubs, rank);
let expected = [
(Rank::Two, "2C"),
(Rank::Three, "3C"),
@@ -3091,12 +3021,7 @@ mod tests {
];
for (suit, rank, expected) in cases {
let card = Card {
id: 0,
suit,
rank,
face_up: true,
};
let card = make_card(suit, rank);
assert_eq!(mobile_label_for(&card), expected);
}
}
@@ -3380,33 +3305,33 @@ mod tests {
fn shadow_offset_increases_during_drag() {
let mut app = app();
// Pick any spawned card id and stage it in DragState.
let card_id: u32 = {
// Pick any spawned card and stage it in DragState.
let card: Card = {
let mut q = app.world_mut().query::<&CardEntity>();
q.iter(app.world())
.next()
.expect("fixture should spawn at least one CardEntity")
.card_id
.card.clone()
};
// Pick a *different* card id to act as the negative control —
// Pick a *different* card to act as the negative control —
// its shadow must remain at the idle offset.
let other_id: u32 = {
let other_card: Card = {
let mut q = app.world_mut().query::<&CardEntity>();
q.iter(app.world())
.map(|c| c.card_id)
.find(|id| *id != card_id)
.map(|c| c.card.clone())
.find(|c| *c != card)
.expect("fixture should spawn more than one CardEntity")
};
// Stage the drag and run one Update so `update_card_shadows_on_drag`
// sees the new DragState.
app.world_mut().resource_mut::<DragState>().cards = vec![card_id];
app.world_mut().resource_mut::<DragState>().cards = vec![card.clone()];
app.update();
// Find the shadow whose parent's CardEntity matches `card_id`.
let dragged_shadow_offset = shadow_offset_for_card(&mut app, card_id);
let other_shadow_offset = shadow_offset_for_card(&mut app, other_id);
// Find the shadow whose parent's CardEntity matches `card`.
let dragged_shadow_offset = shadow_offset_for_card(&mut app, &card);
let other_shadow_offset = shadow_offset_for_card(&mut app, &other_card);
let drag_off = CARD_SHADOW_OFFSET_DRAG;
let idle_off = CARD_SHADOW_OFFSET_IDLE;
@@ -3428,7 +3353,7 @@ mod tests {
// offset on the next frame.
app.world_mut().resource_mut::<DragState>().clear();
app.update();
let after_clear = shadow_offset_for_card(&mut app, card_id);
let after_clear = shadow_offset_for_card(&mut app, &card);
assert!(
(after_clear.x - idle_off.x).abs() < 1e-3 && (after_clear.y - idle_off.y).abs() < 1e-3,
"shadow must snap back to idle offset after drag clears \
@@ -3436,18 +3361,18 @@ mod tests {
);
}
/// Helper: given a `card_id`, returns the world-space offset (x, y) of
/// Helper: given a `card`, returns the world-space offset (x, y) of
/// its `CardShadow` child relative to the parent card's origin.
fn shadow_offset_for_card(app: &mut App, card_id: u32) -> Vec2 {
// Map every CardEntity to its (Entity, card_id).
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card).
let card_entity = {
let mut q = app
.world_mut()
.query::<(bevy::prelude::Entity, &CardEntity)>();
q.iter(app.world())
.find(|(_, c)| c.card_id == card_id)
.find(|(_, c)| c.card == *card)
.map(|(e, _)| e)
.expect("card_id not found in spawned CardEntity set")
.expect("card not found in spawned CardEntity set")
};
let mut q = app
@@ -3458,7 +3383,7 @@ mod tests {
return Vec2::new(transform.translation.x, transform.translation.y);
}
}
panic!("no CardShadow child found for card_id {card_id}");
panic!("no CardShadow child found for card {card:?}");
}
// -----------------------------------------------------------------------
@@ -3538,7 +3463,8 @@ mod tests {
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock = game.0.stock_cards();
let mut stock: Vec<Card> =
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop();
game.0.set_test_stock_cards(stock);
}
@@ -3592,15 +3518,11 @@ mod tests {
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
set.theme_back = Some(theme_back.clone());
let face_down = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: false,
};
let face_down = make_card(Suit::Spades, Rank::Ace);
// Pick a non-zero legacy back so we'd notice if it leaked through.
let sprite = card_sprite(
&face_down,
false,
Vec2::new(80.0, 112.0),
card_back_colour(2),
Some(&set),
@@ -3627,15 +3549,11 @@ mod tests {
"fixture starts with no theme back"
);
let face_down = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: false,
};
let face_down = make_card(Suit::Spades, Rank::Ace);
for selected_back in 0..5 {
let sprite = card_sprite(
&face_down,
false,
Vec2::new(80.0, 112.0),
card_back_colour(selected_back),
Some(&set),
@@ -3804,12 +3722,7 @@ mod tests {
#[test]
fn text_colour_black_suits_are_near_white_not_red() {
for suit in [Suit::Clubs, Suit::Spades] {
let card = Card {
id: 0,
suit,
rank: Rank::Ace,
face_up: true,
};
let card = make_card(suit, Rank::Ace);
let colour = text_colour(&card, false, false);
assert_eq!(
colour, BLACK_SUIT_COLOUR,
@@ -3845,12 +3758,12 @@ mod tests {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.map(|(_, _, z)| *z)
.collect();
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
@@ -3895,20 +3808,20 @@ mod tests {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.collect();
waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
let visible_count = waste_positions.len().min(3);
for (card, pos, _) in waste_positions.iter().rev().take(visible_count) {
assert!(
pos.x >= stock_x - 1e-3,
"waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window",
card.id,
"waste card {:?} x {:.2} drifted left of stock origin {:.2} on portrait window",
card.0,
pos.x,
stock_x,
);
@@ -3925,12 +3838,12 @@ mod tests {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.map(|(_, _, z)| *z)
.collect();
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
@@ -3943,7 +3856,7 @@ mod tests {
// Deduplicated length must equal pre-dedup length → all z distinct.
let raw_count = positions
.iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
.filter(|(c, _, _)| waste_ids.contains(&c.0))
.count();
assert_eq!(
waste_zs.len(),