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
+11 -16
View File
@@ -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,
);
}
}