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
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:
Generated
+4
-4
@@ -1887,9 +1887,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "card_game"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
checksum = "38b68e4fb32f8a1f92edf8488c012f6d8af71491a2f9f8a855362d7eaf1a2d0c"
|
||||
checksum = "d206df6d87340019a0f5b621976cf98bc75c659a7f93ef348aaab2a9336098a9"
|
||||
dependencies = [
|
||||
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
||||
]
|
||||
@@ -4354,9 +4354,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "klondike"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
|
||||
checksum = "0bce541f9b14e9d9d8c9b17d5df40bd0a017709b61d9be8ad5bab7b19a1a0152"
|
||||
checksum = "347d55e6cf7c90b3d038262071eb2fdb0b75a713fe66c452a3400ff08fb716bc"
|
||||
dependencies = [
|
||||
"card_game",
|
||||
"rand 0.10.1",
|
||||
|
||||
+2
-2
@@ -37,8 +37,8 @@ solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
klondike = { version = "0.2.0", registry = "Quaternions" }
|
||||
card_game = { version = "0.3.0", registry = "Quaternions" }
|
||||
klondike = { version = "0.3.0", registry = "Quaternions" }
|
||||
card_game = { version = "0.4.0", registry = "Quaternions" }
|
||||
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
|
||||
@@ -11,10 +11,15 @@
|
||||
//!
|
||||
//! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, 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 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::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);
|
||||
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 0–6)")]
|
||||
Tableau(u8),
|
||||
#[error("invalid foundation index {0} (expected 0–3)")]
|
||||
Foundation(u8),
|
||||
#[error("invalid skip_cards value {0} (expected 0–12)")]
|
||||
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()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,12 @@
|
||||
use std::collections::HashSet;
|
||||
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::deck::{Deck, deal_klondike};
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
use crate::klondike_adapter::KlondikeAdapter;
|
||||
use crate::pile::{Pile, PileType};
|
||||
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
|
||||
/// heuristic to the provably-optimal first move; the hint system falls
|
||||
/// 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(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
config: &SolverConfig,
|
||||
) -> SolveOutcome {
|
||||
let state = SolverState::initial(seed, draw_mode);
|
||||
state.solve(config)
|
||||
let klondike = Klondike::with_seed(seed);
|
||||
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`].
|
||||
@@ -285,23 +352,6 @@ struct 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.
|
||||
fn is_won(&self) -> bool {
|
||||
self.foundation.iter().all(|pile| {
|
||||
@@ -1112,10 +1162,10 @@ mod tests {
|
||||
assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0));
|
||||
}
|
||||
|
||||
/// Scan a wide seed window to find one Winnable + one Unwinnable
|
||||
/// seed under tight budgets. Used during development to source the
|
||||
/// fixture seeds for the engine-level retry test.
|
||||
/// Run with:
|
||||
/// Scan a wide seed window to find Winnable + Unwinnable seeds under the
|
||||
/// upstream session solver. With `card_game v0.4.0` the session solver
|
||||
/// returns Winnable or Inconclusive for all seeds 0..500; no seed in that
|
||||
/// range is proven Unwinnable. Run for diagnostics with:
|
||||
/// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
@@ -1359,25 +1409,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_solve_with_first_move_seed_form_matches_state_form() {
|
||||
// For a fresh seed, the two public entry points must agree —
|
||||
// they share the same internal `solve()` implementation, but
|
||||
// route through different state constructors. This is the
|
||||
// smoke test that catches drift between them.
|
||||
fn try_solve_with_first_move_uses_session_solver() {
|
||||
// `try_solve_with_first_move` now delegates to `Session::solve()` from
|
||||
// the upstream `klondike` crate. `try_solve_from_state` still uses the
|
||||
// internal DFS (needed for mid-game positions until pile mapping lands).
|
||||
// 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 {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
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"
|
||||
);
|
||||
let outcome = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
|
||||
// Verdict must be one of the three valid variants — no panic allowed.
|
||||
match outcome.result {
|
||||
SolverResult::Winnable => {
|
||||
assert!(
|
||||
outcome.first_move.is_some(),
|
||||
"Winnable verdict must carry a first_move"
|
||||
);
|
||||
}
|
||||
SolverResult::Unwinnable | SolverResult::Inconclusive => {
|
||||
assert!(
|
||||
outcome.first_move.is_none(),
|
||||
"non-Winnable verdict must carry first_move == None"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2858,17 +2858,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_winnable_seed_skips_unwinnable_seed() {
|
||||
// Seed 394 was identified by the offline scan
|
||||
// (`solver::tests::find_unwinnable`) as the only Unwinnable
|
||||
// seed in 0..500 under the default solver budget. Seed 395
|
||||
// resolves as Inconclusive — the engine treats Inconclusive
|
||||
// as winnable (see `choose_winnable_seed` doc), so the
|
||||
// helper must return 395 when started at 394.
|
||||
fn choose_winnable_seed_accepts_inconclusive_seed() {
|
||||
// With the upstream session solver (card_game v0.4.0) no seeds in 0..500
|
||||
// are proven Unwinnable — they are either Winnable or Inconclusive.
|
||||
// `choose_winnable_seed` must accept Inconclusive as "probably winnable",
|
||||
// so calling it with any seed in this range must return quickly (at most
|
||||
// the retry cap) rather than looping forever.
|
||||
//
|
||||
// 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);
|
||||
assert_eq!(
|
||||
chosen, 395,
|
||||
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
||||
chosen, 394,
|
||||
"seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user