From a511679a47f4928cf187b3686a951bf2b6e17c8f Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 7 Jul 2026 18:39:22 -0700 Subject: [PATCH 1/7] add staging api --- card_game/src/lib.rs | 117 ++++++++++++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 30 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 9da45c4..8cb1887 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -5,25 +5,67 @@ struct ReadmeDoctests; use core::ops::RangeBounds; -// TODO: pub struct ValidInstruction(I); 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 is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool; fn possible_instructions( &self, config: &Self::Config, ) -> impl Iterator + use; - fn is_instruction_valid(&self, config: &Self::Config, instruction: Self::Instruction) -> bool; - fn process_instruction( - &mut self, - stats: &mut Self::Stats, - config: &Self::Config, - instruction: Self::Instruction, - ); fn is_win(&self) -> bool; + fn stage_instruction<'a>( + &'a mut self, + stats: &'a mut Self::Stats, + config: &'a Self::Config, + instruction: Self::Instruction, + ) -> Option> { + if self.is_instruction_valid(config, &instruction) { + Some(StagedInstruction { + game: self, + stats, + config, + instruction, + }) + } else { + None + } + } +} + +/// Call .commit() to run the staged instruction. +pub struct StagedInstruction<'a, G: Game> { + game: &'a mut G, + stats: &'a mut G::Stats, + config: &'a G::Config, + instruction: G::Instruction, +} +impl<'a, G: Game> StagedInstruction<'a, G> { + pub fn consume(self) -> (&'a mut G, &'a mut G::Stats, &'a G::Config, G::Instruction) { + let StagedInstruction { + game, + stats, + config, + instruction, + } = self; + (game, stats, config, instruction) + } +} +impl StagedInstruction<'_, G> +where + G: InstructionConsumer, +{ + pub fn commit(self) { + G::process_instruction(self); + } +} + +/// Apply an atomic state update +pub trait InstructionConsumer: Game { + fn process_instruction(valid_instruction: StagedInstruction<'_, Self>); } /// card_game supports up to 4 identifiably separate decks. @@ -515,6 +557,7 @@ where G: Eq + core::hash::Hash, G::Stats: Default, G::Instruction: Eq + core::hash::Hash, + G: InstructionConsumer, { pub fn new(state: G, config: SessionConfig) -> Self { Self { @@ -542,14 +585,21 @@ where &self.state.history } pub fn undo(&mut self) { - self.state - .process_instruction(&mut self.stats, &self.config, SessionInstruction::Undo) + if let Some(staged) = + self.state + .stage_instruction(&mut self.stats, &self.config, SessionInstruction::Undo) + { + staged.commit(); + } } pub fn possible_instructions(&self) -> impl Iterator + use { self.state.state.possible_instructions(&self.config.inner) } - pub fn process_instruction(&mut self, instruction: G::Instruction) { - self.state.process_instruction( + pub fn stage_instruction( + &mut self, + instruction: G::Instruction, + ) -> Option>> { + self.state.stage_instruction( &mut self.stats, &self.config, SessionInstruction::InnerInstruction(instruction), @@ -583,7 +633,8 @@ where // Run one possible move if let Some(instruction) = it.next() { - session.process_instruction(instruction); + // TODO: no unwrap + session.stage_instruction(instruction).unwrap().commit(); continue; } @@ -618,7 +669,7 @@ where .possible_instructions(&config.inner) .map(SessionInstruction::InnerInstruction) } - fn is_instruction_valid(&self, config: &Self::Config, instruction: Self::Instruction) -> bool { + fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool { match instruction { SessionInstruction::Undo => !self.history.is_empty(), SessionInstruction::InnerInstruction(instruction) => { @@ -626,32 +677,36 @@ where } } } - fn process_instruction( - &mut self, - stats: &mut Self::Stats, - config: &Self::Config, - instruction: Self::Instruction, - ) { + fn is_win(&self) -> bool { + self.state.is_win() + } +} +impl InstructionConsumer for SessionState +where + G: Game, + G: InstructionConsumer, +{ + fn process_instruction(valid_instruction: StagedInstruction<'_, Self>) { + let (game, stats, config, instruction) = valid_instruction.consume(); match instruction { SessionInstruction::Undo => { - if let Some(snapshot) = self.history.pop() { - self.state = snapshot.state; + if let Some(snapshot) = game.history.pop() { + game.state = snapshot.state; stats.increment_undos(); } } SessionInstruction::InnerInstruction(instruction) => { - self.history.push(StateSnapshot { - state: self.state.clone(), + game.history.push(StateSnapshot { + state: game.state.clone(), instruction: instruction.clone(), }); - self.state - .process_instruction(&mut stats.inner, &config.inner, instruction); + game.state + .stage_instruction(&mut stats.inner, &config.inner, instruction) + .unwrap() + .commit(); } } } - fn is_win(&self) -> bool { - self.state.is_win() - } } #[cfg(feature = "serde")] @@ -660,6 +715,7 @@ where G: serde::Deserialize<'de> + Clone + Eq + core::hash::Hash, G::Stats: Default, G::Instruction: serde::Deserialize<'de> + Eq + core::hash::Hash, + G: InstructionConsumer, SessionConfig: serde::Deserialize<'de>, Vec: serde::Deserialize<'de>, { @@ -681,7 +737,8 @@ where let serialized = SerializedSession::deserialize(deserializer)?; let mut session = Session::new(serialized.initial_state, serialized.config); for instruction in serialized.instructions { - session.process_instruction(instruction); + // TODO: no unwrap + session.stage_instruction(instruction).unwrap().commit(); } Ok(session) } -- 2.47.3 From 8c64583c00b70c698a401abd9a10b4eb1595593b Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 7 Jul 2026 18:44:50 -0700 Subject: [PATCH 2/7] implement staging api --- klondike-bench/src/main.rs | 4 +- klondike-cli/src/main.rs | 13 +++--- klondike/README.md | 2 +- klondike/src/lib.rs | 92 +++++++++++++++++++------------------- klondike/src/test.rs | 10 ++++- 5 files changed, 65 insertions(+), 56 deletions(-) diff --git a/klondike-bench/src/main.rs b/klondike-bench/src/main.rs index 611efa5..7f61c66 100644 --- a/klondike-bench/src/main.rs +++ b/klondike-bench/src/main.rs @@ -21,7 +21,9 @@ fn play_to_win(rng: &mut Rng) -> Option { return None; } - game.process_instruction(&mut stats, &CONFIG, instruction); + game.stage_instruction(&mut stats, &CONFIG, instruction) + .unwrap() + .commit(); } game.is_win().then_some(stats) } diff --git a/klondike-cli/src/main.rs b/klondike-cli/src/main.rs index 60c3740..faee0e5 100644 --- a/klondike-cli/src/main.rs +++ b/klondike-cli/src/main.rs @@ -210,7 +210,7 @@ fn find_valid_instruction( }); let instruction = KlondikeInstruction::DstTableau(DstTableau { tableau, src }); - if state.is_instruction_valid(config, instruction) { + if state.is_instruction_valid(config, &instruction) { return Some(instruction); } } @@ -228,7 +228,7 @@ fn find_valid_instruction( _ => return None, }; state - .is_instruction_valid(config, instruction) + .is_instruction_valid(config, &instruction) .then_some(instruction) } @@ -277,13 +277,16 @@ fn main() -> Result<(), std::io::Error> { .state() .get_auto_move(&session.config().inner) { - session.process_instruction(instruction); + session.stage_instruction(instruction).unwrap().commit(); } else { println!("No valid moves!"); } } SessionInstruction::Stock => { - session.process_instruction(KlondikeInstruction::RotateStock) + session + .stage_instruction(KlondikeInstruction::RotateStock) + .unwrap() + .commit(); } SessionInstruction::Klondike(naive_instruction) => { if let Some(instruction) = find_valid_instruction( @@ -291,7 +294,7 @@ fn main() -> Result<(), std::io::Error> { session.state().state(), naive_instruction, ) { - session.process_instruction(instruction); + session.stage_instruction(instruction).unwrap().commit(); } else { println!("Invalid move!"); } diff --git a/klondike/README.md b/klondike/README.md index 1cd8cd3..a86c762 100644 --- a/klondike/README.md +++ b/klondike/README.md @@ -16,7 +16,7 @@ let mut session = Session::new_default(game); // play game a bit while let Some(instruction) = session.state().state().get_auto_move(&config) { - session.process_instruction(instruction); + session.stage_instruction(instruction).unwrap().commit(); // quit after 200 moves or win if session.is_win() || 200 < session.stats().stats().moves() { diff --git a/klondike/src/lib.rs b/klondike/src/lib.rs index e8c2698..67dd402 100644 --- a/klondike/src/lib.rs +++ b/klondike/src/lib.rs @@ -1,6 +1,6 @@ pub type Rng = rand::rngs::StdRng; -use card_game::{Card, Game, Pile, Rank, Stack}; +use card_game::{Card, Game, InstructionConsumer, Pile, Rank, Stack, StagedInstruction}; #[cfg(test)] mod test; @@ -621,7 +621,7 @@ impl KlondikeState { pub fn is_instruction_valid( &self, config: &KlondikeConfig, - instruction: KlondikeInstruction, + instruction: &KlondikeInstruction, ) -> bool { match instruction { // Stock -> Stock draws a card or resets the stock @@ -830,54 +830,11 @@ impl Game for Klondike { let state = self.state.clone(); let config = config.clone(); KlondikeIter::new() - .filter(move |&instruction| state.is_instruction_valid(&config, instruction)) + .filter(move |instruction| state.is_instruction_valid(&config, instruction)) } - fn is_instruction_valid(&self, config: &Self::Config, instruction: Self::Instruction) -> bool { + fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool { self.state.is_instruction_valid(config, instruction) } - fn process_instruction( - &mut self, - stats: &mut Self::Stats, - config: &Self::Config, - instruction: Self::Instruction, - ) { - stats.increment_moves(); - match instruction { - // Reset the stock if it's empty - KlondikeInstruction::RotateStock => { - if self.state.stock.face_down().is_empty() { - self.state.stock.flip_it_and_reverse_it(); - stats.increment_recycle_count(); - } else { - for _ in 0..config.draw_stock as usize { - self.state.stock.flip_up(); - } - } - } - // Move a card from anywhere to a foundation - KlondikeInstruction::DstFoundation(DstFoundation { src, foundation }) => { - stats.increment_move_to_foundation(); - let (card, did_flip_up) = self.state.take_top_card_flip_up(src); - if did_flip_up { - stats.increment_flip_up_bonus(); - } - self.state.extend_foundation(foundation, card); - } - // Move a stack of cards from anywhere to a tableau - KlondikeInstruction::DstTableau(DstTableau { src, tableau }) => { - match src { - KlondikePileStack::Stock => stats.increment_move_to_tableau(), - KlondikePileStack::Foundation(_) => stats.increment_move_from_foundation(), - KlondikePileStack::Tableau(_) => {} - } - let (cards, did_flip_up) = self.state.take_stack_flip_up(src); - if did_flip_up { - stats.increment_flip_up_bonus(); - } - self.state.extend_tableau(tableau, cards); - } - } - } fn is_win(&self) -> bool { // all foundations contain all ranks self.state.foundations.iter().all(|foundation| { @@ -889,6 +846,47 @@ impl Game for Klondike { }) } } +impl InstructionConsumer for Klondike { + fn process_instruction(valid_instruction: StagedInstruction<'_, Self>) { + let (game, stats, config, instruction) = valid_instruction.consume(); + stats.increment_moves(); + match instruction { + // Reset the stock if it's empty + KlondikeInstruction::RotateStock => { + if game.state.stock.face_down().is_empty() { + game.state.stock.flip_it_and_reverse_it(); + stats.increment_recycle_count(); + } else { + for _ in 0..config.draw_stock as usize { + game.state.stock.flip_up(); + } + } + } + // Move a card from anywhere to a foundation + KlondikeInstruction::DstFoundation(DstFoundation { src, foundation }) => { + stats.increment_move_to_foundation(); + let (card, did_flip_up) = game.state.take_top_card_flip_up(src); + if did_flip_up { + stats.increment_flip_up_bonus(); + } + game.state.extend_foundation(foundation, card); + } + // Move a stack of cards from anywhere to a tableau + KlondikeInstruction::DstTableau(DstTableau { src, tableau }) => { + match src { + KlondikePileStack::Stock => stats.increment_move_to_tableau(), + KlondikePileStack::Foundation(_) => stats.increment_move_from_foundation(), + KlondikePileStack::Tableau(_) => {} + } + let (cards, did_flip_up) = game.state.take_stack_flip_up(src); + if did_flip_up { + stats.increment_flip_up_bonus(); + } + game.state.extend_tableau(tableau, cards); + } + } + } +} #[cfg(feature = "serde")] impl serde::Serialize for KlondikeInstruction { diff --git a/klondike/src/test.rs b/klondike/src/test.rs index ed8168f..1ad8c87 100644 --- a/klondike/src/test.rs +++ b/klondike/src/test.rs @@ -23,7 +23,10 @@ fn test_json() { let solution_result = session.solve(); if let Ok(Some(solution)) = solution_result { for snapshot in solution.clean_solution() { - session.process_instruction(snapshot.instruction().clone()); + session + .stage_instruction(snapshot.instruction().clone()) + .unwrap() + .commit(); } } let serialized = serde_json::to_string(&session).unwrap(); @@ -42,7 +45,10 @@ fn test_rmp() { let solution_result = session.solve(); if let Ok(Some(solution)) = solution_result { for snapshot in solution.clean_solution() { - session.process_instruction(snapshot.instruction().clone()); + session + .stage_instruction(snapshot.instruction().clone()) + .unwrap() + .commit(); } } let serialized = rmp_serde::to_vec(&session).unwrap(); -- 2.47.3 From 984972c126ff6b58c7cf2053c6e77d555e8dd0a8 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 7 Jul 2026 18:50:47 -0700 Subject: [PATCH 3/7] cheat --- card_game/src/lib.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 8cb1887..38e38d5 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -700,10 +700,13 @@ where state: game.state.clone(), instruction: instruction.clone(), }); - game.state - .stage_instruction(&mut stats.inner, &config.inner, instruction) - .unwrap() - .commit(); + // This might be considered cheaing a bit, we're pre-approving the instruction here by accessing the inner fields. + G::process_instruction(StagedInstruction { + game: &mut game.state, + stats: &mut stats.inner, + config: &config.inner, + instruction, + }); } } } -- 2.47.3 From 4cd41fbdee7c739f42cc3ae26857f7ba5de5c79e Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 7 Jul 2026 18:52:01 -0700 Subject: [PATCH 4/7] minimize diff --- card_game/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 38e38d5..d085580 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -11,11 +11,11 @@ pub trait Game: Clone { type Config: Clone + core::fmt::Debug; type Instruction: Clone + core::fmt::Debug; fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score; - fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool; fn possible_instructions( &self, config: &Self::Config, ) -> impl Iterator + use; + fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool; fn is_win(&self) -> bool; fn stage_instruction<'a>( &'a mut self, -- 2.47.3 From b75c35be849beb8cb64b35e5fc0d9d0f4ca76801 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 7 Jul 2026 18:59:12 -0700 Subject: [PATCH 5/7] cleanups & docs --- card_game/src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index d085580..5732300 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -17,22 +17,20 @@ pub trait Game: Clone { ) -> impl Iterator + use; fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool; fn is_win(&self) -> bool; + /// Validate an instruction to prepare to run it. fn stage_instruction<'a>( &'a mut self, stats: &'a mut Self::Stats, config: &'a Self::Config, instruction: Self::Instruction, ) -> Option> { - if self.is_instruction_valid(config, &instruction) { - Some(StagedInstruction { + self.is_instruction_valid(config, &instruction) + .then_some(StagedInstruction { game: self, stats, config, instruction, }) - } else { - None - } } } @@ -44,6 +42,7 @@ pub struct StagedInstruction<'a, G: Game> { instruction: G::Instruction, } impl<'a, G: Game> StagedInstruction<'a, G> { + /// Consume the staged instruction. Use this in your implementation of InstructionConsumer::process_instruction for your game. pub fn consume(self) -> (&'a mut G, &'a mut G::Stats, &'a G::Config, G::Instruction) { let StagedInstruction { game, @@ -58,6 +57,7 @@ impl StagedInstruction<'_, G> where G: InstructionConsumer, { + /// Process the staged instruction. pub fn commit(self) { G::process_instruction(self); } -- 2.47.3 From 1245ba19477f673a7c8e727ab5e678e044fe97f4 Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Tue, 7 Jul 2026 19:03:01 -0700 Subject: [PATCH 6/7] doc --- card_game/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 5732300..601db9f 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -35,6 +35,7 @@ pub trait Game: Clone { } /// Call .commit() to run the staged instruction. +/// Cannot be constructed other than by Game::stage_instruction. pub struct StagedInstruction<'a, G: Game> { game: &'a mut G, stats: &'a mut G::Stats, -- 2.47.3 From eac07676dd3ed2ae9f5fd372b3e9732f6183833c Mon Sep 17 00:00:00 2001 From: Rhys Lloyd Date: Wed, 8 Jul 2026 09:34:49 -0700 Subject: [PATCH 7/7] remove separate trait --- card_game/src/lib.rs | 25 +++++-------------------- klondike/src/lib.rs | 24 +++++++++++------------- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index 601db9f..2f59235 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -16,6 +16,7 @@ pub trait Game: Clone { config: &Self::Config, ) -> impl Iterator + use; fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool; + fn process_instruction(valid_instruction: StagedInstruction<'_, Self>); fn is_win(&self) -> bool; /// Validate an instruction to prepare to run it. fn stage_instruction<'a>( @@ -54,21 +55,13 @@ impl<'a, G: Game> StagedInstruction<'a, G> { (game, stats, config, instruction) } } -impl StagedInstruction<'_, G> -where - G: InstructionConsumer, -{ +impl StagedInstruction<'_, G> { /// Process the staged instruction. pub fn commit(self) { G::process_instruction(self); } } -/// Apply an atomic state update -pub trait InstructionConsumer: Game { - fn process_instruction(valid_instruction: StagedInstruction<'_, Self>); -} - /// card_game supports up to 4 identifiably separate decks. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum Deck { @@ -558,7 +551,6 @@ where G: Eq + core::hash::Hash, G::Stats: Default, G::Instruction: Eq + core::hash::Hash, - G: InstructionConsumer, { pub fn new(state: G, config: SessionConfig) -> Self { Self { @@ -678,15 +670,6 @@ where } } } - fn is_win(&self) -> bool { - self.state.is_win() - } -} -impl InstructionConsumer for SessionState -where - G: Game, - G: InstructionConsumer, -{ fn process_instruction(valid_instruction: StagedInstruction<'_, Self>) { let (game, stats, config, instruction) = valid_instruction.consume(); match instruction { @@ -711,6 +694,9 @@ where } } } + fn is_win(&self) -> bool { + self.state.is_win() + } } #[cfg(feature = "serde")] @@ -719,7 +705,6 @@ where G: serde::Deserialize<'de> + Clone + Eq + core::hash::Hash, G::Stats: Default, G::Instruction: serde::Deserialize<'de> + Eq + core::hash::Hash, - G: InstructionConsumer, SessionConfig: serde::Deserialize<'de>, Vec: serde::Deserialize<'de>, { diff --git a/klondike/src/lib.rs b/klondike/src/lib.rs index 67dd402..2a7a5c7 100644 --- a/klondike/src/lib.rs +++ b/klondike/src/lib.rs @@ -1,6 +1,6 @@ pub type Rng = rand::rngs::StdRng; -use card_game::{Card, Game, InstructionConsumer, Pile, Rank, Stack, StagedInstruction}; +use card_game::{Card, Game, Pile, Rank, Stack, StagedInstruction}; #[cfg(test)] mod test; @@ -835,18 +835,6 @@ impl Game for Klondike { fn is_instruction_valid(&self, config: &Self::Config, instruction: &Self::Instruction) -> bool { self.state.is_instruction_valid(config, instruction) } - fn is_win(&self) -> bool { - // all foundations contain all ranks - self.state.foundations.iter().all(|foundation| { - foundation.len() == Rank::RANKS.len() - && foundation - .iter() - .zip(Rank::RANKS) - .all(|(card, rank)| card.rank() == rank) - }) - } -} -impl InstructionConsumer for Klondike { fn process_instruction(valid_instruction: StagedInstruction<'_, Self>) { let (game, stats, config, instruction) = valid_instruction.consume(); stats.increment_moves(); @@ -886,6 +874,16 @@ impl InstructionConsumer for Klondike { } } } + fn is_win(&self) -> bool { + // all foundations contain all ranks + self.state.foundations.iter().all(|foundation| { + foundation.len() == Rank::RANKS.len() + && foundation + .iter() + .zip(Rank::RANKS) + .all(|(card, rank)| card.rank() == rank) + }) + } } #[cfg(feature = "serde")] -- 2.47.3