95df5421c9
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>
106 lines
3.4 KiB
Rust
106 lines
3.4 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use crate::card::{Card, Suit};
|
||
|
||
/// Identifies which pile on the board a set of cards belongs to.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub enum PileType {
|
||
/// The face-down draw pile.
|
||
Stock,
|
||
/// The face-up discard pile drawn to.
|
||
Waste,
|
||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||
/// is derived from the bottom card of the pile (always an Ace by
|
||
/// construction).
|
||
Foundation(u8),
|
||
/// One of the seven tableau columns (0–6).
|
||
Tableau(usize),
|
||
}
|
||
|
||
/// A named collection of cards in a specific board position.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct Pile {
|
||
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||
pub pile_type: PileType,
|
||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||
pub cards: Vec<Card>,
|
||
}
|
||
|
||
impl Pile {
|
||
/// Creates a new empty pile of the given type.
|
||
pub fn new(pile_type: PileType) -> Self {
|
||
Self { pile_type, cards: Vec::new() }
|
||
}
|
||
|
||
/// Returns a reference to the top (last) card, or `None` if empty.
|
||
pub fn top(&self) -> Option<&Card> {
|
||
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)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::card::{Card, Rank, Suit};
|
||
|
||
#[test]
|
||
fn new_pile_is_empty() {
|
||
let pile = Pile::new(PileType::Stock);
|
||
assert!(pile.cards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn pile_top_returns_last_card() {
|
||
let mut pile = Pile::new(PileType::Waste);
|
||
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||
pile.cards.push(Card { id: 1, suit: Suit::Clubs, rank: Rank::Two, face_up: true });
|
||
assert_eq!(pile.top().unwrap().id, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn pile_top_on_empty_is_none() {
|
||
let pile = Pile::new(PileType::Waste);
|
||
assert!(pile.top().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pile_type_foundation_uses_slot_index() {
|
||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||
}
|
||
|
||
#[test]
|
||
fn pile_type_tableau_uses_index() {
|
||
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));
|
||
}
|
||
}
|