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:
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::error::MoveError;
|
||||
use crate::pile::{Pile, PileType};
|
||||
@@ -9,6 +9,20 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score
|
||||
|
||||
const MAX_UNDO_STACK: usize = 64;
|
||||
|
||||
/// Save-file schema version for `GameState`. Increment when the on-disk
|
||||
/// representation changes incompatibly so `load_game_state_from` can refuse
|
||||
/// older formats and start the player on a fresh game.
|
||||
///
|
||||
/// History:
|
||||
/// - v1: `Foundation(Suit)` keys.
|
||||
/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the
|
||||
/// bottom card of the pile.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// Default value for `GameState::schema_version` when deserialising older
|
||||
/// save files that pre-date the field.
|
||||
fn schema_v1() -> u32 { 1 }
|
||||
|
||||
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
||||
/// that JSON (which requires string map keys) round-trips correctly.
|
||||
mod pile_map_serde {
|
||||
@@ -98,6 +112,11 @@ pub struct GameState {
|
||||
/// Used by the `comeback` achievement condition.
|
||||
#[serde(default)]
|
||||
pub recycle_count: u32,
|
||||
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||
/// the field. The loader refuses any value other than
|
||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
undo_stack: VecDeque<StateSnapshot>,
|
||||
}
|
||||
|
||||
@@ -116,8 +135,8 @@ impl GameState {
|
||||
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
||||
piles.insert(PileType::Stock, stock);
|
||||
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit)));
|
||||
for slot in 0..4_u8 {
|
||||
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
|
||||
}
|
||||
for (i, pile) in tableau.into_iter().enumerate() {
|
||||
piles.insert(PileType::Tableau(i), pile);
|
||||
@@ -135,6 +154,7 @@ impl GameState {
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
undo_stack: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
@@ -247,14 +267,14 @@ impl GameState {
|
||||
let bottom_card = from_pile.cards[start].clone();
|
||||
|
||||
match &to {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only one card can move to foundation at a time".into(),
|
||||
));
|
||||
}
|
||||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||
if !can_place_on_foundation(&bottom_card, dest, *suit) {
|
||||
if !can_place_on_foundation(&bottom_card, dest) {
|
||||
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
|
||||
}
|
||||
}
|
||||
@@ -332,15 +352,13 @@ impl GameState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` when all four foundations each contain 13 cards.
|
||||
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||||
pub fn check_win(&self) -> bool {
|
||||
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
||||
.iter()
|
||||
.all(|&suit| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(suit))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
(0..4_u8).all(|slot| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||
@@ -379,13 +397,34 @@ impl GameState {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
return None;
|
||||
}
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for i in 0..7 {
|
||||
let tableau = PileType::Tableau(i);
|
||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||
for &suit in &suits {
|
||||
let foundation = PileType::Foundation(suit);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation], suit) {
|
||||
// Prefer the slot that already claims this card's suit so
|
||||
// Aces don't sometimes land in slot 0 and then leave the
|
||||
// matching suit-claimed slot empty.
|
||||
let mut candidate: Option<u8> = None;
|
||||
let mut empty_slot: Option<u8> = None;
|
||||
for slot in 0..4_u8 {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
let pile = &self.piles[&foundation];
|
||||
if pile.cards.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
}
|
||||
} else if pile.claimed_suit() == Some(card.suit) {
|
||||
candidate = Some(slot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let target_slot = candidate.or_else(|| {
|
||||
// Only fall back to an empty slot if the card is an Ace,
|
||||
// which is the only rank that can claim an empty slot.
|
||||
if card.rank.value() == 1 { empty_slot } else { None }
|
||||
});
|
||||
if let Some(slot) = target_slot {
|
||||
let foundation = PileType::Foundation(slot);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
||||
return Some((tableau, foundation));
|
||||
}
|
||||
}
|
||||
@@ -403,7 +442,7 @@ impl GameState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
fn new_game() -> GameState {
|
||||
GameState::new(42, DrawMode::DrawOne)
|
||||
@@ -434,8 +473,8 @@ mod tests {
|
||||
#[test]
|
||||
fn new_game_foundations_are_empty() {
|
||||
let g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty());
|
||||
for slot in 0..4_u8 {
|
||||
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +701,7 @@ mod tests {
|
||||
];
|
||||
let result = g.move_cards(
|
||||
PileType::Tableau(0),
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(0),
|
||||
2,
|
||||
);
|
||||
assert!(
|
||||
@@ -706,8 +745,9 @@ mod tests {
|
||||
#[test]
|
||||
fn win_detection_all_foundations_complete() {
|
||||
let mut g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap();
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (slot, suit) in suits.into_iter().enumerate() {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
|
||||
f.cards.clear();
|
||||
for rank in [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
@@ -1039,7 +1079,8 @@ mod tests {
|
||||
|
||||
let mv = g.next_auto_complete_move().expect("should find a move");
|
||||
assert_eq!(mv.0, PileType::Tableau(0));
|
||||
assert_eq!(mv.1, PileType::Foundation(Suit::Clubs));
|
||||
// Slot 0 is the first empty foundation; the Ace lands there.
|
||||
assert_eq!(mv.1, PileType::Foundation(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1049,4 +1090,143 @@ mod tests {
|
||||
g.is_won = true;
|
||||
assert!(g.next_auto_complete_move().is_none());
|
||||
}
|
||||
|
||||
// --- Slot-based foundation behaviour (refactor coverage) ---
|
||||
|
||||
/// Aces land in the first empty slot regardless of suit, and successive
|
||||
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
|
||||
#[test]
|
||||
fn any_ace_lands_in_first_empty_foundation() {
|
||||
let mut g = new_game();
|
||||
// Clear stock/waste/tableau so we can hand-construct moves directly.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Place an Ace of Clubs on tableau 0; move it to slot 0.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
|
||||
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
|
||||
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
|
||||
}
|
||||
|
||||
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
|
||||
/// foundation slot, regardless of which slot index the pile occupies.
|
||||
#[test]
|
||||
fn claimed_suit_is_derived_from_bottom_card() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
g.piles[&PileType::Foundation(2)].claimed_suit(),
|
||||
Some(Suit::Hearts)
|
||||
);
|
||||
}
|
||||
|
||||
/// Undoing the only card from a foundation slot drops the claimed suit;
|
||||
/// the slot then accepts a different Ace.
|
||||
#[test]
|
||||
fn foundation_claim_drops_when_emptied_via_undo() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
|
||||
|
||||
g.undo().unwrap();
|
||||
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
|
||||
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
|
||||
|
||||
// A different Ace can now claim slot 0.
|
||||
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
|
||||
}
|
||||
|
||||
/// Successive Aces from the waste pile distribute across slots 0..=3 in
|
||||
/// order — the player picks the slot, but `move_cards` accepts any
|
||||
/// empty-slot placement for an Ace.
|
||||
#[test]
|
||||
fn multiple_aces_distribute_across_slots() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
let aces = [
|
||||
(Suit::Clubs, 10),
|
||||
(Suit::Diamonds, 11),
|
||||
(Suit::Hearts, 12),
|
||||
(Suit::Spades, 13),
|
||||
];
|
||||
for (slot, (suit, id)) in aces.iter().enumerate() {
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
|
||||
}
|
||||
for (slot, (suit, _)) in aces.iter().enumerate() {
|
||||
assert_eq!(
|
||||
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
|
||||
Some(*suit),
|
||||
"slot {slot} should claim {suit:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-complete prefers the foundation slot whose claimed suit matches
|
||||
/// the candidate card's suit, even if an empty slot exists at a lower
|
||||
/// index.
|
||||
#[test]
|
||||
fn next_auto_complete_move_picks_slot_with_matching_claim() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
|
||||
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
// Tableau 0 holds the 2 of Hearts to play.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
|
||||
});
|
||||
g.is_auto_completable = true;
|
||||
|
||||
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
|
||||
assert_eq!(mv.0, PileType::Tableau(0));
|
||||
assert_eq!(
|
||||
mv.1,
|
||||
PileType::Foundation(1),
|
||||
"must target the Hearts-claimed slot, not the empty slot 0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ pub enum PileType {
|
||||
Stock,
|
||||
/// The face-up discard pile drawn to.
|
||||
Waste,
|
||||
/// One of the four suit-ordered foundation piles.
|
||||
Foundation(Suit),
|
||||
/// 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),
|
||||
}
|
||||
@@ -17,7 +19,7 @@ pub enum PileType {
|
||||
/// 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 suit, or Tableau column).
|
||||
/// 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>,
|
||||
@@ -33,6 +35,16 @@ impl Pile {
|
||||
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)]
|
||||
@@ -61,12 +73,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_foundation_uses_suit() {
|
||||
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-26
@@ -1,16 +1,18 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`.
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
///
|
||||
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher.
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
|
||||
if card.suit != suit {
|
||||
return false;
|
||||
}
|
||||
/// 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.rank.value() == top.rank.value() + 1,
|
||||
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,37 +47,46 @@ mod tests {
|
||||
// Foundation tests
|
||||
#[test]
|
||||
fn foundation_ace_on_empty_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
// 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(Suit::Hearts));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
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(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_wrong_suit_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Spades));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
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(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
// Tableau tests
|
||||
@@ -125,16 +136,16 @@ mod tests {
|
||||
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(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
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 foundation even if rank matches.
|
||||
// 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(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::Suit;
|
||||
|
||||
#[test]
|
||||
fn move_to_foundation_scores_ten() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10);
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -74,7 +73,7 @@ mod tests {
|
||||
#[test]
|
||||
fn non_waste_to_tableau_scores_zero() {
|
||||
// Foundation → Tableau is impossible in practice but must score 0.
|
||||
assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0);
|
||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
||||
// Tableau → Tableau (restack) scores 0.
|
||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user