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>
178 lines
6.4 KiB
Rust
178 lines
6.4 KiB
Rust
use crate::card::Card;
|
|
use crate::pile::Pile;
|
|
|
|
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
|
///
|
|
/// Foundation rules:
|
|
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
|
/// becomes the pile's claimed suit (derived from the bottom card via
|
|
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
|
/// - When the pile is non-empty, the next card must match the top card's
|
|
/// suit and be exactly one rank higher.
|
|
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
|
match pile.cards.last() {
|
|
None => card.rank.value() == 1,
|
|
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
|
///
|
|
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
|
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
|
match pile.cards.last() {
|
|
None => card.rank.value() == 13,
|
|
Some(top) => {
|
|
top.face_up
|
|
&& card.rank.value() + 1 == top.rank.value()
|
|
&& card.suit.is_red() != top.suit.is_red()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::card::{Card, Rank, Suit};
|
|
use crate::pile::{Pile, PileType};
|
|
|
|
fn card(suit: Suit, rank: Rank) -> Card {
|
|
Card { id: 0, suit, rank, face_up: true }
|
|
}
|
|
|
|
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
|
|
Pile { pile_type, cards }
|
|
}
|
|
|
|
// Foundation tests
|
|
#[test]
|
|
fn foundation_ace_on_empty_is_valid() {
|
|
// Every suit's Ace must land on an empty foundation slot regardless of
|
|
// its slot index; the slot claims the suit only after the Ace lands.
|
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
|
let c = card(suit, Rank::Ace);
|
|
let p = Pile::new(PileType::Foundation(0));
|
|
assert!(
|
|
can_place_on_foundation(&c, &p),
|
|
"Ace of {suit:?} must land on empty slot 0",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_non_ace_on_empty_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Two);
|
|
let p = Pile::new(PileType::Foundation(0));
|
|
assert!(!can_place_on_foundation(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_two_on_ace_same_suit_is_valid() {
|
|
let c = card(Suit::Clubs, Rank::Two);
|
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
|
assert!(can_place_on_foundation(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_second_card_must_match_claimed_suit() {
|
|
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
|
// because the slot's claimed suit is Hearts after the Ace lands.
|
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
|
let c = card(Suit::Spades, Rank::Two);
|
|
assert!(!can_place_on_foundation(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_skipping_rank_is_invalid() {
|
|
let c = card(Suit::Diamonds, Rank::Three);
|
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
|
|
assert!(!can_place_on_foundation(&c, &p));
|
|
}
|
|
|
|
// Tableau tests
|
|
#[test]
|
|
fn tableau_king_on_empty_is_valid() {
|
|
let c = card(Suit::Hearts, Rank::King);
|
|
let p = Pile::new(PileType::Tableau(0));
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_non_king_on_empty_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Queen);
|
|
let p = Pile::new(PileType::Tableau(0));
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_red_on_black_one_lower_is_valid() {
|
|
let c = card(Suit::Hearts, Rank::Nine);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_same_color_is_invalid() {
|
|
let c = card(Suit::Clubs, Rank::Nine);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_wrong_rank_difference_is_invalid() {
|
|
let c = card(Suit::Hearts, Rank::Eight);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Ten)]);
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_black_on_red_one_lower_is_valid() {
|
|
let c = card(Suit::Clubs, Rank::Six);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_king_on_queen_completes_suit() {
|
|
// The last card placed to complete a foundation is always King on Queen.
|
|
let c = card(Suit::Spades, Rank::King);
|
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
|
assert!(can_place_on_foundation(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn foundation_king_wrong_suit_is_invalid() {
|
|
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
|
let c = card(Suit::Hearts, Rank::King);
|
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
|
assert!(!can_place_on_foundation(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_ace_on_two_different_color_is_valid() {
|
|
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
|
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
|
let c = card(Suit::Hearts, Rank::Ace);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
|
assert!(can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_same_rank_different_color_is_invalid() {
|
|
// Two cards of the same rank cannot be stacked regardless of colour.
|
|
let c = card(Suit::Hearts, Rank::Nine);
|
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
|
|
#[test]
|
|
fn tableau_face_down_destination_top_is_invalid() {
|
|
// A face-down top card must never be a valid placement target.
|
|
let c = card(Suit::Hearts, Rank::Nine);
|
|
let mut top = card(Suit::Spades, Rank::Ten);
|
|
top.face_up = false;
|
|
let p = pile_with(PileType::Tableau(0), vec![top]);
|
|
assert!(!can_place_on_tableau(&c, &p));
|
|
}
|
|
}
|