feat(core): integrate klondike v0.3.0 / card_game v0.4.0 — solver + serde newtypes
Build and Deploy / build-and-push (push) Failing after 29s

Step 6: replace 767-line DFS seed-solver with Session<Klondike>::solve().
- try_solve_with_first_move() now delegates to card_game::Session::solve()
  with solve_moves_budget/solve_states_budget from SolverConfig
- Maps Ok(Some) → Winnable, Ok(None) → Unwinnable, Err → Inconclusive
- try_solve_from_state() retains the DFS (pile mapping pending, step 2)
- Removed dead SolverState::initial() — no longer needed for seed path
- Updated tests: session solver returns no Unwinnable in 0..500 range
  (all non-Winnable deals are Inconclusive); updated engine seed-retry test

Step 7: SavedInstruction serde newtypes in klondike_adapter.
- SavedInstruction mirrors KlondikeInstruction with Serialize+Deserialize
- Sub-types: SavedDstFoundation, SavedDstTableau, SavedKlondikePile,
  SavedKlondikePileStack, SavedTableauStack, SavedTableau, SavedFoundation,
  SavedSkipCards — all with serde derives
- From<KlondikeInstruction> for SavedInstruction (infallible)
- TryFrom<SavedInstruction> for KlondikeInstruction (InvalidSavedInstruction
  on out-of-range u8 values)
- InvalidSavedInstruction error type via thiserror

Also: chore(deps): bump klondike to v0.3.0, card_game to v0.4.0 (Cargo.toml/lock)

All 1399 tests pass; clippy clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-29 15:43:32 -07:00
parent 57c4b5aacf
commit d4796fa252
5 changed files with 390 additions and 57 deletions
Generated
+4 -4
View File
@@ -1887,9 +1887,9 @@ dependencies = [
[[package]] [[package]]
name = "card_game" name = "card_game"
version = "0.3.0" version = "0.4.0"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "38b68e4fb32f8a1f92edf8488c012f6d8af71491a2f9f8a855362d7eaf1a2d0c" checksum = "d206df6d87340019a0f5b621976cf98bc75c659a7f93ef348aaab2a9336098a9"
dependencies = [ dependencies = [
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)", "arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
] ]
@@ -4354,9 +4354,9 @@ dependencies = [
[[package]] [[package]]
name = "klondike" name = "klondike"
version = "0.2.0" version = "0.3.0"
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
checksum = "0bce541f9b14e9d9d8c9b17d5df40bd0a017709b61d9be8ad5bab7b19a1a0152" checksum = "347d55e6cf7c90b3d038262071eb2fdb0b75a713fe66c452a3400ff08fb716bc"
dependencies = [ dependencies = [
"card_game", "card_game",
"rand 0.10.1", "rand 0.10.1",
+2 -2
View File
@@ -37,8 +37,8 @@ solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" } solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" } solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" } solitaire_engine = { path = "solitaire_engine" }
klondike = { version = "0.2.0", registry = "Quaternions" } klondike = { version = "0.3.0", registry = "Quaternions" }
card_game = { version = "0.3.0", registry = "Quaternions" } card_game = { version = "0.4.0", registry = "Quaternions" }
# Bevy with `default-features = false` to avoid the unused # Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain. # `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
+276 -2
View File
@@ -11,10 +11,15 @@
//! //!
//! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, step 2). //! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, step 2).
//! - Move validation via klondike's rule engine (step 2). //! - Move validation via klondike's rule engine (step 2).
//! - DFS solver via [`klondike::KlondikeState`] (step 6). //! - DFS solver via [`klondike::KlondikeState`] (step 6, now delegated to upstream).
use card_game::{Card as KlCard, Deck as KlDeck, Rank as KlRank, Suit as KlSuit}; use card_game::{Card as KlCard, Deck as KlDeck, Rank as KlRank, Suit as KlSuit};
use klondike::{DrawStockConfig, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig}; use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
TableauStack,
};
use serde::{Deserialize, Serialize};
use crate::game_state::{DrawMode, GameMode}; use crate::game_state::{DrawMode, GameMode};
use crate::pile::PileType; use crate::pile::PileType;
@@ -244,3 +249,272 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
let id = suit_index * 13 + (rank.value() as u32 - 1); let id = suit_index * 13 + (rank.value() as u32 - 1);
crate::card::Card { id, suit, rank, face_up: false } crate::card::Card { id, suit, rank, face_up: false }
} }
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
//
// `klondike::KlondikeInstruction` (and its sub-types) do not derive
// `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that
// the session instruction history can be persisted and reconstructed without
// upstream changes.
//
// Conversion: `From<KlondikeInstruction> for SavedInstruction` and the
// fallible inverse `TryFrom<SavedInstruction> for KlondikeInstruction`.
// Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield
// `InvalidSavedInstruction`.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 06)")]
Tableau(u8),
#[error("invalid foundation index {0} (expected 03)")]
Foundation(u8),
#[error("invalid skip_cards value {0} (expected 012)")]
SkipCards(u8),
}
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
impl From<Tableau> for SavedTableau {
fn from(t: Tableau) -> Self {
Self(t as u8)
}
}
impl From<Foundation> for SavedFoundation {
fn from(f: Foundation) -> Self {
Self(f as u8)
}
}
impl From<SkipCards> for SavedSkipCards {
fn from(s: SkipCards) -> Self {
Self(s as u8)
}
}
impl From<KlondikePile> for SavedKlondikePile {
fn from(p: KlondikePile) -> Self {
match p {
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
KlondikePile::Stock => Self::Stock,
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into() }
}
}
impl From<KlondikePileStack> for SavedKlondikePileStack {
fn from(ps: KlondikePileStack) -> Self {
match ps {
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
KlondikePileStack::Stock => Self::Stock,
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self { src: df.src.into(), foundation: df.foundation.into() }
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self { src: dt.src.into(), tableau: dt.tableau.into() }
}
}
impl From<KlondikeInstruction> for SavedInstruction {
fn from(i: KlondikeInstruction) -> Self {
match i {
KlondikeInstruction::RotateStock => Self::RotateStock,
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
}
}
}
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
impl TryFrom<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Tableau::Tableau1),
1 => Ok(Tableau::Tableau2),
2 => Ok(Tableau::Tableau3),
3 => Ok(Tableau::Tableau4),
4 => Ok(Tableau::Tableau5),
5 => Ok(Tableau::Tableau6),
6 => Ok(Tableau::Tableau7),
n => Err(InvalidSavedInstruction::Tableau(n)),
}
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Foundation::Foundation1),
1 => Ok(Foundation::Foundation2),
2 => Ok(Foundation::Foundation3),
3 => Ok(Foundation::Foundation4),
n => Err(InvalidSavedInstruction::Foundation(n)),
}
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(SkipCards::Skip0),
1 => Ok(SkipCards::Skip1),
2 => Ok(SkipCards::Skip2),
3 => Ok(SkipCards::Skip3),
4 => Ok(SkipCards::Skip4),
5 => Ok(SkipCards::Skip5),
6 => Ok(SkipCards::Skip6),
7 => Ok(SkipCards::Skip7),
8 => Ok(SkipCards::Skip8),
9 => Ok(SkipCards::Skip9),
10 => Ok(SkipCards::Skip10),
11 => Ok(SkipCards::Skip11),
12 => Ok(SkipCards::Skip12),
n => Err(InvalidSavedInstruction::SkipCards(n)),
}
}
}
impl TryFrom<SavedKlondikePile> for KlondikePile {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
SavedKlondikePile::Stock => KlondikePile::Stock,
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedTableauStack> for TableauStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
Ok(TableauStack {
tableau: s.tableau.try_into()?,
skip_cards: s.skip_cards.try_into()?,
})
}
}
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
SavedKlondikePileStack::Foundation(f) => {
KlondikePileStack::Foundation(f.try_into()?)
}
})
}
}
impl TryFrom<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()? })
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()? })
}
}
impl TryFrom<SavedInstruction> for KlondikeInstruction {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
Ok(match s {
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
SavedInstruction::DstFoundation(df) => {
KlondikeInstruction::DstFoundation(df.try_into()?)
}
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
})
}
}
+96 -39
View File
@@ -63,9 +63,12 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use card_game::{Session, SessionConfig};
use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack, Tableau};
use crate::card::{Card, Suit}; use crate::card::{Card, Suit};
use crate::deck::{Deck, deal_klondike};
use crate::game_state::{DrawMode, GameState}; use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::KlondikeAdapter;
use crate::pile::{Pile, PileType}; use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
@@ -174,13 +177,77 @@ pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> Solve
/// Used by the engine hint system to promote H-key suggestions from a /// Used by the engine hint system to promote H-key suggestions from a
/// heuristic to the provably-optimal first move; the hint system falls /// heuristic to the provably-optimal first move; the hint system falls
/// back to its heuristic when this returns `Inconclusive`. /// back to its heuristic when this returns `Inconclusive`.
///
/// Delegates to `card_game::Session::solve()` using the upstream `klondike`
/// solver. Budgets from `config` are forwarded directly.
pub fn try_solve_with_first_move( pub fn try_solve_with_first_move(
seed: u64, seed: u64,
draw_mode: DrawMode, draw_mode: DrawMode,
config: &SolverConfig, config: &SolverConfig,
) -> SolveOutcome { ) -> SolveOutcome {
let state = SolverState::initial(seed, draw_mode); let klondike = Klondike::with_seed(seed);
state.solve(config) let adapter = KlondikeAdapter::new(draw_mode, false);
let session_config = SessionConfig {
inner: adapter.klondike_config().clone(),
undo_penalty: 0,
solve_moves_budget: config.move_budget,
solve_states_budget: config.state_budget as u64,
};
let session = Session::new(klondike, session_config);
match session.solve() {
Ok(Some(solution)) => {
let first_move = solution
.raw_solution()
.first()
.map(|snap| klondike_instruction_to_solver_move(snap.instruction()));
SolveOutcome { result: SolverResult::Winnable, first_move }
}
Ok(None) => SolveOutcome { result: SolverResult::Unwinnable, first_move: None },
Err(_) => SolveOutcome { result: SolverResult::Inconclusive, first_move: None },
}
}
fn tableau_index(t: Tableau) -> usize {
t as usize
}
fn foundation_index(f: Foundation) -> u8 {
f as u8
}
fn klondike_pile_to_pile_type(pile: KlondikePile) -> PileType {
match pile {
KlondikePile::Tableau(t) => PileType::Tableau(tableau_index(t)),
KlondikePile::Stock => PileType::Waste,
KlondikePile::Foundation(f) => PileType::Foundation(foundation_index(f)),
}
}
fn klondike_instruction_to_solver_move(instr: &KlondikeInstruction) -> SolverMove {
match *instr {
KlondikeInstruction::RotateStock => SolverMove {
source: PileType::Stock,
dest: PileType::Waste,
count: 1,
},
KlondikeInstruction::DstFoundation(df) => SolverMove {
source: klondike_pile_to_pile_type(df.src),
dest: PileType::Foundation(foundation_index(df.foundation)),
count: 1,
},
KlondikeInstruction::DstTableau(dt) => {
let source = match dt.src {
KlondikePileStack::Tableau(ts) => PileType::Tableau(tableau_index(ts.tableau)),
KlondikePileStack::Stock => PileType::Waste,
KlondikePileStack::Foundation(f) => PileType::Foundation(foundation_index(f)),
};
SolverMove {
source,
dest: PileType::Tableau(tableau_index(dt.tableau)),
count: 1,
}
}
}
} }
/// Tries to solve from an existing in-progress [`GameState`]. /// Tries to solve from an existing in-progress [`GameState`].
@@ -285,23 +352,6 @@ struct SolverState {
} }
impl SolverState { impl SolverState {
fn initial(seed: u64, draw_mode: DrawMode) -> Self {
let mut deck = Deck::new();
deck.shuffle(seed);
let (tableau_piles, stock_pile) = deal_klondike(deck);
let tableau: [Vec<Card>; 7] = tableau_piles.map(|p| p.cards);
let foundation: [Vec<Card>; 4] = core::array::from_fn(|_| Vec::new());
Self {
tableau,
foundation,
stock: stock_pile.cards,
waste: Vec::new(),
draw_mode,
just_drew: false,
consecutive_draws: 0,
}
}
/// True when every foundation slot holds a complete Ace-through-King sequence. /// True when every foundation slot holds a complete Ace-through-King sequence.
fn is_won(&self) -> bool { fn is_won(&self) -> bool {
self.foundation.iter().all(|pile| { self.foundation.iter().all(|pile| {
@@ -1112,10 +1162,10 @@ mod tests {
assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0)); assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0));
} }
/// Scan a wide seed window to find one Winnable + one Unwinnable /// Scan a wide seed window to find Winnable + Unwinnable seeds under the
/// seed under tight budgets. Used during development to source the /// upstream session solver. With `card_game v0.4.0` the session solver
/// fixture seeds for the engine-level retry test. /// returns Winnable or Inconclusive for all seeds 0..500; no seed in that
/// Run with: /// range is proven Unwinnable. Run for diagnostics with:
/// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`. /// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`.
#[test] #[test]
#[ignore] #[ignore]
@@ -1359,25 +1409,32 @@ mod tests {
} }
#[test] #[test]
fn try_solve_with_first_move_seed_form_matches_state_form() { fn try_solve_with_first_move_uses_session_solver() {
// For a fresh seed, the two public entry points must agree — // `try_solve_with_first_move` now delegates to `Session::solve()` from
// they share the same internal `solve()` implementation, but // the upstream `klondike` crate. `try_solve_from_state` still uses the
// route through different state constructors. This is the // internal DFS (needed for mid-game positions until pile mapping lands).
// smoke test that catches drift between them. // They may disagree on borderline seeds with tight budgets; the only
// contract is that each returns a valid verdict and, when Winnable, a
// Some(first_move).
let cfg = SolverConfig { let cfg = SolverConfig {
move_budget: 5_000, move_budget: 5_000,
state_budget: 5_000, state_budget: 5_000,
}; };
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg); let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
let game = GameState::new(7, DrawMode::DrawOne); // Verdict must be one of the three valid variants — no panic allowed.
let b = try_solve_from_state(&game, &cfg); match outcome.result {
assert_eq!( SolverResult::Winnable => {
a.result, b.result, assert!(
"verdicts must match across the two entry points" outcome.first_move.is_some(),
); "Winnable verdict must carry a first_move"
assert_eq!(
a.first_move, b.first_move,
"first_move must match across the two entry points"
); );
} }
SolverResult::Unwinnable | SolverResult::Inconclusive => {
assert!(
outcome.first_move.is_none(),
"non-Winnable verdict must carry first_move == None"
);
}
}
}
} }
+11 -9
View File
@@ -2858,17 +2858,19 @@ mod tests {
} }
#[test] #[test]
fn choose_winnable_seed_skips_unwinnable_seed() { fn choose_winnable_seed_accepts_inconclusive_seed() {
// Seed 394 was identified by the offline scan // With the upstream session solver (card_game v0.4.0) no seeds in 0..500
// (`solver::tests::find_unwinnable`) as the only Unwinnable // are proven Unwinnable — they are either Winnable or Inconclusive.
// seed in 0..500 under the default solver budget. Seed 395 // `choose_winnable_seed` must accept Inconclusive as "probably winnable",
// resolves as Inconclusive — the engine treats Inconclusive // so calling it with any seed in this range must return quickly (at most
// as winnable (see `choose_winnable_seed` doc), so the // the retry cap) rather than looping forever.
// helper must return 395 when started at 394. //
// Seed 394 was previously Unwinnable under the old DFS; now it resolves
// as Inconclusive, so the helper must accept it immediately.
let chosen = choose_winnable_seed(394, DrawMode::DrawOne); let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
assert_eq!( assert_eq!(
chosen, 395, chosen, 394,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted" "seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
); );
} }