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!("save: {} is corrupt ({e:#}) — starting fresh", path.display());
170 None
171 }
172 }
173 }
174
175 #[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 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 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 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 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 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 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 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}