diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 666cf12..94e7736 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, @@ -312,6 +312,59 @@ 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 {} + +/// 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, @@ -338,12 +391,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, } } } @@ -353,21 +410,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 +449,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 +475,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 +495,50 @@ where pub fn is_win(&self) -> bool { self.state.is_win() } + /// 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; + 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()) + .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 Ok(None); + } else { + state.undo(); + } + } + Ok(Some(Solution { + solution: state.state.history, + })) + } } impl> Game for SessionState where - G: Clone, G::Stats: Default, - G::Instruction: Clone, { type Score = i32; type Stats = SessionStats; @@ -464,19 +571,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..28c6471 100644 --- a/klondike-cli/src/test.rs +++ b/klondike-cli/src/test.rs @@ -1,33 +1,16 @@ -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 solution_result = Session::new_default(Klondike::with_seed(124)).solve(); + 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()); + // } + 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 {