refactor(core): explicit Rank discriminants, checked arithmetic, possible_instructions
- Add Rank=1..13 explicit discriminants so `rank as u8 == rank.value()`; collapse 13-arm value() match to `self as u8` - Add Rank::RANKS and Suit::SUITS iteration constants - Add Rank::checked_add / checked_sub (const fn, type-safe boundary enforcement); update rules.rs to use them - Add GameState::possible_instructions() enumerating all valid move_cards triples (foundation for hints/solver) - Fix waste buffer card peeking through during draw-slide animation by setting Visibility::Hidden on the buffer entity in sync_cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+88
-33
@@ -10,6 +10,9 @@ pub enum Suit {
|
||||
}
|
||||
|
||||
impl Suit {
|
||||
/// All four suits in declaration order.
|
||||
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
||||
|
||||
/// Returns `true` for red suits (Diamonds, Hearts).
|
||||
pub fn is_red(self) -> bool {
|
||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||
@@ -24,38 +27,63 @@ impl Suit {
|
||||
/// Card rank, Ace through King.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Rank {
|
||||
Ace,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
Nine,
|
||||
Ten,
|
||||
Jack,
|
||||
Queen,
|
||||
King,
|
||||
Ace = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
Nine = 9,
|
||||
Ten = 10,
|
||||
Jack = 11,
|
||||
Queen = 12,
|
||||
King = 13,
|
||||
}
|
||||
|
||||
impl Rank {
|
||||
/// All thirteen ranks in ascending order.
|
||||
pub const RANKS: [Self; 13] = [
|
||||
Self::Ace, Self::Two, Self::Three, Self::Four, Self::Five,
|
||||
Self::Six, Self::Seven, Self::Eight, Self::Nine, Self::Ten,
|
||||
Self::Jack, Self::Queen, Self::King,
|
||||
];
|
||||
|
||||
/// Numeric value: Ace = 1, King = 13.
|
||||
pub fn value(self) -> u8 {
|
||||
match self {
|
||||
Rank::Ace => 1,
|
||||
Rank::Two => 2,
|
||||
Rank::Three => 3,
|
||||
Rank::Four => 4,
|
||||
Rank::Five => 5,
|
||||
Rank::Six => 6,
|
||||
Rank::Seven => 7,
|
||||
Rank::Eight => 8,
|
||||
Rank::Nine => 9,
|
||||
Rank::Ten => 10,
|
||||
Rank::Jack => 11,
|
||||
Rank::Queen => 12,
|
||||
Rank::King => 13,
|
||||
self as u8
|
||||
}
|
||||
|
||||
const fn new(n: u8) -> Option<Self> {
|
||||
match n {
|
||||
1 => Some(Self::Ace),
|
||||
2 => Some(Self::Two),
|
||||
3 => Some(Self::Three),
|
||||
4 => Some(Self::Four),
|
||||
5 => Some(Self::Five),
|
||||
6 => Some(Self::Six),
|
||||
7 => Some(Self::Seven),
|
||||
8 => Some(Self::Eight),
|
||||
9 => Some(Self::Nine),
|
||||
10 => Some(Self::Ten),
|
||||
11 => Some(Self::Jack),
|
||||
12 => Some(Self::Queen),
|
||||
13 => Some(Self::King),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
||||
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
||||
Self::new((self as u8).saturating_add(n))
|
||||
}
|
||||
|
||||
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
||||
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
||||
match (self as u8).checked_sub(n) {
|
||||
Some(v) => Self::new(v),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,16 +107,43 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rank_values_are_sequential() {
|
||||
let ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
for (i, r) in ranks.iter().enumerate() {
|
||||
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||
assert_eq!(r.value(), (i + 1) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_as_u8_matches_value() {
|
||||
for r in Rank::RANKS {
|
||||
assert_eq!(r as u8, r.value());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_checked_add_boundary() {
|
||||
assert_eq!(Rank::King.checked_add(1), None);
|
||||
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
||||
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
||||
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_checked_sub_boundary() {
|
||||
assert_eq!(Rank::Ace.checked_sub(1), None);
|
||||
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
||||
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
||||
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_suits_contains_all_four() {
|
||||
assert_eq!(Suit::SUITS.len(), 4);
|
||||
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
||||
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
||||
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
||||
assert!(Suit::SUITS.contains(&Suit::Spades));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
|
||||
@@ -440,6 +440,91 @@ impl GameState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
|
||||
///
|
||||
/// Does not include stock draws — callers check `piles[&PileType::Stock]` directly.
|
||||
/// Every returned triple is guaranteed to succeed when passed to `move_cards`.
|
||||
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
|
||||
if self.is_won {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut moves = Vec::new();
|
||||
|
||||
// Waste top card → foundation or tableau
|
||||
if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) {
|
||||
for slot in 0..4_u8 {
|
||||
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
|
||||
&& can_place_on_foundation(waste_top, f)
|
||||
{
|
||||
moves.push((PileType::Waste, PileType::Foundation(slot), 1));
|
||||
}
|
||||
}
|
||||
for dst in 0..7_usize {
|
||||
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||
&& can_place_on_tableau(waste_top, t)
|
||||
{
|
||||
moves.push((PileType::Waste, PileType::Tableau(dst), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tableau sources
|
||||
for src in 0..7_usize {
|
||||
let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue };
|
||||
if src_pile.cards.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count();
|
||||
if run_len == 0 {
|
||||
continue;
|
||||
}
|
||||
for count in 1..=run_len {
|
||||
let seq_start = src_pile.cards.len() - count;
|
||||
if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) {
|
||||
continue;
|
||||
}
|
||||
let bottom = &src_pile.cards[seq_start];
|
||||
if count == 1 {
|
||||
for slot in 0..4_u8 {
|
||||
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
|
||||
&& can_place_on_foundation(bottom, f)
|
||||
{
|
||||
moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for dst in 0..7_usize {
|
||||
if dst == src {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||
&& can_place_on_tableau(bottom, t)
|
||||
{
|
||||
moves.push((PileType::Tableau(src), PileType::Tableau(dst), count));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Foundation top → tableau (only when house rule is enabled)
|
||||
if self.take_from_foundation {
|
||||
for slot in 0..4_u8 {
|
||||
let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue };
|
||||
let Some(top) = f.cards.last() else { continue };
|
||||
for dst in 0..7_usize {
|
||||
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||
&& can_place_on_tableau(top, t)
|
||||
{
|
||||
moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moves
|
||||
}
|
||||
|
||||
/// Returns the next `(from, to)` move that advances auto-complete, or
|
||||
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
||||
///
|
||||
@@ -1366,4 +1451,78 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||
}
|
||||
|
||||
// --- possible_instructions ---
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_empty_when_won() {
|
||||
let mut g = new_game();
|
||||
g.is_won = true;
|
||||
assert!(g.possible_instructions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_includes_ace_to_foundation() {
|
||||
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::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
let moves = g.possible_instructions();
|
||||
assert!(
|
||||
moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)),
|
||||
"Ace must be moveable to empty foundation slot 0; got {moves:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_all_valid_on_fresh_game() {
|
||||
// Every triple returned must actually succeed when applied to a clone of the state.
|
||||
let g = new_game();
|
||||
for (from, to, count) in g.possible_instructions() {
|
||||
let mut clone = g.clone();
|
||||
assert!(
|
||||
clone.move_cards(from.clone(), to.clone(), count).is_ok(),
|
||||
"instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_no_face_down_sources() {
|
||||
let g = new_game();
|
||||
for (from, _, count) in g.possible_instructions() {
|
||||
if let PileType::Tableau(i) = from {
|
||||
let pile = &g.piles[&PileType::Tableau(i)];
|
||||
let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count();
|
||||
assert!(
|
||||
count <= run_len,
|
||||
"count {count} exceeds face-up run {run_len} for Tableau({i})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_waste_top_included() {
|
||||
let mut g = new_game();
|
||||
// Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear.
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
for i in 0..7 {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||
id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true,
|
||||
});
|
||||
let moves = g.possible_instructions();
|
||||
// King goes on any of the 7 empty tableau piles
|
||||
assert!(
|
||||
(0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))),
|
||||
"King on waste must be moveable to an empty tableau column"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::card::Card;
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
@@ -12,8 +12,8 @@ use crate::pile::Pile;
|
||||
#[must_use]
|
||||
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,
|
||||
None => card.rank == Rank::Ace,
|
||||
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
#[must_use]
|
||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 13,
|
||||
None => card.rank == Rank::King,
|
||||
Some(top) => {
|
||||
top.face_up
|
||||
&& card.rank.value() + 1 == top.rank.value()
|
||||
&& card.rank.checked_add(1) == Some(top.rank)
|
||||
&& card.suit.is_red() != top.suit.is_red()
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
#[must_use]
|
||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||
cards.windows(2).all(|w| {
|
||||
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
|
||||
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user