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
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:
@@ -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
@@ -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
@@ -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<_>>());
|
||||
}
|
||||
|
||||
+609
-180
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user