sokoban_elements/saved_game.rs
1mod errors;
2mod hexoban_move_characters;
3mod octoban_move_characters;
4mod parser;
5mod sokoban_move_characters;
6mod solving_mode;
7mod step;
8mod trioban_move_characters;
9
10// Generated code - `clippy::all` alone is not enough: the workspace lint table also
11// enables pedantic, restriction and nursery lints, none of which are members of `all`.
12#[allow(clippy::all, clippy::pedantic, clippy::restriction, clippy::nursery)]
13mod grammar;
14
15use std::ops::Index;
16
17pub use errors::{SavedGameSyntaxError, SavedGameSyntaxErrorKind};
18use parser::Parser;
19pub use solving_mode::SolvingMode;
20pub use step::Step;
21
22use crate::Tessellation;
23
24/// Sequence of pusher steps with accompanying metadata (ie. title, author, etc..).
25///
26/// Besides movement direction, `SavedGame` also represents:
27///
28/// - forward or reverse [`SolvingMode`]
29/// - pusher jumps (in reverse solving mode)
30/// - pusher selection steps on boards with multiple pushers (`Multiban`)
31///
32/// A saved game is tied to the physical board it was recorded against. Mirroring, rotating,
33/// resizing, or trimming that board can invalidate movement directions and piece positions. The
34/// mismatch is not detected automatically and not enforced anywhere.
35/// [`GameBoard`](crate::GameBoard) and [`SavedGame`] are separate structures, and the client is
36/// expected to maintain the relationship between them.
37///
38/// # Examples
39///
40/// ```rust
41/// # use sokoban_elements::*;
42/// #
43/// # fn main() -> Result<(), SavedGameSyntaxError> {
44/// let saved_game = SavedGame::parsed("llRRddsssSSSnNN", Tessellation::Hexoban)?;
45/// let saved_game = SavedGame::parsed("nlLNrr", Tessellation::Trioban)?;
46///
47/// assert_eq!(saved_game[3].direction(), Direction::NorthEast);
48/// assert_eq!(saved_game[3], Step::PushOrPull(Direction::NorthEast));
49/// # Ok(())
50/// # }
51/// ```
52///
53/// # Editing
54///
55/// `SavedGame` is not editable in a sense where `Step`s can be added, removed, inserted or
56/// modified. This is by design. This type is meant for archival purposes, as an input of Sokoban
57/// data to writers and as output of Sokoban data readers. Game engines and solvers will need to use
58/// their own structures to track movement history. At the end of solving session, they may use
59/// `SavedGame` to archive progress of that session.
60///
61/// # Step counts, and the metrics that are not here
62///
63/// [`moves_count()`](SavedGame::moves_count()) and [`pushes_count()`](SavedGame::pushes_count())
64/// are facts about the step sequence, so they live here.
65///
66/// The rest of the standard metric set does not. Box lines and box changes need to know *which* box
67/// each push moved, and no saved game text format encodes box identity, so they can only be had by
68/// replaying the steps against a [`GameBoard`](crate::GameBoard). Replaying a game is playing it
69/// and that is out of scope for this crate.
70///
71/// # Solutions
72///
73/// Whether a `SavedGame` solves a board, how far it replays on one, and what its
74/// [`SolvingMode::Reverse`] solution looks like played forwards are all questions about playing
75/// rather than about what is recorded, so again - out of scope for this crate. Nothing here plays,
76/// validates or edits a game: game engines and solvers keep their own board state, and are expected
77/// to track these as they go rather than replay a `SavedGame` to recover them.
78///
79/// # Serde
80///
81/// With the `serde` feature, `SavedGame` is a map whose steps are the same text a `.sok` file would
82/// carry:
83///
84/// ```json
85/// {
86/// "tessellation": "Sokoban",
87/// "moves": "[]LuR",
88/// "title": "",
89/// "solved_by": "",
90/// "notes": "",
91/// "current_move_idx": 0
92/// }
93/// ```
94///
95/// There is deliberately no `solving_mode` field. [`SolvingMode::Reverse`] is always encoded in the
96/// text itself as a leading jump section (`[]` when the game does not open with a jump), so the
97/// moves are the single source of truth and no document can declare a mode contradicting its own
98/// steps. A `solving_mode` key is rejected rather than silently ignored, as are all other unknown
99/// fields.
100///
101/// Everything except `tessellation` and `moves` may be omitted. `current_move_idx` is special: the
102/// parser reads it from a `*` marker inside the moves text, so omitting the field keeps whatever
103/// the text says, while providing it overrides that. An empty `moves` string is the empty
104/// [`SolvingMode::Forward`] game (an empty reverse game prints as `"[]"`).
105///
106/// ```rust
107/// use sokoban_elements::*;
108///
109/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
110/// # #[cfg(feature = "serde")] {
111/// let saved_game = SavedGame::parsed("lurd", Tessellation::Sokoban)?;
112///
113/// let json = serde_json::to_string(&saved_game)?;
114/// assert_eq!(serde_json::from_str::<SavedGame>(&json)?, saved_game);
115/// # }
116/// # Ok(())
117/// # }
118/// ```
119#[derive(Debug, Clone, PartialEq)]
120pub struct SavedGame {
121 solving_mode: SolvingMode,
122 steps: Vec<Step>,
123 tessellation: Tessellation,
124
125 pub title: String,
126 pub solved_by: String,
127 pub notes: String,
128
129 /// Index of currently played move from [`steps()`](Self::steps()).
130 ///
131 /// This can be found in some sokoban file formats so [`SavedGame`] has a place for it. On the
132 /// other hand, [`SavedGame`] doesn't use it in any way, so it doesn't validate it against
133 /// [`steps()`](SavedGame::steps()) either. [`SavedGame`] clients are advised to maintain and
134 /// validate this value themselves.
135 pub current_move_idx: usize,
136}
137
138/// Tests if `input` contains only characters that are valid in saved game strings.
139///
140/// This method doesn't test if `input` is a valid saved game. The only way to do that is
141/// to actually [parse](SavedGame::parsed()) it.
142pub(crate) fn is_saved_game_line(line: &str) -> bool {
143 !line
144 .chars()
145 .all(|c| c.is_whitespace() || c.is_ascii_digit())
146 && line.chars().all(|c| {
147 c.is_ascii_digit()
148 || c.is_whitespace()
149 || Parser::is_move_char(c)
150 || Parser::is_push_char(c)
151 || Parser::is_saved_game_marker(c)
152 || (c == Parser::RLE_EOL)
153 || (c == Parser::RLE_GROUP_START)
154 || (c == Parser::RLE_GROUP_END)
155 })
156}
157
158impl SavedGame {
159 /// Constructs `SavedGame` from parsed string of moves in context of
160 /// [`Tessellation`](crate::Tessellation).
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// # use sokoban_elements::*;
166 /// #
167 /// # fn main() -> Result<(), SavedGameSyntaxError> {
168 /// let saved_game: SavedGame = SavedGame::parsed("lurdLurd", Tessellation::Sokoban)?;
169 /// # Ok(())
170 /// # }
171 /// ```
172 ///
173 /// # Grammar
174 ///
175 /// Save game strings use following characters:
176 ///
177 /// | | char |
178 /// | ------------------- | -------------------------------------- |
179 /// | Move pusher | 'l', 'u', 'r', 'd', 'w', 'e', 'n', 's' |
180 /// | Push/pull box | 'L', 'U', 'R', 'D', 'W', 'E', 'N', 'S' |
181 /// | Begin jump | '[' |
182 /// | End jump | ']' |
183 /// | Begin pusher change | '{' |
184 /// | End pusher change | '{' |
185 /// | Current position | '*' |
186 ///
187 /// Mapping between characters and movement direction depends on
188 /// [`Tessellation`](crate::Tessellation) context and is not the same in every
189 /// [`Tessellation`](crate::Tessellation). Also, not all [`Tessellation`](crate::Tessellation)s
190 /// use all movement characters, so this method may return error if it finds legal movement
191 /// character but in wrong game tessellation context. Ie.
192 /// [`Sokoban`](crate::Tessellation::Sokoban) will return error for character `'W'`.
193 ///
194 /// `SavedGame` strings mostly follow [SokobanYASC](https://sourceforge.net/projects/sokobanyasc/)
195 /// specification:
196 ///
197 /// ```sok_file_format
198 /// ::::::::::::::::::::::::::: Moves ::::::::::::::::::::::::::
199 /// :: ::
200 /// :: Remarks: ::
201 /// :: ::
202 /// :: Moves may be run-length encoded, e.g., "3r4U" means ::
203 /// :: "rrrUUUU", and "2(3(dr)R)" means "drdrdrRdrdrdrR". ::
204 /// :: Each line must, however, have at least one proper ::
205 /// :: non-digit character. Spaces between moves are ::
206 /// :: allowed. ::
207 /// :: ::
208 /// :: Jumps and pulls: Only in reverse mode saved games ::
209 /// :: and solutions. ::
210 /// :: ::
211 /// :: Reverse mode saved games and solutions must begin ::
212 /// :: with a jump, even if it is empty. An example: ::
213 /// :: "[]U[rr]d". ::
214 /// :: ::
215 /// :: Pusher changes: Only in puzzles with multiple ::
216 /// :: pushers, e.g., Multiban. Moves inside the braces ::
217 /// :: depict the relative movement to get from the ::
218 /// :: currently active pusher to the next active pusher. ::
219 /// :: At game start, a "{...}" sequence activates the ::
220 /// :: pusher relative to the top-left pusher. An example: ::
221 /// :: "{rddd}Urr{uul}uLU". If the top-left pusher is the ::
222 /// :: first active pusher, then the empty "{}" can be ::
223 /// :: omitted. ::
224 /// :: ::
225 /// :: The current position is optional and defaults to the ::
226 /// :: position after the last move. ::
227 /// :: ::
228 /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
229 /// ```
230 ///
231 /// - [Moves grammar](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/saved_game_text_grammar.svg)
232 /// - [RLE grammar](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/rle_grammar.svg)
233 pub fn parsed(data: &str, tessellation: Tessellation) -> Result<Self, SavedGameSyntaxError> {
234 let (steps, is_reverse, current_idx) = Parser::parse(data, tessellation)?;
235
236 Ok(Self {
237 steps,
238 tessellation,
239 solving_mode: if is_reverse {
240 SolvingMode::Reverse
241 } else {
242 SolvingMode::Forward
243 },
244 current_move_idx: current_idx,
245 title: String::new(),
246 solved_by: String::new(),
247 notes: String::new(),
248 })
249 }
250
251 /// Constructs new [`SavedGame`] from sequence of [`Step`], validating each of them in context
252 /// of given game tessellation.
253 ///
254 /// - if `data` contains [`Step::Jump`], marks created [`SavedGame`] as [`SolvingMode::Reverse`]
255 /// - if `data` doesn't contain [`Step::Jump`] uses value of `solving_mode` argument
256 ///
257 /// # Examples
258 ///
259 /// ```
260 /// use sokoban_elements::*;
261 /// # use std::vec;
262 ///
263 /// # fn main() -> Result<(), SavedGameSyntaxError> {
264 /// let moves = vec![
265 /// Step::Move(Direction::Down),
266 /// Step::PushOrPull(Direction::Down),
267 /// Step::PushOrPull(Direction::Down),
268 /// Step::Jump(Direction::Down),
269 /// Step::Jump(Direction::Down),
270 /// Step::Move(Direction::Down),
271 /// ];
272 ///
273 /// let saved_game: SavedGame =
274 /// SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Forward))?;
275 ///
276 /// assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
277 /// # Ok(())
278 /// # }
279 /// ```
280 pub fn from_steps(
281 data: &[Step], tessellation: Tessellation, solving_mode: Option<SolvingMode>,
282 ) -> Result<Self, SavedGameSyntaxError> {
283 let mut is_reverse = false;
284
285 for (idx, step) in data.iter().enumerate() {
286 if matches!(step, Step::Jump(_)) {
287 is_reverse = true;
288 }
289
290 if !tessellation.known_directions().contains(&step.direction()) {
291 return Err(SavedGameSyntaxError::new(
292 SavedGameSyntaxErrorKind::InvalidStepDirection {
293 input: *step,
294 tessellation,
295 position: idx,
296 },
297 ));
298 }
299 }
300
301 Ok(Self {
302 steps: data.to_vec(),
303 tessellation,
304 solving_mode: if is_reverse {
305 SolvingMode::Reverse
306 } else {
307 solving_mode.unwrap_or_default()
308 },
309 current_move_idx: 0,
310 title: String::new(),
311 solved_by: String::new(),
312 notes: String::new(),
313 })
314 }
315
316 /// Serializes [`steps()`](SavedGame::steps()) into saved game string.
317 pub fn printed_steps(&self) -> String {
318 Parser::print(&self.steps, self.tessellation, self.solving_mode)
319 }
320
321 /// [`SolvingMode`] this [`SavedGame`] was recorded in.
322 pub fn solving_mode(&self) -> SolvingMode { self.solving_mode }
323
324 /// [`Tessellation`](crate::Tessellation) this [`SavedGame`] was recorded against.
325 pub fn tessellation(&self) -> Tessellation { self.tessellation }
326
327 /// Slice of [`Step`]s in this [`SavedGame`].
328 pub fn steps(&self) -> &[Step] { &self.steps }
329
330 /// Total number of steps in this [`SavedGame`].
331 pub fn len(&self) -> usize { self.steps.len() }
332
333 /// True if this [`SavedGame`] has no steps.
334 pub fn is_empty(&self) -> bool { self.steps.is_empty() }
335
336 /// Total number of [`Step::Move`] steps in this [`SavedGame`].
337 pub fn moves_count(&self) -> usize {
338 self.steps
339 .iter()
340 .filter(|s| matches!(s, Step::Move(_)))
341 .count()
342 }
343
344 /// Total number of [`Step::PushOrPull`] steps in this [`SavedGame`].
345 pub fn pushes_count(&self) -> usize {
346 self.steps
347 .iter()
348 .filter(|s| matches!(s, Step::PushOrPull(_)))
349 .count()
350 }
351}
352
353impl Index<usize> for SavedGame {
354 type Output = Step;
355
356 fn index(&self, index: usize) -> &Self::Output { &self.steps[index] }
357}
358
359#[cfg(feature = "serde")]
360mod serde_impl {
361 use serde::de::Error as _;
362 use serde::{Deserialize, Deserializer, Serialize, Serializer};
363
364 use super::{SavedGame, SolvingMode};
365 use crate::Tessellation;
366
367 /// Wire format of [`SavedGame`]: the printed steps plus the metadata that text can't carry.
368 #[derive(Serialize, Deserialize)]
369 #[serde(rename = "SavedGame", deny_unknown_fields)]
370 struct SavedGameRepr {
371 tessellation: Tessellation,
372 moves: String,
373
374 #[serde(default)]
375 title: String,
376
377 #[serde(default)]
378 solved_by: String,
379
380 #[serde(default)]
381 notes: String,
382
383 /// `Option` so that an absent field leaves the index the moves text declared with `*`
384 /// intact. Serializing always writes `Some`.
385 #[serde(default)]
386 current_move_idx: Option<usize>,
387 }
388
389 impl Serialize for SavedGame {
390 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
391 SavedGameRepr {
392 tessellation: self.tessellation,
393 moves: self.printed_steps(),
394 title: self.title.clone(),
395 solved_by: self.solved_by.clone(),
396 notes: self.notes.clone(),
397 current_move_idx: Some(self.current_move_idx),
398 }
399 .serialize(serializer)
400 }
401 }
402
403 impl<'de> Deserialize<'de> for SavedGame {
404 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
405 let repr = SavedGameRepr::deserialize(deserializer)?;
406
407 let mut retv = if repr.moves.trim().is_empty() {
408 // The printer emits "" only for a forward game with no steps, and the saved game
409 // grammar requires at least one section, so `parsed("")` would fail. An empty
410 // reverse game prints as "[]" and parses normally, so the two stay distinct.
411 SavedGame::from_steps(&[], repr.tessellation, Some(SolvingMode::Forward))
412 .map_err(D::Error::custom)?
413 } else {
414 // Parsing validates every step against the tessellation and recovers the solving
415 // mode from the leading jump section.
416 SavedGame::parsed(&repr.moves, repr.tessellation).map_err(D::Error::custom)?
417 };
418
419 retv.title = repr.title;
420 retv.solved_by = repr.solved_by;
421 retv.notes = repr.notes;
422 if let Some(current_move_idx) = repr.current_move_idx {
423 retv.current_move_idx = current_move_idx;
424 }
425
426 Ok(retv)
427 }
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::{Direction, Tessellation};
435
436 /// How many [`Step::Jump`] steps a game holds.
437 ///
438 /// Not the count of jump *sequences*.
439 fn jump_steps(saved_game: &SavedGame) -> usize {
440 saved_game
441 .steps()
442 .iter()
443 .filter(|s| matches!(s, Step::Jump(_)))
444 .count()
445 }
446
447 #[test]
448 fn it_can_be_constructed_from_char_data() -> Result<(), SavedGameSyntaxError> {
449 let saved_game: SavedGame = SavedGame::parsed("lurdLURD", Tessellation::Sokoban)?;
450
451 assert_eq!(saved_game.len(), 8);
452 assert_eq!(saved_game.moves_count(), 4);
453 assert_eq!(saved_game.pushes_count(), 4);
454 assert_eq!(jump_steps(&saved_game), 0);
455
456 Ok(())
457 }
458
459 #[test]
460 fn it_correctly_recognizes_reverse_mode_saved_games_in_char_data()
461 -> Result<(), SavedGameSyntaxError> {
462 let saved_game: SavedGame = SavedGame::parsed("lurdLURD", Tessellation::Sokoban)?;
463 assert_eq!(saved_game.solving_mode(), SolvingMode::Forward);
464
465 // An empty `[]` section is what marks a game as reverse, and it contributes no step of its
466 // own - so the marker is not mistaken for a jump anywhere it appears.
467 let saved_game: SavedGame = SavedGame::parsed("[]", Tessellation::Sokoban)?;
468 assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
469 assert_eq!(jump_steps(&saved_game), 0);
470
471 let saved_game: SavedGame = SavedGame::parsed("lurd[]", Tessellation::Sokoban)?;
472 assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
473 assert_eq!(jump_steps(&saved_game), 0);
474
475 let saved_game: SavedGame = SavedGame::parsed("l[u]rd[]", Tessellation::Sokoban)?;
476 assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
477 assert_eq!(jump_steps(&saved_game), 1);
478
479 Ok(())
480 }
481
482 #[test]
483 fn it_can_be_constructed_from_steps() -> Result<(), SavedGameSyntaxError> {
484 let moves = vec![
485 Step::PushOrPull(Direction::Left),
486 Step::Move(Direction::Up),
487 Step::PushOrPull(Direction::Right),
488 Step::Jump(Direction::Down),
489 ];
490
491 let saved_game: SavedGame = SavedGame::from_steps(&moves, Tessellation::Sokoban, None)?;
492
493 assert_eq!(saved_game.moves_count(), 1);
494 assert_eq!(saved_game.pushes_count(), 2);
495 assert_eq!(jump_steps(&saved_game), 1);
496
497 Ok(())
498 }
499
500 #[test]
501 fn when_constructed_from_steps_may_use_default_solving_mode() -> Result<(), SavedGameSyntaxError>
502 {
503 let moves = vec![
504 Step::PushOrPull(Direction::Left),
505 Step::Move(Direction::Up),
506 Step::PushOrPull(Direction::Right),
507 ];
508
509 let saved_game: SavedGame = SavedGame::from_steps(&moves, Tessellation::Sokoban, None)?;
510 assert_eq!(saved_game.solving_mode, SolvingMode::Forward);
511
512 Ok(())
513 }
514
515 #[test]
516 fn when_constructed_from_steps_may_use_solving_mode_argument()
517 -> Result<(), SavedGameSyntaxError> {
518 let moves = vec![
519 Step::PushOrPull(Direction::Left),
520 Step::Move(Direction::Up),
521 Step::PushOrPull(Direction::Right),
522 ];
523
524 let saved_game: SavedGame =
525 SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Reverse))?;
526 assert_eq!(saved_game.solving_mode, SolvingMode::Reverse);
527
528 Ok(())
529 }
530
531 #[test]
532 fn when_constructed_from_steps_may_ignore_solving_mode_argument()
533 -> Result<(), SavedGameSyntaxError> {
534 let moves = vec![
535 Step::PushOrPull(Direction::Left),
536 Step::Move(Direction::Up),
537 Step::PushOrPull(Direction::Right),
538 Step::Jump(Direction::Right),
539 ];
540
541 let saved_game: SavedGame =
542 SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Forward))?;
543 assert_eq!(saved_game.solving_mode, SolvingMode::Reverse);
544
545 Ok(())
546 }
547
548 #[test]
549 fn it_can_convert_steps_to_string() -> Result<(), SavedGameSyntaxError> {
550 let saved_game: SavedGame = SavedGame::parsed("lurdllurd", Tessellation::Sokoban)?;
551 let converted = saved_game.printed_steps();
552 assert_eq!(converted, "lurdllurd");
553
554 let saved_game: SavedGame = SavedGame::parsed("lur{dllu}rd", Tessellation::Sokoban)?;
555 let converted = saved_game.printed_steps();
556 assert_eq!(converted, "lur{dllu}rd");
557
558 let saved_game: SavedGame = SavedGame::parsed("lur[dllu]rd", Tessellation::Sokoban)?;
559 let converted = saved_game.printed_steps();
560 assert_eq!(converted, "[]lur[dllu]rd");
561
562 Ok(())
563 }
564
565 #[test]
566 fn when_converting_steps_to_string_ensures_start_jump_for_reverse_mode_saved_games()
567 -> Result<(), SavedGameSyntaxError> {
568 let moves = vec![
569 Step::PushOrPull(Direction::Left),
570 Step::Move(Direction::Up),
571 Step::PushOrPull(Direction::Right),
572 ];
573 let saved_game: SavedGame =
574 SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Reverse))?;
575 let converted = saved_game.printed_steps();
576 assert_eq!(converted, "[]LuR");
577
578 let moves = vec![
579 Step::Jump(Direction::Left),
580 Step::PushOrPull(Direction::Left),
581 Step::Move(Direction::Up),
582 Step::PushOrPull(Direction::Right),
583 ];
584 let saved_game: SavedGame =
585 SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Reverse))?;
586 let converted = saved_game.printed_steps();
587 assert_eq!(converted, "[l]LuR");
588
589 Ok(())
590 }
591
592 #[cfg(feature = "serde")]
593 mod serde {
594 use std::error::Error;
595
596 use super::*;
597
598 fn round_tripped(saved_game: &SavedGame) -> Result<SavedGame, Box<dyn Error>> {
599 let json = serde_json::to_string(saved_game)?;
600
601 Ok(serde_json::from_str::<SavedGame>(&json)?)
602 }
603
604 #[test]
605 fn it_round_trips_through_json() -> Result<(), Box<dyn Error>> {
606 let cases = [
607 (Tessellation::Sokoban, "lurdLURD"),
608 (Tessellation::Hexoban, "llRRddsssSSSnNN"),
609 (Tessellation::Trioban, "nlLNrr"),
610 (Tessellation::Octoban, "lurdLURD"),
611 ];
612
613 for (tessellation, moves) in cases {
614 let saved_game = SavedGame::parsed(moves, tessellation)?;
615
616 assert_eq!(round_tripped(&saved_game)?, saved_game, "{tessellation:?}");
617 }
618
619 Ok(())
620 }
621
622 /// Pusher selection steps are printed as `{..}` sections and must survive the text form.
623 #[test]
624 fn it_round_trips_pusher_changes() -> Result<(), Box<dyn Error>> {
625 let saved_game = SavedGame::parsed("lur{dllu}rd", Tessellation::Sokoban)?;
626
627 let parsed = round_tripped(&saved_game)?;
628 assert_eq!(parsed, saved_game);
629 assert_eq!(parsed.printed_steps(), "lur{dllu}rd");
630
631 Ok(())
632 }
633
634 #[test]
635 fn it_round_trips_reverse_mode_games() -> Result<(), Box<dyn Error>> {
636 let saved_game = SavedGame::parsed("[l]LuR", Tessellation::Sokoban)?;
637 assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
638
639 let parsed = round_tripped(&saved_game)?;
640 assert_eq!(parsed, saved_game);
641 assert_eq!(parsed.solving_mode(), SolvingMode::Reverse);
642
643 Ok(())
644 }
645
646 /// An empty forward game prints as `""` and an empty reverse game as `"[]"`, so the two
647 /// stay distinguishable even though the parser rejects the empty string.
648 #[test]
649 fn when_game_has_no_steps_solving_mode_still_round_trips() -> Result<(), Box<dyn Error>> {
650 for solving_mode in [SolvingMode::Forward, SolvingMode::Reverse] {
651 let saved_game =
652 SavedGame::from_steps(&[], Tessellation::Sokoban, Some(solving_mode))?;
653
654 let parsed = round_tripped(&saved_game)?;
655 assert_eq!(parsed, saved_game);
656 assert!(parsed.is_empty());
657 assert_eq!(parsed.solving_mode(), solving_mode);
658 }
659
660 Ok(())
661 }
662
663 #[test]
664 fn it_round_trips_metadata() -> Result<(), Box<dyn Error>> {
665 let mut saved_game = SavedGame::parsed("lurd", Tessellation::Sokoban)?;
666 saved_game.title = "Best attempt".to_string();
667 saved_game.solved_by = "Tomislav".to_string();
668 saved_game.notes = "Not really solved".to_string();
669 saved_game.current_move_idx = 3;
670
671 let parsed = round_tripped(&saved_game)?;
672
673 assert_eq!(parsed.title, "Best attempt");
674 assert_eq!(parsed.solved_by, "Tomislav");
675 assert_eq!(parsed.notes, "Not really solved");
676 assert_eq!(parsed.current_move_idx, 3);
677
678 Ok(())
679 }
680
681 #[test]
682 fn when_only_moves_are_given_the_rest_defaults() -> Result<(), Box<dyn Error>> {
683 let saved_game: SavedGame =
684 serde_json::from_str(r#"{"tessellation": "Sokoban", "moves": "lurd"}"#)?;
685
686 assert_eq!(saved_game, SavedGame::parsed("lurd", Tessellation::Sokoban)?);
687
688 Ok(())
689 }
690
691 /// `current_move_idx` is also encoded inside the moves text as `*`, so an absent field must
692 /// not overwrite what the text says.
693 #[test]
694 fn when_current_move_idx_is_absent_the_moves_marker_wins() -> Result<(), Box<dyn Error>> {
695 let saved_game: SavedGame =
696 serde_json::from_str(r#"{"tessellation": "Sokoban", "moves": "lu*rd"}"#)?;
697
698 assert_eq!(saved_game.current_move_idx, 2);
699 assert_eq!(saved_game.len(), 4);
700
701 Ok(())
702 }
703
704 #[test]
705 fn when_current_move_idx_is_given_it_wins() -> Result<(), Box<dyn Error>> {
706 let saved_game: SavedGame = serde_json::from_str(
707 r#"{"tessellation": "Sokoban", "moves": "lu*rd", "current_move_idx": 0}"#,
708 )?;
709
710 assert_eq!(saved_game.current_move_idx, 0);
711
712 Ok(())
713 }
714
715 /// Solving mode is derived from the moves text alone, so a document that also declares it
716 /// must be rejected rather than silently ignored.
717 #[test]
718 fn it_rejects_a_solving_mode_field() {
719 let result = serde_json::from_str::<SavedGame>(
720 r#"{"tessellation": "Sokoban", "moves": "lurd", "solving_mode": "Reverse"}"#,
721 );
722
723 assert!(result.is_err());
724 }
725
726 #[test]
727 fn it_rejects_steps_illegal_for_the_tessellation() {
728 let result = serde_json::from_str::<SavedGame>(
729 r#"{"tessellation": "Sokoban", "moves": "lurdnw"}"#,
730 );
731
732 assert!(result.is_err());
733 }
734 }
735}