27 Commits

Author SHA1 Message Date
Quaternions 5b277601ea ooh at 99% cap 2026-05-19 18:02:24 -07:00
Quaternions 418e422f12 cap hashmap at 32M entries 2026-05-19 17:23:56 -07:00
Quaternions bc2d1b126e clean history 2026-05-19 10:22:44 -07:00
Quaternions 08e8656ecf test klondike iter 2026-05-19 09:46:11 -07:00
Quaternions 73ffef76b0 delete infinite loop test 2026-05-19 09:45:49 -07:00
Quaternions 0a34deb630 Game implies Clone + Debug for associated types 2026-05-19 08:21:21 -07:00
Quaternions bc05bbdc50 O(1) undo 2026-05-19 08:18:28 -07:00
Quaternions f9012b01c4 format 2026-05-19 08:02:23 -07:00
Quaternions e18e242eae refactor is_winnable 2026-05-19 08:02:00 -07:00
Quaternions 576489c226 Revert "temporarily remove is_winnable because it doesn't work"
This reverts commit 5a52f2ab7a.
2026-05-19 07:10:38 -07:00
Quaternions 90f8fe2e77 1000 games 2026-05-18 16:45:49 -07:00
Quaternions 7f708df327 stats histogram 2026-05-18 16:43:45 -07:00
Quaternions 6bfa05c292 add benchmark 2026-05-18 15:57:53 -07:00
Quaternions da0dfe98c4 update readme 2026-05-18 15:51:17 -07:00
Quaternions b840d56725 use workspace lints 2026-05-18 15:43:32 -07:00
Quaternions 37837e76c1 use get_auto_move in readme 2026-05-18 14:04:58 -07:00
Quaternions e6113b6e91 tweak doc 2026-05-18 13:57:47 -07:00
Quaternions 5cb5cb9a50 tweak doc 2026-05-18 13:43:39 -07:00
Quaternions 446cd87665 update readme 2026-05-18 13:34:03 -07:00
Quaternions a9b93f3807 update readme 2026-05-18 13:29:59 -07:00
Quaternions 13bc36ce0c tweak with_rng signature to allow Rng reuse 2026-05-18 13:28:40 -07:00
Quaternions 07c3dc6667 fix doc 2026-05-18 13:26:08 -07:00
Quaternions 835a4dcc5f fix registry 2026-05-18 13:15:11 -07:00
Quaternions ec25f11ca5 card_game v0.2.0 2026-05-18 13:12:19 -07:00
Quaternions f8dd9e008c workspace dependencies 2026-05-18 13:12:03 -07:00
Quaternions 90d46902ea move auto moves into klondike 2026-05-18 13:08:15 -07:00
Quaternions fd6b2a23ea add is_win_trivial 2026-05-18 13:00:15 -07:00
13 changed files with 270 additions and 113 deletions
Generated
+10 -1
View File
@@ -22,7 +22,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]] [[package]]
name = "card_game" name = "card_game"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
] ]
@@ -132,6 +132,15 @@ dependencies = [
"rand", "rand",
] ]
[[package]]
name = "klondike-bench"
version = "0.1.0"
dependencies = [
"card_game",
"klondike",
"rand",
]
[[package]] [[package]]
name = "klondike-cli" name = "klondike-cli"
version = "0.1.0" version = "0.1.0"
+5
View File
@@ -2,10 +2,15 @@
members = [ members = [
"card_game", "card_game",
"klondike", "klondike",
"klondike-bench",
"klondike-cli", "klondike-cli",
] ]
resolver = "3" resolver = "3"
[workspace.dependencies]
card_game = { version = "0.2.0", path = "card_game", registry = "Quaternions" }
klondike = { version = "0.1.0", path = "klondike", registry = "Quaternions" }
[workspace.lints.rust] [workspace.lints.rust]
# unsafe_code = "forbid" # unsafe_code = "forbid"
# missing_docs = "warn" # missing_docs = "warn"
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "card_game" name = "card_game"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
repository = "https://git.aleshym.co/Quaternions/card_game" repository = "https://git.aleshym.co/Quaternions/card_game"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -10,3 +10,6 @@ keywords = ["card", "cards", "solitaire", "klondike"]
[dependencies] [dependencies]
arrayvec = "0.7.6" arrayvec = "0.7.6"
[lints]
workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
Card Game Card Game
========= =========
`card_game` is a library to implement card games. Mainly interesting for the `Game` trait and the `Session` type. `card_game` is a collection of algorithms, structs, and enums which are useful to implement card games.
## Example ## Example
+93 -23
View File
@@ -6,10 +6,10 @@ struct ReadmeDoctests;
use core::ops::RangeBounds; use core::ops::RangeBounds;
// TODO: pub struct ValidInstruction<I>(I); // TODO: pub struct ValidInstruction<I>(I);
pub trait Game { pub trait Game: Clone + core::fmt::Debug {
type Stats; type Stats: Clone + core::fmt::Debug;
type Config; type Config: Clone + core::fmt::Debug;
type Instruction; type Instruction: Clone + core::fmt::Debug;
fn possible_instructions(&self) -> impl Iterator<Item = Self::Instruction> + use<Self>; fn possible_instructions(&self) -> impl Iterator<Item = Self::Instruction> + use<Self>;
fn is_instruction_valid(&self, config: &Self::Config, instruction: Self::Instruction) -> bool; fn is_instruction_valid(&self, config: &Self::Config, instruction: Self::Instruction) -> bool;
fn process_instruction( fn process_instruction(
@@ -135,7 +135,8 @@ impl Rank {
} }
} }
} }
/// An identifier which specifies the deck id, suit, and card value.
/// A card which specifies the deck id, suit, and card value.
/// 2 bits for deck ID /// 2 bits for deck ID
/// 2 bits for suit ID /// 2 bits for suit ID
/// 4 bits for card Value /// 4 bits for card Value
@@ -224,6 +225,7 @@ impl<const CAP: usize> IntoIterator for Stack<CAP> {
} }
} }
/// A pile is a stack of face down cards and a stack of face up cards.
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct Pile<const DN: usize, const UP: usize> { pub struct Pile<const DN: usize, const UP: usize> {
face_down: Stack<DN>, face_down: Stack<DN>,
@@ -319,21 +321,36 @@ impl<S> SessionStats<S> {
} }
} }
#[derive(Debug)]
pub struct Oom;
#[derive(Clone, Debug)]
pub struct Session<G: Game> { pub struct Session<G: Game> {
stats: SessionStats<G::Stats>, stats: SessionStats<G::Stats>,
config: G::Config, config: G::Config,
state: SessionState<G>, state: SessionState<G>,
} }
#[derive(Clone, Eq, Hash, PartialEq)] #[derive(Clone, Debug)]
pub struct SessionState<G: Game> { pub struct StateSnapshot<G: Game> {
seed: G,
state: G, state: G,
history: Vec<G::Instruction>, instruction: G::Instruction,
}
impl<G: Game> StateSnapshot<G> {
pub const fn state(&self) -> &G {
&self.state
}
pub const fn instruction(&self) -> &G::Instruction {
&self.instruction
}
}
#[derive(Clone, Debug)]
pub struct SessionState<G: Game> {
state: G,
history: Vec<StateSnapshot<G>>,
} }
impl<G: Game + Clone> SessionState<G> { impl<G: Game + Clone> SessionState<G> {
fn new(state: G) -> Self { fn new(state: G) -> Self {
Self { Self {
seed: state.clone(),
state, state,
history: Vec::new(), history: Vec::new(),
} }
@@ -343,6 +360,7 @@ impl<G: Game> Session<G>
where where
G: Clone + Eq + core::hash::Hash, G: Clone + Eq + core::hash::Hash,
G::Stats: Clone + Default, G::Stats: Clone + Default,
G::Config: Clone,
G::Instruction: Clone + Eq + core::hash::Hash, G::Instruction: Clone + Eq + core::hash::Hash,
{ {
pub fn new(state: G, config: G::Config) -> Self { pub fn new(state: G, config: G::Config) -> Self {
@@ -367,7 +385,7 @@ where
pub const fn config(&self) -> &G::Config { pub const fn config(&self) -> &G::Config {
&self.config &self.config
} }
pub fn history(&self) -> &[G::Instruction] { pub fn history(&self) -> &[StateSnapshot<G>] {
&self.state.history &self.state.history
} }
pub fn undo(&mut self) { pub fn undo(&mut self) {
@@ -387,12 +405,67 @@ where
pub fn is_win(&self) -> bool { pub fn is_win(&self) -> bool {
self.state.is_win() self.state.is_win()
} }
pub fn is_winnable(&self) -> Result<Option<Vec<StateSnapshot<G>>>, Oom> {
const HUGE_CAP: usize = 1 << 25;
let mut state_moves = std::collections::HashMap::with_capacity(HUGE_CAP);
let mut state = self.clone();
while !state.is_win() {
// don't look for empty hash map buckets when the hash map is 99% full!
if HUGE_CAP * 127 <= state_moves.len() * 128 {
return Err(Oom);
}
// Continue existing iterator if it exists
let it = state_moves
.entry(state.state().clone())
.or_insert_with(|| state.state().possible_instructions());
// 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();
}
}
// history includes cycles
let mut state_index: std::collections::HashMap<_, _> = state
.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) = state
.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())
{
state.state.history.drain(longest_range);
for (i, snapshot) in state.history().iter().enumerate() {
state_index.insert(snapshot.state().clone(), i);
}
}
Ok(Some(state.state.history))
}
} }
impl<G: Game> Game for SessionState<G> impl<G: Game> Game for SessionState<G>
where where
G: Clone,
G::Stats: Default, G::Stats: Default,
G::Instruction: Clone,
{ {
type Stats = SessionStats<G::Stats>; type Stats = SessionStats<G::Stats>;
type Config = G::Config; type Config = G::Config;
@@ -418,19 +491,16 @@ where
) { ) {
match instruction { match instruction {
SessionInstruction::Undo => { SessionInstruction::Undo => {
// replay the entire history of the game except one move if let Some(snapshot) = self.history.pop() {
self.history.pop(); self.state = snapshot.state;
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, instruction.clone());
}
self.state = state;
stats.inner_stats = inner_stats;
stats.increment_undos(); stats.increment_undos();
} }
}
SessionInstruction::InnerInstruction(instruction) => { SessionInstruction::InnerInstruction(instruction) => {
self.history.push(instruction.clone()); self.history.push(StateSnapshot {
state: self.state.clone(),
instruction: instruction.clone(),
});
self.state self.state
.process_instruction(&mut stats.inner_stats, config, instruction); .process_instruction(&mut stats.inner_stats, config, instruction);
} }
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "klondike-bench"
version = "0.1.0"
edition = "2024"
[dependencies]
card_game.workspace = true
klondike.workspace = true
rand = { version = "0.10.1", default-features = false }
[lints]
workspace = true
+46
View File
@@ -0,0 +1,46 @@
use card_game::Game;
use klondike::{Klondike, KlondikeConfig, KlondikeStats, Rng};
const MAX_MOVES: usize = 250;
fn play_to_win(rng: &mut Rng) -> Option<KlondikeStats> {
// create game session
let mut game = Klondike::with_rng(rng);
let mut stats = KlondikeStats::new();
const CONFIG: KlondikeConfig = KlondikeConfig {
draw_stock: klondike::DrawStockConfig::DrawOne,
};
// play game a bit
while let Some(instruction) = game.get_auto_move()
&& !game.is_win()
{
// quit before 250 moves
if MAX_MOVES < stats.moves() + 1 {
return None;
}
game.process_instruction(&mut stats, &CONFIG, instruction);
}
game.is_win().then_some(stats)
}
fn main() {
use rand::SeedableRng;
let mut rng = Rng::seed_from_u64(0);
const GAMES: u32 = 1000;
let mut wins = 0;
let mut score_tally = [0usize; MAX_MOVES * 10 / 5];
let mut recycle_tally = [0usize; MAX_MOVES];
let mut moves_tally = [0usize; MAX_MOVES];
for _ in 0..GAMES {
if let Some(stats) = play_to_win(&mut rng) {
wins += 1;
score_tally[stats.score() / 5] += 1;
recycle_tally[stats.recycle_count()] += 1;
moves_tally[stats.moves()] += 1;
}
}
println!("score_tally={score_tally:?}");
println!("recycle_tally={recycle_tally:?}");
println!("moves_tally={moves_tally:?}");
println!("wins = {wins}/{GAMES} win_rate = {}%", wins * 100 / GAMES);
}
+5 -2
View File
@@ -4,6 +4,9 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
card_game = { version = "0.1.0", path = "../card_game" } card_game.workspace = true
klondike = { version = "0.1.0", path = "../klondike" } klondike.workspace = true
rand = { version = "0.10.1", default-features = false, features = ["thread_rng"] } rand = { version = "0.10.1", default-features = false, features = ["thread_rng"] }
[lints]
workspace = true
+3 -48
View File
@@ -4,8 +4,8 @@ use klondike::{
KlondikePile, KlondikePileStack, KlondikeStats, SkipCards, Tableau, TableauStack, KlondikePile, KlondikePileStack, KlondikeStats, SkipCards, Tableau, TableauStack,
}; };
// #[cfg(test)] #[cfg(test)]
// mod test; mod test;
use std::fmt::Display; use std::fmt::Display;
struct Displayed<T>(T); struct Displayed<T>(T);
@@ -212,51 +212,6 @@ fn find_valid_instruction(
.then_some(instruction) .then_some(instruction)
} }
fn get_good_move(state: &Klondike) -> Option<KlondikeInstruction> {
fn useless_moves(instruction: &KlondikeInstruction) -> bool {
!matches!(
instruction,
// foundation -> foundation is a useless move
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(_),
..
})
)
}
fn instruction_priority(state: &Klondike, instruction: &KlondikeInstruction) -> usize {
// 1 Move into foundation
// 2 T->T Move to reveal new card (moving a non-king to reveal empty tableau also counts)
// 3 Move from stock
// 4 Rotate stock
// 5 T->T Move not revealing new card
// 6 Move from foundation
match instruction {
KlondikeInstruction::DstFoundation(_) => 1,
&KlondikeInstruction::DstTableau(dst_tableau) => match dst_tableau.src {
KlondikePileStack::Tableau(TableauStack {
tableau,
skip_cards: SkipCards::Skip0,
}) if !state.state().is_tableau_face_down_empty(tableau)
|| state
.state()
.stack_bottom_card(dst_tableau.src)
.is_some_and(|card| card.rank() != Rank::King) =>
{
2
}
KlondikePileStack::Stock => 3,
KlondikePileStack::Tableau(_) => 5,
KlondikePileStack::Foundation(_) => 6,
},
KlondikeInstruction::RotateStock => 4,
}
}
state
.possible_instructions()
.filter(useless_moves)
.min_by_key(|ins| instruction_priority(state, ins))
}
fn main() -> Result<(), std::io::Error> { fn main() -> Result<(), std::io::Error> {
use rand::RngExt; use rand::RngExt;
let mut rng = rand::rng(); let mut rng = rand::rng();
@@ -297,7 +252,7 @@ fn main() -> Result<(), std::io::Error> {
} }
} }
SessionInstruction::Auto => { SessionInstruction::Auto => {
if let Some(instruction) = get_good_move(session.state()) { if let Some(instruction) = session.state().get_auto_move() {
session.process_instruction(instruction); session.process_instruction(instruction);
} else { } else {
println!("No valid moves!"); println!("No valid moves!");
+9 -27
View File
@@ -1,33 +1,15 @@
use klondike::Klondike;
use card_game::Session; use card_game::Session;
use klondike::Klondike;
#[test] #[test]
fn test_is_winnable() { fn test_is_winnable() {
// is winnable // is winnable
let is_winnable = Session::new_default(Klondike::with_seed(123)).is_winnable(); let is_winnable = Session::new_default(Klondike::with_seed(0)).is_winnable().unwrap();
println!("is_winnable = {is_winnable:?}"); if let Some(win_moves) = is_winnable {
} // for (i, ins) in win_moves.into_iter().enumerate() {
#[test] // println!("{i} = {:?}", ins.instruction());
fn test_klondike() { // }
// create game session println!("Game is winnable with {} moves", win_moves.len());
let game = Klondike::with_seed(123); } else {
let mut session = Session::new_default(game); println!("Game is not winnable");
// 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);
} }
// 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}");
} }
+4 -1
View File
@@ -4,5 +4,8 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
card_game = { version = "0.1.0", path = "../card_game" } card_game.workspace = true
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] } rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
[lints]
workspace = true
+5 -5
View File
@@ -1,12 +1,12 @@
Card Game Klondike
========= ========
`card_game` is a library to implement card games. Mainly interesting for the `Game` trait and the `Session` type. Contains klondike as the reference implementation. `klondike` is a pure-logic implementation of Klondike using `card_game`. Graphics not included.
## Example ## Example
```rust ```rust
use card_game::{Session, Game}; use card_game::Session;
use klondike::Klondike; use klondike::Klondike;
// create game session // create game session
@@ -14,7 +14,7 @@ let game = Klondike::with_seed(123);
let mut session = Session::new_default(game); let mut session = Session::new_default(game);
// play game a bit // play game a bit
while let Some(instruction) = session.possible_instructions().next() { while let Some(instruction) = session.state().get_auto_move() {
session.process_instruction(instruction); session.process_instruction(instruction);
// quit after 1000 moves // quit after 1000 moves
+73 -4
View File
@@ -299,6 +299,16 @@ impl KlondikeInstruction {
Self::RotateStock => return None, Self::RotateStock => return None,
}) })
} }
/// foundation -> foundation is a useless move
pub fn is_useless(&self) -> bool {
matches!(
self,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(_),
..
})
)
}
} }
const TABLEAUS: usize = 7; const TABLEAUS: usize = 7;
@@ -523,6 +533,10 @@ impl Iterator for KlondikeIter {
instruction instruction
} }
} }
#[test]
fn test_klondike_iter() {
assert_eq!(KlondikeIter::new().count(), 721);
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Klondike { pub struct Klondike {
@@ -531,14 +545,14 @@ pub struct Klondike {
impl Klondike { impl Klondike {
pub fn with_seed(seed: u64) -> Self { pub fn with_seed(seed: u64) -> Self {
use rand::SeedableRng; use rand::SeedableRng;
let rng = Rng::seed_from_u64(seed); let mut rng = Rng::seed_from_u64(seed);
Self::with_rng(rng) Self::with_rng(&mut rng)
} }
pub fn with_rng(mut rng: Rng) -> Self { pub fn with_rng(rng: &mut Rng) -> Self {
// shuffle a new deck // shuffle a new deck
let mut deck = Stack::full_deck(card_game::Deck::Deck1); let mut deck = Stack::full_deck(card_game::Deck::Deck1);
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
deck.shuffle(&mut rng); deck.shuffle(rng);
let mut deck = deck.into_iter(); let mut deck = deck.into_iter();
// generate tableaus // generate tableaus
@@ -575,6 +589,61 @@ impl Klondike {
pub const fn state(&self) -> &KlondikeState { pub const fn state(&self) -> &KlondikeState {
&self.state &self.state
} }
/// Check if the game should be auto-completed
pub fn is_win_trivial(&self) -> bool {
// all face down cards empty means win
self.state.stock.face_down().is_empty()
&& self.state.tableau1.face_down().is_empty()
&& self.state.tableau2.face_down().is_empty()
&& self.state.tableau3.face_down().is_empty()
&& self.state.tableau4.face_down().is_empty()
&& self.state.tableau5.face_down().is_empty()
&& self.state.tableau6.face_down().is_empty()
&& self.state.tableau7.face_down().is_empty()
}
fn instruction_priority(&self, instruction: &KlondikeInstruction) -> usize {
// 1 Move into foundation
// 2 T->T Move to reveal new card (moving a non-king to reveal empty tableau also counts)
// 3 Move from stock
// 4 Rotate stock
// 5 T->T Move not revealing new card
// 6 Move from foundation
match instruction {
KlondikeInstruction::DstFoundation(_) => 1,
&KlondikeInstruction::DstTableau(dst_tableau) => match dst_tableau.src {
KlondikePileStack::Tableau(TableauStack {
tableau,
skip_cards: SkipCards::Skip0,
}) if !self.state().is_tableau_face_down_empty(tableau)
|| self
.state()
.stack_bottom_card(dst_tableau.src)
.is_some_and(|card| card.rank() != Rank::King) =>
{
2
}
KlondikePileStack::Stock => 3,
KlondikePileStack::Tableau(_) => 5,
KlondikePileStack::Foundation(_) => 6,
},
KlondikeInstruction::RotateStock => 4,
}
}
/// A single move that usually makes progress towards a winning game
pub fn get_auto_move(&self) -> Option<KlondikeInstruction> {
self.possible_instructions()
.filter(|ins| !ins.is_useless())
.min_by_key(|ins| self.instruction_priority(ins))
}
/// A list of possible moves with useless moves filtered out and sorted by a simple priority function
pub fn get_sorted_moves(&self) -> Vec<KlondikeInstruction> {
let mut useful_moves: Vec<_> = self
.possible_instructions()
.filter(|ins| !ins.is_useless())
.collect();
useful_moves.sort_by_key(|ins| self.instruction_priority(ins));
useful_moves
}
} }
impl Game for Klondike { impl Game for Klondike {