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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user