refactor(core): complete card_game::Card migration across engine + wasm
Finish the half-applied Card refactor. solitaire_core::card::Card is now an alias for the opaque card_game::Card: suit()/rank() are methods, there is no id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors return Vec<(Card, bool)> where the bool is face-up. Card identity is now the Card value itself (via Eq/Hash), not a numeric u32: - CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards. - Drag/selection collections and the touch/keyboard selection setters use Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card. - replay_overlay and feedback/settle/deal animations updated accordingly. solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the desktop engine), and consumes the (Card, bool) pile tuples. test-support: TestPileState tableau overrides now carry a per-card face-up flag so tests can place face-down tableau cards. set_test_tableau_cards keeps its Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face takes Vec<(Card, bool)>. cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy --workspace --all-targets -- -D warnings is clean. Save/serde format unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+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,13 +1,12 @@
|
||||
use crate::card::Card;
|
||||
use crate::error::MoveError;
|
||||
use crate::klondike_adapter::{
|
||||
DrawMode, KlondikeAdapter, SavedInstruction, card_from_kl,
|
||||
DrawMode, KlondikeAdapter, SavedInstruction,
|
||||
compute_time_bonus as scoring_time_bonus,
|
||||
foundation_from_slot as adapter_foundation_from_slot,
|
||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||
tableau_from_index as adapter_tableau_from_index,
|
||||
};
|
||||
use card_game::{Game as _, Session, SessionConfig};
|
||||
use card_game::{Card, Game as _, Session, SessionConfig};
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||
@@ -142,13 +141,15 @@ struct PersistedGameStateIn {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TestPileState {
|
||||
/// Override for face-down stock cards. `None` means "use session".
|
||||
pub stock: Option<Vec<crate::card::Card>>,
|
||||
pub stock: Option<Vec<Card>>,
|
||||
/// Override for face-up waste cards. `None` means "use session".
|
||||
pub waste: Option<Vec<crate::card::Card>>,
|
||||
pub waste: Option<Vec<Card>>,
|
||||
/// Per-tableau overrides. Missing keys fall back to the session.
|
||||
pub tableau: std::collections::HashMap<Tableau, Vec<crate::card::Card>>,
|
||||
/// Each entry carries its own face-up flag so tests can place face-down
|
||||
/// cards (e.g. an un-flipped tableau card).
|
||||
pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
|
||||
/// Per-foundation overrides. Missing keys fall back to the session.
|
||||
pub foundation: std::collections::HashMap<Foundation, Vec<crate::card::Card>>,
|
||||
pub foundation: std::collections::HashMap<Foundation, Vec<Card>>,
|
||||
}
|
||||
|
||||
/// Full state of an in-progress Klondike Solitaire game.
|
||||
@@ -417,55 +418,52 @@ impl GameState {
|
||||
self.session.history().len()
|
||||
}
|
||||
|
||||
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<Card> {
|
||||
cards
|
||||
.into_iter()
|
||||
.map(|mut card| {
|
||||
card.face_up = face_up;
|
||||
card
|
||||
})
|
||||
.collect()
|
||||
fn cards_with_face(
|
||||
cards: impl IntoIterator<Item = Card>,
|
||||
face_up: bool,
|
||||
) -> Vec<(Card, bool)> {
|
||||
cards.into_iter().map(|card| (card, face_up)).collect()
|
||||
}
|
||||
|
||||
pub fn stock_cards(&self) -> Vec<Card> {
|
||||
pub fn stock_cards(&self) -> Vec<(Card, bool)> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(ref cards) = state.stock
|
||||
{
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), false)).collect();
|
||||
}
|
||||
let state = self.session.state().state().state();
|
||||
Self::cards_with_face(state.stock().face_down().iter().map(card_from_kl), false)
|
||||
Self::cards_with_face(state.stock().face_down().iter().cloned(), false)
|
||||
}
|
||||
|
||||
pub fn waste_cards(&self) -> Vec<Card> {
|
||||
pub fn waste_cards(&self) -> Vec<(Card, bool)> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(ref cards) = state.waste
|
||||
{
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), true)).collect();
|
||||
}
|
||||
let state = self.session.state().state().state();
|
||||
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
|
||||
Self::cards_with_face(state.stock().face_up().iter().cloned(), true)
|
||||
}
|
||||
|
||||
/// Returns the cards in the requested pile.
|
||||
/// Returns the cards in the requested pile as `(card, face_up)` tuples.
|
||||
///
|
||||
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
|
||||
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
|
||||
/// to read the face-down draw cards.
|
||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||
pub fn pile(&self, pile: KlondikePile) -> Vec<(Card, bool)> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
if let Some(ref cards) = state.waste {
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), true)).collect();
|
||||
}
|
||||
}
|
||||
KlondikePile::Foundation(f) => {
|
||||
if let Some(cards) = state.foundation.get(&f) {
|
||||
return cards.clone();
|
||||
return cards.iter().map(|c| (c.clone(), true)).collect();
|
||||
}
|
||||
}
|
||||
KlondikePile::Tableau(t) => {
|
||||
@@ -485,21 +483,13 @@ impl GameState {
|
||||
Foundation::Foundation3 => state.foundation3(),
|
||||
Foundation::Foundation4 => state.foundation4(),
|
||||
};
|
||||
Self::cards_with_face(cards.iter().map(card_from_kl), true)
|
||||
Self::cards_with_face(cards.iter().cloned(), true)
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => {
|
||||
let mut cards = Self::cards_with_face(
|
||||
state
|
||||
.tableau_face_down_cards(tableau)
|
||||
.iter()
|
||||
.map(card_from_kl),
|
||||
false,
|
||||
);
|
||||
let mut cards =
|
||||
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
||||
cards.extend(Self::cards_with_face(
|
||||
state
|
||||
.tableau_face_up_cards(tableau)
|
||||
.iter()
|
||||
.map(card_from_kl),
|
||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||
true,
|
||||
));
|
||||
cards
|
||||
@@ -515,7 +505,7 @@ impl GameState {
|
||||
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
|
||||
}
|
||||
|
||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<(Card, bool)>, MoveError> {
|
||||
let foundation = Self::foundation_from_slot(slot)?;
|
||||
Ok(self.pile(KlondikePile::Foundation(foundation)))
|
||||
}
|
||||
@@ -560,8 +550,20 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific tableau column.
|
||||
///
|
||||
/// All provided cards are treated as face-up. Use
|
||||
/// [`Self::set_test_tableau_cards_with_face`] when a test needs to place
|
||||
/// face-down cards.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
|
||||
let with_face = cards.into_iter().map(|c| (c, true)).collect();
|
||||
self.set_test_tableau_cards_with_face(tableau, with_face);
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific tableau column,
|
||||
/// specifying each card's face-up flag (`true` = face-up).
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_tableau_cards_with_face(&mut self, tableau: Tableau, cards: Vec<(Card, bool)>) {
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
@@ -578,21 +580,15 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific pile.
|
||||
///
|
||||
/// For `KlondikePile::Stock`, all provided cards go to the face-down stock
|
||||
/// override. Use [`Self::set_test_waste_cards`] to override the waste pile
|
||||
/// separately.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
let mut stock = Vec::new();
|
||||
let mut waste = Vec::new();
|
||||
for card in cards {
|
||||
if card.face_up {
|
||||
waste.push(card);
|
||||
} else {
|
||||
stock.push(card);
|
||||
}
|
||||
}
|
||||
self.set_test_stock_cards(stock);
|
||||
self.set_test_waste_cards(waste);
|
||||
self.set_test_stock_cards(cards);
|
||||
}
|
||||
KlondikePile::Tableau(t) => self.set_test_tableau_cards(t, cards),
|
||||
KlondikePile::Foundation(f) => self.set_test_foundation_cards(f, cards),
|
||||
@@ -612,7 +608,7 @@ impl GameState {
|
||||
if pile.is_empty() {
|
||||
return false;
|
||||
}
|
||||
pile.len() > count && !pile[pile.len() - count - 1].face_up
|
||||
pile.len() > count && !pile[pile.len() - count - 1].1
|
||||
}
|
||||
|
||||
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
|
||||
@@ -962,24 +958,24 @@ impl GameState {
|
||||
.is_instruction_valid(&config, instruction)
|
||||
}
|
||||
|
||||
/// Returns the current pile containing `card_id`, if any.
|
||||
pub fn pile_containing_card(&self, card_id: u32) -> Option<KlondikePile> {
|
||||
if self.stock_cards().iter().any(|card| card.id == card_id)
|
||||
|| self.waste_cards().iter().any(|card| card.id == card_id)
|
||||
/// Returns the current pile containing `card`, if any.
|
||||
pub fn pile_containing_card(&self, card: Card) -> Option<KlondikePile> {
|
||||
if self.stock_cards().iter().any(|(c, _)| *c == card)
|
||||
|| self.waste_cards().iter().any(|(c, _)| *c == card)
|
||||
{
|
||||
return Some(KlondikePile::Stock);
|
||||
}
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = Self::foundation_from_slot(slot).ok()?;
|
||||
let pile = self.pile(KlondikePile::Foundation(foundation));
|
||||
if pile.iter().any(|card| card.id == card_id) {
|
||||
if pile.iter().any(|(c, _)| *c == card) {
|
||||
return Some(KlondikePile::Foundation(foundation));
|
||||
}
|
||||
}
|
||||
for index in 0..7_usize {
|
||||
let tableau = Self::tableau_from_index(index).ok()?;
|
||||
let pile = self.pile(KlondikePile::Tableau(tableau));
|
||||
if pile.iter().any(|card| card.id == card_id) {
|
||||
if pile.iter().any(|(c, _)| *c == card) {
|
||||
return Some(KlondikePile::Tableau(tableau));
|
||||
}
|
||||
}
|
||||
@@ -1039,7 +1035,7 @@ mod tests {
|
||||
|
||||
for _ in 0..MAX_STEPS {
|
||||
let moves = game.possible_instructions();
|
||||
if let Some((from, to, _count)) = moves.iter().copied().find(|(from, to, count)| {
|
||||
if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| {
|
||||
*count == 1
|
||||
&& matches!(from, KlondikePile::Foundation(_))
|
||||
&& matches!(to, KlondikePile::Tableau(_))
|
||||
@@ -1047,7 +1043,7 @@ mod tests {
|
||||
return Some((game, from, to));
|
||||
}
|
||||
|
||||
if let Some((from, to, count)) = moves.iter().copied().find(|(from, to, count)| {
|
||||
if let Some((from, to, count)) = moves.iter().cloned().find(|(from, to, count)| {
|
||||
*count == 1
|
||||
&& !matches!(from, KlondikePile::Foundation(_))
|
||||
&& matches!(to, KlondikePile::Foundation(_))
|
||||
|
||||
@@ -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,7 +16,6 @@ use klondike::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::card;
|
||||
use crate::game_state::GameMode;
|
||||
|
||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||
@@ -210,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.
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod klondike_adapter;
|
||||
// `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;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use card_game::Game;
|
||||
use card_game::{Card, Game};
|
||||
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
@@ -14,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,
|
||||
@@ -37,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> {
|
||||
@@ -170,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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,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",
|
||||
);
|
||||
}
|
||||
@@ -218,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.
|
||||
@@ -232,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",
|
||||
);
|
||||
|
||||
@@ -168,7 +168,7 @@ 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::card::{Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawMode, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
@@ -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);
|
||||
|
||||
+142
-229
@@ -159,10 +159,10 @@ fn card_back_colour(selected_card_back: usize) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct CardEntity {
|
||||
pub card_id: u32,
|
||||
pub card: Card,
|
||||
}
|
||||
|
||||
/// Marker for the text child inside a card entity.
|
||||
@@ -562,20 +562,21 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
||||
/// available and falling back to a solid-colour sprite in tests.
|
||||
fn card_sprite(
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
card_size: Vec2,
|
||||
back_colour: Color,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) -> Sprite {
|
||||
if let Some(set) = card_images {
|
||||
let image = if card.face_up {
|
||||
let suit_idx = match card.suit {
|
||||
let image = if face_up {
|
||||
let suit_idx = match card.suit() {
|
||||
Suit::Clubs => 0,
|
||||
Suit::Diamonds => 1,
|
||||
Suit::Hearts => 2,
|
||||
Suit::Spades => 3,
|
||||
};
|
||||
let rank_idx = match card.rank {
|
||||
let rank_idx = match card.rank() {
|
||||
Rank::Ace => 0,
|
||||
Rank::Two => 1,
|
||||
Rank::Three => 2,
|
||||
@@ -613,7 +614,7 @@ fn card_sprite(
|
||||
// the suit glyph colour, applied by `text_colour`, not the face
|
||||
// background). Pre-Terminal this branch dispatched through a
|
||||
// separate `face_colour(card, color_blind)` helper.
|
||||
let body_colour = if card.face_up {
|
||||
let body_colour = if face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
back_colour
|
||||
@@ -732,7 +733,7 @@ fn sync_cards(
|
||||
// top card's slide animation plays — it must never be visible to the player.
|
||||
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||
// and its rank/suit peek behind the incoming card.
|
||||
let waste_buffer_id: Option<u32> = {
|
||||
let waste_buffer_id: Option<Card> = {
|
||||
let visible = match game.draw_mode {
|
||||
DrawMode::DrawOne => 1_usize,
|
||||
DrawMode::DrawThree => 3_usize,
|
||||
@@ -741,10 +742,10 @@ fn sync_cards(
|
||||
(waste_cards.len() > visible)
|
||||
.then_some(waste_cards)
|
||||
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
|
||||
.map(|c| c.id)
|
||||
.map(|(c, _face_up)| c)
|
||||
};
|
||||
|
||||
// Map card_id -> (Entity, current_translation, anim_end) for in-place
|
||||
// Map Card -> (Entity, current_translation, anim_end) for in-place
|
||||
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
||||
// is currently driving the card (e.g. a drag-rejection return tween).
|
||||
//
|
||||
@@ -755,19 +756,19 @@ fn sync_cards(
|
||||
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||
// while the win-cascade was mid-flight); cancel the
|
||||
// stale `CardAnimation` and apply the new position.
|
||||
let mut existing: HashMap<u32, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
|
||||
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
|
||||
for (entity, marker, transform, anim) in entities.iter() {
|
||||
existing.insert(
|
||||
marker.card_id,
|
||||
marker.card.clone(),
|
||||
(entity, transform.translation, anim.map(|a| a.end)),
|
||||
);
|
||||
}
|
||||
|
||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
|
||||
|
||||
// Despawn any entity whose card is no longer tracked.
|
||||
for (card_id, (entity, _, _)) in &existing {
|
||||
if !live_ids.contains(card_id) {
|
||||
for (card, (entity, _, _)) in &existing {
|
||||
if !live_ids.contains(card) {
|
||||
commands.entity(*entity).despawn();
|
||||
}
|
||||
}
|
||||
@@ -775,8 +776,8 @@ fn sync_cards(
|
||||
// For each card in the current state: spawn or update its entity, then
|
||||
// apply visibility. The waste buffer card is hidden so it cannot peek
|
||||
// behind the incoming top card during the draw slide animation.
|
||||
for (card, position, z) in positions {
|
||||
let entity = match existing.get(&card.id) {
|
||||
for ((card, face_up), position, z) in positions {
|
||||
let entity = match existing.get(&card) {
|
||||
Some(&(entity, cur, anim_end)) => {
|
||||
// If a CardAnimation is in flight, check whether its destination
|
||||
// still matches the game-state target. If the game moved the card
|
||||
@@ -794,6 +795,7 @@ fn sync_cards(
|
||||
&mut commands,
|
||||
entity,
|
||||
&card,
|
||||
face_up,
|
||||
position,
|
||||
z,
|
||||
layout,
|
||||
@@ -812,6 +814,7 @@ fn sync_cards(
|
||||
None => spawn_card_entity(
|
||||
&mut commands,
|
||||
&card,
|
||||
face_up,
|
||||
position,
|
||||
z,
|
||||
layout,
|
||||
@@ -823,7 +826,7 @@ fn sync_cards(
|
||||
font_handle,
|
||||
),
|
||||
};
|
||||
let visibility = if waste_buffer_id == Some(card.id) {
|
||||
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
@@ -832,9 +835,9 @@ fn sync_cards(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an ordered vec of (card, position, z) for every card in the game.
|
||||
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
|
||||
fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
|
||||
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
|
||||
let piles = [
|
||||
(KlondikePile::Stock, true),
|
||||
(KlondikePile::Stock, false),
|
||||
@@ -914,7 +917,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
|
||||
let mut y_offset = 0.0_f32;
|
||||
let rendered_len = cards[render_start..].len();
|
||||
for (slot, card) in cards[render_start..].iter().enumerate() {
|
||||
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
|
||||
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
|
||||
// When len > visible, slot 0 is a hidden buffer card kept at
|
||||
// x=0 to prevent a flash during the draw tween. When len ≤
|
||||
@@ -928,9 +931,9 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
};
|
||||
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||
out.push((card.clone(), pos, z));
|
||||
out.push(((card.clone(), *face_up), pos, z));
|
||||
if is_tableau {
|
||||
let step = if card.face_up {
|
||||
let step = if *face_up {
|
||||
layout.tableau_fan_frac
|
||||
} else {
|
||||
layout.tableau_facedown_fan_frac
|
||||
@@ -942,8 +945,8 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
out
|
||||
}
|
||||
|
||||
fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
let mut cards = Vec::with_capacity(52);
|
||||
fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
|
||||
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
|
||||
cards.extend(game.stock_cards());
|
||||
cards.extend(game.waste_cards());
|
||||
for foundation in [
|
||||
@@ -972,6 +975,7 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
fn spawn_card_entity(
|
||||
commands: &mut Commands,
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
@@ -984,6 +988,7 @@ fn spawn_card_entity(
|
||||
) -> Entity {
|
||||
let sprite = card_sprite(
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
back_colour,
|
||||
card_images,
|
||||
@@ -991,7 +996,7 @@ fn spawn_card_entity(
|
||||
);
|
||||
|
||||
let mut entity = commands.spawn((
|
||||
CardEntity { card_id: card.id },
|
||||
CardEntity { card: card.clone() },
|
||||
sprite,
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
@@ -1024,7 +1029,7 @@ fn spawn_card_entity(
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(card),
|
||||
label_visibility(face_up),
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -1033,6 +1038,7 @@ fn spawn_card_entity(
|
||||
add_android_corner_label(
|
||||
b,
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
@@ -1048,6 +1054,7 @@ fn update_card_entity(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
@@ -1066,6 +1073,7 @@ fn update_card_entity(
|
||||
// Always refresh the visual appearance.
|
||||
commands.entity(entity).insert(card_sprite(
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
back_colour,
|
||||
card_images,
|
||||
@@ -1126,7 +1134,7 @@ fn update_card_entity(
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(card),
|
||||
label_visibility(face_up),
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -1135,6 +1143,7 @@ fn update_card_entity(
|
||||
add_android_corner_label(
|
||||
b,
|
||||
card,
|
||||
face_up,
|
||||
layout.card_size,
|
||||
color_blind,
|
||||
high_contrast,
|
||||
@@ -1145,7 +1154,7 @@ fn update_card_entity(
|
||||
}
|
||||
|
||||
fn label_for(card: &Card) -> String {
|
||||
let rank = match card.rank {
|
||||
let rank = match card.rank() {
|
||||
Rank::Ace => "A",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
@@ -1160,7 +1169,7 @@ fn label_for(card: &Card) -> String {
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
};
|
||||
let suit = match card.suit {
|
||||
let suit = match card.suit() {
|
||||
Suit::Clubs => "C",
|
||||
Suit::Diamonds => "D",
|
||||
Suit::Hearts => "H",
|
||||
@@ -1188,7 +1197,7 @@ fn label_for(card: &Card) -> String {
|
||||
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
|
||||
/// and has no constant-fallback equivalent.
|
||||
fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
|
||||
if card.suit.is_red() {
|
||||
if card.suit().is_red() {
|
||||
if color_blind {
|
||||
// CBM lime wins — the colour-blind swap replaces the
|
||||
// red hue entirely, and the lime is already high-
|
||||
@@ -1206,8 +1215,8 @@ fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn label_visibility(card: &Card) -> Visibility {
|
||||
if card.face_up {
|
||||
fn label_visibility(face_up: bool) -> Visibility {
|
||||
if face_up {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
@@ -1217,7 +1226,7 @@ fn label_visibility(card: &Card) -> Visibility {
|
||||
/// Rank+suit string for the readability overlay on touch HUD layouts.
|
||||
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||
fn mobile_label_for(card: &Card) -> String {
|
||||
let rank = match card.rank {
|
||||
let rank = match card.rank() {
|
||||
Rank::Ace => "A",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
@@ -1232,7 +1241,7 @@ fn mobile_label_for(card: &Card) -> String {
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
};
|
||||
let suit = match card.suit {
|
||||
let suit = match card.suit() {
|
||||
Suit::Clubs => "♣",
|
||||
Suit::Diamonds => "♦",
|
||||
Suit::Hearts => "♥",
|
||||
@@ -1254,12 +1263,13 @@ fn mobile_label_for(card: &Card) -> String {
|
||||
fn add_android_corner_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
card: &Card,
|
||||
face_up: bool,
|
||||
card_size: Vec2,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
if !card.face_up {
|
||||
if !face_up {
|
||||
return;
|
||||
}
|
||||
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||
@@ -1309,7 +1319,7 @@ fn add_android_corner_label(
|
||||
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||
// Terminal theme background.
|
||||
let text_col = if card.suit.is_red() {
|
||||
let text_col = if card.suit().is_red() {
|
||||
if color_blind {
|
||||
RED_SUIT_COLOUR_CBM
|
||||
} else if high_contrast {
|
||||
@@ -1355,9 +1365,9 @@ fn start_flip_anim(
|
||||
return;
|
||||
}
|
||||
|
||||
for CardFlippedEvent(card_id) in events.read() {
|
||||
for CardFlippedEvent(flipped_card) in events.read() {
|
||||
for (entity, marker) in &card_entities {
|
||||
if marker.card_id == *card_id {
|
||||
if marker.card == *flipped_card {
|
||||
commands.entity(entity).insert(CardFlipAnim {
|
||||
timer: 0.0,
|
||||
phase: FlipPhase::ScalingDown,
|
||||
@@ -1394,7 +1404,7 @@ fn tick_flip_anim(
|
||||
transform.scale.x = 0.0;
|
||||
// Fire the reveal event exactly once, at the phase transition,
|
||||
// so the flip sound is synchronised with the visual face reveal.
|
||||
reveal_events.write(CardFaceRevealedEvent(card_entity.card_id));
|
||||
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
|
||||
}
|
||||
}
|
||||
FlipPhase::ScalingUp => {
|
||||
@@ -1438,11 +1448,10 @@ fn update_drag_shadow(
|
||||
let card_h = layout.0.card_size.y;
|
||||
|
||||
// Find the world position of the first (top) dragged card.
|
||||
let first_id = drag.cards.first().copied();
|
||||
let top_pos = first_id.and_then(|id| {
|
||||
let top_pos = drag.cards.first().and_then(|first_card| {
|
||||
card_entities
|
||||
.iter()
|
||||
.find(|(marker, _)| marker.card_id == id)
|
||||
.find(|(marker, _)| marker.card == *first_card)
|
||||
.map(|(_, t)| t.translation)
|
||||
});
|
||||
|
||||
@@ -1498,10 +1507,10 @@ fn update_card_shadows_on_drag(
|
||||
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||
) {
|
||||
let dragged: HashSet<u32> = drag.cards.iter().copied().collect();
|
||||
let dragged: HashSet<&Card> = drag.cards.iter().collect();
|
||||
|
||||
for (card_entity, card_sprite, children) in cards.iter() {
|
||||
let is_dragged = dragged.contains(&card_entity.card_id);
|
||||
let is_dragged = dragged.contains(&card_entity.card);
|
||||
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||
let Some(card_size) = card_sprite.custom_size else {
|
||||
continue;
|
||||
@@ -1548,8 +1557,8 @@ fn tick_hint_highlight(
|
||||
} else {
|
||||
let is_face_up = all_cards(&game.0)
|
||||
.iter()
|
||||
.find(|c| c.id == card_entity.card_id)
|
||||
.is_some_and(|c| c.face_up);
|
||||
.find(|(c, _face_up)| *c == card_entity.card)
|
||||
.is_some_and(|(_, face_up)| *face_up);
|
||||
if is_face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
@@ -1718,7 +1727,7 @@ fn handle_right_click(
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(source_pile) = game.0.pile_containing_card(card.id) else {
|
||||
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1766,10 +1775,10 @@ fn find_top_card_at(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let card = all_cards(game)
|
||||
let found = all_cards(game)
|
||||
.into_iter()
|
||||
.find(|c| c.id == card_entity.card_id && c.face_up);
|
||||
if let Some(card) = card {
|
||||
.find(|(c, face_up)| *c == card_entity.card && *face_up);
|
||||
if let Some((card, _)) = found {
|
||||
let z = transform.translation.z;
|
||||
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||
best = Some((z, card));
|
||||
@@ -2236,13 +2245,13 @@ fn resize_cards_in_place(
|
||||
>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
|
||||
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
|
||||
.into_iter()
|
||||
.map(|(c, p, z)| (c.id, (p, z)))
|
||||
.map(|((c, _face_up), p, z)| (c, (p, z)))
|
||||
.collect();
|
||||
|
||||
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
|
||||
let Some(&(pos, z)) = pos_by_id.get(&marker.card_id) else {
|
||||
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
|
||||
continue;
|
||||
};
|
||||
sprite.custom_size = Some(layout.card_size);
|
||||
@@ -2369,7 +2378,7 @@ fn update_tableau_fan_frac(
|
||||
game.0
|
||||
.pile(solitaire_core::KlondikePile::Tableau(tableau))
|
||||
.into_iter()
|
||||
.filter(|c| c.face_up)
|
||||
.filter(|(_, face_up)| *face_up)
|
||||
.count()
|
||||
})
|
||||
.max()
|
||||
@@ -2408,6 +2417,12 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::card::Deck;
|
||||
|
||||
/// Convenience constructor — all unit tests use Deck1.
|
||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||
Card::new(Deck::Deck1, suit, rank)
|
||||
}
|
||||
|
||||
fn app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -2421,58 +2436,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn label_for_ace_of_hearts_is_ah() {
|
||||
let c = Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let c = make_card(Suit::Hearts, Rank::Ace);
|
||||
assert_eq!(label_for(&c), "AH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_for_ten_of_clubs_is_10c() {
|
||||
let c = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ten,
|
||||
face_up: true,
|
||||
};
|
||||
let c = make_card(Suit::Clubs, Rank::Ten);
|
||||
assert_eq!(label_for(&c), "10C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_colour_is_red_for_hearts_and_diamonds() {
|
||||
let h = Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let d = Card {
|
||||
id: 0,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let h = make_card(Suit::Hearts, Rank::Ace);
|
||||
let d = make_card(Suit::Diamonds, Rank::Ace);
|
||||
assert_eq!(text_colour(&h, false, false), RED_SUIT_COLOUR);
|
||||
assert_eq!(text_colour(&d, false, false), RED_SUIT_COLOUR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_colour_is_near_white_for_clubs_and_spades() {
|
||||
let c = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let s = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let c = make_card(Suit::Clubs, Rank::Ace);
|
||||
let s = make_card(Suit::Spades, Rank::Ace);
|
||||
assert_eq!(text_colour(&c, false, false), BLACK_SUIT_COLOUR);
|
||||
assert_eq!(text_colour(&s, false, false), BLACK_SUIT_COLOUR);
|
||||
}
|
||||
@@ -2540,8 +2525,8 @@ mod tests {
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
assert_eq!(waste_ids.len(), 3);
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
@@ -2550,7 +2535,7 @@ mod tests {
|
||||
// Filter rendered positions to only waste cards (by card ID).
|
||||
let waste_rendered: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.0))
|
||||
.collect();
|
||||
// Draw-One: renders up to 2 waste cards (1 visible + 1 hidden to
|
||||
// prevent the evicted card from flashing during the draw tween).
|
||||
@@ -2563,9 +2548,9 @@ mod tests {
|
||||
"at least the top waste card must be rendered"
|
||||
);
|
||||
// The top (last) waste card must always be among the rendered cards.
|
||||
let top_id = g.waste_cards().last().unwrap().id;
|
||||
let top_id = g.waste_cards().last().unwrap().0.clone();
|
||||
assert!(
|
||||
waste_rendered.iter().any(|(c, _, _)| c.id == top_id),
|
||||
waste_rendered.iter().any(|(c, _, _)| c.0 == top_id),
|
||||
"top waste card must be rendered"
|
||||
);
|
||||
}
|
||||
@@ -2584,14 +2569,15 @@ mod tests {
|
||||
"need at least 3 waste cards for this test"
|
||||
);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let mut waste_rendered: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.0))
|
||||
.collect();
|
||||
// Draw-Three: at most 4 waste cards rendered (3 visible + 1 hidden to
|
||||
// prevent the evicted card from flashing during the draw tween).
|
||||
@@ -2616,8 +2602,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
// Top card (rightmost by x) must be the last card in the waste pile.
|
||||
let top_id = waste_pile.last().unwrap().id;
|
||||
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
|
||||
let top_id = waste_pile.last().unwrap().0.clone();
|
||||
assert_eq!(waste_rendered.last().unwrap().0.0, top_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2636,13 +2622,14 @@ mod tests {
|
||||
let count = waste_pile.len();
|
||||
assert!(count >= 2, "need at least 2 waste cards");
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let mut waste_rendered: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.0))
|
||||
.collect();
|
||||
// All waste cards should be visible (no hidden buffer when len ≤ visible).
|
||||
assert_eq!(
|
||||
@@ -2673,13 +2660,13 @@ mod tests {
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
let waste_rendered: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||
.filter(|(card, _, _)| waste_ids.contains(&card.0))
|
||||
.collect();
|
||||
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
|
||||
assert_eq!(
|
||||
@@ -2883,12 +2870,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn text_colour_color_blind_mode_swaps_red_suits_to_lime() {
|
||||
let red_card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
};
|
||||
let red_card = make_card(Suit::Diamonds, Rank::Queen);
|
||||
let cbm_colour = text_colour(&red_card, true, false);
|
||||
assert_eq!(
|
||||
cbm_colour, RED_SUIT_COLOUR_CBM,
|
||||
@@ -2902,12 +2884,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn text_colour_color_blind_mode_does_not_change_dark_suits() {
|
||||
let black_card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
};
|
||||
let black_card = make_card(Suit::Clubs, Rank::Jack);
|
||||
assert_eq!(
|
||||
text_colour(&black_card, true, false),
|
||||
BLACK_SUIT_COLOUR,
|
||||
@@ -2928,12 +2905,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn text_colour_high_contrast_boosts_red_suits_to_hc_red() {
|
||||
let red_card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
let red_card = make_card(Suit::Hearts, Rank::Five);
|
||||
assert_eq!(
|
||||
text_colour(&red_card, false, true),
|
||||
RED_SUIT_COLOUR_HC,
|
||||
@@ -2948,12 +2920,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn text_colour_high_contrast_boosts_black_suits_to_hc_white() {
|
||||
let black_card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
};
|
||||
let black_card = make_card(Suit::Spades, Rank::Two);
|
||||
assert_eq!(
|
||||
text_colour(&black_card, false, true),
|
||||
TEXT_PRIMARY_HC,
|
||||
@@ -2967,12 +2934,7 @@ mod tests {
|
||||
// the CBM lime is itself a high-luminance accent and the HC
|
||||
// boost would pick a different hue, defeating the purpose of
|
||||
// the colour-blind swap.
|
||||
let red_card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let red_card = make_card(Suit::Diamonds, Rank::Ace);
|
||||
assert_eq!(
|
||||
text_colour(&red_card, true, true),
|
||||
RED_SUIT_COLOUR_CBM,
|
||||
@@ -2984,12 +2946,7 @@ mod tests {
|
||||
fn text_colour_high_contrast_alone_boosts_dark_suits_under_cbm() {
|
||||
// CBM doesn't touch the dark suits, so HC remains the only
|
||||
// source of variation for the dark row when both are on.
|
||||
let black_card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
};
|
||||
let black_card = make_card(Suit::Clubs, Rank::King);
|
||||
assert_eq!(
|
||||
text_colour(&black_card, true, true),
|
||||
TEXT_PRIMARY_HC,
|
||||
@@ -3003,24 +2960,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn label_visibility_face_up_is_inherited() {
|
||||
let card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
assert_eq!(label_visibility(&card), Visibility::Inherited);
|
||||
assert_eq!(label_visibility(true), Visibility::Inherited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_visibility_face_down_is_hidden() {
|
||||
let card = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
};
|
||||
assert_eq!(label_visibility(&card), Visibility::Hidden);
|
||||
assert_eq!(label_visibility(false), Visibility::Hidden);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -3032,12 +2977,7 @@ mod tests {
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let letters = ["C", "D", "H", "S"];
|
||||
for (suit, letter) in suits.iter().zip(letters.iter()) {
|
||||
let card = Card {
|
||||
id: 0,
|
||||
suit: *suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
};
|
||||
let card = make_card(*suit, Rank::King);
|
||||
assert!(
|
||||
label_for(&card).ends_with(letter),
|
||||
"label for {suit:?} must end with '{letter}'"
|
||||
@@ -3047,12 +2987,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn label_for_face_cards_use_letter_prefix() {
|
||||
let make = |rank| Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank,
|
||||
face_up: true,
|
||||
};
|
||||
let make = |rank| make_card(Suit::Spades, rank);
|
||||
assert!(label_for(&make(Rank::Jack)).starts_with('J'));
|
||||
assert!(label_for(&make(Rank::Queen)).starts_with('Q'));
|
||||
assert!(label_for(&make(Rank::King)).starts_with('K'));
|
||||
@@ -3060,12 +2995,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn label_for_numeric_ranks_two_through_nine() {
|
||||
let make = |rank| Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank,
|
||||
face_up: true,
|
||||
};
|
||||
let make = |rank| make_card(Suit::Clubs, rank);
|
||||
let expected = [
|
||||
(Rank::Two, "2C"),
|
||||
(Rank::Three, "3C"),
|
||||
@@ -3091,12 +3021,7 @@ mod tests {
|
||||
];
|
||||
|
||||
for (suit, rank, expected) in cases {
|
||||
let card = Card {
|
||||
id: 0,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
};
|
||||
let card = make_card(suit, rank);
|
||||
assert_eq!(mobile_label_for(&card), expected);
|
||||
}
|
||||
}
|
||||
@@ -3380,33 +3305,33 @@ mod tests {
|
||||
fn shadow_offset_increases_during_drag() {
|
||||
let mut app = app();
|
||||
|
||||
// Pick any spawned card id and stage it in DragState.
|
||||
let card_id: u32 = {
|
||||
// Pick any spawned card and stage it in DragState.
|
||||
let card: Card = {
|
||||
let mut q = app.world_mut().query::<&CardEntity>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("fixture should spawn at least one CardEntity")
|
||||
.card_id
|
||||
.card.clone()
|
||||
};
|
||||
|
||||
// Pick a *different* card id to act as the negative control —
|
||||
// Pick a *different* card to act as the negative control —
|
||||
// its shadow must remain at the idle offset.
|
||||
let other_id: u32 = {
|
||||
let other_card: Card = {
|
||||
let mut q = app.world_mut().query::<&CardEntity>();
|
||||
q.iter(app.world())
|
||||
.map(|c| c.card_id)
|
||||
.find(|id| *id != card_id)
|
||||
.map(|c| c.card.clone())
|
||||
.find(|c| *c != card)
|
||||
.expect("fixture should spawn more than one CardEntity")
|
||||
};
|
||||
|
||||
// Stage the drag and run one Update so `update_card_shadows_on_drag`
|
||||
// sees the new DragState.
|
||||
app.world_mut().resource_mut::<DragState>().cards = vec![card_id];
|
||||
app.world_mut().resource_mut::<DragState>().cards = vec![card.clone()];
|
||||
app.update();
|
||||
|
||||
// Find the shadow whose parent's CardEntity matches `card_id`.
|
||||
let dragged_shadow_offset = shadow_offset_for_card(&mut app, card_id);
|
||||
let other_shadow_offset = shadow_offset_for_card(&mut app, other_id);
|
||||
// Find the shadow whose parent's CardEntity matches `card`.
|
||||
let dragged_shadow_offset = shadow_offset_for_card(&mut app, &card);
|
||||
let other_shadow_offset = shadow_offset_for_card(&mut app, &other_card);
|
||||
|
||||
let drag_off = CARD_SHADOW_OFFSET_DRAG;
|
||||
let idle_off = CARD_SHADOW_OFFSET_IDLE;
|
||||
@@ -3428,7 +3353,7 @@ mod tests {
|
||||
// offset on the next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
let after_clear = shadow_offset_for_card(&mut app, card_id);
|
||||
let after_clear = shadow_offset_for_card(&mut app, &card);
|
||||
assert!(
|
||||
(after_clear.x - idle_off.x).abs() < 1e-3 && (after_clear.y - idle_off.y).abs() < 1e-3,
|
||||
"shadow must snap back to idle offset after drag clears \
|
||||
@@ -3436,18 +3361,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: given a `card_id`, returns the world-space offset (x, y) of
|
||||
/// Helper: given a `card`, returns the world-space offset (x, y) of
|
||||
/// its `CardShadow` child relative to the parent card's origin.
|
||||
fn shadow_offset_for_card(app: &mut App, card_id: u32) -> Vec2 {
|
||||
// Map every CardEntity to its (Entity, card_id).
|
||||
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
||||
// Map every CardEntity to its (Entity, card).
|
||||
let card_entity = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query::<(bevy::prelude::Entity, &CardEntity)>();
|
||||
q.iter(app.world())
|
||||
.find(|(_, c)| c.card_id == card_id)
|
||||
.find(|(_, c)| c.card == *card)
|
||||
.map(|(e, _)| e)
|
||||
.expect("card_id not found in spawned CardEntity set")
|
||||
.expect("card not found in spawned CardEntity set")
|
||||
};
|
||||
|
||||
let mut q = app
|
||||
@@ -3458,7 +3383,7 @@ mod tests {
|
||||
return Vec2::new(transform.translation.x, transform.translation.y);
|
||||
}
|
||||
}
|
||||
panic!("no CardShadow child found for card_id {card_id}");
|
||||
panic!("no CardShadow child found for card {card:?}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -3538,7 +3463,8 @@ mod tests {
|
||||
assert_eq!(stock_badge_text(&mut app), "24");
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let mut stock = game.0.stock_cards();
|
||||
let mut stock: Vec<Card> =
|
||||
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
||||
let _ = stock.pop();
|
||||
game.0.set_test_stock_cards(stock);
|
||||
}
|
||||
@@ -3592,15 +3518,11 @@ mod tests {
|
||||
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
|
||||
set.theme_back = Some(theme_back.clone());
|
||||
|
||||
let face_down = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
};
|
||||
let face_down = make_card(Suit::Spades, Rank::Ace);
|
||||
// Pick a non-zero legacy back so we'd notice if it leaked through.
|
||||
let sprite = card_sprite(
|
||||
&face_down,
|
||||
false,
|
||||
Vec2::new(80.0, 112.0),
|
||||
card_back_colour(2),
|
||||
Some(&set),
|
||||
@@ -3627,15 +3549,11 @@ mod tests {
|
||||
"fixture starts with no theme back"
|
||||
);
|
||||
|
||||
let face_down = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
};
|
||||
let face_down = make_card(Suit::Spades, Rank::Ace);
|
||||
for selected_back in 0..5 {
|
||||
let sprite = card_sprite(
|
||||
&face_down,
|
||||
false,
|
||||
Vec2::new(80.0, 112.0),
|
||||
card_back_colour(selected_back),
|
||||
Some(&set),
|
||||
@@ -3804,12 +3722,7 @@ mod tests {
|
||||
#[test]
|
||||
fn text_colour_black_suits_are_near_white_not_red() {
|
||||
for suit in [Suit::Clubs, Suit::Spades] {
|
||||
let card = Card {
|
||||
id: 0,
|
||||
suit,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let card = make_card(suit, Rank::Ace);
|
||||
let colour = text_colour(&card, false, false);
|
||||
assert_eq!(
|
||||
colour, BLACK_SUIT_COLOUR,
|
||||
@@ -3845,12 +3758,12 @@ mod tests {
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.0))
|
||||
.map(|(_, _, z)| *z)
|
||||
.collect();
|
||||
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
@@ -3895,20 +3808,20 @@ mod tests {
|
||||
|
||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
|
||||
.into_iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.0))
|
||||
.collect();
|
||||
waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
|
||||
let visible_count = waste_positions.len().min(3);
|
||||
for (card, pos, _) in waste_positions.iter().rev().take(visible_count) {
|
||||
assert!(
|
||||
pos.x >= stock_x - 1e-3,
|
||||
"waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window",
|
||||
card.id,
|
||||
"waste card {:?} x {:.2} drifted left of stock origin {:.2} on portrait window",
|
||||
card.0,
|
||||
pos.x,
|
||||
stock_x,
|
||||
);
|
||||
@@ -3925,12 +3838,12 @@ mod tests {
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> =
|
||||
g.waste_cards().iter().map(|c| c.id).collect();
|
||||
let waste_ids: std::collections::HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.0))
|
||||
.map(|(_, _, z)| *z)
|
||||
.collect();
|
||||
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
@@ -3943,7 +3856,7 @@ mod tests {
|
||||
// Deduplicated length must equal pre-dedup length → all z distinct.
|
||||
let raw_count = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.0))
|
||||
.count();
|
||||
assert_eq!(
|
||||
waste_zs.len(),
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{DrawMode, game_state::GameState};
|
||||
|
||||
@@ -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 {
|
||||
@@ -579,7 +580,7 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
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
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -370,10 +370,10 @@ pub fn emit_hint_visuals(
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let source_cards = pile_cards(game, from);
|
||||
let top_card_id = source_cards.last().filter(|c| c.face_up).map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
|
||||
if let Some(card) = top_card {
|
||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||
if card_entity.card_id == card_id {
|
||||
if card_entity.card == card {
|
||||
// Tint the card gold without replacing the Sprite (which would
|
||||
// discard the image handle set by CardImageSet). Uses the
|
||||
// design-system `STATE_WARNING` token so the source-card
|
||||
@@ -390,7 +390,7 @@ pub fn emit_hint_visuals(
|
||||
// Emit HintVisualEvent so the destination pile marker is also
|
||||
// tinted gold for 2 s.
|
||||
hint_visual.write(HintVisualEvent {
|
||||
source_card_id: card_id,
|
||||
source_card: card,
|
||||
dest_pile: *to,
|
||||
});
|
||||
}
|
||||
@@ -401,7 +401,7 @@ pub fn emit_hint_visuals(
|
||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||
let msg = match to {
|
||||
KlondikePile::Foundation(_) => {
|
||||
let claimed = game.pile(*to).first().map(|c| c.suit);
|
||||
let claimed = game.pile(*to).first().map(|(c, _)| c.suit());
|
||||
if let Some(suit) = claimed {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
@@ -687,10 +687,10 @@ fn follow_drag(
|
||||
|
||||
// Elevate cards: push to DRAG_Z and dim slightly so the board
|
||||
// beneath stays readable.
|
||||
for (i, &id) in drag.cards.iter().enumerate() {
|
||||
for (i, card) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform, mut sprite)) = card_transforms
|
||||
.iter_mut()
|
||||
.find(|(ce, _, _)| ce.card_id == id)
|
||||
.find(|(ce, _, _)| ce.card == *card)
|
||||
{
|
||||
transform.translation.z = dragged_card_z(i);
|
||||
sprite.color.set_alpha(0.85);
|
||||
@@ -702,10 +702,10 @@ fn follow_drag(
|
||||
let bottom_pos = world + drag.cursor_offset;
|
||||
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||
|
||||
for (i, &id) in drag.cards.iter().enumerate() {
|
||||
for (i, card) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform, _)) = card_transforms
|
||||
.iter_mut()
|
||||
.find(|(ce, _, _)| ce.card_id == id)
|
||||
.find(|(ce, _, _)| ce.card == *card)
|
||||
{
|
||||
transform.translation.x = bottom_pos.x;
|
||||
transform.translation.y = bottom_pos.y + fan * i as f32;
|
||||
@@ -807,15 +807,16 @@ fn end_drag(
|
||||
// that fires below does not fight this tween.
|
||||
let origin_cards = pile_cards(&game.0, &origin);
|
||||
if !origin_cards.is_empty() {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id)
|
||||
for card in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
origin_cards.iter().position(|(c, _)| c == card)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
.find(|(_, ce, _)| ce.card == *card)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
@@ -939,10 +940,10 @@ fn touch_follow_drag(
|
||||
|
||||
drag.committed = true;
|
||||
|
||||
for (i, &id) in drag.cards.iter().enumerate() {
|
||||
for (i, card) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform, mut sprite)) = card_transforms
|
||||
.iter_mut()
|
||||
.find(|(ce, _, _)| ce.card_id == id)
|
||||
.find(|(ce, _, _)| ce.card == *card)
|
||||
{
|
||||
transform.translation.z = dragged_card_z(i);
|
||||
sprite.color.set_alpha(0.85);
|
||||
@@ -953,10 +954,10 @@ fn touch_follow_drag(
|
||||
let bottom_pos = world + drag.cursor_offset;
|
||||
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||
|
||||
for (i, &id) in drag.cards.iter().enumerate() {
|
||||
for (i, card) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform, _)) = card_transforms
|
||||
.iter_mut()
|
||||
.find(|(ce, _, _)| ce.card_id == id)
|
||||
.find(|(ce, _, _)| ce.card == *card)
|
||||
{
|
||||
transform.translation.x = bottom_pos.x;
|
||||
transform.translation.y = bottom_pos.y + fan * i as f32;
|
||||
@@ -1046,15 +1047,16 @@ fn touch_end_drag(
|
||||
// feel identical.
|
||||
let origin_cards = pile_cards(&game.0, &origin);
|
||||
if !origin_cards.is_empty() {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) = origin_cards.iter().position(|c| c.id == card_id)
|
||||
for card in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
origin_cards.iter().position(|(c, _)| c == card)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
.find(|(_, ce, _)| ce.card == *card)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
@@ -1142,8 +1144,8 @@ fn card_position(
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||
let mut y_offset = 0.0_f32;
|
||||
for card in pile_cards(game, pile).iter().take(stack_index) {
|
||||
let step = if card.face_up {
|
||||
for (_, face_up) in pile_cards(game, pile).iter().take(stack_index) {
|
||||
let step = if *face_up {
|
||||
layout.tableau_fan_frac
|
||||
} else {
|
||||
layout.tableau_facedown_fan_frac
|
||||
@@ -1170,7 +1172,7 @@ fn find_draggable_at(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
) -> Option<(KlondikePile, usize, Vec<u32>)> {
|
||||
) -> Option<(KlondikePile, usize, Vec<Card>)> {
|
||||
// Search order: waste, foundations, tableau. Stock is skipped (click-to-draw).
|
||||
// Within a pile, we consider cards top-down because the visual top card is drawn last.
|
||||
let piles = [
|
||||
@@ -1199,8 +1201,8 @@ fn find_draggable_at(
|
||||
// Iterate from topmost to bottommost so the first hit is the one
|
||||
// visually on top.
|
||||
for i in (0..pile_cards.len()).rev() {
|
||||
let card = &pile_cards[i];
|
||||
if !card.face_up {
|
||||
let (_, face_up) = pile_cards[i];
|
||||
if !face_up {
|
||||
continue;
|
||||
}
|
||||
let pos = card_position(game, layout, &pile, i);
|
||||
@@ -1222,8 +1224,8 @@ fn find_draggable_at(
|
||||
}
|
||||
(i, i + 1)
|
||||
};
|
||||
let ids: Vec<u32> = pile_cards[start..end].iter().map(|c| c.id).collect();
|
||||
return Some((pile, start, ids));
|
||||
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
|
||||
return Some((pile, start, cards));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -1302,7 +1304,7 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
|
||||
///
|
||||
/// Returns `None` if no legal move exists from the card's current location.
|
||||
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
||||
let source = game.pile_containing_card(card.id)?;
|
||||
let source = game.pile_containing_card(card.clone())?;
|
||||
|
||||
for foundation in foundations() {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
@@ -1361,7 +1363,7 @@ fn handle_double_click(
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut last_click: Local<HashMap<u32, f32>>,
|
||||
mut last_click: Local<HashMap<Card, f32>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||
) {
|
||||
@@ -1382,27 +1384,27 @@ fn handle_double_click(
|
||||
};
|
||||
|
||||
// The topmost card in the draggable run — used as the double-click key.
|
||||
let Some(&top_card_id) = card_ids.last() else {
|
||||
let Some(top_card) = card_ids.last() else {
|
||||
return;
|
||||
};
|
||||
let top_index = stack_index + card_ids.len() - 1;
|
||||
let pile_cards = pile_cards(&game.0, &pile);
|
||||
let Some(top_card) = pile_cards.get(top_index) else {
|
||||
let Some((pile_top_card, pile_top_face_up)) = pile_cards.get(top_index) else {
|
||||
return;
|
||||
};
|
||||
if !top_card.face_up || top_card.id != top_card_id {
|
||||
if !*pile_top_face_up || pile_top_card != top_card {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
let prev = last_click
|
||||
.get(&top_card_id)
|
||||
.get(top_card)
|
||||
.copied()
|
||||
.unwrap_or(f32::NEG_INFINITY);
|
||||
|
||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||
// Double-click confirmed.
|
||||
last_click.remove(&top_card_id);
|
||||
last_click.remove(top_card);
|
||||
|
||||
// Priority 1: move the single top card (foundation preferred, then tableau).
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
@@ -1418,7 +1420,7 @@ fn handle_double_click(
|
||||
// stack (card_ids.len() > 1), try moving the whole stack to another
|
||||
// tableau column.
|
||||
if card_ids.len() > 1
|
||||
&& let Some(bottom_card) = pile_cards.get(stack_index)
|
||||
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
|
||||
{
|
||||
@@ -1445,7 +1447,7 @@ fn handle_double_click(
|
||||
});
|
||||
} else {
|
||||
// Single click — record the time.
|
||||
last_click.insert(top_card_id, now);
|
||||
last_click.insert(top_card.clone(), now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1513,7 +1515,7 @@ fn handle_double_tap(
|
||||
}
|
||||
|
||||
// Uncommitted touch ended = pure tap.
|
||||
let Some(&top_card_id) = drag.cards.last() else {
|
||||
let Some(top_card) = drag.cards.last() else {
|
||||
return;
|
||||
};
|
||||
let Some(ref tapped_pile) = drag.origin_pile else {
|
||||
@@ -1524,10 +1526,12 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(top_card) = pile_cards.iter().find(|c| c.id == top_card_id) else {
|
||||
let Some((found_card, found_face_up)) =
|
||||
pile_cards.iter().find(|(c, _)| c == top_card)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !top_card.face_up {
|
||||
if !*found_face_up {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1561,9 +1565,9 @@ fn handle_double_tap(
|
||||
// --- One-tap auto-move (original behaviour) ---
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
if let Some(dest) = best_destination(found_card, &game.0) {
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if ce.card_id == top_card_id {
|
||||
if ce.card == *top_card {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
commands.entity(entity).insert(HintHighlight {
|
||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||
@@ -1582,7 +1586,7 @@ fn handle_double_tap(
|
||||
// Priority 2: move whole face-up stack to best tableau column.
|
||||
if drag.cards.len() > 1 {
|
||||
let stack_index = pile_cards.len() - drag.cards.len();
|
||||
if let Some(bottom_card) = pile_cards.get(stack_index)
|
||||
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
tapped_pile,
|
||||
@@ -1591,7 +1595,7 @@ fn handle_double_tap(
|
||||
)
|
||||
{
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if drag.cards.contains(&ce.card_id) {
|
||||
if drag.cards.contains(&ce.card) {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
commands.entity(entity).insert(HintHighlight {
|
||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||
@@ -1659,7 +1663,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
// Pass 1 — foundation moves (highest priority, shown first).
|
||||
for from in &sources {
|
||||
let from_pile = pile_cards(game, from);
|
||||
let Some(_card) = from_pile.last().filter(|c| c.face_up) else {
|
||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||
continue;
|
||||
};
|
||||
for foundation in foundations() {
|
||||
@@ -1675,7 +1679,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
// repeat the same source card multiple times for different destinations).
|
||||
for from in &sources {
|
||||
let from_pile = pile_cards(game, from);
|
||||
let Some(_card) = from_pile.last().filter(|c| c.face_up) else {
|
||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||
continue;
|
||||
};
|
||||
let already_has_foundation_hint = hints
|
||||
@@ -1701,7 +1705,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
for foundation in foundations() {
|
||||
let from = KlondikePile::Foundation(foundation);
|
||||
let from_pile = pile_cards(game, &from);
|
||||
let Some(_card) = from_pile.last().filter(|c| c.face_up) else {
|
||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||
continue;
|
||||
};
|
||||
for tableau in tableaus() {
|
||||
@@ -1731,7 +1735,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
hints
|
||||
}
|
||||
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
|
||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
match pile {
|
||||
KlondikePile::Stock => game.waste_cards(),
|
||||
_ => game.pile(*pile),
|
||||
@@ -1912,29 +1916,14 @@ mod tests {
|
||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
// Manually construct a tableau with three face-up cards all stacked.
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![
|
||||
Card {
|
||||
id: 100,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 102,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
},
|
||||
],
|
||||
vec![king, queen.clone(), jack.clone()],
|
||||
);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
@@ -1948,36 +1937,26 @@ mod tests {
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![101, 102]);
|
||||
assert_eq!(ids, vec![queen, jack]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_non_top_waste_card() {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
game.set_test_waste_cards(vec![
|
||||
Card {
|
||||
id: 200,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 201,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Three,
|
||||
face_up: true,
|
||||
},
|
||||
]);
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (id 201), with count = 1.
|
||||
// the visually top card (three_hearts), with count = 1.
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![201]);
|
||||
assert_eq!(ids, vec![three_hearts]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2028,30 +2007,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
|
||||
// Three waste cards; top (id=202) is rightmost in the fan.
|
||||
game.set_test_waste_cards(vec![
|
||||
Card {
|
||||
id: 200,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 201,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Three,
|
||||
face_up: true,
|
||||
},
|
||||
Card {
|
||||
id: 202,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Four,
|
||||
face_up: true,
|
||||
},
|
||||
]);
|
||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||
@@ -2066,7 +2030,7 @@ mod tests {
|
||||
);
|
||||
let (pile, _start, ids) = result.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(ids, vec![202], "only the top card is draggable from waste");
|
||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2102,6 +2066,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn best_destination_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
@@ -2109,12 +2074,7 @@ mod tests {
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||
let card = Card {
|
||||
id: 400,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
};
|
||||
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
assert!(best_destination(&card, &game).is_none());
|
||||
}
|
||||
|
||||
@@ -2124,6 +2084,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
@@ -2132,24 +2093,11 @@ mod tests {
|
||||
// Only tableau 0 has anything; every other column is empty.
|
||||
// A King is the only card that can go on an empty tableau column.
|
||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![Card {
|
||||
id: 200,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
}],
|
||||
);
|
||||
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||
|
||||
let bottom_card = Card {
|
||||
id: 200,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
};
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&king,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
@@ -2162,6 +2110,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
@@ -2169,24 +2118,11 @@ mod tests {
|
||||
|
||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||
// All other piles are empty — no legal tableau target.
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![Card {
|
||||
id: 300,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
}],
|
||||
);
|
||||
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||
|
||||
let bottom_card = Card {
|
||||
id: 300,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
};
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&two_clubs,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
@@ -2203,20 +2139,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn find_hint_finds_ace_to_foundation() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Place Ace of Clubs on top of tableau 0.
|
||||
clear_test_piles(&mut game);
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![Card {
|
||||
id: 500,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
}],
|
||||
);
|
||||
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
@@ -2254,6 +2184,7 @@ mod tests {
|
||||
/// are no other moves and the stock is non-empty.
|
||||
#[test]
|
||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
@@ -2261,12 +2192,7 @@ mod tests {
|
||||
// move exists. Leave one card in the stock.
|
||||
clear_test_piles(&mut game);
|
||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||
game.set_test_stock_cards(vec![Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: false,
|
||||
}]);
|
||||
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||
@@ -2312,20 +2238,25 @@ mod tests {
|
||||
/// gets a CardAnimation" — same coverage, new component.
|
||||
#[test]
|
||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
use solitaire_core::card::Deck as D;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
// Simulate a stack drag of two cards.
|
||||
let dragged_ids: Vec<u32> = vec![10, 11];
|
||||
let dragged_cards: Vec<Card> = vec![
|
||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||
];
|
||||
|
||||
let mut animated: Vec<u32> = Vec::new();
|
||||
for &card_id in &dragged_ids {
|
||||
// In `end_drag` we iterate `drag.cards` and look up each id in
|
||||
// `card_entities`. The ids we would insert a `CardAnimation` on
|
||||
let mut animated: Vec<Card> = Vec::new();
|
||||
for card in &dragged_cards {
|
||||
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||
// must exactly match the dragged set.
|
||||
animated.push(card_id);
|
||||
animated.push(card.clone());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
animated, dragged_ids,
|
||||
"every card id in drag.cards must receive a CardAnimation on rejection"
|
||||
animated, dragged_cards,
|
||||
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ 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::card::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawMode, game_state::GameState};
|
||||
|
||||
/// Build a minimal Bevy app exercising only the polling system
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +808,7 @@ 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::card::{Card as CoreCard, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawMode, game_state::GameState};
|
||||
|
||||
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +933,7 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-18
@@ -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