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
+54 -58
View File
@@ -1,13 +1,12 @@
use crate::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{
DrawMode, KlondikeAdapter, SavedInstruction, card_from_kl,
DrawMode, KlondikeAdapter, SavedInstruction,
compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Game as _, Session, SessionConfig};
use card_game::{Card, Game as _, Session, SessionConfig};
use klondike::{
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
@@ -142,13 +141,15 @@ struct PersistedGameStateIn {
#[derive(Clone, Debug, Default)]
pub struct TestPileState {
/// Override for face-down stock cards. `None` means "use session".
pub stock: Option<Vec<crate::card::Card>>,
pub stock: Option<Vec<Card>>,
/// Override for face-up waste cards. `None` means "use session".
pub waste: Option<Vec<crate::card::Card>>,
pub waste: Option<Vec<Card>>,
/// Per-tableau overrides. Missing keys fall back to the session.
pub tableau: std::collections::HashMap<Tableau, Vec<crate::card::Card>>,
/// Each entry carries its own face-up flag so tests can place face-down
/// cards (e.g. an un-flipped tableau card).
pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
/// Per-foundation overrides. Missing keys fall back to the session.
pub foundation: std::collections::HashMap<Foundation, Vec<crate::card::Card>>,
pub foundation: std::collections::HashMap<Foundation, Vec<Card>>,
}
/// Full state of an in-progress Klondike Solitaire game.
@@ -417,55 +418,52 @@ impl GameState {
self.session.history().len()
}
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<Card> {
cards
.into_iter()
.map(|mut card| {
card.face_up = face_up;
card
})
.collect()
fn cards_with_face(
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect()
}
pub fn stock_cards(&self) -> Vec<Card> {
pub fn stock_cards(&self) -> Vec<(Card, bool)> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.stock
{
return cards.clone();
return cards.iter().map(|c| (c.clone(), false)).collect();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false)
Self::cards_with_face(state.stock().face_down().iter().cloned(), false)
}
pub fn waste_cards(&self) -> Vec<Card> {
pub fn waste_cards(&self) -> Vec<(Card, bool)> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.waste
{
return cards.clone();
return cards.iter().map(|c| (c.clone(), true)).collect();
}
let state = self.session.state().state().state();
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
Self::cards_with_face(state.stock().face_up().iter().cloned(), true)
}
/// Returns the cards in the requested pile.
/// Returns the cards in the requested pile as `(card, face_up)` tuples.
///
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
/// to read the face-down draw cards.
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
pub fn pile(&self, pile: KlondikePile) -> Vec<(Card, bool)> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state {
match pile {
KlondikePile::Stock => {
if let Some(ref cards) = state.waste {
return cards.clone();
return cards.iter().map(|c| (c.clone(), true)).collect();
}
}
KlondikePile::Foundation(f) => {
if let Some(cards) = state.foundation.get(&f) {
return cards.clone();
return cards.iter().map(|c| (c.clone(), true)).collect();
}
}
KlondikePile::Tableau(t) => {
@@ -485,21 +483,13 @@ impl GameState {
Foundation::Foundation3 => state.foundation3(),
Foundation::Foundation4 => state.foundation4(),
};
Self::cards_with_face(cards.iter().map(card_from_kl), true)
Self::cards_with_face(cards.iter().cloned(), true)
}
KlondikePile::Tableau(tableau) => {
let mut cards = Self::cards_with_face(
state
.tableau_face_down_cards(tableau)
.iter()
.map(card_from_kl),
false,
);
let mut cards =
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
cards.extend(Self::cards_with_face(
state
.tableau_face_up_cards(tableau)
.iter()
.map(card_from_kl),
state.tableau_face_up_cards(tableau).iter().cloned(),
true,
));
cards
@@ -515,7 +505,7 @@ impl GameState {
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
}
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<(Card, bool)>, MoveError> {
let foundation = Self::foundation_from_slot(slot)?;
Ok(self.pile(KlondikePile::Foundation(foundation)))
}
@@ -560,8 +550,20 @@ impl GameState {
}
/// Test-support helper: override cards for a specific tableau column.
///
/// All provided cards are treated as face-up. Use
/// [`Self::set_test_tableau_cards_with_face`] when a test needs to place
/// face-down cards.
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
let with_face = cards.into_iter().map(|c| (c, true)).collect();
self.set_test_tableau_cards_with_face(tableau, with_face);
}
/// Test-support helper: override cards for a specific tableau column,
/// specifying each card's face-up flag (`true` = face-up).
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards_with_face(&mut self, tableau: Tableau, cards: Vec<(Card, bool)>) {
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
@@ -578,21 +580,15 @@ impl GameState {
}
/// Test-support helper: override cards for a specific pile.
///
/// For `KlondikePile::Stock`, all provided cards go to the face-down stock
/// override. Use [`Self::set_test_waste_cards`] to override the waste pile
/// separately.
#[cfg(feature = "test-support")]
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
match pile {
KlondikePile::Stock => {
let mut stock = Vec::new();
let mut waste = Vec::new();
for card in cards {
if card.face_up {
waste.push(card);
} else {
stock.push(card);
}
}
self.set_test_stock_cards(stock);
self.set_test_waste_cards(waste);
self.set_test_stock_cards(cards);
}
KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
@@ -612,7 +608,7 @@ impl GameState {
if pile.is_empty() {
return false;
}
pile.len() > count && !pile[pile.len() - count - 1].face_up
pile.len() > count && !pile[pile.len() - count - 1].1
}
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
@@ -962,24 +958,24 @@ impl GameState {
.is_instruction_valid(&config, instruction)
}
/// Returns the current pile containing `card_id`, if any.
pub fn pile_containing_card(&self, card_id: u32) -> Option<KlondikePile> {
if self.stock_cards().iter().any(|card| card.id == card_id)
|| self.waste_cards().iter().any(|card| card.id == card_id)
/// Returns the current pile containing `card`, if any.
pub fn pile_containing_card(&self, card: Card) -> Option<KlondikePile> {
if self.stock_cards().iter().any(|(c, _)| *c == card)
|| self.waste_cards().iter().any(|(c, _)| *c == card)
{
return Some(KlondikePile::Stock);
}
for slot in 0..4_u8 {
let foundation = Self::foundation_from_slot(slot).ok()?;
let pile = self.pile(KlondikePile::Foundation(foundation));
if pile.iter().any(|card| card.id == card_id) {
if pile.iter().any(|(c, _)| *c == card) {
return Some(KlondikePile::Foundation(foundation));
}
}
for index in 0..7_usize {
let tableau = Self::tableau_from_index(index).ok()?;
let pile = self.pile(KlondikePile::Tableau(tableau));
if pile.iter().any(|card| card.id == card_id) {
if pile.iter().any(|(c, _)| *c == card) {
return Some(KlondikePile::Tableau(tableau));
}
}
@@ -1039,7 +1035,7 @@ mod tests {
for _ in 0..MAX_STEPS {
let moves = game.possible_instructions();
if let Some((from, to, _count)) = moves.iter().copied().find(|(from, to, count)| {
if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| {
*count == 1
&& matches!(from, KlondikePile::Foundation(_))
&& matches!(to, KlondikePile::Tableau(_))
@@ -1047,7 +1043,7 @@ mod tests {
return Some((game, from, to));
}
if let Some((from, to, count)) = moves.iter().copied().find(|(from, to, count)| {
if let Some((from, to, count)) = moves.iter().cloned().find(|(from, to, count)| {
*count == 1
&& !matches!(from, KlondikePile::Foundation(_))
&& matches!(to, KlondikePile::Foundation(_))