fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+89 -22
View File
@@ -355,7 +355,11 @@ mod tests {
ids.sort();
let len = ids.len();
ids.dedup();
assert_eq!(ids.len(), len, "duplicate achievement ID in ALL_ACHIEVEMENTS");
assert_eq!(
ids.len(),
len,
"duplicate achievement ID in ALL_ACHIEVEMENTS"
);
}
#[test]
@@ -422,13 +426,19 @@ mod tests {
for hour in [22u32, 23, 0, 1, 2] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"night_owl"), "expected night_owl at hour {hour}");
assert!(
ids.contains(&"night_owl"),
"expected night_owl at hour {hour}"
);
}
// Daytime hours must not trigger.
for hour in [3u32, 7, 12, 20, 21] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"night_owl"), "unexpected night_owl at hour {hour}");
assert!(
!ids.contains(&"night_owl"),
"unexpected night_owl at hour {hour}"
);
}
}
@@ -440,13 +450,19 @@ mod tests {
for hour in [5u32, 6] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"early_bird"), "expected early_bird at hour {hour}");
assert!(
ids.contains(&"early_bird"),
"expected early_bird at hour {hour}"
);
}
// Outside the window must not trigger.
for hour in [0u32, 3, 4, 7, 12, 23] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"early_bird"), "unexpected early_bird at hour {hour}");
assert!(
!ids.contains(&"early_bird"),
"unexpected early_bird at hour {hour}"
);
}
}
@@ -506,7 +522,10 @@ mod tests {
#[test]
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
assert_eq!(
achievement_by_id("first_win").map(|d| d.name),
Some("First Win")
);
assert!(achievement_by_id("nonexistent").is_none());
}
@@ -538,7 +557,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
assert!(
ids.contains(&"speed_demon"),
"speed_demon should unlock at 179s"
);
}
#[test]
@@ -546,7 +568,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
assert!(
!ids.contains(&"speed_demon"),
"speed_demon must not unlock at 181s"
);
}
#[test]
@@ -562,7 +587,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
assert!(
!ids.contains(&"lightning"),
"lightning must not unlock at exactly 90s"
);
}
#[test]
@@ -570,7 +598,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
assert!(
ids.contains(&"no_undo"),
"no_undo should unlock when undo was not used"
);
}
#[test]
@@ -578,7 +609,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
assert!(
!ids.contains(&"no_undo"),
"no_undo must not unlock when undo was used"
);
}
#[test]
@@ -586,7 +620,10 @@ mod tests {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
assert!(
ids.contains(&"high_scorer"),
"high_scorer should unlock at best_single_score=5000"
);
}
#[test]
@@ -594,7 +631,10 @@ mod tests {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
assert!(
!ids.contains(&"high_scorer"),
"high_scorer must not unlock at best_single_score=4999"
);
}
#[test]
@@ -602,7 +642,10 @@ mod tests {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
assert!(
ids.contains(&"on_a_roll"),
"on_a_roll should unlock at streak=3"
);
}
#[test]
@@ -610,7 +653,10 @@ mod tests {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
assert!(
ids.contains(&"comeback"),
"comeback should unlock at last_win_recycle_count=3"
);
}
#[test]
@@ -631,12 +677,18 @@ mod tests {
c.win_streak_current = 9;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"unstoppable"));
assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll");
assert!(
ids.contains(&"on_a_roll"),
"streak 9 must still satisfy on_a_roll"
);
c.win_streak_current = 10;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"unstoppable"));
assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll");
assert!(
ids.contains(&"on_a_roll"),
"streak 10 must also satisfy on_a_roll"
);
}
#[test]
@@ -657,12 +709,18 @@ mod tests {
c.games_played = 499;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"veteran"));
assert!(ids.contains(&"century"), "499 games must also satisfy century");
assert!(
ids.contains(&"century"),
"499 games must also satisfy century"
);
c.games_played = 500;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"veteran"));
assert!(ids.contains(&"century"), "500 games must also satisfy century");
assert!(
ids.contains(&"century"),
"500 games must also satisfy century"
);
}
#[test]
@@ -727,7 +785,10 @@ mod tests {
assert!(ids.contains(&"first_win"), "first_win should unlock");
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock");
assert!(ids.contains(&"no_undo"), "no_undo should unlock");
assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously");
assert!(
ids.len() >= 3,
"at least 3 achievements must fire simultaneously"
);
}
#[test]
@@ -742,7 +803,10 @@ mod tests {
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "perfectionist must unlock");
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
assert!(
ids.contains(&"no_undo"),
"no_undo must also unlock when perfectionist does"
);
}
#[test]
@@ -778,6 +842,9 @@ mod tests {
c.last_win_score = 50_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "score far above threshold must pass");
assert!(
ids.contains(&"perfectionist"),
"score far above threshold must pass"
);
}
}
+37 -23
View File
@@ -27,27 +27,37 @@ impl Suit {
/// Card rank, Ace through King.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Rank {
Ace = 1,
Two = 2,
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13,
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,
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.
@@ -57,20 +67,20 @@ impl Rank {
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),
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,
_ => None,
}
}
@@ -147,7 +157,11 @@ mod tests {
#[test]
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
assert_ne!(
suit.is_red(),
suit.is_black(),
"{suit:?} must be exactly one of red/black"
);
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
+47 -17
View File
@@ -1,13 +1,23 @@
use rand::{seq::SliceRandom, SeedableRng};
use rand::rngs::StdRng;
use crate::card::{Card, Rank, Suit};
use crate::pile::{Pile, PileType};
use rand::rngs::StdRng;
use rand::{SeedableRng, seq::SliceRandom};
const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const ALL_RANKS: [Rank; 13] = [
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,
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,
];
/// A standard 52-card deck.
@@ -23,7 +33,12 @@ impl Deck {
let mut id = 0u32;
for &suit in &ALL_SUITS {
for &rank in &ALL_RANKS {
cards.push(Card { id, suit, rank, face_up: false });
cards.push(Card {
id,
suit,
rank,
face_up: false,
});
id += 1;
}
}
@@ -50,7 +65,11 @@ impl Default for Deck {
/// Column `i` contains `i + 1` cards; only the top card is face-up.
/// Stock receives the remaining 24 cards, all face-down.
pub fn deal_klondike(deck: Deck) -> ([Pile; 7], Pile) {
debug_assert_eq!(deck.cards.len(), 52, "deal_klondike requires a full 52-card deck");
debug_assert_eq!(
deck.cards.len(),
52,
"deal_klondike requires a full 52-card deck"
);
let mut tableau: [Pile; 7] = core::array::from_fn(|i| Pile::new(PileType::Tableau(i)));
// Safety: the debug_assert above documents the 52-card contract; index arithmetic is bounded.
let mut idx = 0usize;
@@ -102,21 +121,26 @@ mod tests {
#[test]
fn same_seed_produces_same_order() {
let mut d1 = Deck::new(); d1.shuffle(42);
let mut d2 = Deck::new(); d2.shuffle(42);
let mut d1 = Deck::new();
d1.shuffle(42);
let mut d2 = Deck::new();
d2.shuffle(42);
assert_eq!(d1.cards, d2.cards);
}
#[test]
fn different_seeds_produce_different_orders() {
let mut d1 = Deck::new(); d1.shuffle(1);
let mut d2 = Deck::new(); d2.shuffle(2);
let mut d1 = Deck::new();
d1.shuffle(1);
let mut d2 = Deck::new();
d2.shuffle(2);
assert_ne!(d1.cards, d2.cards);
}
#[test]
fn deal_klondike_correct_tableau_sizes() {
let mut deck = Deck::new(); deck.shuffle(0);
let mut deck = Deck::new();
deck.shuffle(0);
let (tableau, stock) = deal_klondike(deck);
for (i, pile) in tableau.iter().enumerate() {
assert_eq!(pile.cards.len(), i + 1, "col {i} wrong size");
@@ -126,7 +150,8 @@ mod tests {
#[test]
fn deal_klondike_top_cards_are_face_up() {
let mut deck = Deck::new(); deck.shuffle(0);
let mut deck = Deck::new();
deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
assert!(pile.cards.last().unwrap().face_up);
@@ -135,7 +160,8 @@ mod tests {
#[test]
fn deal_klondike_non_top_cards_are_face_down() {
let mut deck = Deck::new(); deck.shuffle(0);
let mut deck = Deck::new();
deck.shuffle(0);
let (tableau, _) = deal_klondike(deck);
for pile in &tableau {
for card in &pile.cards[..pile.cards.len().saturating_sub(1)] {
@@ -146,17 +172,21 @@ mod tests {
#[test]
fn deal_klondike_stock_is_face_down() {
let mut deck = Deck::new(); deck.shuffle(0);
let mut deck = Deck::new();
deck.shuffle(0);
let (_, stock) = deal_klondike(deck);
assert!(stock.cards.iter().all(|c| !c.face_up));
}
#[test]
fn deal_klondike_all_52_cards_present() {
let mut deck = Deck::new(); deck.shuffle(99);
let mut deck = Deck::new();
deck.shuffle(99);
let (tableau, stock) = deal_klondike(deck);
let mut ids: Vec<u32> = stock.cards.iter().map(|c| c.id).collect();
for pile in &tableau { ids.extend(pile.cards.iter().map(|c| c.id)); }
for pile in &tableau {
ids.extend(pile.cards.iter().map(|c| c.id));
}
ids.sort_unstable();
assert_eq!(ids, (0u32..52).collect::<Vec<_>>());
}
File diff suppressed because it is too large Load Diff
+35 -7
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::card::{Card, Suit};
use serde::{Deserialize, Serialize};
/// Identifies which pile on the board a set of cards belongs to.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -28,7 +28,10 @@ pub struct Pile {
impl Pile {
/// Creates a new empty pile of the given type.
pub fn new(pile_type: PileType) -> Self {
Self { pile_type, cards: Vec::new() }
Self {
pile_type,
cards: Vec::new(),
}
}
/// Returns a reference to the top (last) card, or `None` if empty.
@@ -61,8 +64,18 @@ mod tests {
#[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 });
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);
}
@@ -91,15 +104,30 @@ mod tests {
#[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 });
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 });
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));
}
}
+18 -4
View File
@@ -52,7 +52,12 @@ mod tests {
use crate::pile::{Pile, PileType};
fn card(suit: Suit, rank: Rank) -> Card {
Card { id: 0, suit, rank, face_up: true }
Card {
id: 0,
suit,
rank,
face_up: true,
}
}
fn pile_with(pile_type: PileType, cards: Vec<Card>) -> Pile {
@@ -100,7 +105,10 @@ mod tests {
#[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)]);
let p = pile_with(
PileType::Foundation(0),
vec![card(Suit::Diamonds, Rank::Ace)],
);
assert!(!can_place_on_foundation(&c, &p));
}
@@ -151,7 +159,10 @@ 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(0), vec![card(Suit::Spades, Rank::Queen)]);
let p = pile_with(
PileType::Foundation(0),
vec![card(Suit::Spades, Rank::Queen)],
);
assert!(can_place_on_foundation(&c, &p));
}
@@ -159,7 +170,10 @@ mod tests {
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)]);
let p = pile_with(
PileType::Foundation(0),
vec![card(Suit::Spades, Rank::Queen)],
);
assert!(!can_place_on_foundation(&c, &p));
}
+17 -5
View File
@@ -38,7 +38,11 @@ pub fn score_flip() -> i32 {
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
/// `recycle_count` is the new total count **after** this recycle.
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
let (free, penalty) = if is_draw_three { (3_u32, -20_i32) } else { (1_u32, -100_i32) };
let (free, penalty) = if is_draw_three {
(3_u32, -20_i32)
} else {
(1_u32, -100_i32)
};
if recycle_count > free { penalty } else { 0 }
}
@@ -58,7 +62,10 @@ mod tests {
#[test]
fn move_to_foundation_scores_ten() {
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
assert_eq!(
score_move(&PileType::Tableau(0), &PileType::Foundation(0)),
10
);
}
#[test]
@@ -94,10 +101,12 @@ mod tests {
#[test]
fn foundation_to_tableau_penalises_fifteen() {
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
assert_eq!(
score_move(&PileType::Foundation(0), &PileType::Tableau(0)),
-15
);
}
#[test]
fn move_to_stock_or_waste_scores_zero() {
// These destinations are illegal moves in practice, but the function
@@ -110,7 +119,10 @@ mod tests {
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
// Very short elapsed time would overflow without the .min() guard.
let bonus = compute_time_bonus(1);
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
assert!(
bonus >= 0,
"time bonus must be non-negative after u64→i32 cast"
);
}
#[test]
+187 -58
View File
@@ -64,7 +64,7 @@ use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck};
use crate::deck::{Deck, deal_klondike};
use crate::game_state::{DrawMode, GameState};
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
@@ -212,7 +212,11 @@ pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOu
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InternalMove {
/// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize },
TableauToTableau {
from: usize,
to: usize,
count: usize,
},
/// Move the top of a tableau column to a foundation slot.
TableauToFoundation { from: usize, slot: u8 },
/// Move the top of the waste pile to a tableau column.
@@ -303,10 +307,9 @@ impl SolverState {
self.foundation.iter().all(|pile| {
pile.len() == 13
&& pile[0].rank == crate::card::Rank::Ace
&& pile.windows(2).all(|w| {
w[0].suit == w[1].suit
&& w[1].rank.value() == w[0].rank.value() + 1
})
&& pile
.windows(2)
.all(|w| w[0].suit == w[1].suit && w[1].rank.value() == w[0].rank.value() + 1)
})
}
@@ -350,10 +353,8 @@ impl SolverState {
&& top.face_up
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
let foundation_pile =
Self::pile_view(PileType::Foundation(slot), &self.foundation[slot as usize]);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(InternalMove::TableauToFoundation { from: i, slot });
}
@@ -364,10 +365,8 @@ impl SolverState {
if let Some(top) = self.waste.last()
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
let foundation_pile =
Self::pile_view(PileType::Foundation(slot), &self.foundation[slot as usize]);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(InternalMove::WasteToFoundation { slot });
}
@@ -401,13 +400,14 @@ impl SolverState {
// column onto another empty column".
let leaves_source_empty = start == 0;
let dest_empty = self.tableau[dst].is_empty();
if leaves_source_empty
&& dest_empty
&& bottom.rank == crate::card::Rank::King
{
if leaves_source_empty && dest_empty && bottom.rank == crate::card::Rank::King {
continue;
}
moves.push(InternalMove::TableauToTableau { from: src, to: dst, count });
moves.push(InternalMove::TableauToTableau {
from: src,
to: dst,
count,
});
}
}
}
@@ -432,8 +432,7 @@ impl SolverState {
// a single full cycle requires at most `ceil(stock_cycle_len / draw_count)`
// draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so
// anything past that without intervening progress is wasteful.
let cycled_without_progress =
self.consecutive_draws > stock_cycle_len.saturating_add(1);
let cycled_without_progress = self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress {
moves.push(InternalMove::Draw);
}
@@ -578,9 +577,7 @@ impl SolverState {
while let Some(frame) = stack.last_mut() {
// Budget gates — checked before consuming the next move so
// the budget exhaustion is reflected in the verdict.
if *moves_consumed >= config.move_budget
|| visited.len() >= config.state_budget
{
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true;
return None;
}
@@ -622,7 +619,12 @@ impl SolverState {
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let already_won = self.is_won();
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let first_move = self.search(
config,
&mut visited,
&mut moves_consumed,
&mut budget_exceeded,
);
let result = if already_won || first_move.is_some() {
SolverResult::Winnable
} else if budget_exceeded {
@@ -800,18 +802,38 @@ mod tests {
}
fn ace(suit: Suit, id: u32) -> Card {
Card { id, suit, rank: Rank::Ace, face_up: true }
Card {
id,
suit,
rank: Rank::Ace,
face_up: true,
}
}
fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card {
Card { id, suit, rank, face_up: true }
Card {
id,
suit,
rank,
face_up: true,
}
}
fn full_run(suit: Suit, base_id: u32) -> Vec<Card> {
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,
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,
];
ranks
.iter()
@@ -846,14 +868,28 @@ mod tests {
tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)];
tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)];
let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne);
let state = synthetic(
tableau,
foundations,
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let cfg = SolverConfig::default();
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let first_move = state.search(
&cfg,
&mut visited,
&mut moves_consumed,
&mut budget_exceeded,
);
assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable");
assert!(
first_move.is_some(),
"obviously-winnable position must be recognised as Winnable"
);
assert!(!budget_exceeded);
assert!(
moves_consumed < 1000,
@@ -872,8 +908,18 @@ mod tests {
// Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom
// card; the Two on top of it has no valid destination.
tableau[0] = vec![
Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true },
Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true },
Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: true,
},
Card {
id: 1,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
},
];
// Other six columns isolated. Put a face-up King with no
// matching Queen anywhere — it cannot move because every
@@ -894,9 +940,20 @@ mod tests {
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve");
assert!(!budget_exceeded, "small synthetic state must complete within budget");
let first_move = state.search(
&cfg,
&mut visited,
&mut moves_consumed,
&mut budget_exceeded,
);
assert!(
first_move.is_none(),
"buried Ace under same-suit Two with no recovery must not solve"
);
assert!(
!budget_exceeded,
"small synthetic state must complete within budget"
);
}
#[test]
@@ -960,9 +1017,12 @@ mod tests {
#[test]
fn longest_face_up_run_handles_face_down_at_top() {
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false },
];
let cards = vec![Card {
id: 1,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
}];
assert_eq!(longest_face_up_run(&cards), 0);
}
@@ -970,10 +1030,30 @@ mod tests {
fn longest_face_up_run_extends_through_valid_run() {
let cards = vec![
// bottom: face-down filler
Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false },
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true },
Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Two,
face_up: false,
},
Card {
id: 1,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
},
Card {
id: 2,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 3,
suit: Suit::Clubs,
rank: Rank::Jack,
face_up: true,
},
];
assert_eq!(longest_face_up_run(&cards), 3);
}
@@ -983,9 +1063,24 @@ mod tests {
// K♠ Q♥ Q♣ — second pair fails the descending check, so the
// run is just the top single card (Q♣).
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true },
Card {
id: 1,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
},
Card {
id: 2,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 3,
suit: Suit::Clubs,
rank: Rank::Queen,
face_up: true,
},
];
assert_eq!(longest_face_up_run(&cards), 1);
}
@@ -1082,7 +1177,9 @@ mod tests {
println!(
"\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}",
total / samples_ms.len() as u128,
counts[0], counts[1], counts[2],
counts[0],
counts[1],
counts[2],
);
}
@@ -1122,9 +1219,18 @@ mod tests {
// `target_foundation_slot` ordering.
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
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::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
];
for (slot, suit) in suit_for_slot.iter().enumerate() {
let pile = game
@@ -1166,7 +1272,9 @@ mod tests {
SolverResult::Winnable,
"near-finished state must solve as Winnable"
);
let mv = outcome.first_move.expect("Winnable must include a first_move");
let mv = outcome
.first_move
.expect("Winnable must include a first_move");
// The first move must be a King going from a tableau column to
// its matching foundation slot. Single-card move.
assert_eq!(mv.count, 1);
@@ -1200,15 +1308,30 @@ mod tests {
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
// destination, so the Ace is buried forever.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
t0.cards.push(Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: true,
});
t0.cards.push(Card {
id: 1,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
});
// Tableau 1: a face-up King with nothing else — irrelevant; the
// pruning check elides "King → empty" no-ops.
game.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
.push(Card {
id: 2,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
});
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
@@ -1248,7 +1371,13 @@ mod tests {
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
let game = GameState::new(7, DrawMode::DrawOne);
let b = try_solve_from_state(&game, &cfg);
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
assert_eq!(
a.result, b.result,
"verdicts must match across the two entry points"
);
assert_eq!(
a.first_move, b.first_move,
"first_move must match across the two entry points"
);
}
}