1use std::collections::HashMap;
18#[cfg(not(target_arch = "wasm32"))]
19use std::path::Path;
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24pub const SAVE_VERSION: u32 = 3;
31
32pub const DEFAULT_SAVE_FILE: &str = ".dotzuki-save.json";
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PlayerSave {
38 pub x: i32,
40 pub y: i32,
42 #[serde(default = "default_facing")]
44 pub facing: String,
45 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PartyMemberSave {
66 pub id: String,
68 pub hp: u32,
70 pub mp: u32,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub status: Option<String>,
75 #[serde(default = "default_level", skip_serializing_if = "is_default_level")]
77 pub level: u8,
78 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct GameSave {
96 pub version: u32,
99 pub map: Option<String>,
101 pub player: PlayerSave,
103 #[serde(default)]
105 pub flags: HashMap<String, bool>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub lang: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub party: Option<Vec<PartyMemberSave>>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub inventory: Option<HashMap<String, u32>>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub money: Option<u32>,
120}
121
122impl GameSave {
123 pub fn to_json(&self) -> String {
128 serde_json::to_string_pretty(self).expect("save serialization is infallible")
129 }
130
131 pub fn from_json(text: &str) -> Result<Self> {
138 serde_json::from_str(text).context("failed to parse save JSON")
139 }
140
141 #[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 #[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 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 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 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 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 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 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 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}