Skip to main content

sokoban_elements/
puzzle.rs

1use crate::{GameBoard, SavedGame, SokobanPlus};
2
3/// Sokoban puzzle that consists of [`GameBoard`] and accompanying metadata.
4///
5/// # Serde
6///
7/// With the `serde` feature, every field except `board` may be omitted. See
8/// [`GameBoard`](crate::GameBoard), [`SavedGame`](crate::SavedGame) and
9/// [`SokobanPlus`](crate::SokobanPlus) for their wire formats.
10// `deny_unknown_fields` deliberately does *not* belong here, unlike on the three hand-written
11// wire-format mirrors (`GameBoardRepr`, `SavedGameRepr`, `SokobanPlusRepr`). `serde` documents it
12// as incompatible with `#[serde(flatten)]`, on the flattened field as much as on the outer struct:
13// a flattened value is handed every key its container did not claim, so a downstream wrapper adding
14// fields of its own beside a flattened `Puzzle` or `Collection` would fail to deserialize. Those
15// three mirrors are closed formats this crate fully owns; these two are extension points, as are
16// `ReaderSettings` and `WriterSettings` in `src/io/settings.rs`.
17//
18// Nor may these fields grow a `skip_serializing_if` - see the serde rules in `src/lib.rs`.
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct Puzzle {
22    pub board:        GameBoard,
23    #[cfg_attr(feature = "serde", serde(default))]
24    pub title:        String,
25    #[cfg_attr(feature = "serde", serde(default))]
26    pub author:       String,
27    #[cfg_attr(feature = "serde", serde(default))]
28    pub notes:        String,
29    #[cfg_attr(feature = "serde", serde(default))]
30    pub sokoban_plus: Option<SokobanPlus>,
31    /// ⚠️ Puzzle may pair a Hexoban board with a Sokoban saved game — nothing validates this.
32    ///
33    /// One thing that could've been done is to make `SavedGame` generic over the tessellation, but
34    /// that would have made the code more complex and less ergonomic. Therefore, we document this
35    /// and leave it to the user to ensure that the saved games match the board's tessellation.
36    ///
37    /// [`SavedGame`] and [`GameBoard`] are independent types and callers are responsible for
38    /// maintaining relationships between their instances.
39    #[cfg_attr(feature = "serde", serde(default))]
40    pub saved_games:  Vec<SavedGame>,
41}
42
43/// Collection of Sokoban puzzles and accompanying metadata.
44#[derive(Debug, Default, Clone, PartialEq)]
45#[cfg_attr(
46    feature = "serde",
47    derive(serde::Serialize, serde::Deserialize),
48    serde(default)
49)]
50pub struct Collection {
51    pub title:      String,
52    pub author:     String,
53    pub created_at: String,
54    pub updated_at: String,
55    pub email:      String,
56    pub url:        String,
57    pub notes:      String,
58    pub puzzles:    Vec<Puzzle>,
59}
60
61impl Collection {
62    pub fn new() -> Self {
63        Self {
64            title:      String::new(),
65            author:     String::new(),
66            created_at: String::new(),
67            updated_at: String::new(),
68            email:      String::new(),
69            url:        String::new(),
70            notes:      String::new(),
71            puzzles:    Vec::new(),
72        }
73    }
74}
75
76#[cfg(all(test, feature = "serde"))]
77mod tests {
78    use std::error::Error;
79
80    use super::*;
81    use crate::{BoxID, PlusID, Tessellation};
82
83    fn puzzle() -> Result<Puzzle, Box<dyn Error>> {
84        let mut sokoban_plus = SokobanPlus::new();
85        sokoban_plus.set_box_plus_id(BoxID::from(0_u8), Some(PlusID::new(1)?));
86
87        Ok(Puzzle {
88            board:        GameBoard::parsed("#####|#@$.#|#####", Tessellation::Sokoban)?,
89            title:        "Puzzle 1".to_string(),
90            author:       "Tomislav".to_string(),
91            notes:        "First one".to_string(),
92            saved_games:  vec![SavedGame::parsed("[]LuR", Tessellation::Sokoban)?],
93            sokoban_plus: Some(sokoban_plus),
94        })
95    }
96
97    #[test]
98    fn it_round_trips_collections_through_json() -> Result<(), Box<dyn Error>> {
99        let mut collection = Collection::new();
100        collection.title = "Test collection".to_string();
101        collection.author = "Tomislav".to_string();
102        collection.puzzles = vec![
103            puzzle()?,
104            Puzzle {
105                board:        GameBoard::parsed("---#-#-#-", Tessellation::Hexoban)?,
106                title:        String::new(),
107                author:       String::new(),
108                notes:        String::new(),
109                saved_games:  Vec::new(),
110                sokoban_plus: None,
111            },
112        ];
113
114        let json = serde_json::to_string(&collection)?;
115
116        assert_eq!(serde_json::from_str::<Collection>(&json)?, collection);
117
118        Ok(())
119    }
120
121    #[test]
122    fn when_puzzle_has_only_a_board_the_rest_defaults() -> Result<(), Box<dyn Error>> {
123        let puzzle: Puzzle =
124            serde_json::from_str(r##"{"board": {"tessellation": "Sokoban", "board": "#@$.#"}}"##)?;
125
126        assert_eq!(puzzle.board.width(), 5);
127        assert!(puzzle.title.is_empty());
128        assert!(puzzle.saved_games.is_empty());
129        assert_eq!(puzzle.sokoban_plus, None);
130
131        Ok(())
132    }
133
134    #[test]
135    fn when_collection_is_empty_it_round_trips() -> Result<(), Box<dyn Error>> {
136        let collection: Collection = serde_json::from_str("{}")?;
137
138        assert_eq!(collection, Collection::new());
139
140        Ok(())
141    }
142}