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
+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
/// 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<PileType> = {
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::<GameStateResource>();
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::<GameStateResource>();
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::<GameStateResource>();
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();