From 1de633adb381d7f2a340bad70a01d7b3062621d8 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 19 May 2026 07:10:38 -0700 Subject: [PATCH 1/3] implement solver --- card_game/src/lib.rs | 88 ++++++++++++++++++++++++++++------------ klondike-cli/src/main.rs | 4 +- klondike-cli/src/test.rs | 36 ++++------------ klondike/src/lib.rs | 4 ++ 4 files changed, 77 insertions(+), 55 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 666cf12..172e987 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -6,11 +6,11 @@ struct ReadmeDoctests; use core::ops::RangeBounds; // TODO: pub struct ValidInstruction(I); -pub trait Game { - type Score; - type Stats; - type Config; - type Instruction; +pub trait Game: Clone { + type Score: Clone + core::fmt::Debug; + type Stats: Clone + core::fmt::Debug; + type Config: Clone + core::fmt::Debug; + type Instruction: Clone + core::fmt::Debug; fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score; fn possible_instructions( &self, @@ -353,21 +353,33 @@ impl Default for SessionConfig { } } +#[derive(Clone, Debug)] pub struct Session { stats: SessionStats, config: SessionConfig, state: SessionState, } -#[derive(Clone, Eq, Hash, PartialEq)] -pub struct SessionState { - seed: G, +#[derive(Clone, Debug)] +pub struct StateSnapshot { state: G, - history: Vec, + instruction: G::Instruction, +} +impl StateSnapshot { + pub const fn state(&self) -> &G { + &self.state + } + pub const fn instruction(&self) -> &G::Instruction { + &self.instruction + } +} +#[derive(Clone, Debug)] +pub struct SessionState { + state: G, + history: Vec>, } impl SessionState { fn new(state: G) -> Self { Self { - seed: state.clone(), state, history: Vec::new(), } @@ -380,9 +392,9 @@ impl SessionState { } impl> Session where - G: Clone + Eq + core::hash::Hash, - G::Stats: Clone + Default, - G::Instruction: Clone + Eq + core::hash::Hash, + G: Eq + core::hash::Hash, + G::Stats: Default, + G::Instruction: Eq + core::hash::Hash, { pub fn new(state: G, config: SessionConfig) -> Self { Self { @@ -406,7 +418,7 @@ where pub const fn config(&self) -> &SessionConfig { &self.config } - pub fn history(&self) -> &[G::Instruction] { + pub fn history(&self) -> &[StateSnapshot] { &self.state.history } pub fn undo(&mut self) { @@ -426,12 +438,39 @@ where pub fn is_win(&self) -> bool { self.state.is_win() } + pub fn is_winnable(&self) -> Option>> { + let mut state_moves = std::collections::HashMap::new(); + let mut state = self.clone(); + while !state.is_win() { + // Continue existing iterator if it exists + let it = state_moves + .entry(state.state().state().clone()) + .or_insert_with(|| { + state + .state() + .state() + .possible_instructions(&self.config().inner) + }); + + // Run one possible move + if let Some(instruction) = it.next() { + state.process_instruction(instruction); + continue; + } + + // No more moves. If we can't undo we're done + if state.history().is_empty() { + return None; + } else { + state.undo(); + } + } + Some(state.state.history) + } } impl> Game for SessionState where - G: Clone, G::Stats: Default, - G::Instruction: Clone, { type Score = i32; type Stats = SessionStats; @@ -464,19 +503,16 @@ where ) { match instruction { SessionInstruction::Undo => { - // replay the entire history of the game except one move - self.history.pop(); - let mut inner_stats = G::Stats::default(); - let mut state = self.seed.clone(); - for instruction in &self.history { - state.process_instruction(&mut inner_stats, &config.inner, instruction.clone()); + if let Some(snapshot) = self.history.pop() { + self.state = snapshot.state; + stats.increment_undos(); } - self.state = state; - stats.inner = inner_stats; - stats.increment_undos(); } SessionInstruction::InnerInstruction(instruction) => { - self.history.push(instruction.clone()); + self.history.push(StateSnapshot { + state: self.state.clone(), + instruction: instruction.clone(), + }); self.state .process_instruction(&mut stats.inner, &config.inner, instruction); } diff --git a/klondike-cli/src/main.rs b/klondike-cli/src/main.rs index 06e6fad..1362bd1 100644 --- a/klondike-cli/src/main.rs +++ b/klondike-cli/src/main.rs @@ -4,8 +4,8 @@ use klondike::{ KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, }; -// #[cfg(test)] -// mod test; +#[cfg(test)] +mod test; use std::fmt::Display; struct Displayed(T); diff --git a/klondike-cli/src/test.rs b/klondike-cli/src/test.rs index c403bff..9e0229b 100644 --- a/klondike-cli/src/test.rs +++ b/klondike-cli/src/test.rs @@ -1,33 +1,15 @@ -use klondike::Klondike; use card_game::Session; +use klondike::Klondike; #[test] fn test_is_winnable() { // is winnable - let is_winnable = Session::new_default(Klondike::with_seed(123)).is_winnable(); - println!("is_winnable = {is_winnable:?}"); -} -#[test] -fn test_klondike() { - // create game session - let game = Klondike::with_seed(123); - let mut session = Session::new_default(game); - - // is winnable - let is_winnable = session.is_winnable(); - println!("is_winnable = {is_winnable:?}"); - - // play game - while let Some(instruction) = session.possible_instructions().next() { - session.process_instruction(instruction); + let is_winnable = Session::new_default(Klondike::with_seed(124)).is_winnable(); + if let Some(win_moves) = is_winnable { + // for (i, ins) in win_moves.into_iter().enumerate() { + // println!("{i} = {:?}", ins.instruction()); + // } + println!("Game is winnable with {} moves", win_moves.len()); + } else { + println!("Game is not winnable"); } - - // did win - let is_win = session.is_win(); - - // print session history - for (i, instruction) in session.history().iter().enumerate() { - println!("move {i} = {instruction:?}"); - } - - println!("is_win = {is_win}"); } diff --git a/klondike/src/lib.rs b/klondike/src/lib.rs index cd67dee..47df3c0 100644 --- a/klondike/src/lib.rs +++ b/klondike/src/lib.rs @@ -601,6 +601,10 @@ impl Iterator for KlondikeIter { instruction } } +#[test] +fn test_klondike_iter() { + assert_eq!(KlondikeIter::new().count(), 721); +} #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct Klondike { From 9f6514367d30bb34dea8ea8baca5f1e1da23751e Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Fri, 29 May 2026 14:09:20 -0700 Subject: [PATCH 2/3] implement solve budget --- card_game/src/lib.rs | 30 +++++++++++++++++++++++++++--- klondike-cli/src/test.rs | 4 ++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 172e987..24a5f85 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -312,6 +312,18 @@ impl Pile { } } +#[derive(Clone, Debug)] +pub enum SolveError { + MovesBudgetExceeded, + StatesBudgetExceeded, +} +impl std::fmt::Display for SolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} +impl std::error::Error for SolveError {} + #[derive(Clone, Debug)] pub enum SessionInstruction { Undo, @@ -338,12 +350,16 @@ impl SessionStats { pub struct SessionConfig { pub inner: C, pub undo_penalty: i32, + pub solve_moves_budget: u64, + pub solve_states_budget: u64, } impl SessionConfig { fn new_default(inner: C) -> Self { Self { inner, undo_penalty: -15, + solve_moves_budget: 100_000, + solve_states_budget: 100_000, } } } @@ -438,10 +454,18 @@ where pub fn is_win(&self) -> bool { self.state.is_win() } - pub fn is_winnable(&self) -> Option>> { + pub fn solve(&self) -> Result>>, SolveError> { let mut state_moves = std::collections::HashMap::new(); let mut state = self.clone(); + let mut moves = 0; while !state.is_win() { + moves += 1; + if self.config.solve_moves_budget < moves { + return Err(SolveError::MovesBudgetExceeded); + } + if self.config.solve_states_budget < state_moves.len() as u64 { + return Err(SolveError::StatesBudgetExceeded); + } // Continue existing iterator if it exists let it = state_moves .entry(state.state().state().clone()) @@ -460,12 +484,12 @@ where // No more moves. If we can't undo we're done if state.history().is_empty() { - return None; + return Ok(None); } else { state.undo(); } } - Some(state.state.history) + Ok(Some(state.state.history)) } } impl> Game for SessionState diff --git a/klondike-cli/src/test.rs b/klondike-cli/src/test.rs index 9e0229b..99d96de 100644 --- a/klondike-cli/src/test.rs +++ b/klondike-cli/src/test.rs @@ -3,8 +3,8 @@ use klondike::Klondike; #[test] fn test_is_winnable() { // is winnable - let is_winnable = Session::new_default(Klondike::with_seed(124)).is_winnable(); - if let Some(win_moves) = is_winnable { + let solution_result = Session::new_default(Klondike::with_seed(124)).solve(); + if let Ok(Some(win_moves)) = solution_result { // for (i, ins) in win_moves.into_iter().enumerate() { // println!("{i} = {:?}", ins.instruction()); // } From e3870112cf5b47e4dad526f3050e984cd1b3b8c1 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Fri, 29 May 2026 14:22:34 -0700 Subject: [PATCH 3/3] clean solution --- card_game/src/lib.rs | 48 ++++++++++++++++++++++++++++++++++++++-- klondike-cli/src/test.rs | 3 ++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 24a5f85..94e7736 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -324,6 +324,47 @@ impl std::fmt::Display for SolveError { } impl std::error::Error for SolveError {} +/// The solution tends to be very large with long chains of moves that go back to the same state. +/// It is recommended to call .clean_solution() if the solution is actually going to be shown to a user. +pub struct Solution { + solution: Vec>, +} +impl Solution { + pub const fn raw_solution(&self) -> &[StateSnapshot] { + self.solution.as_slice() + } + /// Repeatedly remove the largest range of moves that goes back into the same state. + /// This is a very expensive operation when the solution is very long! + pub fn clean_solution(self) -> Vec> { + let mut history = self.solution; + // history includes cycles + let mut state_index: std::collections::HashMap<_, _> = history + .iter() + .enumerate() + .map(|(i, snapshot)| (snapshot.state().clone(), i)) + .collect(); + + // find the longest range where the start and end are the same state + while let Some(longest_range) = history + .iter() + .enumerate() + .filter_map(|(index, snapshot)| { + let &last_index = state_index.get(snapshot.state())?; + let longness = last_index - index; + (longness != 0).then_some(index..last_index) + }) + .max_by_key(|range| range.len()) + { + history.drain(longest_range); + for (i, snapshot) in history.iter().enumerate() { + state_index.insert(snapshot.state().clone(), i); + } + } + + history + } +} + #[derive(Clone, Debug)] pub enum SessionInstruction { Undo, @@ -454,7 +495,8 @@ where pub fn is_win(&self) -> bool { self.state.is_win() } - pub fn solve(&self) -> Result>>, SolveError> { + /// Attempt to produce a solution. + pub fn solve(&self) -> Result>, SolveError> { let mut state_moves = std::collections::HashMap::new(); let mut state = self.clone(); let mut moves = 0; @@ -489,7 +531,9 @@ where state.undo(); } } - Ok(Some(state.state.history)) + Ok(Some(Solution { + solution: state.state.history, + })) } } impl> Game for SessionState diff --git a/klondike-cli/src/test.rs b/klondike-cli/src/test.rs index 99d96de..28c6471 100644 --- a/klondike-cli/src/test.rs +++ b/klondike-cli/src/test.rs @@ -4,7 +4,8 @@ use klondike::Klondike; fn test_is_winnable() { // is winnable let solution_result = Session::new_default(Klondike::with_seed(124)).solve(); - if let Ok(Some(win_moves)) = solution_result { + if let Ok(Some(solution)) = solution_result { + let win_moves = solution.clean_solution(); // for (i, ins) in win_moves.into_iter().enumerate() { // println!("{i} = {:?}", ins.instruction()); // }