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