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
+64 -54
View File
@@ -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<PileType> {
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<PileType> {
// 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<PileType> = {
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();