cuqueclicker_lib/save/mod.rs
1//! Save persistence with versioned migration chain.
2//!
3//! Wire format on disk: a single JSON object with a `"version": u32` field.
4//! Pre-versioned saves (no field) are treated as V1 by [`migrate::peek_version`].
5//! Each version has a frozen struct in `versions/vN.rs` and a conversion into
6//! the next version's struct, walked end-to-end on load.
7//!
8//! Once shipped, a `versions/vN.rs` is FROZEN. New schema changes go in
9//! `vN+1.rs` together with a `From<vN> for vN+1` impl and a unit test. See
10//! the "Save versioning" section in `CLAUDE.md` for the full policy.
11//!
12//! Callers (the `Persistence` impls under `platform/`) should never touch
13//! `serde_json` on `GameState` directly — go through [`load_from_str`] /
14//! [`save_to_string`] so the version dispatch stays in one place.
15
16pub mod migrate;
17pub mod versions;
18
19use crate::game::state::GameState;
20
21/// The version number every fresh save is written as. Bump in lockstep
22/// with adding a new `versions/vN.rs` and routing it in [`load_from_str`].
23pub const CURRENT_VERSION: u32 = 3;
24
25/// Best-effort load from a JSON string. Falls back to a default state if
26/// the input is malformed at any layer of the chain. The result is always
27/// passed through [`GameState::migrate_runtime`] so ephemeral
28/// `#[serde(skip)]` fields (flash vecs, count-up tweens, etc.) are seeded.
29///
30/// Versions outside the known set deserialize as default — this is
31/// pessimistic on purpose (a future version is more likely to have new
32/// fields than the current code can interpret).
33pub fn load_from_str(json: &str) -> GameState {
34 match migrate::peek_version(json) {
35 1 => match serde_json::from_str::<versions::v1::GameStateV1>(json) {
36 Ok(v1) => versions::v3::GameStateV3::from(versions::v2::GameStateV2::from(v1))
37 .into_current()
38 .migrate_runtime(),
39 Err(_) => GameState::default().migrate_runtime(),
40 },
41 2 => match serde_json::from_str::<versions::v2::GameStateV2>(json) {
42 Ok(v2) => versions::v3::GameStateV3::from(v2)
43 .into_current()
44 .migrate_runtime(),
45 Err(_) => GameState::default().migrate_runtime(),
46 },
47 3 => match serde_json::from_str::<versions::v3::GameStateV3>(json) {
48 Ok(v3) => v3.into_current().migrate_runtime(),
49 Err(_) => GameState::default().migrate_runtime(),
50 },
51 _ => GameState::default().migrate_runtime(),
52 }
53}
54
55/// Serialize the live state to its on-disk JSON form. The `version` field
56/// is whatever the caller has on `state` — `GameState::default()` and the
57/// migration chain both stamp [`CURRENT_VERSION`], so the only way to write
58/// a wrong version is to mutate `state.version` by hand, which nothing does.
59pub fn save_to_string(state: &GameState) -> serde_json::Result<String> {
60 serde_json::to_string_pretty(state)
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn pre_versioned_json_loads_through_v1() {
69 // A save written by main (no `version` field, today's shape) must
70 // load without losing data.
71 let legacy = r#"{
72 "cuques": 1234.5,
73 "total_clicks": 99,
74 "lifetime_cuques": 1234.5,
75 "best_fps": 0.0,
76 "golden_caught": 0,
77 "fingerers_owned": {"index_finger": 7},
78 "achievements_earned": ["first_finger"],
79 "upgrades_earned": ["click_mult_1"],
80 "prestige": 0,
81 "total_play_ticks": 0,
82 "buffs": []
83 }"#;
84 let s = load_from_str(legacy);
85 assert_eq!(s.version, CURRENT_VERSION);
86 assert_eq!(s.cuques, 1234.5);
87 assert_eq!(s.total_clicks, 99);
88 assert_eq!(s.fingerer_count("index_finger"), 7);
89 assert!(s.has_upgrade("click_mult_1"));
90 assert!(s.has_achievement("first_finger"));
91 }
92
93 #[test]
94 fn malformed_json_falls_back_to_default() {
95 let s = load_from_str("{ not valid json");
96 assert_eq!(s.cuques, 0.0);
97 assert_eq!(s.version, CURRENT_VERSION);
98 }
99
100 #[test]
101 fn round_trip_through_save_to_string_preserves_state() {
102 let original = GameState {
103 cuques: 4242.0,
104 total_clicks: 17,
105 ..GameState::default()
106 };
107 let json = save_to_string(&original).expect("serialize");
108 let loaded = load_from_str(&json);
109 assert_eq!(loaded.cuques, 4242.0);
110 assert_eq!(loaded.total_clicks, 17);
111 assert_eq!(loaded.version, CURRENT_VERSION);
112 }
113}