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:
@@ -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<PileType> {
|
||||
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<PileType> = {
|
||||
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<PileType> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user