Skip to main content

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}