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!("save: {} is corrupt ({e:#}) — starting fresh", path.display());
170                None
171            }
172        }
173    }
174
175    /// Write the save as pretty JSON, via a temp file + rename so a crash
176    /// mid-write can't leave a truncated save behind.
177    ///
178    /// # Errors
179    ///
180    /// Fails when the temp file can't be written or renamed into place.
181    #[cfg(not(target_arch = "wasm32"))]
182    pub fn write(&self, path: &Path) -> Result<()> {
183        let text = self.to_json();
184        let mut tmp = path.as_os_str().to_owned();
185        tmp.push(".tmp");
186        let tmp = Path::new(&tmp);
187        std::fs::write(tmp, text)
188            .with_context(|| format!("failed to write {}", tmp.display()))?;
189        std::fs::rename(tmp, path)
190            .with_context(|| format!("failed to rename save into {}", path.display()))?;
191        Ok(())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::sync::atomic::{AtomicU32, Ordering};
199
200    static NEXT_ID: AtomicU32 = AtomicU32::new(0);
201
202    /// A unique temp save path (no file written unless the test writes one).
203    fn save_path(test: &str) -> std::path::PathBuf {
204        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
205        std::env::temp_dir().join(format!(
206            "dotzuki-runner-save-{test}-{}-{id}.json",
207            std::process::id()
208        ))
209    }
210
211    fn base_save() -> GameSave {
212        GameSave {
213            version: SAVE_VERSION,
214            map: Some("Town".to_string()),
215            player: PlayerSave {
216                x: 3,
217                y: 4,
218                facing: "down".to_string(),
219                level: 0,
220            },
221            flags: HashMap::new(),
222            lang: None,
223            party: None,
224            inventory: None,
225            money: None,
226        }
227    }
228
229    #[test]
230    fn v2_round_trip_with_party_and_inventory() {
231        let path = save_path("roundtrip");
232        let mut save = base_save();
233        save.party = Some(vec![
234            PartyMemberSave {
235                id: "aria".to_string(),
236                hp: 32,
237                mp: 20,
238                status: Some("poison".to_string()),
239                level: 1,
240                exp: 0,
241            },
242            PartyMemberSave {
243                id: "bryn".to_string(),
244                hp: 0,
245                mp: 55,
246                status: None,
247                level: 1,
248                exp: 0,
249            },
250        ]);
251        save.inventory = Some(HashMap::from([("potion".to_string(), 2)]));
252        save.write(&path).expect("write");
253        let loaded = GameSave::load(&path).expect("v2 save loads");
254        let party = loaded.party.expect("party");
255        assert_eq!(party.len(), 2);
256        assert_eq!((party[0].id.as_str(), party[0].hp, party[0].mp), ("aria", 32, 20));
257        assert_eq!(party[0].status.as_deref(), Some("poison"));
258        assert_eq!((party[1].id.as_str(), party[1].hp), ("bryn", 0));
259        assert_eq!(loaded.inventory.as_ref().unwrap().get("potion"), Some(&2));
260        assert_eq!(loaded.money, None, "v2-shaped save ⇒ money defaults");
261        let _ = std::fs::remove_file(&path);
262    }
263
264    #[test]
265    fn v3_round_trip_with_money() {
266        let path = save_path("v3money");
267        let mut save = base_save();
268        save.money = Some(750);
269        save.write(&path).expect("write");
270        let text = std::fs::read_to_string(&path).unwrap();
271        assert!(
272            text.contains("\"version\": 3"),
273            "written with the current version: {text}"
274        );
275        let loaded = GameSave::load(&path).expect("v3 save loads");
276        assert_eq!(loaded.version, 3);
277        assert_eq!(loaded.money, Some(750));
278        let _ = std::fs::remove_file(&path);
279    }
280
281    #[test]
282    fn v2_json_loads_with_money_defaulted() {
283        // A hand-written v2 save (the previous shape: no money field).
284        let path = save_path("v2");
285        std::fs::write(
286            &path,
287            r#"{
288  "version": 2,
289  "map": "Town",
290  "player": { "x": 1, "y": 2, "facing": "up" },
291  "flags": { "MET_GUIDE": true },
292  "party": [{ "id": "aria", "hp": 32, "mp": 20 }],
293  "inventory": { "potion": 2 }
294}"#,
295        )
296        .unwrap();
297        let loaded = GameSave::load(&path).expect("v2 save must still load");
298        assert_eq!(loaded.version, 2);
299        assert_eq!(loaded.party.as_ref().unwrap()[0].hp, 32);
300        assert_eq!(loaded.inventory.as_ref().unwrap().get("potion"), Some(&2));
301        assert_eq!(loaded.money, None, "v2 ⇒ money defaults at boot");
302        let _ = std::fs::remove_file(&path);
303    }
304
305    #[test]
306    fn v1_json_loads_with_defaults() {
307        // A hand-written v1 save (the old shape: no party/inventory fields).
308        let path = save_path("v1");
309        std::fs::write(
310            &path,
311            r#"{
312  "version": 1,
313  "map": "Town",
314  "player": { "x": 1, "y": 2, "facing": "up" },
315  "flags": { "MET_GUIDE": true },
316  "lang": "en"
317}"#,
318        )
319        .unwrap();
320        let loaded = GameSave::load(&path).expect("v1 save must still load");
321        assert_eq!(loaded.version, 1);
322        assert_eq!(loaded.map.as_deref(), Some("Town"));
323        assert!(loaded.flags.get("MET_GUIDE").copied().unwrap_or(false));
324        assert!(loaded.party.is_none(), "v1 ⇒ fresh party");
325        assert!(loaded.inventory.is_none(), "v1 ⇒ fresh inventory");
326        let _ = std::fs::remove_file(&path);
327    }
328
329    #[test]
330    fn party_member_level_exp_round_trip_and_defaults() {
331        // level/exp ride the save when a levels block produced them…
332        let path = save_path("levels");
333        let mut save = base_save();
334        save.party = Some(vec![PartyMemberSave {
335            id: "aria".to_string(),
336            hp: 63,
337            mp: 21,
338            status: None,
339            level: 2,
340            exp: 3,
341        }]);
342        save.write(&path).expect("write");
343        let loaded = GameSave::load(&path).expect("save loads");
344        let aria = &loaded.party.expect("party")[0];
345        assert_eq!((aria.level, aria.exp), (2, 3));
346        let _ = std::fs::remove_file(&path);
347
348        // …and a member WITHOUT the fields (an older save shape) reads as
349        // level 1 / 0 EXP — no version bump needed.
350        let path = save_path("levelsdefault");
351        std::fs::write(
352            &path,
353            r#"{
354  "version": 3,
355  "map": "Town",
356  "player": { "x": 1, "y": 2 },
357  "party": [{ "id": "aria", "hp": 32, "mp": 20 }]
358}"#,
359        )
360        .unwrap();
361        let loaded = GameSave::load(&path).expect("save without level/exp loads");
362        let aria = &loaded.party.expect("party")[0];
363        assert_eq!((aria.level, aria.exp), (1, 0));
364        let _ = std::fs::remove_file(&path);
365    }
366
367    #[test]
368    fn player_level_round_trip_and_default() {
369        // A non-zero elevation level rides the save…
370        let path = save_path("playerlevel");
371        let mut save = base_save();
372        save.player.level = 2;
373        save.write(&path).expect("write");
374        let loaded = GameSave::load(&path).expect("save loads");
375        assert_eq!(loaded.player.level, 2);
376        let _ = std::fs::remove_file(&path);
377
378        // …and a save without the field (the older shape) reads as ground.
379        let path = save_path("playerleveldefault");
380        std::fs::write(
381            &path,
382            r#"{ "version": 3, "map": "Town", "player": { "x": 1, "y": 2 } }"#,
383        )
384        .unwrap();
385        let loaded = GameSave::load(&path).expect("save without level loads");
386        assert_eq!(loaded.player.level, 0);
387        let _ = std::fs::remove_file(&path);
388    }
389
390    #[test]
391    fn future_version_is_a_fresh_boot() {
392        let path = save_path("v99");
393        std::fs::write(
394            &path,
395            r#"{ "version": 99, "map": "Town", "player": { "x": 0, "y": 0 } }"#,
396        )
397        .unwrap();
398        assert!(GameSave::load(&path).is_none(), "version 99 ⇒ fresh boot");
399        let _ = std::fs::remove_file(&path);
400    }
401
402    #[test]
403    fn corrupt_save_is_a_fresh_boot() {
404        let path = save_path("corrupt");
405        std::fs::write(&path, "{ not json").unwrap();
406        assert!(GameSave::load(&path).is_none());
407        let _ = std::fs::remove_file(&path);
408    }
409}