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
+2 -7
View File
@@ -168,7 +168,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::card::{Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App {
@@ -207,12 +207,7 @@ mod tests {
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::card::Card {
id: 7_001,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g.is_auto_completable = true;
let expected = (
@@ -33,6 +33,7 @@ use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::card::Card;
use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
@@ -210,12 +211,12 @@ pub(crate) fn apply_drag_visual(
// Only lift cards that are in a *committed* drag. Pending drags (below
// threshold) must stay at scale 1.0 to avoid visible premature lift.
let (dragged_ids, committed): (&[u32], bool) = drag
let (dragged_cards, committed): (&[Card], bool) = drag
.as_ref()
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
for (_, card, mut transform) in &mut cards {
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
let is_active_drag = committed && dragged_cards.contains(&card.card);
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
+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(),
+7 -16
View File
@@ -34,6 +34,7 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawMode, game_state::GameState};
@@ -185,7 +186,7 @@ fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> boo
let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.iter().enumerate().rev() {
if !card.face_up {
if !card.1 {
continue;
}
// Only the topmost card is draggable on non-tableau piles.
@@ -446,7 +447,7 @@ fn tableau_or_stack_pos(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
if matches!(pile, KlondikePile::Stock) {
game.waste_cards()
} else {
@@ -579,7 +580,7 @@ mod tests {
// -----------------------------------------------------------------------
use crate::layout::compute_layout;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
/// Builds an `App` with `MinimalPlugins` and the overlay system
@@ -618,7 +619,7 @@ mod tests {
game.0.set_test_waste_cards(vec![dragged.clone()]);
}
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![dragged.id];
drag.cards = vec![dragged];
drag.origin_pile = Some(KlondikePile::Stock);
drag.committed = true;
}
@@ -632,19 +633,9 @@ mod tests {
set_tableau_top(
&mut game,
2,
Card {
id: 9101,
suit: Suit::Clubs,
rank: Rank::Six,
face_up: true,
},
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
let dragged = Card {
id: 9102,
suit: Suit::Spades,
rank: Rank::Five,
face_up: true,
};
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game);
begin_drag_with(&mut app, dragged);
+7 -7
View File
@@ -2,7 +2,7 @@
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::card::Suit;
use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameMode;
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
@@ -104,8 +104,8 @@ pub struct WinStreakMilestoneEvent {
}
/// Fired when a card's face-up state changes during gameplay.
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
#[derive(Message, Debug, Clone)]
pub struct CardFlippedEvent(pub Card);
/// Fired by the flip animation at its midpoint — the instant the card face
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
@@ -113,8 +113,8 @@ pub struct CardFlippedEvent(pub u32);
/// Audio systems should listen to this event rather than `CardFlippedEvent`
/// so the flip sound is synchronised with the visual reveal, not the move
/// that triggered the animation.
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFaceRevealedEvent(pub u32);
#[derive(Message, Debug, Clone)]
pub struct CardFaceRevealedEvent(pub Card);
/// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any
@@ -299,8 +299,8 @@ pub struct ScanThemesRequestEvent;
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Message, Debug, Clone)]
pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
/// The source card to be highlighted.
pub source_card: Card,
/// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: KlondikePile,
}
+34 -19
View File
@@ -43,6 +43,7 @@ use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::card::Card;
use solitaire_core::{Foundation, KlondikePile};
use solitaire_data::AnimSpeed;
@@ -187,6 +188,20 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
}
/// Converts a `Card` to a `u32` seed suitable for deterministic per-card
/// jitter. Uses suit index × 13 + (rank value 1) to produce a stable 051
/// integer that survives changes to the internal `Card` representation.
fn card_to_id(card: &Card) -> u32 {
use solitaire_core::card::Suit;
let suit_index = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
suit_index * 13 + (card.rank().value() as u32 - 1)
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -245,16 +260,16 @@ fn start_shake_anim(
continue;
}
let dest_pile = &ev.to;
// Collect the card ids that belong to the destination pile.
// Collect the cards that belong to the destination pile.
let dest_cards = pile_cards(&game.0, dest_pile);
let dest_card_ids: Vec<u32> = dest_cards.iter().map(|c| c.id).collect();
let dest_card_set: Vec<Card> = dest_cards.iter().map(|(c, _)| c.clone()).collect();
if dest_card_ids.is_empty() {
if dest_card_set.is_empty() {
continue;
}
for (entity, card_marker, transform) in card_entities.iter() {
if dest_card_ids.contains(&card_marker.card_id) {
if dest_card_set.contains(&card_marker.card) {
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: transform.translation.x,
@@ -311,27 +326,27 @@ fn start_settle_anim(
card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands,
) {
// Build the list of card ids that should bounce this frame from every
// Build the list of cards that should bounce this frame from every
// queued request; multiple events can fire in the same frame (e.g. a move
// followed by a draw via keyboard accelerators).
let mut bounce_ids: Vec<u32> = Vec::new();
let mut bounce_ids: Vec<Card> = Vec::new();
for ev in moves.read() {
let pile = pile_cards(&game.0, &ev.to);
if !pile.is_empty() {
// The moved cards land on top — take the last `count` ids.
// The moved cards land on top — take the last `count` cards.
let n = ev.count.min(pile.len());
if n > 0 {
let start = pile.len() - n;
bounce_ids.extend(pile[start..].iter().map(|c| c.id));
bounce_ids.extend(pile[start..].iter().map(|(c, _)| c.clone()));
}
}
}
if draws.read().next().is_some()
&& let Some(top) = game.0.waste_cards().last()
&& let Some((top, _)) = game.0.waste_cards().last()
{
bounce_ids.push(top.id);
bounce_ids.push(top.clone());
}
if bounce_ids.is_empty() {
@@ -339,7 +354,7 @@ fn start_settle_anim(
}
for (entity, card_marker) in card_entities.iter() {
if bounce_ids.contains(&card_marker.card_id) {
if bounce_ids.contains(&card_marker.card) {
commands.entity(entity).insert(SettleAnim::default());
}
}
@@ -410,7 +425,7 @@ fn start_deal_anim(
// ±10 % jitter, deterministic per card id, so the deal feels organic
// without losing reproducibility (a given seed still produces the
// same per-card stagger pattern across runs).
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_marker.card_id));
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card)));
commands.entity(entity).insert((
Transform::from_translation(stock_start.with_z(final_pos.z)),
CardAnim {
@@ -524,13 +539,13 @@ fn start_foundation_flourish(
let pile_type = KlondikePile::Foundation(foundation);
// Top card of the completed foundation is the King.
let cards = game.0.pile(pile_type);
let Some(king_id) = cards.last().map(|c| c.id) else {
let Some(king_card) = cards.last().map(|(c, _)| c.clone()) else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id {
if card_marker.card == king_card {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
@@ -633,7 +648,7 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<solitaire_core::card::Card> {
) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
@@ -865,19 +880,19 @@ mod tests {
// Pick a card from Tableau(0) so the event refers to a real pile.
let dest_pile = KlondikePile::Tableau(Tableau::Tableau1);
let card_id = app
let card = app
.world()
.resource::<GameStateResource>()
.0
.pile(dest_pile)
.last()
.map(|c| c.id)
.map(|(c, _)| c.clone())
.expect("Tableau(0) should have at least one card in a fresh game");
// Spawn a minimal CardEntity matching that id so the system would
// Spawn a minimal CardEntity matching that card so the system would
// find it and insert ShakeAnim if the gate were absent.
app.world_mut()
.spawn((CardEntity { card_id }, Transform::default()));
.spawn((CardEntity { card }, Transform::default()));
app.world_mut()
.resource_mut::<Messages<MoveRejectedEvent>>()
+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)],
);
}
+1 -1
View File
@@ -2426,7 +2426,7 @@ fn foundation_selection_label(
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
.map(|c| c.suit);
.map(|c| c.0.suit());
match claimed {
Some(suit) => {
let s = match suit {
+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"
);
}
+4 -14
View File
@@ -187,7 +187,7 @@ mod tests {
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal Bevy app exercising only the polling system
@@ -264,13 +264,8 @@ mod tests {
.zip(suits.iter())
{
let mut cards = Vec::new();
for (i, rank) in ranks_below_king.iter().enumerate() {
cards.push(Card {
id: (foundation as u32) * 13 + i as u32,
suit: *suit,
rank: *rank,
face_up: true,
});
for rank in ranks_below_king.iter() {
cards.push(Card::new(Deck::Deck1, *suit, *rank));
}
game.set_test_foundation_cards(foundation, cards);
}
@@ -285,12 +280,7 @@ mod tests {
{
game.set_test_tableau_cards(
tableau,
vec![Card {
id: 100 + tableau as u32,
suit: *suit,
rank: Rank::King,
face_up: true,
}],
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
}
game
+26 -32
View File
@@ -304,7 +304,7 @@ pub fn find_top_face_up_card_at(
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
for i in (0..pile_cards.len()).rev() {
let card = &pile_cards[i];
if !card.face_up {
if !card.1 {
continue;
}
// Only the top card is draggable on non-tableau piles.
@@ -320,7 +320,7 @@ pub fn find_top_face_up_card_at(
{
continue;
}
return Some((pile, card.clone()));
return Some((pile, card.0.clone()));
}
}
None
@@ -339,7 +339,7 @@ fn card_position(
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 {
let step = if card.1 {
TABLEAU_FAN_FRAC
} else {
TABLEAU_FACEDOWN_FAN_FRAC
@@ -352,13 +352,27 @@ fn card_position(
}
}
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),
}
}
/// Maps a `card_game::Card` to a stable `u32` identity used by `CardEntity`
/// and systems that still track cards by numeric ID.
/// Encoding: `suit_index * 13 + (rank.value() - 1)`, range 0..=51.
fn card_to_id(card: &Card) -> u32 {
use solitaire_core::card::Suit;
let suit_index: u32 = match card.suit() {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
suit_index * 13 + (card.rank().value() as u32 - 1)
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
@@ -498,7 +512,7 @@ fn radial_open_on_right_click(
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
cards: vec![card_to_id(&card)],
legal_destinations,
centre: world,
hovered_index: None,
@@ -571,7 +585,7 @@ fn radial_open_on_long_press(
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
cards: vec![card_to_id(&card)],
legal_destinations,
centre: world,
hovered_index: None,
@@ -794,7 +808,7 @@ mod tests {
use super::*;
use crate::layout::compute_layout;
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card as CoreCard, Rank, Suit};
use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
@@ -844,12 +858,7 @@ mod tests {
// Ace of Clubs on Tableau(0).
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
}],
vec![CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g
}
@@ -879,14 +888,9 @@ mod tests {
] {
g.set_test_tableau_cards(tableau, Vec::new());
}
g.set_test_tableau_cards(
g.set_test_tableau_cards_with_face(
Tableau::Tableau1,
vec![CoreCard {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
}],
vec![(CoreCard::new(Deck::Deck1, Suit::Spades, Rank::King), false)],
);
g
}
@@ -979,12 +983,7 @@ mod tests {
#[test]
fn legal_destinations_for_ace_includes_only_first_empty_foundation() {
let g = ace_only_state();
let card = CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
let dests =
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
// Ace can be placed on every empty foundation. We only need
@@ -999,12 +998,7 @@ mod tests {
#[test]
fn legal_destinations_excludes_source_pile() {
let g = ace_only_state();
let card = CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
};
let card = CoreCard::new(Deck::Deck1, Suit::Clubs, Rank::Ace);
let dests = legal_destinations_for_card(
&card,
&KlondikePile::Foundation(Foundation::Foundation1),
@@ -236,9 +236,9 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
/// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&Card>) -> String {
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card {
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
None => "--".to_string(),
}
}
+3 -2
View File
@@ -7,6 +7,7 @@ use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
@@ -27,8 +28,8 @@ pub struct GameStateResource(pub GameState);
/// This prevents accidental drags on quick taps, especially on touch screens.
#[derive(Resource, Debug, Clone)]
pub struct DragState {
/// IDs of the cards being dragged (bottom-to-top stacking order).
pub cards: Vec<u32>,
/// Cards being dragged (bottom-to-top stacking order).
pub cards: Vec<Card>,
/// Pile the drag originated from.
pub origin_pile: Option<KlondikePile>,
/// World-space offset from the cursor/touch to the bottom card's centre.
+46 -104
View File
@@ -91,9 +91,9 @@ pub enum KeyboardDragState {
/// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column).
count: usize,
/// Card ids being lifted, in the same bottom-to-top order
/// Cards being lifted, in the same bottom-to-top order
/// `DragState.cards` expects.
cards: Vec<u32>,
cards: Vec<Card>,
/// Pre-computed list of piles the lifted stack can legally be
/// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to
@@ -393,7 +393,7 @@ fn handle_selection_keys(
KlondikePile::Tableau(Tableau::Tableau7),
];
all.into_iter()
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1))
.collect()
};
@@ -424,7 +424,7 @@ fn handle_selection_keys(
&& let Some(ref pile) = selection.selected_pile
{
let selected_cards = pile_cards(&game.0, pile);
let Some(card) = selected_cards.last().filter(|c| c.face_up) else {
let Some((card, _)) = selected_cards.last().filter(|c| c.1) else {
return;
};
// Priority 1: foundation move (single card).
@@ -441,7 +441,7 @@ fn handle_selection_keys(
let run_len = face_up_run_len(&selected_cards);
let bottom_card = selected_cards
.get(selected_cards.len().saturating_sub(run_len))
.cloned();
.map(|(c, _)| c.clone());
if let Some(bottom) = bottom_card
&& let Some((dest, count)) =
best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
@@ -483,8 +483,9 @@ fn handle_selection_keys(
1
};
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> = source_cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = source_cards.get(start) else {
let lifted_cards: Vec<Card> =
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
let Some((bottom, _)) = source_cards.get(start) else {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
@@ -574,10 +575,10 @@ pub(crate) fn legal_destinations_for(
/// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
let mut count = 0;
for card in cards.iter().rev() {
if card.face_up {
for (_, face_up) in cards.iter().rev() {
if *face_up {
count += 1;
} else {
break;
@@ -596,7 +597,7 @@ fn try_foundation_dest(
card: &solitaire_core::card::Card,
game: &solitaire_core::game_state::GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.id)?;
let source = game.pile_containing_card(card.clone())?;
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -695,7 +696,7 @@ fn update_selection_highlight(
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
&card,
card_size,
source_color,
);
@@ -712,7 +713,7 @@ fn update_selection_highlight(
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
&card,
card_size,
dest_color,
);
@@ -723,10 +724,13 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
pile_cards(game, pile)
.last()
.filter(|(_, up)| *up)
.map(|(c, _)| c.clone())
}
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),
@@ -734,16 +738,16 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
}
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
/// the matching `CardEntity::card_id`. No-op if no entity matches.
/// the matching `CardEntity::card`. No-op if no entity matches.
fn spawn_highlight_on_card(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card: &Card,
card_size: Vec2,
color: Color,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id {
if card_entity.card == *card {
commands.entity(entity).with_children(|b| {
b.spawn((
SelectionHighlight,
@@ -881,58 +885,23 @@ mod tests {
#[test]
fn face_up_run_len_all_face_up() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
];
assert_eq!(face_up_run_len(&cards), 3);
}
#[test]
fn face_up_run_len_mixed_stops_at_face_down() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: false,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
Card {
id: 2,
suit: Suit::Spades,
rank: Rank::Jack,
face_up: true,
},
Card {
id: 3,
suit: Suit::Diamonds,
rank: Rank::Ten,
face_up: true,
},
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
(Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
(Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true),
];
// Only the top two cards are face-up.
assert_eq!(face_up_run_len(&cards), 2);
@@ -940,33 +909,18 @@ mod tests {
#[test]
fn face_up_run_len_top_card_face_down_is_zero() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![
Card {
id: 0,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: false,
},
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
];
assert_eq!(face_up_run_len(&cards), 0);
}
#[test]
fn face_up_run_len_single_face_up_card() {
use solitaire_core::card::{Card, Rank, Suit};
let cards = vec![Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
}];
use solitaire_core::card::{Card, Deck, Rank, Suit};
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
assert_eq!(face_up_run_len(&cards), 1);
}
@@ -979,7 +933,7 @@ mod tests {
// -----------------------------------------------------------------------
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::card::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
@@ -1031,30 +985,15 @@ mod tests {
// Place test cards.
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)],
);
g.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)],
);
g.set_test_tableau_cards(
Tableau::Tableau3,
vec![Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
}],
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)],
);
g
}
@@ -1150,7 +1089,7 @@ mod tests {
} => {
assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]);
assert!(
!legal_destinations.is_empty(),
"lifted stack must have at least one legal destination"
@@ -1162,7 +1101,10 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]);
assert_eq!(
drag.cards,
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]
);
assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
@@ -1267,7 +1209,7 @@ mod tests {
// keyboard sentinel.
{
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100];
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)];
drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
drag.committed = true;
drag.active_touch_id = None;
+1 -1
View File
@@ -520,7 +520,7 @@ fn sync_pile_marker_visibility(
fn pile_cards(
game: &solitaire_core::game_state::GameState,
pile: &KlondikePile,
) -> Vec<solitaire_core::card::Card> {
) -> Vec<(solitaire_core::card::Card, bool)> {
match pile {
KlondikePile::Stock => {
let stock = game.stock_cards();
+39 -16
View File
@@ -29,6 +29,7 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::card::Card;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
@@ -49,8 +50,8 @@ use crate::ui_theme::ACCENT_PRIMARY;
/// card ids that will be moved (1 for a single card, multiple for a face-up run).
#[derive(Resource, Debug, Default)]
pub struct TouchSelectionState {
/// Currently selected source pile and the card ids to move (bottom-to-top).
pub selected: Option<(KlondikePile, Vec<u32>)>,
/// Currently selected source pile and the cards to move (bottom-to-top).
pub selected: Option<(KlondikePile, Vec<Card>)>,
}
impl TouchSelectionState {
@@ -60,12 +61,12 @@ impl TouchSelectionState {
}
/// Takes the current selection, leaving `selected` as `None`.
pub fn take(&mut self) -> Option<(KlondikePile, Vec<u32>)> {
pub fn take(&mut self) -> Option<(KlondikePile, Vec<Card>)> {
self.selected.take()
}
/// Sets the current selection.
pub fn set(&mut self, pile: KlondikePile, cards: Vec<u32>) {
pub fn set(&mut self, pile: KlondikePile, cards: Vec<Card>) {
self.selected = Some((pile, cards));
}
@@ -142,7 +143,7 @@ pub(crate) fn update_touch_selection_highlight(
commands.entity(entity).despawn();
}
let Some((_, ref card_ids)) = selection.selected else {
let Some((_, ref cards)) = selection.selected else {
return;
};
let Some(layout) = layout else {
@@ -154,8 +155,8 @@ pub(crate) fn update_touch_selection_highlight(
// but highlighting the whole run gives the player clear confirmation
// of how many cards are involved in the move.
let card_size = layout.0.card_size;
for &card_id in card_ids {
spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size);
for card in cards {
spawn_touch_highlight(&mut commands, &card_entities, card, card_size);
}
}
@@ -163,11 +164,11 @@ pub(crate) fn update_touch_selection_highlight(
fn spawn_touch_highlight(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card: &Card,
card_size: Vec2,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id {
if card_entity.card == *card {
commands.entity(entity).with_children(|b| {
b.spawn((
TouchSelectionHighlight,
@@ -193,6 +194,17 @@ fn spawn_touch_highlight(
mod tests {
use super::*;
use solitaire_core::Tableau;
use solitaire_core::card::{Card, Deck, Rank, Suit};
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
/// numeric ids. Identity is now the `Card` value.
fn test_cards() -> [Card; 3] {
[
Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Two),
Card::new(Deck::Deck1, Suit::Spades, Rank::Three),
]
}
#[test]
fn selection_state_default_is_idle() {
@@ -204,20 +216,24 @@ mod tests {
#[test]
fn set_and_take_roundtrip() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1, 2, 3]);
let cards = test_cards().to_vec();
state.set(KlondikePile::Tableau(Tableau::Tableau1), cards.clone());
assert!(state.has_selection());
let taken = state.take();
assert!(taken.is_some());
let (pile, cards) = taken.unwrap();
let (pile, taken_cards) = taken.unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(cards, vec![1, 2, 3]);
assert_eq!(taken_cards, cards);
assert!(!state.has_selection());
}
#[test]
fn clear_removes_selection() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Stock, vec![42]);
state.set(
KlondikePile::Stock,
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
state.clear();
assert!(!state.has_selection());
}
@@ -232,10 +248,17 @@ mod tests {
#[test]
fn set_overwrites_previous_selection() {
let mut state = TouchSelectionState::default();
state.set(KlondikePile::Tableau(Tableau::Tableau1), vec![1]);
state.set(KlondikePile::Tableau(Tableau::Tableau4), vec![7, 8]);
state.set(
KlondikePile::Tableau(Tableau::Tableau1),
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
let second = vec![
Card::new(Deck::Deck1, Suit::Hearts, Rank::Seven),
Card::new(Deck::Deck1, Suit::Spades, Rank::Eight),
];
state.set(KlondikePile::Tableau(Tableau::Tableau4), second.clone());
let (pile, cards) = state.take().unwrap();
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau4));
assert_eq!(cards, vec![7, 8]);
assert_eq!(cards, second);
}
}