feat(core): unlock foundations — Foundation(u8) slots, suit derived from contents

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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-01 22:17:17 +00:00
parent fdb6c2ecfe
commit 95df5421c9
14 changed files with 487 additions and 197 deletions
+201 -21
View File
@@ -1,6 +1,6 @@
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit}; use crate::card::Card;
use crate::deck::{deal_klondike, Deck}; use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError; use crate::error::MoveError;
use crate::pile::{Pile, PileType}; 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; 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<PileType, Pile>` as a `Vec` of `(key, value)` pairs so /// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
/// that JSON (which requires string map keys) round-trips correctly. /// that JSON (which requires string map keys) round-trips correctly.
mod pile_map_serde { mod pile_map_serde {
@@ -98,6 +112,11 @@ pub struct GameState {
/// Used by the `comeback` achievement condition. /// Used by the `comeback` achievement condition.
#[serde(default)] #[serde(default)]
pub recycle_count: u32, 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<StateSnapshot>, undo_stack: VecDeque<StateSnapshot>,
} }
@@ -116,8 +135,8 @@ impl GameState {
let mut piles: HashMap<PileType, Pile> = HashMap::new(); let mut piles: HashMap<PileType, Pile> = HashMap::new();
piles.insert(PileType::Stock, stock); piles.insert(PileType::Stock, stock);
piles.insert(PileType::Waste, Pile::new(PileType::Waste)); piles.insert(PileType::Waste, Pile::new(PileType::Waste));
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit))); piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
} }
for (i, pile) in tableau.into_iter().enumerate() { for (i, pile) in tableau.into_iter().enumerate() {
piles.insert(PileType::Tableau(i), pile); piles.insert(PileType::Tableau(i), pile);
@@ -135,6 +154,7 @@ impl GameState {
is_auto_completable: false, is_auto_completable: false,
undo_count: 0, undo_count: 0,
recycle_count: 0, recycle_count: 0,
schema_version: GAME_STATE_SCHEMA_VERSION,
undo_stack: VecDeque::new(), undo_stack: VecDeque::new(),
} }
} }
@@ -247,14 +267,14 @@ impl GameState {
let bottom_card = from_pile.cards[start].clone(); let bottom_card = from_pile.cards[start].clone();
match &to { match &to {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
if count != 1 { if count != 1 {
return Err(MoveError::RuleViolation( return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(), "only one card can move to foundation at a time".into(),
)); ));
} }
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?; 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())); return Err(MoveError::RuleViolation("invalid foundation placement".into()));
} }
} }
@@ -332,13 +352,11 @@ impl GameState {
Ok(()) 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 { pub fn check_win(&self) -> bool {
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] (0..4_u8).all(|slot| {
.iter()
.all(|&suit| {
self.piles self.piles
.get(&PileType::Foundation(suit)) .get(&PileType::Foundation(slot))
.is_some_and(|p| p.cards.len() == 13) .is_some_and(|p| p.cards.len() == 13)
}) })
} }
@@ -379,13 +397,34 @@ impl GameState {
if !self.is_auto_completable || self.is_won { if !self.is_auto_completable || self.is_won {
return None; return None;
} }
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
for i in 0..7 { for i in 0..7 {
let tableau = PileType::Tableau(i); let tableau = PileType::Tableau(i);
if let Some(card) = self.piles[&tableau].cards.last() { if let Some(card) = self.piles[&tableau].cards.last() {
for &suit in &suits { // Prefer the slot that already claims this card's suit so
let foundation = PileType::Foundation(suit); // Aces don't sometimes land in slot 0 and then leave the
if can_place_on_foundation(card, &self.piles[&foundation], suit) { // matching suit-claimed slot empty.
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = 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)); return Some((tableau, foundation));
} }
} }
@@ -403,7 +442,7 @@ impl GameState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::card::{Card, Rank}; use crate::card::{Card, Rank, Suit};
fn new_game() -> GameState { fn new_game() -> GameState {
GameState::new(42, DrawMode::DrawOne) GameState::new(42, DrawMode::DrawOne)
@@ -434,8 +473,8 @@ mod tests {
#[test] #[test]
fn new_game_foundations_are_empty() { fn new_game_foundations_are_empty() {
let g = new_game(); let g = new_game();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty()); assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
} }
} }
@@ -662,7 +701,7 @@ mod tests {
]; ];
let result = g.move_cards( let result = g.move_cards(
PileType::Tableau(0), PileType::Tableau(0),
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
2, 2,
); );
assert!( assert!(
@@ -706,8 +745,9 @@ mod tests {
#[test] #[test]
fn win_detection_all_foundations_complete() { fn win_detection_all_foundations_complete() {
let mut g = new_game(); let mut g = new_game();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap(); for (slot, suit) in suits.into_iter().enumerate() {
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
f.cards.clear(); f.cards.clear();
for rank in [ for rank in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, 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"); let mv = g.next_auto_complete_move().expect("should find a move");
assert_eq!(mv.0, PileType::Tableau(0)); 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] #[test]
@@ -1049,4 +1090,143 @@ mod tests {
g.is_won = true; g.is_won = true;
assert!(g.next_auto_complete_move().is_none()); 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",
);
}
} }
+38 -5
View File
@@ -8,8 +8,10 @@ pub enum PileType {
Stock, Stock,
/// The face-up discard pile drawn to. /// The face-up discard pile drawn to.
Waste, Waste,
/// One of the four suit-ordered foundation piles. /// One of the four foundation slots (0..=3). The claimed suit, if any,
Foundation(Suit), /// is derived from the bottom card of the pile (always an Ace by
/// construction).
Foundation(u8),
/// One of the seven tableau columns (06). /// One of the seven tableau columns (06).
Tableau(usize), Tableau(usize),
} }
@@ -17,7 +19,7 @@ pub enum PileType {
/// A named collection of cards in a specific board position. /// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile { 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, pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card. /// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>, pub cards: Vec<Card>,
@@ -33,6 +35,16 @@ impl Pile {
pub fn top(&self) -> Option<&Card> { pub fn top(&self) -> Option<&Card> {
self.cards.last() self.cards.last()
} }
/// For foundation piles: returns `Some(suit)` once at least one card has
/// landed (the bottom card is always an Ace of the claimed suit).
/// Returns `None` for empty foundations or non-foundation piles.
pub fn claimed_suit(&self) -> Option<Suit> {
match self.pile_type {
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
_ => None,
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -61,12 +73,33 @@ mod tests {
} }
#[test] #[test]
fn pile_type_foundation_uses_suit() { fn pile_type_foundation_uses_slot_index() {
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades)); assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
} }
#[test] #[test]
fn pile_type_tableau_uses_index() { fn pile_type_tableau_uses_index() {
assert_ne!(PileType::Tableau(0), PileType::Tableau(6)); 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));
}
} }
+37 -26
View File
@@ -1,16 +1,18 @@
use crate::card::{Card, Suit}; use crate::card::Card;
use crate::pile::Pile; 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. /// Foundation rules:
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool { /// - When the pile is empty, any Ace is accepted; the placed Ace's suit
if card.suit != suit { /// becomes the pile's claimed suit (derived from the bottom card via
return false; /// [`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() { match pile.cards.last() {
None => card.rank.value() == 1, 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 // Foundation tests
#[test] #[test]
fn foundation_ace_on_empty_is_valid() { fn foundation_ace_on_empty_is_valid() {
let c = card(Suit::Hearts, Rank::Ace); // Every suit's Ace must land on an empty foundation slot regardless of
let p = Pile::new(PileType::Foundation(Suit::Hearts)); // its slot index; the slot claims the suit only after the Ace lands.
assert!(can_place_on_foundation(&c, &p, Suit::Hearts)); 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] #[test]
fn foundation_non_ace_on_empty_is_invalid() { fn foundation_non_ace_on_empty_is_invalid() {
let c = card(Suit::Hearts, Rank::Two); let c = card(Suit::Hearts, Rank::Two);
let p = Pile::new(PileType::Foundation(Suit::Hearts)); let p = Pile::new(PileType::Foundation(0));
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts)); assert!(!can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_two_on_ace_same_suit_is_valid() { fn foundation_two_on_ace_same_suit_is_valid() {
let c = card(Suit::Clubs, Rank::Two); let c = card(Suit::Clubs, Rank::Two);
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
assert!(can_place_on_foundation(&c, &p, Suit::Clubs)); assert!(can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_wrong_suit_is_invalid() { fn foundation_second_card_must_match_claimed_suit() {
let c = card(Suit::Hearts, Rank::Ace); // Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
let p = Pile::new(PileType::Foundation(Suit::Spades)); // because the slot's claimed suit is Hearts after the Ace lands.
assert!(!can_place_on_foundation(&c, &p, Suit::Spades)); 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] #[test]
fn foundation_skipping_rank_is_invalid() { fn foundation_skipping_rank_is_invalid() {
let c = card(Suit::Diamonds, Rank::Three); let c = card(Suit::Diamonds, Rank::Three);
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds)); assert!(!can_place_on_foundation(&c, &p));
} }
// Tableau tests // Tableau tests
@@ -125,16 +136,16 @@ mod tests {
fn foundation_king_on_queen_completes_suit() { fn foundation_king_on_queen_completes_suit() {
// The last card placed to complete a foundation is always King on Queen. // The last card placed to complete a foundation is always King on Queen.
let c = card(Suit::Spades, Rank::King); let c = card(Suit::Spades, Rank::King);
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
assert!(can_place_on_foundation(&c, &p, Suit::Spades)); assert!(can_place_on_foundation(&c, &p));
} }
#[test] #[test]
fn foundation_king_wrong_suit_is_invalid() { 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 c = card(Suit::Hearts, Rank::King);
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]); let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
assert!(!can_place_on_foundation(&c, &p, Suit::Spades)); assert!(!can_place_on_foundation(&c, &p));
} }
#[test] #[test]
+3 -4
View File
@@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::card::Suit;
#[test] #[test]
fn move_to_foundation_scores_ten() { fn move_to_foundation_scores_ten() {
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10); assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10); assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
} }
#[test] #[test]
@@ -74,7 +73,7 @@ mod tests {
#[test] #[test]
fn non_waste_to_tableau_scores_zero() { fn non_waste_to_tableau_scores_zero() {
// Foundation → Tableau is impossible in practice but must score 0. // 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. // Tableau → Tableau (restack) scores 0.
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0); assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
} }
+58 -2
View File
@@ -7,7 +7,7 @@ use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; 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; use crate::stats::StatsSnapshot;
@@ -72,10 +72,21 @@ pub fn game_state_file_path() -> Option<PathBuf> {
} }
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is /// 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<GameState> { pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won { if gs.is_won {
None None
} else { } else {
@@ -331,4 +342,49 @@ mod tests {
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename"); 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());
}
} }
+2 -1
View File
@@ -196,7 +196,8 @@ mod tests {
// At least one MoveRequestEvent should have been fired. // At least one MoveRequestEvent should have been fired.
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent"); assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
assert_eq!(fired[0].from, PileType::Tableau(0)); 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] #[test]
+6 -6
View File
@@ -436,10 +436,10 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
let piles = [ let piles = [
PileType::Stock, PileType::Stock,
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -985,8 +985,8 @@ fn handle_right_click(
let pile_type = &pile_marker.0; let pile_type = &pile_marker.0;
let Some(pile) = game.0.piles.get(pile_type) else { continue }; let Some(pile) = game.0.piles.get(pile_type) else { continue };
let legal = match pile_type { let legal = match pile_type {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
can_place_on_foundation(&card, pile, *suit) can_place_on_foundation(&card, pile)
} }
PileType::Tableau(_) => can_place_on_tableau(&card, pile), PileType::Tableau(_) => can_place_on_tableau(&card, pile),
_ => false, _ => false,
+7 -8
View File
@@ -13,7 +13,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; 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 { fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
let piles = [ let piles = [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -158,12 +157,12 @@ fn update_drop_highlights(
for (marker, mut sprite, _rch) in &mut markers { for (marker, mut sprite, _rch) in &mut markers {
let valid = match &marker.0 { let valid = match &marker.0 {
PileType::Foundation(suit) => { PileType::Foundation(slot) => {
if drag_count != 1 { if drag_count != 1 {
false false
} else { } else {
let pile = game.0.piles.get(&PileType::Foundation(*suit)); let pile = game.0.piles.get(&PileType::Foundation(*slot));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit)) pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
} }
} }
PileType::Tableau(idx) => { PileType::Tableau(idx) => {
+14 -17
View File
@@ -479,7 +479,6 @@ fn handle_undo(
/// - Any face-up card on Waste or Tableau piles that can legally move to any /// - Any face-up card on Waste or Tableau piles that can legally move to any
/// Foundation or Tableau destination. /// Foundation or Tableau destination.
pub fn has_legal_moves(game: &GameState) -> bool { pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; 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; return true;
} }
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
// Check each playable source pile. // Check each playable source pile.
let sources: Vec<PileType> = { let sources: Vec<PileType> = {
let mut v = vec![PileType::Waste]; 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(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundations. // Check foundation slots.
for &suit in &suits { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest) 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; return true;
} }
} }
@@ -1116,8 +1113,8 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all tableau and foundations, put Ace of Clubs on tableau 0. // Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); 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(); game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Clear all foundations and all tableau. // Clear all foundations and all tableau.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1234,8 +1231,8 @@ mod tests {
let mut gs = app.world_mut().resource_mut::<GameStateResource>(); let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); 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::<GameStateResource>(); let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); 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::<GameStateResource>(); let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
+26 -4
View File
@@ -1557,6 +1557,7 @@ fn update_hud(
/// indicator stays in sync with the selection resource. /// indicator stays in sync with the selection resource.
fn update_selection_hud( fn update_selection_hud(
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<HudSelection>>, mut q: Query<&mut Text, With<HudSelection>>,
) { ) {
let Ok(mut t) = q.single_mut() else { return }; let Ok(mut t) = q.single_mut() else { return };
@@ -1564,7 +1565,29 @@ fn update_selection_hud(
None => String::new(), None => String::new(),
Some(PileType::Waste) => "▶ Waste".to_string(), Some(PileType::Waste) => "▶ Waste".to_string(),
Some(PileType::Stock) => "▶ Stock".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 { let s = match suit {
Suit::Clubs => "Clubs", Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds", Suit::Diamonds => "Diamonds",
@@ -1573,9 +1596,8 @@ fn update_selection_hud(
}; };
format!("{s} Foundation") format!("{s} Foundation")
} }
Some(PileType::Tableau(idx)) => format!("Column {}", idx + 1), None => format!("Foundation {}", slot + 1),
}; }
**t = label;
} }
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time /// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
+57 -47
View File
@@ -320,9 +320,13 @@ fn handle_keyboard_hint(
} }
// Fire an informational toast describing where the hinted card should // 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 { let msg = match to {
PileType::Foundation(suit) => { 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 { let suit_name = match suit {
Suit::Clubs => "Clubs", Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds", Suit::Diamonds => "Diamonds",
@@ -330,6 +334,9 @@ fn handle_keyboard_hint(
Suit::Spades => "Spades", Suit::Spades => "Spades",
}; };
format!("Hint: move to {suit_name} foundation") 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), PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
_ => "Hint: move card".to_string(), _ => "Hint: move card".to_string(),
@@ -634,12 +641,11 @@ fn end_drag(
let bottom_card_id = drag.cards[0]; let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target { let ok = match &target {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
count == 1 count == 1
&& can_place_on_foundation( && can_place_on_foundation(
&bottom_card, &bottom_card,
&game.0.piles[&target], &game.0.piles[&target],
*suit,
) )
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
@@ -879,9 +885,9 @@ fn touch_end_drag(
let bottom_card_id = drag.cards[0]; let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target { let ok = match &target {
PileType::Foundation(suit) => { PileType::Foundation(_) => {
count == 1 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(_) => { PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target]) 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. // Within a pile, we consider cards top-down because the visual top card is drawn last.
let piles = [ let piles = [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -1079,10 +1085,10 @@ fn find_drop_target(
origin: &PileType, origin: &PileType,
) -> Option<PileType> { ) -> Option<PileType> {
let piles = [ let piles = [
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), 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. /// Returns `None` if no legal move exists from the card's current location.
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> { pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
// Try all four foundations first. // Try all four foundation slots first.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest) if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile, suit) { && can_place_on_foundation(card, pile) {
return Some(dest); 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 /// This is the backing data for the cycling hint system: the H key steps
/// through `hints[HintCycleIndex % hints.len()]` on each press. /// through `hints[HintCycleIndex % hints.len()]` on each press.
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> { pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let sources: Vec<PileType> = { let sources: Vec<PileType> = {
let mut s = vec![PileType::Waste]; let mut s = vec![PileType::Waste];
for i in 0..7_usize { for i in 0..7_usize {
@@ -1313,12 +1318,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
for from in &sources { for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue }; let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
for &suit in &suits { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(dest_pile) = game.piles.get(&dest) 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)); 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. // no need to check the remaining three for this card.
break; break;
} }
@@ -1616,7 +1621,7 @@ mod tests {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
for pile in [ for pile in [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
] { ] {
let (_, size) = pile_drop_rect(&pile, &layout, &game); let (_, size) = pile_drop_rect(&pile, &layout, &game);
assert_eq!(size, layout.card_size); assert_eq!(size, layout.card_size);
@@ -1638,13 +1643,15 @@ mod tests {
waste.cards.clear(); waste.cards.clear();
waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true }); waste.cards.push(Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true });
// Foundation for Clubs is empty — Ace should go there. // All four foundation slots empty — the Ace lands in slot 0 (first
let foundation = game.piles.get_mut(&PileType::Foundation(Suit::Clubs)).unwrap(); // empty slot in iteration order).
foundation.cards.clear(); 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 card = Card { id: 200, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
let dest = best_destination(&card, &game); let dest = best_destination(&card, &game);
assert_eq!(dest, Some(PileType::Foundation(Suit::Clubs))); assert_eq!(dest, Some(PileType::Foundation(0)));
} }
#[test] #[test]
@@ -1653,9 +1660,9 @@ mod tests {
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic); let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
// Clear all foundations — a Two of Clubs cannot go there. // Clear all foundation slots — a Two of Clubs cannot go there.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
// Put a Two of Clubs as the card. // Put a Two of Clubs as the card.
@@ -1682,8 +1689,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear everything except one card that has nowhere to go. // Clear everything except one card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1704,8 +1711,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear all piles for a clean test. // Clear all piles for a clean test.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1737,8 +1744,8 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1768,8 +1775,8 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); 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 { game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 500, suit: Suit::Clubs, rank: Rank::Ace, face_up: true, 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); let hint = find_hint(&game);
assert!(hint.is_some(), "should find a hint"); assert!(hint.is_some(), "should find a hint");
let (from, to, count) = hint.unwrap(); let (from, to, count) = hint.unwrap();
assert_eq!(from, PileType::Tableau(0)); assert_eq!(from, PileType::Tableau(0));
assert_eq!(to, PileType::Foundation(Suit::Clubs)); assert_eq!(to, PileType::Foundation(0));
assert_eq!(count, 1); assert_eq!(count, 1);
} }
@@ -1822,8 +1832,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Put only a Two on tableau 0, empty everything else. // Put only a Two on tableau 0, empty everything else.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); 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 // Remove all foundation, tableau, and waste cards so no pile-to-pile
// move exists. Leave one card in the stock. // move exists. Leave one card in the stock.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
@@ -1904,8 +1914,8 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear every pile, then put a single card that has nowhere to go. // Clear every pile, then put a single card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
} }
for i in 0..7_usize { for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
+11 -16
View File
@@ -7,7 +7,6 @@ use std::collections::HashMap;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet}; use bevy::prelude::{Resource, SystemSet};
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
/// Schedule labels for layout-related systems so cross-plugin ordering is /// 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)); pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations. // Column 2 is skipped — visual separation between waste and foundations.
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; for slot in 0..4_u8 {
for (i, suit) in foundation_suits.into_iter().enumerate() {
pile_positions.insert( pile_positions.insert(
PileType::Foundation(suit), PileType::Foundation(slot),
Vec2::new(col_x(3 + i), top_y), Vec2::new(col_x(3 + slot as usize), top_y),
); );
} }
@@ -167,11 +165,10 @@ mod tests {
fn assert_all_piles_present(layout: &Layout) { fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&PileType::Stock)); assert!(layout.pile_positions.contains_key(&PileType::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste)); 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!( assert!(
layout.pile_positions.contains_key(&PileType::Foundation(suit)), layout.pile_positions.contains_key(&PileType::Foundation(slot)),
"missing foundation for {:?}", "missing foundation slot {slot}",
suit
); );
} }
for i in 0..7 { for i in 0..7 {
@@ -257,15 +254,13 @@ mod tests {
#[test] #[test]
fn foundations_align_with_tableau_cols_3_to_6() { fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; for slot in 0..4_u8 {
for (i, suit) in foundation_suits.into_iter().enumerate() { let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let f_x = layout.pile_positions[&PileType::Foundation(suit)].x; let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x;
assert!( assert!(
(f_x - t_x).abs() < 1e-5, (f_x - t_x).abs() < 1e-5,
"foundation {:?} should align with tableau {}", "foundation slot {slot} should align with tableau {}",
suit, 3 + slot as usize,
3 + i
); );
} }
} }
+12 -16
View File
@@ -18,7 +18,6 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::card::Suit;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
@@ -82,15 +81,12 @@ impl Plugin for SelectionPlugin {
/// The ordered list of piles that are considered for keyboard cycling. /// The ordered list of piles that are considered for keyboard cycling.
/// ///
/// Order: Waste → Foundation×4 → Tableau 06. /// Order: Waste → Foundation slots 03 → Tableau 06.
fn cycled_piles() -> Vec<PileType> { fn cycled_piles() -> Vec<PileType> {
let mut piles = vec![ let mut piles = vec![PileType::Waste];
PileType::Waste, for slot in 0..4_u8 {
PileType::Foundation(Suit::Clubs), piles.push(PileType::Foundation(slot));
PileType::Foundation(Suit::Diamonds), }
PileType::Foundation(Suit::Hearts),
PileType::Foundation(Suit::Spades),
];
for i in 0..7_usize { for i in 0..7_usize {
piles.push(PileType::Tableau(i)); piles.push(PileType::Tableau(i));
} }
@@ -183,10 +179,10 @@ fn handle_selection_keys(
let available: Vec<PileType> = { let available: Vec<PileType> = {
let all = [ let all = [
PileType::Waste, PileType::Waste,
PileType::Foundation(Suit::Clubs), PileType::Foundation(0),
PileType::Foundation(Suit::Diamonds), PileType::Foundation(1),
PileType::Foundation(Suit::Hearts), PileType::Foundation(2),
PileType::Foundation(Suit::Spades), PileType::Foundation(3),
PileType::Tableau(0), PileType::Tableau(0),
PileType::Tableau(1), PileType::Tableau(1),
PileType::Tableau(2), PileType::Tableau(2),
@@ -325,10 +321,10 @@ fn try_foundation_dest(
game: &solitaire_core::game_state::GameState, game: &solitaire_core::game_state::GameState,
) -> Option<PileType> { ) -> Option<PileType> {
use solitaire_core::rules::can_place_on_foundation; use solitaire_core::rules::can_place_on_foundation;
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(slot);
if let Some(pile) = game.piles.get(&dest) if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(card, pile, suit) { && can_place_on_foundation(card, pile) {
return Some(dest); return Some(dest);
} }
} }
+5 -14
View File
@@ -225,8 +225,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let mut piles: Vec<PileType> = Vec::with_capacity(13); let mut piles: Vec<PileType> = Vec::with_capacity(13);
piles.push(PileType::Stock); piles.push(PileType::Stock);
piles.push(PileType::Waste); piles.push(PileType::Waste);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for slot in 0..4_u8 {
piles.push(PileType::Foundation(suit)); piles.push(PileType::Foundation(slot));
} }
for i in 0..7 { for i in 0..7 {
piles.push(PileType::Tableau(i)); piles.push(PileType::Tableau(i));
@@ -244,18 +244,9 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
PileMarker(pile.clone()), PileMarker(pile.clone()),
)); ));
// Task #35 — suit symbol on empty foundation placeholders. // Foundation slots no longer carry a suit letter — any Ace can claim
if let PileType::Foundation(suit) = &pile { // any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
let symbol = suit_symbol(suit).to_string(); // foundation markers render as plain translucent rectangles.
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),
));
});
}
// Task #43 — King indicator on empty tableau placeholders. // Task #43 — King indicator on empty tableau placeholders.
if let PileType::Tableau(_) = &pile { if let PileType::Tableau(_) = &pile {