diff --git a/Cargo.lock b/Cargo.lock index 343cacc..ffa87bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "0.7.6" source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.11.1" @@ -25,6 +31,8 @@ name = "card_game" version = "0.4.0" dependencies = [ "arrayvec", + "serde", + "serde_derive", ] [[package]] @@ -44,6 +52,17 @@ dependencies = [ "rand_core", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -53,12 +72,34 @@ dependencies = [ "libc", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "foldhash" version = "0.1.5" @@ -118,6 +159,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itoa" version = "1.0.18" @@ -129,7 +182,12 @@ name = "klondike" version = "0.3.0" dependencies = [ "card_game", + "insta", "rand", + "rmp-serde", + "serde", + "serde_derive", + "serde_json", ] [[package]] @@ -162,6 +220,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -174,6 +238,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "prettyplease" version = "0.2.37" @@ -225,6 +304,38 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "semver" version = "1.0.28" @@ -273,6 +384,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "syn" version = "2.0.117" @@ -284,6 +401,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -348,6 +478,21 @@ dependencies = [ "semver", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/card_game/Cargo.toml b/card_game/Cargo.toml index 051d596..64feed5 100644 --- a/card_game/Cargo.toml +++ b/card_game/Cargo.toml @@ -10,6 +10,12 @@ keywords = ["card", "cards", "solitaire", "klondike"] [dependencies] arrayvec = { version = "0.7.6", registry = "Quaternions", features = ["len_u8"], default-features = false } +serde = { version = "1.0.228", default-features = false, optional = true } +serde_derive = { version = "1.0.228", default-features = false, optional = true } [lints] workspace = true + +[features] +default = ["serde"] +serde = ["dep:serde", "dep:serde_derive"] diff --git a/card_game/src/lib.rs b/card_game/src/lib.rs index ab698e0..9da45c4 100644 --- a/card_game/src/lib.rs +++ b/card_game/src/lib.rs @@ -146,6 +146,11 @@ impl Rank { /// 2 bits for suit ID /// 4 bits for card Value #[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize), + serde(transparent) +)] pub struct Card(core::num::NonZeroU8); impl Card { pub const fn new(deck: Deck, suit: Suit, rank: Rank) -> Self { @@ -230,8 +235,54 @@ impl IntoIterator for Stack { } } +#[cfg(feature = "serde")] +impl<'de, const CAP: usize> serde::Deserialize<'de> for Stack { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct StackVisitor; + impl<'de, const CAP: usize> serde::de::Visitor<'de> for StackVisitor { + type Value = Stack; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Card Stack") + } + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + use serde::de::Error; + let mut stack = Stack::new(); + while let Some(card) = seq.next_element()? { + stack.try_push(card).map_err(A::Error::custom)?; + } + Ok(stack) + } + } + deserializer.deserialize_seq(StackVisitor) + } +} +#[cfg(feature = "serde")] +impl serde::Serialize for Stack { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.len()))?; + for card in self.as_slice() { + seq.serialize_element(card)?; + } + seq.end() + } +} + /// A pile is a stack of face down cards and a stack of face up cards. #[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] pub struct Pile { face_down: Stack, face_up: Stack, @@ -372,6 +423,10 @@ pub enum SessionInstruction { } #[derive(Clone, Debug, Default)] +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] pub struct SessionStats { inner: S, undos: u32, @@ -388,6 +443,10 @@ impl SessionStats { } } #[derive(Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] pub struct SessionConfig { pub inner: C, pub undo_penalty: i32, @@ -417,6 +476,10 @@ pub struct Session { state: SessionState, } #[derive(Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] pub struct StateSnapshot { state: G, instruction: G::Instruction, @@ -590,3 +653,95 @@ where self.state.is_win() } } + +#[cfg(feature = "serde")] +impl<'de, G: Game> serde::de::Deserialize<'de> for Session +where + G: serde::Deserialize<'de> + Clone + Eq + core::hash::Hash, + G::Stats: Default, + G::Instruction: serde::Deserialize<'de> + Eq + core::hash::Hash, + SessionConfig: serde::Deserialize<'de>, + Vec: serde::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde_derive::Deserialize)] + #[serde(bound(deserialize = " + G: serde::Deserialize<'de>, + SessionConfig: serde::Deserialize<'de>, + Vec: serde::Deserialize<'de>, + "))] + struct SerializedSession { + config: SessionConfig, + initial_state: G, + instructions: Vec, + } + let serialized = SerializedSession::deserialize(deserializer)?; + let mut session = Session::new(serialized.initial_state, serialized.config); + for instruction in serialized.instructions { + session.process_instruction(instruction); + } + Ok(session) + } +} +#[cfg(feature = "serde")] +impl serde::Serialize for Session +where + G: serde::Serialize, + G::Config: serde::Serialize, + G::Instruction: serde::Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + struct History<'a, G: Game>(&'a [StateSnapshot]); + impl serde::Serialize for History<'_, G> + where + G: serde::Serialize, + G::Instruction: serde::Serialize, + { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let &History(history) = self; + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(history.len()))?; + for snapshot in history { + seq.serialize_element(&snapshot.instruction)?; + } + seq.end() + } + } + + #[derive(serde_derive::Serialize)] + #[serde(bound(serialize = " + G: serde::Serialize, + SessionConfig: serde::Serialize, + G::Instruction: serde::Serialize, + "))] + struct SerializedSession<'a, G: Game> { + config: &'a SessionConfig, + initial_state: &'a G, + instructions: History<'a, G>, + } + + // serialize the initial state of the game. + // if there is history, it is the first snapshot's state, + // otherwise it is the current game state since there are no moves. + let initial_state = if let Some(snapshot) = self.state.history.first() { + snapshot.state() + } else { + &self.state.state + }; + let session = SerializedSession { + config: &self.config, + initial_state, + instructions: History(&self.state.history), + }; + session.serialize(serializer) + } +} diff --git a/klondike-cli/src/main.rs b/klondike-cli/src/main.rs index 22ba410..60c3740 100644 --- a/klondike-cli/src/main.rs +++ b/klondike-cli/src/main.rs @@ -4,9 +4,6 @@ use klondike::{ KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, }; -#[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 deleted file mode 100644 index 28c6471..0000000 --- a/klondike-cli/src/test.rs +++ /dev/null @@ -1,16 +0,0 @@ -use card_game::Session; -use klondike::Klondike; -#[test] -fn test_is_winnable() { - // is winnable - 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"); - } -} diff --git a/klondike/Cargo.toml b/klondike/Cargo.toml index 1c55cb4..bdaec6b 100644 --- a/klondike/Cargo.toml +++ b/klondike/Cargo.toml @@ -6,6 +6,18 @@ edition = "2024" [dependencies] card_game.workspace = true rand = { version = "0.10.1", default-features = false, features = ["std_rng"] } +serde = { version = "1.0.228", default-features = false, optional = true } +serde_derive = { version = "1.0.228", default-features = false, optional = true } + +[dev-dependencies] +insta = "1.47.2" +rmp-serde = "1.3.1" +serde = { version = "1.0.228", default-features = false } +serde_json = "1.0.149" [lints] workspace = true + +[features] +default = ["serde"] +serde = ["dep:serde", "dep:serde_derive", "card_game/serde"] diff --git a/klondike/src/lib.rs b/klondike/src/lib.rs index 48b491d..425201f 100644 --- a/klondike/src/lib.rs +++ b/klondike/src/lib.rs @@ -2,11 +2,18 @@ pub type Rng = rand::rngs::StdRng; use card_game::{Card, Game, Pile, Rank, Stack}; +#[cfg(test)] +mod test; + // test readme #[doc = include_str!("../README.md")] #[cfg(doctest)] struct ReadmeDoctests; +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum DrawStockConfig { #[default] @@ -14,6 +21,10 @@ pub enum DrawStockConfig { DrawThree = 3, } +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum MoveFromFoundationConfig { #[default] @@ -21,6 +32,10 @@ pub enum MoveFromFoundationConfig { Disallowed, } +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] #[derive(Clone, Copy, Debug)] pub struct ScoringConfig { pub move_to_foundation: i32, @@ -44,6 +59,10 @@ impl Default for ScoringConfig { } } +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] #[derive(Clone, Debug, Default)] pub struct KlondikeConfig { pub draw_stock: DrawStockConfig, @@ -379,6 +398,10 @@ const fn sum(n: usize) -> usize { const STOCK: usize = 52 - sum(TABLEAUS); const NUM_RANKS: usize = Rank::RANKS.len(); +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize) +)] #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct KlondikeState { stock: Pile, @@ -613,6 +636,11 @@ fn test_klondike_iter() { assert_eq!(KlondikeIter::new().count(), 721); } +#[cfg_attr( + feature = "serde", + derive(serde_derive::Deserialize, serde_derive::Serialize), + serde(transparent) +)] #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct Klondike { state: KlondikeState, @@ -797,3 +825,28 @@ impl Game for Klondike { }) } } + +#[cfg(feature = "serde")] +impl serde::Serialize for KlondikeInstruction { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // There is 721 klondike instructions + let instruction_id = KlondikeIter::new() + .position(|instruction| &instruction == self) + .unwrap(); + serializer.serialize_u16(instruction_id as u16) + } +} +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for KlondikeInstruction { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let instruction_id = u16::deserialize(deserializer)?; + let instruction = KlondikeIter::new().nth(instruction_id as usize).unwrap(); + Ok(instruction) + } +} diff --git a/klondike/src/snapshots/klondike__test__json.snap b/klondike/src/snapshots/klondike__test__json.snap new file mode 100644 index 0000000..949eccd --- /dev/null +++ b/klondike/src/snapshots/klondike__test__json.snap @@ -0,0 +1,5 @@ +--- +source: klondike/src/test.rs +expression: serialized +--- +{"config":{"inner":{"draw_stock":"DrawOne","move_from_foundation":"Allowed","scoring":{"move_to_foundation":10,"flip_up_bonus":5,"move_to_tableau":5,"move_from_foundation":-15,"recycle":0}},"undo_penalty":-15,"solve_moves_budget":100000,"solve_states_budget":100000},"initial_state":{"stock":{"face_down":[52,51,21,59,36,45,49,50,22,3,58,39,41,61,13,57,34,56,28,1,19,12,17,5],"face_up":[]},"foundations":[[],[],[],[]],"tableau1":{"face_down":[],"face_up":[40]},"tableau2":{"face_down":[9],"face_up":[4]},"tableau3":{"face_down":[35,8],"face_up":[60]},"tableau4":{"face_down":[11,27,44],"face_up":[25]},"tableau5":{"face_down":[6,7,23,18],"face_up":[29]},"tableau6":{"face_down":[2,55,10,42,24],"face_up":[54]},"tableau7":{"face_down":[26,43,38,53,37,33],"face_up":[20]}},"instructions":[336,100,720,619,606,6,720,19,16,388,497,580,388,720,139,720,19,720,17,31,720,720,720,7,720,720,523,458,720,720,720,720,720,720,720,720,43,43,720,720,720,139,720,19,720,43,720,43,720,720,720,720,720,720,720,720,720,623,619,720,720,720,720,720,720,720,720,720,720,693,281,333,253,209,113,87,720,720,720,720,720,720,720,720,75,223,619,533,74,2,720,116,331,279,279,305,305,29,25,24,12,37,25,222,24,42,126,471,720,720,720,362,292,331,253,720,19,720,183,151,12,67,336,61,93,235,720,7,2,0,7,38,26,14,27,15,26,36,0,7,3,6,18,43,36,24,12,0,1,19,38,26,39,27,15,38,26,36]} diff --git a/klondike/src/snapshots/klondike__test__save_rmp.snap b/klondike/src/snapshots/klondike__test__save_rmp.snap new file mode 100644 index 0000000..a7ace5b --- /dev/null +++ b/klondike/src/snapshots/klondike__test__save_rmp.snap @@ -0,0 +1,6 @@ +--- +source: klondike/src/test.rs +expression: serialized +extension: bin +snapshot_kind: binary +--- diff --git a/klondike/src/snapshots/klondike__test__save_rmp.snap.bin b/klondike/src/snapshots/klondike__test__save_rmp.snap.bin new file mode 100644 index 0000000..0de4f97 Binary files /dev/null and b/klondike/src/snapshots/klondike__test__save_rmp.snap.bin differ diff --git a/klondike/src/test.rs b/klondike/src/test.rs new file mode 100644 index 0000000..ed8168f --- /dev/null +++ b/klondike/src/test.rs @@ -0,0 +1,55 @@ +use crate::Klondike; +use card_game::Session; + +#[test] +fn test_is_winnable() { + // is winnable + 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"); + } +} + +#[cfg(feature = "serde")] +#[test] +fn test_json() { + let mut session = Session::new_default(Klondike::with_seed(124)); + let solution_result = session.solve(); + if let Ok(Some(solution)) = solution_result { + for snapshot in solution.clean_solution() { + session.process_instruction(snapshot.instruction().clone()); + } + } + let serialized = serde_json::to_string(&session).unwrap(); + println!("serialized = {serialized}"); + let round_trip_session: Session = serde_json::from_str(&serialized).unwrap(); + let serialized2 = serde_json::to_string(&round_trip_session).unwrap(); + assert_eq!(serialized, serialized2); + + insta::assert_snapshot!(serialized); +} + +#[cfg(feature = "serde")] +#[test] +fn test_rmp() { + let mut session = Session::new_default(Klondike::with_seed(124)); + let solution_result = session.solve(); + if let Ok(Some(solution)) = solution_result { + for snapshot in solution.clean_solution() { + session.process_instruction(snapshot.instruction().clone()); + } + } + let serialized = rmp_serde::to_vec(&session).unwrap(); + println!("serialized.len() = {}", serialized.len()); + let round_trip_session: Session = rmp_serde::from_slice(&serialized).unwrap(); + let serialized2 = rmp_serde::to_vec(&round_trip_session).unwrap(); + assert_eq!(serialized, serialized2); + + insta::assert_binary_snapshot!("save_rmp.bin", serialized); +}