Compare commits

...

2 Commits

Author SHA1 Message Date
funman300 1438fd6265 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>
2026-06-09 17:45:34 -07:00
funman300 920f2c8597 refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:38:04 -07:00
47 changed files with 653 additions and 1131 deletions
Generated
+2
View File
@@ -7336,11 +7336,13 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"card_game",
"chrono",
"dirs",
"jni 0.21.1",
"jsonwebtoken",
"keyring-core",
"klondike",
"reqwest",
"serde",
"serde_json",
@@ -19,8 +19,8 @@
//! --per-tier Seeds to emit per tier (default 40)
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::DrawMode;
use solitaire_data::solver::{SolverConfig, SolverResult, try_solve};
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
+2 -2
View File
@@ -17,8 +17,8 @@
//! --count Number of Winnable seeds to emit (default 75)
//! --help Print this message
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::DrawMode;
use solitaire_data::solver::{SolverConfig, SolverResult, try_solve};
fn main() {
let mut args = std::env::args().skip(1).peekable();
+1 -110
View File
@@ -1,110 +1 @@
use serde::{Deserialize, Serialize};
pub use card_game::{Rank, Suit};
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool,
}
impl Card {
/// Creates a card with explicit face orientation.
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
Self {
id,
suit,
rank,
face_up,
}
}
/// Creates a face-up card.
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, true)
}
/// Creates a face-down card.
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_values_are_sequential() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(r.value(), (i + 1) as u8);
}
}
#[test]
fn rank_as_u8_matches_value() {
for r in Rank::RANKS {
assert_eq!(r as u8, r.value());
}
}
#[test]
fn rank_checked_add_boundary() {
assert_eq!(Rank::King.checked_add(1), None);
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
}
#[test]
fn rank_checked_sub_boundary() {
assert_eq!(Rank::Ace.checked_sub(1), None);
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
}
#[test]
fn suit_suits_contains_all_four() {
assert_eq!(Suit::SUITS.len(), 4);
assert!(Suit::SUITS.contains(&Suit::Clubs));
assert!(Suit::SUITS.contains(&Suit::Diamonds));
assert!(Suit::SUITS.contains(&Suit::Hearts));
assert!(Suit::SUITS.contains(&Suit::Spades));
}
#[test]
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(
suit.is_red(),
suit.is_black(),
"{suit:?} must be exactly one of red/black"
);
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
#[test]
fn card_constructors_set_fields() {
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
assert_eq!(up.id, 10);
assert_eq!(up.suit, Suit::Spades);
assert_eq!(up.rank, Rank::Queen);
assert!(up.face_up);
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
assert_eq!(down.id, 11);
assert_eq!(down.suit, Suit::Diamonds);
assert_eq!(down.rank, Rank::King);
assert!(!down.face_up);
}
}
pub use card_game::{Card, Deck, Rank, Suit};
+56 -75
View File
@@ -1,12 +1,12 @@
use crate::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{
KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus,
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,
@@ -33,15 +33,6 @@ fn schema_v1() -> u32 {
1
}
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// Difficulty tier for `GameMode::Difficulty`. Controls which pre-verified seed
/// catalog is drawn from. `Random` skips verification entirely and uses a
/// system-time seed — deals may or may not be winnable.
@@ -150,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.
@@ -185,8 +178,6 @@ pub struct GameState {
/// When `true`, the player may move the top card of a foundation pile back
/// onto a compatible tableau column.
pub take_from_foundation: bool,
/// Save-file schema version.
pub schema_version: u32,
pub(crate) session: Session<Klondike>,
/// Score recorded immediately before each instruction was applied.
/// Parallel to `session.history()` during live play; used by `undo()` to
@@ -215,7 +206,6 @@ impl PartialEq for GameState {
&& self.undo_count == other.undo_count
&& self.recycle_count == other.recycle_count
&& self.take_from_foundation == other.take_from_foundation
&& self.schema_version == other.schema_version
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
&& (0..4_u8)
@@ -243,7 +233,7 @@ impl Serialize for GameState {
undo_count: self.undo_count,
recycle_count: self.recycle_count,
take_from_foundation: self.take_from_foundation,
schema_version: self.schema_version,
schema_version: GAME_STATE_SCHEMA_VERSION,
saved_moves: self.saved_moves(),
}
.serialize(serializer)
@@ -279,9 +269,6 @@ impl<'de> Deserialize<'de> for GameState {
// due to the pre-Phase-3 undo drift bug.
recycle_count: 0,
take_from_foundation: persisted.take_from_foundation,
// Always stamp the current schema version after a successful load so
// storage.rs schema checks pass and re-saving writes the v4 format.
schema_version: GAME_STATE_SCHEMA_VERSION,
session: Self::new_session(persisted.seed, persisted.draw_mode),
// score_history cannot be faithfully rebuilt from the instruction
// history because live-play undo penalties are not recorded in
@@ -358,7 +345,6 @@ impl GameState {
undo_count: 0,
recycle_count: 0,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
session: Self::new_session(seed, draw_mode),
score_history: Vec::new(),
is_recycle_history: Vec::new(),
@@ -432,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) => {
@@ -500,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
@@ -530,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)))
}
@@ -575,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);
@@ -593,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),
@@ -627,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*
@@ -977,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));
}
}
@@ -1054,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(_))
@@ -1062,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(_))
+10 -25
View File
@@ -9,7 +9,6 @@
//! upstream `card_game` / `klondike` types live here so that the product modules
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
use card_game::Card as KlCard;
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
@@ -17,8 +16,16 @@ use klondike::{
};
use serde::{Deserialize, Serialize};
use crate::card;
use crate::game_state::{DrawMode, GameMode};
use crate::game_state::GameMode;
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
@@ -201,28 +208,6 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
}
}
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
/// derived from suit and rank (051, Clubs-first ordering).
///
/// The id is consistent for the same logical card across all reconstructions.
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
let suit = kl_card.suit();
let rank = kl_card.rank();
let suit_index = match suit {
card::Suit::Clubs => 0,
card::Suit::Diamonds => 1,
card::Suit::Hearts => 2,
card::Suit::Spades => 3,
};
let id = suit_index * 13 + (rank.value() as u32 - 1);
card::Card {
id,
suit,
rank,
face_up: false,
}
}
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
//
// These types were introduced when upstream `klondike` had no serde feature.
+2 -3
View File
@@ -3,8 +3,6 @@ pub mod card;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod pile;
pub mod solver;
// Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
@@ -13,8 +11,9 @@ pub mod solver;
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
// re-exported — they are only used internally in `klondike_adapter.rs` and do
// not appear in any public method signature.
pub use card_game::Session;
pub use card_game::{Card, Session};
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
pub use klondike_adapter::DrawMode;
#[cfg(test)]
mod proptest_tests;
-90
View File
@@ -1,90 +0,0 @@
use crate::card::{Card, Suit};
use klondike::KlondikePile;
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
///
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
/// sync system may populate `cards`; no game logic should mutate this struct directly.
/// [`GameState`] is always the authoritative source of truth.
///
/// [`GameState`]: crate::game_state::GameState
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pile {
/// Which logical Klondike pile this is.
pub pile_type: KlondikePile,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
/// Populated by the sync system; do not mutate from game-logic code.
pub cards: Vec<Card>,
}
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: KlondikePile) -> Self {
Self {
pile_type,
cards: Vec::new(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
pub fn top(&self) -> Option<&Card> {
self.cards.last()
}
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
KlondikePile::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
#[test]
fn new_pile_is_empty() {
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.cards.is_empty());
}
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
assert_eq!(pile.top().unwrap().id, 1);
}
#[test]
fn pile_top_on_empty_is_none() {
let pile = Pile::new(KlondikePile::Stock);
assert!(pile.top().is_none());
}
#[test]
fn claimed_suit_is_none_for_empty_foundation() {
let pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation1));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
+20 -20
View File
@@ -1,8 +1,9 @@
use card_game::Game;
use card_game::{Card, Game};
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use proptest::prelude::*;
use crate::game_state::{DrawMode, GameState};
use crate::game_state::GameState;
use crate::klondike_adapter::DrawMode;
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
@@ -13,13 +14,13 @@ use crate::klondike_adapter::{
// Shared helpers
// ---------------------------------------------------------------------------
/// Collect all card IDs across every pile in a fixed traversal order:
/// Collect all cards across every pile in a fixed traversal order:
/// stock → waste → foundations 14 → tableaux 17.
///
/// The order is deterministic for a given game state, so two calls on
/// equivalent states produce identical Vec outputs — the right fingerprint
/// for undo-reversibility checks.
fn all_card_ids(game: &GameState) -> Vec<u32> {
fn all_cards(game: &GameState) -> Vec<Card> {
let foundations = [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -36,19 +37,19 @@ fn all_card_ids(game: &GameState) -> Vec<u32> {
Tableau::Tableau7,
];
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
ids.extend(game.waste_cards().iter().map(|c| c.id));
let mut cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
for f in &foundations {
ids.extend(
cards.extend(
game.pile(KlondikePile::Foundation(*f))
.iter()
.map(|c| c.id),
.map(|(c, _)| c.clone()),
);
}
for t in &tableaux {
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
}
ids
cards
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
@@ -169,13 +170,12 @@ proptest! {
let mut game = GameState::new(seed, draw_mode);
apply_random_actions(&mut game, &actions);
let mut ids = all_card_ids(&game);
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
ids.sort_unstable();
ids.dedup();
let cards = all_cards(&game);
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
prop_assert_eq!(
ids.len(), 52,
"duplicate card IDs found after dedup — a card was cloned"
unique.len(), 52,
"duplicate cards found after dedup — a card was cloned"
);
}
@@ -192,8 +192,8 @@ proptest! {
let a = GameState::new(seed, draw_mode);
let b = GameState::new(seed, draw_mode);
prop_assert_eq!(
all_card_ids(&a),
all_card_ids(&b),
all_cards(&a),
all_cards(&b),
"same seed + draw_mode produced different deals",
);
}
@@ -217,7 +217,7 @@ proptest! {
apply_random_actions(&mut game, &setup_actions);
// Snapshot the state before the move.
let before_ids = all_card_ids(&game);
let before_ids = all_cards(&game);
let before_move_count = game.move_count;
// Apply one move.
@@ -231,7 +231,7 @@ proptest! {
"undo must succeed immediately after a successful move",
);
prop_assert_eq!(
all_card_ids(&game),
all_cards(&game),
before_ids,
"pile layout after undo differs from the pre-move snapshot",
);
+2
View File
@@ -7,6 +7,8 @@ edition.workspace = true
[dependencies]
solitaire_core = { workspace = true }
solitaire_sync = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
+6
View File
@@ -99,6 +99,12 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
}
}
pub mod solver;
pub use solver::{
SolveOutcome, SolverConfig, SolverMove, SolverResult, try_solve, try_solve_from_state,
try_solve_with_first_move,
};
pub mod stats;
pub use stats::{StatsExt, StatsSnapshot};
+1 -1
View File
@@ -26,7 +26,7 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::SavedKlondikePile;
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
+2 -2
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -200,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying
/// [`solitaire_data::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
@@ -5,9 +5,9 @@
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::KlondikeAdapter;
use solitaire_core::DrawMode;
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::KlondikeAdapter;
/// Verdict returned by [`try_solve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -160,7 +160,8 @@ fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverM
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len();
let face_up_count =
source_state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
@@ -242,10 +243,6 @@ mod tests {
#[test]
fn budget_is_passed_through_not_clamped() {
// 0xD1FF_0000_0000_0012 is a Medium-tier catalog seed: Inconclusive at
// the Easy budget (1 000 states) but Winnable at Medium (5 000 states).
// Differing results confirm solve_game_state passes the caller's
// state_budget unchanged to the underlying solver.
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 };
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
assert_eq!(
@@ -260,12 +257,6 @@ mod tests {
#[test]
fn budget_above_five_thousand_is_not_clamped() {
// 0xD1FF_0000_0000_00DE is a hard catalog seed: Inconclusive at 5 000
// states but Winnable at 50 000. Before this fix, solve_game_state
// applied `config.state_budget.min(5_000)` internally, so a 50k config
// was silently reduced to 5k — making both calls return Inconclusive and
// preventing the generator from certifying Hard/Expert/Grandmaster seeds.
// This assertion fails if the cap is re-introduced.
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 };
assert_eq!(
+1 -1
View File
@@ -5,7 +5,7 @@
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
pub use solitaire_sync::StatsSnapshot;
+8 -18
View File
@@ -9,7 +9,7 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use solitaire_core::game_state::GameState;
use crate::stats::StatsSnapshot;
@@ -85,9 +85,6 @@ pub fn game_state_file_path() -> Option<PathBuf> {
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won { None } else { Some(gs) }
}
@@ -282,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
mod tests {
use super::*;
use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -380,7 +377,7 @@ mod tests {
#[test]
fn game_state_round_trip() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("round_trip");
let _ = fs::remove_file(&path);
@@ -409,7 +406,7 @@ mod tests {
#[test]
fn save_game_state_skips_won_games() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("won_skip");
let _ = fs::remove_file(&path);
@@ -424,7 +421,7 @@ mod tests {
#[test]
fn delete_game_state_removes_file() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("delete");
let gs = GameState::new(1, DrawMode::DrawOne);
save_game_state_to(&path, &gs).expect("save");
@@ -442,7 +439,7 @@ mod tests {
#[test]
fn save_game_state_is_atomic() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::GameState;
let path = gs_path("atomic");
let gs = GameState::new(55, DrawMode::DrawThree);
save_game_state_to(&path, &gs).expect("save");
@@ -510,7 +507,7 @@ mod tests {
#[test]
fn game_state_v4_mid_game_round_trip() {
use solitaire_core::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
use solitaire_core::game_state::GameState;
let path = gs_path("v4_mid_game");
let _ = fs::remove_file(&path);
@@ -557,7 +554,6 @@ mod tests {
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
assert_eq!(loaded.schema_version, GAME_STATE_SCHEMA_VERSION);
assert_eq!(
loaded, gs,
"all pile layouts and counters must be identical after schema-v4 round-trip",
@@ -574,7 +570,7 @@ mod tests {
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
use solitaire_core::game_state::GameState;
let path = gs_path("v3_migrate");
let _ = fs::remove_file(&path);
@@ -599,12 +595,6 @@ mod tests {
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
// After migration, the in-memory schema version must be current.
assert_eq!(
loaded.schema_version, GAME_STATE_SCHEMA_VERSION,
"migrated game must report current schema version",
);
// The loaded game should match a fresh game that had one draw applied.
let mut expected = GameState::new(42, DrawMode::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
+1 -1
View File
@@ -4,7 +4,7 @@
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
use chrono::{Datelike, NaiveDate};
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
/// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75;
+3 -3
View File
@@ -819,7 +819,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
.draw_mode = solitaire_core::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
.draw_mode = solitaire_core::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -1393,7 +1393,7 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Headless app variant that injects a default `ReplayPlaybackState`
+3 -8
View File
@@ -168,8 +168,8 @@ 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::game_state::{DrawMode, GameState};
use solitaire_core::card::{Deck, Rank, Suit};
use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
@@ -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);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -117,7 +117,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
+10 -19
View File
@@ -34,8 +34,9 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
@@ -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 {
@@ -562,7 +563,7 @@ mod tests {
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use crate::layout::compute_layout;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -579,8 +580,8 @@ mod tests {
// -----------------------------------------------------------------------
use crate::layout::compute_layout;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
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
/// registered, plus the resources the system needs. Callers
@@ -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);
@@ -362,7 +362,7 @@ mod tests {
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
+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,
}
+36 -21
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),
@@ -850,7 +865,7 @@ mod tests {
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::Tableau;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_data::Settings;
let mut app = App::new();
@@ -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>>()
@@ -904,7 +919,7 @@ mod tests {
#[test]
fn foundation_flourish_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
use solitaire_data::Settings;
let mut app = App::new();
+32 -77
View File
@@ -14,8 +14,8 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
use solitaire_data::solver::{SolverConfig, SolverResult, try_solve};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
@@ -316,7 +316,7 @@ fn seed_from_system_time() -> u64 {
}
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
/// arithmetic) until the [`solitaire_data::solver`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
@@ -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
@@ -16,7 +16,7 @@
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
+3 -3
View File
@@ -10,7 +10,7 @@ use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
use crate::auto_complete_plugin::AutoCompleteState;
#[cfg(not(target_arch = "wasm32"))]
@@ -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 {
@@ -2726,7 +2726,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use chrono::Local;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
+102 -171
View File
@@ -52,7 +52,7 @@ use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
/// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering.
@@ -79,13 +79,13 @@ fn dragged_card_z(index: usize) -> f32 {
/// Solver budgets used by the H-key hint system.
///
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
/// Wraps `solitaire_data::solver::SolverConfig` as a Bevy resource so
/// tests can inject tighter budgets to exercise the heuristic-fallback
/// path. Production initialises this to `SolverConfig::default()` (100k
/// move / 200k state budgets, the same numbers the new-game retry loop
/// uses).
#[derive(Resource, Debug, Clone, Default)]
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
pub struct HintSolverConfig(pub solitaire_data::solver::SolverConfig);
/// Registers keyboard, mouse, and touch input systems.
///
@@ -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),
@@ -1789,7 +1793,7 @@ mod tests {
use super::*;
use crate::layout::compute_layout;
use solitaire_core::{Foundation, Tableau};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
fn clear_test_piles(game: &mut GameState) {
game.set_test_stock_cards(Vec::new());
@@ -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::game_state::{DrawMode, GameMode};
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"
);
}
+3 -3
View File
@@ -21,7 +21,7 @@
//! active opens the overlay as normal.
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
use solitaire_data::save_game_state_to;
use crate::events::{
@@ -965,7 +965,7 @@ mod tests {
/// Provides a fresh `GameStateResource` (not won) so the modal can
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
fn forfeit_app() -> App {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
@@ -1020,7 +1020,7 @@ mod tests {
/// hotkey was received but is currently a no-op.
#[test]
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
+6 -16
View File
@@ -28,7 +28,7 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use solitaire_data::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
@@ -187,8 +187,8 @@ 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::game_state::{DrawMode, GameState};
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
/// and the resources/messages it touches.
@@ -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
+2 -2
View File
@@ -23,8 +23,8 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use solitaire_core::DrawMode;
use solitaire_data::solver::{SolverConfig, SolverResult, try_solve};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource;
+27 -33
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,8 +808,8 @@ 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::game_state::{DrawMode, GameState};
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
/// resources / messages it depends on. No window, no camera — 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 -3
View File
@@ -2,7 +2,7 @@ use super::*;
use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_data::{Replay, ReplayMove};
@@ -2314,7 +2314,7 @@ fn format_suit_glyph_all_suits() {
fn format_foundations_row_empty_board() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
);
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
@@ -2326,7 +2326,7 @@ fn format_foundations_row_empty_board() {
fn format_stock_waste_row_initial_state() {
let game = solitaire_core::game_state::GameState::new_with_mode(
42,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
);
let text = format_stock_waste_row(&game);
+1 -1
View File
@@ -556,7 +556,7 @@ mod tests {
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use std::time::Duration;
+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.
+47 -105
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,8 +933,8 @@ mod tests {
// -----------------------------------------------------------------------
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
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
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
@@ -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;
+2 -2
View File
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
use solitaire_data::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
@@ -241,7 +241,7 @@ enum SettingsButton {
ToggleTouchInputMode,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::solver::try_solve`] until one is provably
/// [`solitaire_data::solver::try_solve`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
+2 -2
View File
@@ -1327,7 +1327,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
.draw_mode = solitaire_core::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -1952,7 +1952,7 @@ mod tests {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
let mut r = solitaire_data::Replay::new(
1,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::DrawMode::DrawOne,
solitaire_core::game_state::GameMode::Classic,
time_seconds,
0,
+1 -1
View File
@@ -604,7 +604,7 @@ mod tests {
/// would silently drop the link.
#[test]
fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::{DrawMode, game_state::GameMode};
use solitaire_data::{
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
};
+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();
+1 -1
View File
@@ -299,7 +299,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::{DrawMode, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
+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);
}
}
+3 -3
View File
@@ -1210,7 +1210,7 @@ mod tests {
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::game_state::DrawMode::DrawOne,
solitaire_core::DrawMode::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.update();
@@ -1539,7 +1539,7 @@ mod tests {
.challenge_index = 4;
// Switch game mode to Challenge.
{
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
}
@@ -1585,7 +1585,7 @@ mod tests {
/// mode-multiplier rows.
#[test]
fn cache_win_data_captures_undo_count_and_mode() {
use solitaire_core::game_state::DrawMode;
use solitaire_core::DrawMode;
let mut app = make_app();
// Set up a Zen-mode game with 2 undos used.
+33 -19
View File
@@ -23,7 +23,7 @@ use solitaire_core::{Foundation, KlondikePile, Tableau};
use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::error::MoveError;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
use solitaire_core::klondike_adapter::{
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
};
@@ -87,18 +87,31 @@ pub struct CardSnapshot {
pub face_up: bool,
}
impl From<&solitaire_core::card::Card> for CardSnapshot {
fn from(c: &solitaire_core::card::Card) -> Self {
/// Stable 0..=51 identifier derived from suit and rank. Mirrors the desktop
/// engine's `card_to_id` so replay snapshots are identical across platforms —
/// `Card` itself carries no id field (suit + rank are unique within a deck).
fn card_to_id(card: &solitaire_core::card::Card) -> u32 {
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)
}
impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot {
fn from((card, face_up): &(solitaire_core::card::Card, bool)) -> Self {
Self {
id: c.id,
suit: match c.suit {
id: card_to_id(card),
suit: match card.suit() {
Suit::Clubs => "clubs",
Suit::Diamonds => "diamonds",
Suit::Hearts => "hearts",
Suit::Spades => "spades",
},
rank: c.rank.value(),
face_up: c.face_up,
rank: card.rank().value(),
face_up: *face_up,
}
}
}
@@ -389,16 +402,17 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
let mut out_of_range_card_ids = Vec::new();
let mut total_cards_seen = 0_usize;
let mut feed = |cards: &[solitaire_core::card::Card]| {
for card in cards {
let mut feed = |cards: &[(solitaire_core::card::Card, bool)]| {
for (card, _) in cards {
total_cards_seen += 1;
if card.id >= 52 {
out_of_range_card_ids.push(card.id);
let id = card_to_id(card);
if id >= 52 {
out_of_range_card_ids.push(id);
continue;
}
let idx = card.id as usize;
let idx = id as usize;
if seen[idx] {
duplicate_card_ids.push(card.id);
duplicate_card_ids.push(id);
} else {
seen[idx] = true;
}
@@ -418,16 +432,16 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
.filter(|id| !seen[*id as usize])
.collect::<Vec<_>>();
let stock_has_face_up_cards = stock.iter().any(|c| c.face_up);
let waste_has_face_down_cards = waste.iter().any(|c| !c.face_up);
let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up);
let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up);
let foundation_has_face_down_cards = foundations
.iter()
.any(|pile| pile.iter().any(|c| !c.face_up));
.any(|pile| pile.iter().any(|(_, face_up)| !*face_up));
let tableau_visibility_violation = tableaus.iter().any(|pile| {
let mut seen_face_up = false;
for card in pile {
if card.face_up {
for (_, face_up) in pile {
if *face_up {
seen_face_up = true;
} else if seen_face_up {
return true;
@@ -599,7 +613,7 @@ impl SolitaireGame {
.pile(KlondikePile::Tableau(tableau))
.iter()
.rev()
.take_while(|card| card.face_up)
.take_while(|(_, face_up)| *face_up)
.count();
let skip = tableau_stack.skip_cards.0 as usize;
let count = face_up_count.checked_sub(skip).ok_or_else(|| {