Compare commits
2 Commits
37a21b9b42
...
1438fd6265
| Author | SHA1 | Date | |
|---|---|---|---|
| 1438fd6265 | |||
| 920f2c8597 |
Generated
+2
@@ -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.
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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(_))
|
||||
|
||||
@@ -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 (0–51, 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 1–4 → tableaux 1–7.
|
||||
///
|
||||
/// 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",
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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!(
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
+154
-241
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 0–51
|
||||
/// 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();
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(|| {
|
||||
|
||||
Reference in New Issue
Block a user