refactor(core): complete card_game::Card migration across engine + wasm
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:
+142
-229
@@ -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+2660–U+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(),
|
||||
|
||||
Reference in New Issue
Block a user