Skip to main content

dotzuki_runner/
save.rs

1//! Save/load for `dotzuki run`: [`GameSave`], a versioned JSON snapshot.
2//!
3//! The save lives at `<project>/.dotzuki-save.json` (a dotfile in the project
4//! dir; override with `--save-file`). It captures only *stable* overworld
5//! state — current map, player tile + facing, persistent story flags,
6//! language — never a suspended scene engine, so it is written at stable
7//! points (see [`crate::game::RunnerGame`]) and resumes into a fresh
8//! overworld.
9//!
10//! A plain versioned serde JSON file was chosen over the engine's binary
11//! slot/CRC16 framework (`dotzuki_engine::save`): one file, human-readable,
12//! trivially debuggable, and corruption tolerance is handled by the
13//! load-then-validate path below. Loading never fails hard: a missing,
14//! unreadable, corrupt or version-mismatched file logs a warning and
15//! returns `None`, and the game boots fresh.
16
17use std::collections::HashMap;
18#[cfg(not(target_arch = "wasm32"))]
19use std::path::Path;
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24/// Current save format version. Saves from a NEWER version are ignored
25/// (fresh boot); older saves load with per-field defaults (v1 has no party
26/// or inventory — both start fresh; v1/v2 have no money — the manifest's
27/// `shop.startMoney` default applies; party members without `level`/`exp`
28/// default to level 1 / 0 EXP — the levels fields are optional, so the
29/// version stays 3).
30pub const SAVE_VERSION: u32 = 3;
31
32/// Default save file name in the project directory.
33pub const DEFAULT_SAVE_FILE: &str = ".dotzuki-save.json";
34
35/// Saved player state (tile coordinates + facing + elevation level).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PlayerSave {
38    /// Tile X.
39    pub x: i32,
40    /// Tile Y.
41    pub y: i32,
42    /// Facing (`"down"` / `"up"` / `"left"` / `"right"`).
43    #[serde(default = "default_facing")]
44    pub facing: String,
45    /// Elevation level on the map (multi-level maps); absent ⇒ 0 (ground).
46    #[serde(default, skip_serializing_if = "is_ground_level")]
47    pub level: u8,
48}
49
50fn default_facing() -> String {
51    "down".to_string()
52}
53
54fn is_ground_level(v: &u8) -> bool {
55    *v == 0
56}
57
58/// One party member's persistent battle state (v2): current HP/MP and the
59/// non-volatile status (a `kind: Status` record id) carried between battles.
60/// Base stats are NOT saved — they are rebuilt from the records each battle.
61/// With a `battle.levels` block, `level`/`exp` ride along as OPTIONAL fields
62/// (the version stays 3: a save without them simply defaults to level 1 /
63/// 0 EXP, and older tooling ignores unknown keys).
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PartyMemberSave {
66    /// Party record id.
67    pub id: String,
68    /// Current HP (0 = fainted until healed).
69    pub hp: u32,
70    /// Current MP (resource pool).
71    pub mp: u32,
72    /// The persistent status record id, if any.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub status: Option<String>,
75    /// Current level (`battle.levels`); absent ⇒ 1.
76    #[serde(default = "default_level", skip_serializing_if = "is_default_level")]
77    pub level: u8,
78    /// EXP progress toward the next level; absent ⇒ 0.
79    #[serde(default, skip_serializing_if = "is_zero")]
80    pub exp: u32,
81}
82
83fn default_level() -> u8 {
84    1
85}
86fn is_default_level(v: &u8) -> bool {
87    *v == 1
88}
89fn is_zero(v: &u32) -> bool {
90    *v == 0
91}
92
93/// A `dotzuki run` save file.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct GameSave {
96    /// Format version; must be `<=` [`SAVE_VERSION`] to load (v1/v2 files
97    /// parse fine — the newer fields simply default to absent).
98    pub version: u32,
99    /// Current map id; `None` for a dialogue-only (map-less) project.
100    pub map: Option<String>,
101    /// Player tile + facing.
102    pub player: PlayerSave,
103    /// Persistent story flags (cross-scene truth), restored verbatim.
104    #[serde(default)]
105    pub flags: HashMap<String, bool>,
106    /// UI/script language at save time (informational; `--lang` wins).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub lang: Option<String>,
109    /// Persistent party state (v2); absent ⇒ fresh party at the first battle.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub party: Option<Vec<PartyMemberSave>>,
112    /// Persistent battle inventory (v2); absent ⇒ the manifest's
113    /// `battle.items.starting` counts at the first battle.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub inventory: Option<HashMap<String, u32>>,
116    /// The player's money (v3); absent ⇒ the manifest's `shop.startMoney`
117    /// (or its default) applies.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub money: Option<u32>,
120}
121
122impl GameSave {
123    /// Serialize as pretty JSON — the platform-neutral form a WASM shell
124    /// persists to localStorage (see [`RunnerGame::export_save`]).
125    ///
126    /// [`RunnerGame::export_save`]: crate::game::RunnerGame::export_save
127    pub fn to_json(&self) -> String {
128        serde_json::to_string_pretty(self).expect("save serialization is infallible")
129    }
130
131    /// Parse a save from JSON (no version check — callers compare
132    /// `version` against [`SAVE_VERSION`]).
133    ///
134    /// # Errors
135    ///
136    /// Fails when the text is not a valid [`GameSave`] JSON document.
137    pub fn from_json(text: &str) -> Result<Self> {
138        serde_json::from_str(text).context("failed to parse save JSON")
139    }
140
141    /// Read and validate a save file.
142    ///
143    /// Returns `None` — with a warning for anything but a simply-absent
144    /// file — when the file is missing, unreadable, unparseable, or from a
145    /// NEWER version: a bad save never crashes the boot. Older versions load
146    /// with per-field defaults (a v1 save has no party/inventory, so both
147    /// start fresh).
148    #[cfg(not(target_arch = "wasm32"))]
149    pub fn load(path: &Path) -> Option<Self> {
150        let text = match std::fs::read_to_string(path) {
151            Ok(text) => text,
152            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
153            Err(e) => {
154                log::warn!("save: cannot read {}: {e} — starting fresh", path.display());
155                return None;
156            }
157        };
158        match Self::from_json(&text) {
159            Ok(save) if save.version <= SAVE_VERSION => Some(save),
160            Ok(save) => {
161                log::warn!(
162                    "save: {} is version {} (newer than {SAVE_VERSION}) — starting fresh",
163                    path.display(),
164                    save.version
165                );
166                None
167            }
168            Err(e) => {
169                log::warn!(
170                    "save: {} is corrupt ({e:#}) — starting fresh",
171                    path.display()
172                );
173                None
174            }
175        }
176    }
177
178    /// Write the save as pretty JSON, via a temp file + rename so a crash
179    /// mid-write can't leave a truncated save behind.
180    ///
181    /// # Errors
182    ///
183    /// Fails when the temp file can't be written or renamed into place.
184    #[cfg(not(target_arch = "wasm32"))]
185    pub fn write(&self, path: &Path) -> Result<()> {
186        let text = self.to_json();
187        let mut tmp = path.as_os_str().to_owned();
188        tmp.push(".tmp");
189        let tmp = Path::new(&tmp);
190        std::fs::write(tmp, text).with_context(|| format!("failed to write {}", tmp.display()))?;
191        std::fs::rename(tmp, path)
192            .with_context(|| format!("failed to rename save into {}", path.display()))?;
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::sync::atomic::{AtomicU32, Ordering};
201
202    static NEXT_ID: AtomicU32 = AtomicU32::new(0);
203
204    /// A unique temp save path (no file written unless the test writes one).
205    fn save_path(test: &str) -> std::path::PathBuf {
206        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
207        std::env::temp_dir().join(format!(
208            "dotzuki-runner-save-{test}-{}-{id}.json",
209            std::process::id()
210        ))
211    }
212
213    fn base_save() -> GameSave {
214        GameSave {
215            version: SAVE_VERSION,
216            map: Some("Town".to_string()),
217            player: PlayerSave {
218                x: 3,
219                y: 4,
220                facing: "down".to_string(),
221                level: 0,
222            },
223            flags: HashMap::new(),
224            lang: None,
225            party: None,
226            inventory: None,
227            money: None,
228        }
229    }
230
231    #[test]
232    fn v2_round_trip_with_party_and_inventory() {
233        let path = save_path("roundtrip");
234        let mut save = base_save();
235        save.party = Some(vec![
236            PartyMemberSave {
237                id: "aria".to_string(),
238                hp: 32,
239                mp: 20,
240                status: Some("poison".to_string()),
241                level: 1,
242                exp: 0,
243            },
244            PartyMemberSave {
245                id: "bryn".to_string(),
246                hp: 0,
247                mp: 55,
248                status: None,
249                level: 1,
250                exp: 0,
251            },
252        ]);
253        save.inventory = Some(HashMap::from([("potion".to_string(), 2)]));
254        save.write(&path).expect("write");
255        let loaded = GameSave::load(&path).expect("v2 save loads");
256        let party = loaded.party.expect("party");
257        assert_eq!(party.len(), 2);
258        assert_eq!(
259            (party[0].id.as_str(), party[0].hp, party[0].mp),
260            ("aria", 32, 20)
261        );
262        assert_eq!(party[0].status.as_deref(), Some("poison"));
263        assert_eq!((party[1].id.as_str(), party[1].hp), ("bryn", 0));
264        assert_eq!(loaded.inventory.as_ref().unwrap().get("potion"), Some(&2));
265        assert_eq!(loaded.money, None, "v2-shaped save ⇒ money defaults");
266        let _ = std::fs::remove_file(&path);
267    }
268
269    #[test]
270    fn v3_round_trip_with_money() {
271        let path = save_path("v3money");
272        let mut save = base_save();
273        save.money = Some(750);
274        save.write(&path).expect("write");
275        let text = std::fs::read_to_string(&path).unwrap();
276        assert!(
277            text.contains("\"version\": 3"),
278            "written with the current version: {text}"
279        );
280        let loaded = GameSave::load(&path).expect("v3 save loads");
281        assert_eq!(loaded.version, 3);
282        assert_eq!(loaded.money, Some(750));
283        let _ = std::fs::remove_file(&path);
284    }
285
286    #[test]
287    fn v2_json_loads_with_money_defaulted() {
288        // A hand-written v2 save (the previous shape: no money field).
289        let path = save_path("v2");
290        std::fs::write(
291            &path,
292            r#"{
293  "version": 2,
294  "map": "Town",
295  "player": { "x": 1, "y": 2, "facing": "up" },
296  "flags": { "MET_GUIDE": true },
297  "party": [{ "id": "aria", "hp": 32, "mp": 20 }],
298  "inventory": { "potion": 2 }
299}"#,
300        )
301        .unwrap();
302        let loaded = GameSave::load(&path).expect("v2 save must still load");
303        assert_eq!(loaded.version, 2);
304        assert_eq!(loaded.party.as_ref().unwrap()[0].hp, 32);
305        assert_eq!(loaded.inventory.as_ref().unwrap().get("potion"), Some(&2));
306        assert_eq!(loaded.money, None, "v2 ⇒ money defaults at boot");
307        let _ = std::fs::remove_file(&path);
308    }
309
310    #[test]
311    fn v1_json_loads_with_defaults() {
312        // A hand-written v1 save (the old shape: no party/inventory fields).
313        let path = save_path("v1");
314        std::fs::write(
315            &path,
316            r#"{
317  "version": 1,
318  "map": "Town",
319  "player": { "x": 1, "y": 2, "facing": "up" },
320  "flags": { "MET_GUIDE": true },
321  "lang": "en"
322}"#,
323        )
324        .unwrap();
325        let loaded = GameSave::load(&path).expect("v1 save must still load");
326        assert_eq!(loaded.version, 1);
327        assert_eq!(loaded.map.as_deref(), Some("Town"));
328        assert!(loaded.flags.get("MET_GUIDE").copied().unwrap_or(false));
329        assert!(loaded.party.is_none(), "v1 ⇒ fresh party");
330        assert!(loaded.inventory.is_none(), "v1 ⇒ fresh inventory");
331        let _ = std::fs::remove_file(&path);
332    }
333
334    #[test]
335    fn party_member_level_exp_round_trip_and_defaults() {
336        // level/exp ride the save when a levels block produced them…
337        let path = save_path("levels");
338        let mut save = base_save();
339        save.party = Some(vec![PartyMemberSave {
340            id: "aria".to_string(),
341            hp: 63,
342            mp: 21,
343            status: None,
344            level: 2,
345            exp: 3,
346        }]);
347        save.write(&path).expect("write");
348        let loaded = GameSave::load(&path).expect("save loads");
349        let aria = &loaded.party.expect("party")[0];
350        assert_eq!((aria.level, aria.exp), (2, 3));
351        let _ = std::fs::remove_file(&path);
352
353        // …and a member WITHOUT the fields (an older save shape) reads as
354        // level 1 / 0 EXP — no version bump needed.
355        let path = save_path("levelsdefault");
356        std::fs::write(
357            &path,
358            r#"{
359  "version": 3,
360  "map": "Town",
361  "player": { "x": 1, "y": 2 },
362  "party": [{ "id": "aria", "hp": 32, "mp": 20 }]
363}"#,
364        )
365        .unwrap();
366        let loaded = GameSave::load(&path).expect("save without level/exp loads");
367        let aria = &loaded.party.expect("party")[0];
368        assert_eq!((aria.level, aria.exp), (1, 0));
369        let _ = std::fs::remove_file(&path);
370    }
371
372    #[test]
373    fn player_level_round_trip_and_default() {
374        // A non-zero elevation level rides the save…
375        let path = save_path("playerlevel");
376        let mut save = base_save();
377        save.player.level = 2;
378        save.write(&path).expect("write");
379        let loaded = GameSave::load(&path).expect("save loads");
380        assert_eq!(loaded.player.level, 2);
381        let _ = std::fs::remove_file(&path);
382
383        // …and a save without the field (the older shape) reads as ground.
384        let path = save_path("playerleveldefault");
385        std::fs::write(
386            &path,
387            r#"{ "version": 3, "map": "Town", "player": { "x": 1, "y": 2 } }"#,
388        )
389        .unwrap();
390        let loaded = GameSave::load(&path).expect("save without level loads");
391        assert_eq!(loaded.player.level, 0);
392        let _ = std::fs::remove_file(&path);
393    }
394
395    #[test]
396    fn future_version_is_a_fresh_boot() {
397        let path = save_path("v99");
398        std::fs::write(
399            &path,
400            r#"{ "version": 99, "map": "Town", "player": { "x": 0, "y": 0 } }"#,
401        )
402        .unwrap();
403        assert!(GameSave::load(&path).is_none(), "version 99 ⇒ fresh boot");
404        let _ = std::fs::remove_file(&path);
405    }
406
407    #[test]
408    fn corrupt_save_is_a_fresh_boot() {
409        let path = save_path("corrupt");
410        std::fs::write(&path, "{ not json").unwrap();
411        assert!(GameSave::load(&path).is_none());
412        let _ = std::fs::remove_file(&path);
413    }
414}