From 95df5421c94039ac0a678f194888025a04325f36 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 1 May 2026 22:17:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(core):=20unlock=20foundations=20=E2=80=94?= =?UTF-8?q?=20Foundation(u8)=20slots,=20suit=20derived=20from=20contents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard Klondike behaviour: any Ace can land in any empty foundation, and that slot then claims the suit until the pile empties. The previous PileType::Foundation(Suit) variant pre-assigned each of the four foundations to a fixed suit ("C / D / H / S" placeholders) and rejected mismatched Aces — non-standard and (per the smoke-test feedback) confusing. Replaces the variant payload with a slot index Foundation(u8) (0..=3) and derives the claimed suit from the bottom card via a new Pile::claimed_suit() method. The bottom card is, by construction, the Ace that established the claim; using it directly eliminates an entire class of "stuck claim after undo" bugs that a separate claimed_suit field would have introduced. can_place_on_foundation drops its suit parameter — the rule reduces to "empty pile accepts any Ace; non-empty pile accepts the next rank up of the bottom card's suit." Iteration sites across input_plugin, cursor_plugin, selection_plugin, card_plugin, auto_complete_plugin, game_plugin, layout, and hud_plugin all swap the four-suit list for `(0..4u8).map(PileType::Foundation)`. next_auto_complete_move now prefers a slot whose claimed_suit matches the candidate card before falling back to the first empty slot for an Ace — so the same suit consistently auto-targets the same slot across the whole game, matching player expectations. The HUD selection label and the hint toast read claimed_suit() and fall back to "Foundation N" / "move to foundation" only when the slot is empty. Empty foundation pile markers no longer render the suit-letter children — they're plain translucent rectangles, matching empty tableau placeholders. Save-format invalidation: GameState gains a schema_version field (serde-default to 1 for back-compat parsing of old files), the constant is bumped to 2, and load_game_state_from rejects mismatched schemas. Old in-progress saves silently fall through to "fresh game on launch" — the user accepted this loss given the mechanic change. Stats / progress / achievements / settings live in separate files, contain no PileType data, and are unaffected. 9 new tests pin the contract: - Pile::claimed_suit returns None for empty / non-foundation, Some for non-empty foundation - Any Ace lands in the first empty foundation; successive Aces distribute across slots 0..3 - Claim drops when the slot is emptied via undo - Auto-complete picks the slot with a matching claim, not the first empty slot - A v1-format game_state.json is rejected; sibling stats save/load is unaffected Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_core/src/game_state.rs | 228 +++++++++++++++++-- solitaire_core/src/pile.rs | 43 +++- solitaire_core/src/rules.rs | 63 ++--- solitaire_core/src/scoring.rs | 7 +- solitaire_data/src/storage.rs | 60 ++++- solitaire_engine/src/auto_complete_plugin.rs | 3 +- solitaire_engine/src/card_plugin.rs | 12 +- solitaire_engine/src/cursor_plugin.rs | 15 +- solitaire_engine/src/game_plugin.rs | 31 ++- solitaire_engine/src/hud_plugin.rs | 30 ++- solitaire_engine/src/input_plugin.rs | 118 +++++----- solitaire_engine/src/layout.rs | 27 +-- solitaire_engine/src/selection_plugin.rs | 28 +-- solitaire_engine/src/table_plugin.rs | 19 +- 14 files changed, 487 insertions(+), 197 deletions(-) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 49bacf1..d45f215 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, VecDeque}; use serde::{Deserialize, Serialize}; -use crate::card::{Card, Suit}; +use crate::card::Card; use crate::deck::{deal_klondike, Deck}; use crate::error::MoveError; use crate::pile::{Pile, PileType}; @@ -9,6 +9,20 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score const MAX_UNDO_STACK: usize = 64; +/// Save-file schema version for `GameState`. Increment when the on-disk +/// representation changes incompatibly so `load_game_state_from` can refuse +/// older formats and start the player on a fresh game. +/// +/// History: +/// - v1: `Foundation(Suit)` keys. +/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the +/// bottom card of the pile. +pub const GAME_STATE_SCHEMA_VERSION: u32 = 2; + +/// Default value for `GameState::schema_version` when deserialising older +/// save files that pre-date the field. +fn schema_v1() -> u32 { 1 } + /// Serialize `HashMap` as a `Vec` of `(key, value)` pairs so /// that JSON (which requires string map keys) round-trips correctly. mod pile_map_serde { @@ -98,6 +112,11 @@ pub struct GameState { /// Used by the `comeback` achievement condition. #[serde(default)] pub recycle_count: u32, + /// Save-file schema version. Defaults to `1` for older files that pre-date + /// the field. The loader refuses any value other than + /// [`GAME_STATE_SCHEMA_VERSION`]. + #[serde(default = "schema_v1")] + pub schema_version: u32, undo_stack: VecDeque, } @@ -116,8 +135,8 @@ impl GameState { let mut piles: HashMap = HashMap::new(); piles.insert(PileType::Stock, stock); piles.insert(PileType::Waste, Pile::new(PileType::Waste)); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit))); + for slot in 0..4_u8 { + piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot))); } for (i, pile) in tableau.into_iter().enumerate() { piles.insert(PileType::Tableau(i), pile); @@ -135,6 +154,7 @@ impl GameState { is_auto_completable: false, undo_count: 0, recycle_count: 0, + schema_version: GAME_STATE_SCHEMA_VERSION, undo_stack: VecDeque::new(), } } @@ -247,14 +267,14 @@ impl GameState { let bottom_card = from_pile.cards[start].clone(); match &to { - PileType::Foundation(suit) => { + PileType::Foundation(_) => { if count != 1 { return Err(MoveError::RuleViolation( "only one card can move to foundation at a time".into(), )); } let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; - if !can_place_on_foundation(&bottom_card, dest, *suit) { + if !can_place_on_foundation(&bottom_card, dest) { return Err(MoveError::RuleViolation("invalid foundation placement".into())); } } @@ -332,15 +352,13 @@ impl GameState { Ok(()) } - /// Returns `true` when all four foundations each contain 13 cards. + /// Returns `true` when all four foundation slots each contain 13 cards. pub fn check_win(&self) -> bool { - [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] - .iter() - .all(|&suit| { - self.piles - .get(&PileType::Foundation(suit)) - .is_some_and(|p| p.cards.len() == 13) - }) + (0..4_u8).all(|slot| { + self.piles + .get(&PileType::Foundation(slot)) + .is_some_and(|p| p.cards.len() == 13) + }) } /// Returns `true` when stock and waste are empty and all tableau cards are face-up. @@ -379,13 +397,34 @@ impl GameState { if !self.is_auto_completable || self.is_won { return None; } - let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; for i in 0..7 { let tableau = PileType::Tableau(i); if let Some(card) = self.piles[&tableau].cards.last() { - for &suit in &suits { - let foundation = PileType::Foundation(suit); - if can_place_on_foundation(card, &self.piles[&foundation], suit) { + // Prefer the slot that already claims this card's suit so + // Aces don't sometimes land in slot 0 and then leave the + // matching suit-claimed slot empty. + let mut candidate: Option = None; + let mut empty_slot: Option = None; + for slot in 0..4_u8 { + let foundation = PileType::Foundation(slot); + let pile = &self.piles[&foundation]; + if pile.cards.is_empty() { + if empty_slot.is_none() { + empty_slot = Some(slot); + } + } else if pile.claimed_suit() == Some(card.suit) { + candidate = Some(slot); + break; + } + } + let target_slot = candidate.or_else(|| { + // Only fall back to an empty slot if the card is an Ace, + // which is the only rank that can claim an empty slot. + if card.rank.value() == 1 { empty_slot } else { None } + }); + if let Some(slot) = target_slot { + let foundation = PileType::Foundation(slot); + if can_place_on_foundation(card, &self.piles[&foundation]) { return Some((tableau, foundation)); } } @@ -403,7 +442,7 @@ impl GameState { #[cfg(test)] mod tests { use super::*; - use crate::card::{Card, Rank}; + use crate::card::{Card, Rank, Suit}; fn new_game() -> GameState { GameState::new(42, DrawMode::DrawOne) @@ -434,8 +473,8 @@ mod tests { #[test] fn new_game_foundations_are_empty() { let g = new_game(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty()); + for slot in 0..4_u8 { + assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty()); } } @@ -662,7 +701,7 @@ mod tests { ]; let result = g.move_cards( PileType::Tableau(0), - PileType::Foundation(Suit::Clubs), + PileType::Foundation(0), 2, ); assert!( @@ -706,8 +745,9 @@ mod tests { #[test] fn win_detection_all_foundations_complete() { let mut g = new_game(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap(); + let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; + for (slot, suit) in suits.into_iter().enumerate() { + let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap(); f.cards.clear(); for rank in [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, @@ -1039,7 +1079,8 @@ mod tests { let mv = g.next_auto_complete_move().expect("should find a move"); assert_eq!(mv.0, PileType::Tableau(0)); - assert_eq!(mv.1, PileType::Foundation(Suit::Clubs)); + // Slot 0 is the first empty foundation; the Ace lands there. + assert_eq!(mv.1, PileType::Foundation(0)); } #[test] @@ -1049,4 +1090,143 @@ mod tests { g.is_won = true; assert!(g.next_auto_complete_move().is_none()); } + + // --- Slot-based foundation behaviour (refactor coverage) --- + + /// Aces land in the first empty slot regardless of suit, and successive + /// Aces fan out across slots 0, 1, 2, 3 in deterministic order. + #[test] + fn any_ace_lands_in_first_empty_foundation() { + let mut g = new_game(); + // Clear stock/waste/tableau so we can hand-construct moves directly. + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + // Place an Ace of Clubs on tableau 0; move it to slot 0. + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); + // Now place an Ace of Spades on tableau 0 and move it to slot 1. + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap(); + + assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs)); + assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades)); + } + + /// `Pile::claimed_suit` reads the bottom card's suit on a populated + /// foundation slot, regardless of which slot index the pile occupies. + #[test] + fn claimed_suit_is_derived_from_bottom_card() { + let mut g = new_game(); + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap(); + + assert_eq!( + g.piles[&PileType::Foundation(2)].claimed_suit(), + Some(Suit::Hearts) + ); + } + + /// Undoing the only card from a foundation slot drops the claimed suit; + /// the slot then accepts a different Ace. + #[test] + fn foundation_claim_drops_when_emptied_via_undo() { + let mut g = new_game(); + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, + }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); + assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts)); + + g.undo().unwrap(); + assert!(g.piles[&PileType::Foundation(0)].cards.is_empty()); + assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none()); + + // A different Ace can now claim slot 0. + let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap(); + t0.cards.clear(); + t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true }); + g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap(); + assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades)); + } + + /// Successive Aces from the waste pile distribute across slots 0..=3 in + /// order — the player picks the slot, but `move_cards` accepts any + /// empty-slot placement for an Ace. + #[test] + fn multiple_aces_distribute_across_slots() { + let mut g = new_game(); + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + let aces = [ + (Suit::Clubs, 10), + (Suit::Diamonds, 11), + (Suit::Hearts, 12), + (Suit::Spades, 13), + ]; + for (slot, (suit, id)) in aces.iter().enumerate() { + g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { + id: *id, suit: *suit, rank: Rank::Ace, face_up: true, + }); + g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap(); + } + for (slot, (suit, _)) in aces.iter().enumerate() { + assert_eq!( + g.piles[&PileType::Foundation(slot as u8)].claimed_suit(), + Some(*suit), + "slot {slot} should claim {suit:?}", + ); + } + } + + /// Auto-complete prefers the foundation slot whose claimed suit matches + /// the candidate card's suit, even if an empty slot exists at a lower + /// index. + #[test] + fn next_auto_complete_move_picks_slot_with_matching_claim() { + let mut g = new_game(); + g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + g.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + for i in 0..7 { + g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); + } + // Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts. + g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card { + id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true, + }); + // Tableau 0 holds the 2 of Hearts to play. + g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { + id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true, + }); + g.is_auto_completable = true; + + let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1"); + assert_eq!(mv.0, PileType::Tableau(0)); + assert_eq!( + mv.1, + PileType::Foundation(1), + "must target the Hearts-claimed slot, not the empty slot 0", + ); + } } diff --git a/solitaire_core/src/pile.rs b/solitaire_core/src/pile.rs index 8b011b3..baa76bb 100644 --- a/solitaire_core/src/pile.rs +++ b/solitaire_core/src/pile.rs @@ -8,8 +8,10 @@ pub enum PileType { Stock, /// The face-up discard pile drawn to. Waste, - /// One of the four suit-ordered foundation piles. - Foundation(Suit), + /// One of the four foundation slots (0..=3). The claimed suit, if any, + /// is derived from the bottom card of the pile (always an Ace by + /// construction). + Foundation(u8), /// One of the seven tableau columns (0–6). Tableau(usize), } @@ -17,7 +19,7 @@ pub enum PileType { /// A named collection of cards in a specific board position. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Pile { - /// Which pile this is (Stock, Waste, Foundation suit, or Tableau column). + /// Which pile this is (Stock, Waste, Foundation slot, or Tableau column). pub pile_type: PileType, /// Cards in the pile, bottom-to-top stacking order. Last element is the top card. pub cards: Vec, @@ -33,6 +35,16 @@ impl Pile { pub fn top(&self) -> Option<&Card> { self.cards.last() } + + /// For foundation piles: returns `Some(suit)` once at least one card has + /// landed (the bottom card is always an Ace of the claimed suit). + /// Returns `None` for empty foundations or non-foundation piles. + pub fn claimed_suit(&self) -> Option { + match self.pile_type { + PileType::Foundation(_) => self.cards.first().map(|c| c.suit), + _ => None, + } + } } #[cfg(test)] @@ -61,12 +73,33 @@ mod tests { } #[test] - fn pile_type_foundation_uses_suit() { - assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades)); + fn pile_type_foundation_uses_slot_index() { + assert_ne!(PileType::Foundation(0), PileType::Foundation(3)); } #[test] fn pile_type_tableau_uses_index() { assert_ne!(PileType::Tableau(0), PileType::Tableau(6)); } + + #[test] + fn claimed_suit_is_none_for_empty_foundation() { + let pile = Pile::new(PileType::Foundation(0)); + assert!(pile.claimed_suit().is_none()); + } + + #[test] + fn claimed_suit_is_none_for_non_foundation() { + let mut pile = Pile::new(PileType::Tableau(0)); + pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }); + assert!(pile.claimed_suit().is_none()); + } + + #[test] + fn claimed_suit_returns_bottom_card_suit() { + let mut pile = Pile::new(PileType::Foundation(2)); + pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true }); + pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true }); + assert_eq!(pile.claimed_suit(), Some(Suit::Hearts)); + } } diff --git a/solitaire_core/src/rules.rs b/solitaire_core/src/rules.rs index eaca819..56b48ec 100644 --- a/solitaire_core/src/rules.rs +++ b/solitaire_core/src/rules.rs @@ -1,16 +1,18 @@ -use crate::card::{Card, Suit}; +use crate::card::Card; use crate::pile::Pile; -/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`. +/// Returns `true` if `card` can be placed on the foundation `pile`. /// -/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher. -pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool { - if card.suit != suit { - return false; - } +/// Foundation rules: +/// - When the pile is empty, any Ace is accepted; the placed Ace's suit +/// becomes the pile's claimed suit (derived from the bottom card via +/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)). +/// - When the pile is non-empty, the next card must match the top card's +/// suit and be exactly one rank higher. +pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool { match pile.cards.last() { None => card.rank.value() == 1, - Some(top) => card.rank.value() == top.rank.value() + 1, + Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1, } } @@ -45,37 +47,46 @@ mod tests { // Foundation tests #[test] fn foundation_ace_on_empty_is_valid() { - let c = card(Suit::Hearts, Rank::Ace); - let p = Pile::new(PileType::Foundation(Suit::Hearts)); - assert!(can_place_on_foundation(&c, &p, Suit::Hearts)); + // Every suit's Ace must land on an empty foundation slot regardless of + // its slot index; the slot claims the suit only after the Ace lands. + for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { + let c = card(suit, Rank::Ace); + let p = Pile::new(PileType::Foundation(0)); + assert!( + can_place_on_foundation(&c, &p), + "Ace of {suit:?} must land on empty slot 0", + ); + } } #[test] fn foundation_non_ace_on_empty_is_invalid() { let c = card(Suit::Hearts, Rank::Two); - let p = Pile::new(PileType::Foundation(Suit::Hearts)); - assert!(!can_place_on_foundation(&c, &p, Suit::Hearts)); + let p = Pile::new(PileType::Foundation(0)); + assert!(!can_place_on_foundation(&c, &p)); } #[test] fn foundation_two_on_ace_same_suit_is_valid() { let c = card(Suit::Clubs, Rank::Two); - let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]); - assert!(can_place_on_foundation(&c, &p, Suit::Clubs)); + let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]); + assert!(can_place_on_foundation(&c, &p)); } #[test] - fn foundation_wrong_suit_is_invalid() { - let c = card(Suit::Hearts, Rank::Ace); - let p = Pile::new(PileType::Foundation(Suit::Spades)); - assert!(!can_place_on_foundation(&c, &p, Suit::Spades)); + fn foundation_second_card_must_match_claimed_suit() { + // Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected + // because the slot's claimed suit is Hearts after the Ace lands. + let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]); + let c = card(Suit::Spades, Rank::Two); + assert!(!can_place_on_foundation(&c, &p)); } #[test] fn foundation_skipping_rank_is_invalid() { let c = card(Suit::Diamonds, Rank::Three); - let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]); - assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds)); + let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]); + assert!(!can_place_on_foundation(&c, &p)); } // Tableau tests @@ -125,16 +136,16 @@ mod tests { fn foundation_king_on_queen_completes_suit() { // The last card placed to complete a foundation is always King on Queen. let c = card(Suit::Spades, Rank::King); - let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]); - assert!(can_place_on_foundation(&c, &p, Suit::Spades)); + let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]); + assert!(can_place_on_foundation(&c, &p)); } #[test] fn foundation_king_wrong_suit_is_invalid() { - // King of Hearts cannot go on a Spades foundation even if rank matches. + // King of Hearts cannot go on a Spades-claimed foundation even if rank matches. let c = card(Suit::Hearts, Rank::King); - let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]); - assert!(!can_place_on_foundation(&c, &p, Suit::Spades)); + let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]); + assert!(!can_place_on_foundation(&c, &p)); } #[test] diff --git a/solitaire_core/src/scoring.rs b/solitaire_core/src/scoring.rs index ba19998..c604fb8 100644 --- a/solitaire_core/src/scoring.rs +++ b/solitaire_core/src/scoring.rs @@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 { #[cfg(test)] mod tests { use super::*; - use crate::card::Suit; #[test] fn move_to_foundation_scores_ten() { - assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10); - assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10); + assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10); + assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10); } #[test] @@ -74,7 +73,7 @@ mod tests { #[test] fn non_waste_to_tableau_scores_zero() { // Foundation → Tableau is impossible in practice but must score 0. - assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0); + assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0); // Tableau → Tableau (restack) scores 0. assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0); } diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 6fd09b0..c4eceae 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -7,7 +7,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; -use solitaire_core::game_state::GameState; +use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION}; use crate::stats::StatsSnapshot; @@ -72,10 +72,21 @@ pub fn game_state_file_path() -> Option { } /// Load an in-progress `GameState` from `path`. Returns `None` if the file is -/// missing, corrupt, or represents a finished game. +/// missing, corrupt, represents a finished game, or carries a save-schema +/// version other than [`GAME_STATE_SCHEMA_VERSION`]. +/// +/// Schema mismatch is treated as "no save" so a player upgrading across an +/// incompatible game-state format change starts fresh instead of seeing a +/// half-loaded game (or a deserialiser error). v1 saves with the old +/// `Foundation(Suit)` key shape will fail to parse outright; any v1 saves +/// that happen to round-trip but report `schema_version: 1` are also rejected +/// here. pub fn load_game_state_from(path: &Path) -> Option { let data = fs::read(path).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?; + if gs.schema_version != GAME_STATE_SCHEMA_VERSION { + return None; + } if gs.is_won { None } else { @@ -331,4 +342,49 @@ mod tests { let tmp = path.with_extension("json.tmp"); assert!(!tmp.exists(), ".tmp must be cleaned up after rename"); } + + /// Pre-v2 save files used `Foundation(Suit)` keys and either fail to + /// parse outright or surface a `schema_version: 1`. Either path must + /// produce `None` so the player launches into a fresh game. + /// + /// Sibling assertion: the stats round-trip path is unaffected — only + /// the game-state schema bumped. + #[test] + fn save_format_v1_is_rejected() { + let path = gs_path("schema_v1"); + let _ = fs::remove_file(&path); + + // A pared-down v1 JSON literal: foundation pile keys use the old + // suit-tagged form and the file omits `schema_version` (so it + // deserialises with the default of 1). Even if a future change + // makes `Foundation(Suit)` parse-compatible, the schema-version + // gate keeps this case rejected. + let v1_json = r#"{ + "piles": [ + [{"Foundation": "Hearts"}, {"pile_type": {"Foundation": "Hearts"}, "cards": []}] + ], + "draw_mode": "DrawOne", + "score": 0, + "move_count": 0, + "elapsed_seconds": 0, + "seed": 42, + "is_won": false, + "is_auto_completable": false, + "undo_count": 0, + "undo_stack": [] + }"#; + fs::write(&path, v1_json).expect("write v1 fixture"); + + assert!( + load_game_state_from(&path).is_none(), + "v1 game_state.json must be rejected (parse failure or schema bump)", + ); + + // Sibling sanity: stats files are independent and still round-trip. + let stats_path = tmp_path("schema_unrelated_stats"); + let _ = fs::remove_file(&stats_path); + save_stats_to(&stats_path, &StatsSnapshot::default()).expect("save stats"); + let loaded = load_stats_from(&stats_path); + assert_eq!(loaded, StatsSnapshot::default()); + } } diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 39e550d..feb7404 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -196,7 +196,8 @@ mod tests { // At least one MoveRequestEvent should have been fired. assert!(!fired.is_empty(), "expected at least one MoveRequestEvent"); assert_eq!(fired[0].from, PileType::Tableau(0)); - assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs)); + // First empty foundation slot wins on a fresh nearly-won board. + assert_eq!(fired[0].to, PileType::Foundation(0)); } #[test] diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index a80549e..96f8ea7 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -436,10 +436,10 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve let piles = [ PileType::Stock, PileType::Waste, - PileType::Foundation(Suit::Clubs), - PileType::Foundation(Suit::Diamonds), - PileType::Foundation(Suit::Hearts), - PileType::Foundation(Suit::Spades), + PileType::Foundation(0), + PileType::Foundation(1), + PileType::Foundation(2), + PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), @@ -985,8 +985,8 @@ fn handle_right_click( let pile_type = &pile_marker.0; let Some(pile) = game.0.piles.get(pile_type) else { continue }; let legal = match pile_type { - PileType::Foundation(suit) => { - can_place_on_foundation(&card, pile, *suit) + PileType::Foundation(_) => { + can_place_on_foundation(&card, pile) } PileType::Tableau(_) => can_place_on_tableau(&card, pile), _ => false, diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index f1d6ad5..fd5fa6c 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -13,7 +13,6 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; -use solitaire_core::card::Suit; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; @@ -82,10 +81,10 @@ fn update_cursor_icon( fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool { let piles = [ PileType::Waste, - PileType::Foundation(Suit::Clubs), - PileType::Foundation(Suit::Diamonds), - PileType::Foundation(Suit::Hearts), - PileType::Foundation(Suit::Spades), + PileType::Foundation(0), + PileType::Foundation(1), + PileType::Foundation(2), + PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), @@ -158,12 +157,12 @@ fn update_drop_highlights( for (marker, mut sprite, _rch) in &mut markers { let valid = match &marker.0 { - PileType::Foundation(suit) => { + PileType::Foundation(slot) => { if drag_count != 1 { false } else { - let pile = game.0.piles.get(&PileType::Foundation(*suit)); - pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit)) + let pile = game.0.piles.get(&PileType::Foundation(*slot)); + pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p)) } } PileType::Tableau(idx) => { diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 1f31ca3..a973dae 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -479,7 +479,6 @@ fn handle_undo( /// - Any face-up card on Waste or Tableau piles that can legally move to any /// Foundation or Tableau destination. pub fn has_legal_moves(game: &GameState) -> bool { - use solitaire_core::card::Suit; use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; @@ -490,8 +489,6 @@ pub fn has_legal_moves(game: &GameState) -> bool { return true; } - let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; - // Check each playable source pile. let sources: Vec = { let mut v = vec![PileType::Waste]; @@ -505,11 +502,11 @@ pub fn has_legal_moves(game: &GameState) -> bool { let Some(from_pile) = game.piles.get(from) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; - // Check foundations. - for &suit in &suits { - let dest = PileType::Foundation(suit); + // Check foundation slots. + for slot in 0..4_u8 { + let dest = PileType::Foundation(slot); if let Some(dest_pile) = game.piles.get(&dest) - && can_place_on_foundation(card, dest_pile, suit) { + && can_place_on_foundation(card, dest_pile) { return true; } } @@ -1116,8 +1113,8 @@ mod tests { game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Clear all tableau and foundations, put Ace of Clubs on tableau 0. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1139,8 +1136,8 @@ mod tests { game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); // Clear all foundations and all tableau. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1234,8 +1231,8 @@ mod tests { let mut gs = app.world_mut().resource_mut::(); gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1273,8 +1270,8 @@ mod tests { let mut gs = app.world_mut().resource_mut::(); gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1340,8 +1337,8 @@ mod tests { let mut gs = app.world_mut().resource_mut::(); gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 20cfa04..e41fec2 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -1557,6 +1557,7 @@ fn update_hud( /// indicator stays in sync with the selection resource. fn update_selection_hud( selection: Option>, + game: Option>, mut q: Query<&mut Text, With>, ) { let Ok(mut t) = q.single_mut() else { return }; @@ -1564,7 +1565,29 @@ fn update_selection_hud( None => String::new(), Some(PileType::Waste) => "▶ Waste".to_string(), Some(PileType::Stock) => "▶ Stock".to_string(), - Some(PileType::Foundation(suit)) => { + Some(PileType::Foundation(slot)) => match game.as_deref() { + Some(g) => foundation_selection_label(*slot, &g.0), + // No game resource means we can't probe claimed_suit; show the + // slot-based placeholder so the HUD still surfaces the selection. + None => format!("▶ Foundation {}", slot + 1), + }, + Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1), + }; + **t = label; +} + +/// Returns the HUD selection label for a foundation slot. +/// +/// When the slot has a claimed suit (any card has landed) the announcement is +/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a +/// "▶ Foundation N" placeholder labelled by the 1-based slot index. +fn foundation_selection_label(slot: u8, game: &solitaire_core::game_state::GameState) -> String { + let claimed = game + .piles + .get(&PileType::Foundation(slot)) + .and_then(|p| p.claimed_suit()); + match claimed { + Some(suit) => { let s = match suit { Suit::Clubs => "Clubs", Suit::Diamonds => "Diamonds", @@ -1573,9 +1596,8 @@ fn update_selection_hud( }; format!("▶ {s} Foundation") } - Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1), - }; - **t = label; + None => format!("▶ Foundation {}", slot + 1), + } } /// Fires `InfoToastEvent("Auto-completing...")` exactly once each time diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 40ab342..f26b3aa 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -320,16 +320,23 @@ fn handle_keyboard_hint( } // Fire an informational toast describing where the hinted card should - // move so the player always sees the suggestion in text. + // move so the player always sees the suggestion in text. When the + // destination foundation already claims a suit, surface that suit so the + // player keeps thinking in suit terms; otherwise fall back to "foundation". let msg = match to { - PileType::Foundation(suit) => { - let suit_name = match suit { - Suit::Clubs => "Clubs", - Suit::Diamonds => "Diamonds", - Suit::Hearts => "Hearts", - Suit::Spades => "Spades", - }; - format!("Hint: move to {suit_name} foundation") + PileType::Foundation(_) => { + let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit()); + if let Some(suit) = claimed { + let suit_name = match suit { + Suit::Clubs => "Clubs", + Suit::Diamonds => "Diamonds", + Suit::Hearts => "Hearts", + Suit::Spades => "Spades", + }; + format!("Hint: move to {suit_name} foundation") + } else { + "Hint: move to foundation".to_string() + } } PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1), _ => "Hint: move card".to_string(), @@ -634,12 +641,11 @@ fn end_drag( let bottom_card_id = drag.cards[0]; if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { let ok = match &target { - PileType::Foundation(suit) => { + PileType::Foundation(_) => { count == 1 && can_place_on_foundation( &bottom_card, &game.0.piles[&target], - *suit, ) } PileType::Tableau(_) => { @@ -879,9 +885,9 @@ fn touch_end_drag( let bottom_card_id = drag.cards[0]; if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { let ok = match &target { - PileType::Foundation(suit) => { + PileType::Foundation(_) => { count == 1 - && can_place_on_foundation(&bottom_card, &game.0.piles[&target], *suit) + && can_place_on_foundation(&bottom_card, &game.0.piles[&target]) } PileType::Tableau(_) => { can_place_on_tableau(&bottom_card, &game.0.piles[&target]) @@ -1016,10 +1022,10 @@ fn find_draggable_at( // Within a pile, we consider cards top-down because the visual top card is drawn last. let piles = [ PileType::Waste, - PileType::Foundation(Suit::Clubs), - PileType::Foundation(Suit::Diamonds), - PileType::Foundation(Suit::Hearts), - PileType::Foundation(Suit::Spades), + PileType::Foundation(0), + PileType::Foundation(1), + PileType::Foundation(2), + PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), @@ -1079,10 +1085,10 @@ fn find_drop_target( origin: &PileType, ) -> Option { let piles = [ - PileType::Foundation(Suit::Clubs), - PileType::Foundation(Suit::Diamonds), - PileType::Foundation(Suit::Hearts), - PileType::Foundation(Suit::Spades), + PileType::Foundation(0), + PileType::Foundation(1), + PileType::Foundation(2), + PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), @@ -1138,11 +1144,11 @@ const DOUBLE_CLICK_WINDOW: 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 { - // Try all four foundations first. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - let dest = PileType::Foundation(suit); + // Try all four foundation slots first. + for slot in 0..4_u8 { + let dest = PileType::Foundation(slot); if let Some(pile) = game.piles.get(&dest) - && can_place_on_foundation(card, pile, suit) { + && can_place_on_foundation(card, pile) { return Some(dest); } } @@ -1298,7 +1304,6 @@ fn handle_double_click( /// This is the backing data for the cycling hint system: the H key steps /// through `hints[HintCycleIndex % hints.len()]` on each press. pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { - let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let sources: Vec = { let mut s = vec![PileType::Waste]; for i in 0..7_usize { @@ -1313,12 +1318,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { for from in &sources { let Some(from_pile) = game.piles.get(from) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; - for &suit in &suits { - let dest = PileType::Foundation(suit); + for slot in 0..4_u8 { + let dest = PileType::Foundation(slot); if let Some(dest_pile) = game.piles.get(&dest) - && can_place_on_foundation(card, dest_pile, suit) { + && can_place_on_foundation(card, dest_pile) { hints.push((from.clone(), dest, 1)); - // Each source card can go to at most one foundation suit; + // Each source card can land on at most one foundation slot; // no need to check the remaining three for this card. break; } @@ -1616,7 +1621,7 @@ mod tests { let layout = compute_layout(Vec2::new(1280.0, 800.0)); for pile in [ PileType::Waste, - PileType::Foundation(Suit::Hearts), + PileType::Foundation(2), ] { let (_, size) = pile_drop_rect(&pile, &layout, &game); assert_eq!(size, layout.card_size); @@ -1638,13 +1643,15 @@ mod tests { waste.cards.clear(); waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }); - // Foundation for Clubs is empty — Ace should go there. - let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap(); - foundation.cards.clear(); + // All four foundation slots empty — the Ace lands in slot 0 (first + // empty slot in iteration order). + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); + } let card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }; let dest = best_destination(&card, &game); - assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs))); + assert_eq!(dest, Some(PileType::Foundation(0))); } #[test] @@ -1653,9 +1660,9 @@ mod tests { use solitaire_core::game_state::GameMode; let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic); - // Clear all foundations — a Two of Clubs cannot go there. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + // Clear all foundation slots — a Two of Clubs cannot go there. + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } // Put a Two of Clubs as the card. @@ -1682,8 +1689,8 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Clear everything except one card that has nowhere to go. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1704,8 +1711,8 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Clear all piles for a clean test. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1737,8 +1744,8 @@ mod tests { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1768,8 +1775,8 @@ mod tests { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1806,13 +1813,16 @@ mod tests { game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card { id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, }); - game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap().cards.clear(); + // All foundation slots empty — Ace lands in slot 0 (first match). + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); + } let hint = find_hint(&game); assert!(hint.is_some(), "should find a hint"); let (from, to, count) = hint.unwrap(); assert_eq!(from, PileType::Tableau(0)); - assert_eq!(to, PileType::Foundation(Suit::Clubs)); + assert_eq!(to, PileType::Foundation(0)); assert_eq!(count, 1); } @@ -1822,8 +1832,8 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Put only a Two on tableau 0, empty everything else. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1872,8 +1882,8 @@ mod tests { // Remove all foundation, tableau, and waste cards so no pile-to-pile // move exists. Leave one card in the stock. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); @@ -1904,8 +1914,8 @@ mod tests { let mut game = GameState::new(1, DrawMode::DrawOne); // Clear every pile, then put a single card that has nowhere to go. - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); + for slot in 0..4_u8 { + game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear(); } for i in 0..7_usize { game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 3fc01cf..764c167 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use bevy::math::Vec2; use bevy::prelude::{Resource, SystemSet}; -use solitaire_core::card::Suit; use solitaire_core::pile::PileType; /// Schedule labels for layout-related systems so cross-plugin ordering is @@ -138,11 +137,10 @@ pub fn compute_layout(window: Vec2) -> Layout { pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y)); // Column 2 is skipped — visual separation between waste and foundations. - let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; - for (i, suit) in foundation_suits.into_iter().enumerate() { + for slot in 0..4_u8 { pile_positions.insert( - PileType::Foundation(suit), - Vec2::new(col_x(3 + i), top_y), + PileType::Foundation(slot), + Vec2::new(col_x(3 + slot as usize), top_y), ); } @@ -167,11 +165,10 @@ mod tests { fn assert_all_piles_present(layout: &Layout) { assert!(layout.pile_positions.contains_key(&PileType::Stock)); assert!(layout.pile_positions.contains_key(&PileType::Waste)); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { + for slot in 0..4_u8 { assert!( - layout.pile_positions.contains_key(&PileType::Foundation(suit)), - "missing foundation for {:?}", - suit + layout.pile_positions.contains_key(&PileType::Foundation(slot)), + "missing foundation slot {slot}", ); } for i in 0..7 { @@ -257,15 +254,13 @@ mod tests { #[test] fn foundations_align_with_tableau_cols_3_to_6() { let layout = compute_layout(Vec2::new(1280.0, 800.0)); - let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; - for (i, suit) in foundation_suits.into_iter().enumerate() { - let f_x = layout.pile_positions[&PileType::Foundation(suit)].x; - let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x; + for slot in 0..4_u8 { + let f_x = layout.pile_positions[&PileType::Foundation(slot)].x; + let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x; assert!( (f_x - t_x).abs() < 1e-5, - "foundation {:?} should align with tableau {}", - suit, - 3 + i + "foundation slot {slot} should align with tableau {}", + 3 + slot as usize, ); } } diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index 71ae2c8..c311b15 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -18,7 +18,6 @@ use bevy::input::ButtonInput; use bevy::prelude::*; -use solitaire_core::card::Suit; use solitaire_core::pile::PileType; use crate::card_plugin::CardEntity; @@ -82,15 +81,12 @@ impl Plugin for SelectionPlugin { /// The ordered list of piles that are considered for keyboard cycling. /// -/// Order: Waste → Foundation×4 → Tableau 0–6. +/// Order: Waste → Foundation slots 0–3 → Tableau 0–6. fn cycled_piles() -> Vec { - let mut piles = vec![ - PileType::Waste, - PileType::Foundation(Suit::Clubs), - PileType::Foundation(Suit::Diamonds), - PileType::Foundation(Suit::Hearts), - PileType::Foundation(Suit::Spades), - ]; + let mut piles = vec![PileType::Waste]; + for slot in 0..4_u8 { + piles.push(PileType::Foundation(slot)); + } for i in 0..7_usize { piles.push(PileType::Tableau(i)); } @@ -183,10 +179,10 @@ fn handle_selection_keys( let available: Vec = { let all = [ PileType::Waste, - PileType::Foundation(Suit::Clubs), - PileType::Foundation(Suit::Diamonds), - PileType::Foundation(Suit::Hearts), - PileType::Foundation(Suit::Spades), + PileType::Foundation(0), + PileType::Foundation(1), + PileType::Foundation(2), + PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), @@ -325,10 +321,10 @@ fn try_foundation_dest( game: &solitaire_core::game_state::GameState, ) -> Option { use solitaire_core::rules::can_place_on_foundation; - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - let dest = PileType::Foundation(suit); + for slot in 0..4_u8 { + let dest = PileType::Foundation(slot); if let Some(pile) = game.piles.get(&dest) - && can_place_on_foundation(card, pile, suit) { + && can_place_on_foundation(card, pile) { return Some(dest); } } diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index 27048ef..e42814f 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -225,8 +225,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { let mut piles: Vec = Vec::with_capacity(13); piles.push(PileType::Stock); piles.push(PileType::Waste); - for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { - piles.push(PileType::Foundation(suit)); + for slot in 0..4_u8 { + piles.push(PileType::Foundation(slot)); } for i in 0..7 { piles.push(PileType::Tableau(i)); @@ -244,18 +244,9 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { PileMarker(pile.clone()), )); - // Task #35 — suit symbol on empty foundation placeholders. - if let PileType::Foundation(suit) = &pile { - let symbol = suit_symbol(suit).to_string(); - entity.with_children(|b| { - b.spawn(( - Text2d::new(symbol), - TextFont { font_size, ..default() }, - TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)), - Transform::from_xyz(0.0, 0.0, 0.1), - )); - }); - } + // Foundation slots no longer carry a suit letter — any Ace can claim + // any empty slot, so a fixed C/D/H/S badge would be misleading. Empty + // foundation markers render as plain translucent rectangles. // Task #43 — King indicator on empty tableau placeholders. if let PileType::Tableau(_) = &pile {