Skip to main content

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 = 5;
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    use versions::{v1, v2, v3, v4, v5};
35    match migrate::peek_version(json) {
36        1 => match serde_json::from_str::<v1::GameStateV1>(json) {
37            Ok(v) => v5::GameStateV5::from(v4::GameStateV4::from(v3::GameStateV3::from(
38                v2::GameStateV2::from(v),
39            )))
40            .into_current()
41            .migrate_runtime(),
42            Err(_) => GameState::default().migrate_runtime(),
43        },
44        2 => match serde_json::from_str::<v2::GameStateV2>(json) {
45            Ok(v) => v5::GameStateV5::from(v4::GameStateV4::from(v3::GameStateV3::from(v)))
46                .into_current()
47                .migrate_runtime(),
48            Err(_) => GameState::default().migrate_runtime(),
49        },
50        3 => match serde_json::from_str::<v3::GameStateV3>(json) {
51            Ok(v) => v5::GameStateV5::from(v4::GameStateV4::from(v))
52                .into_current()
53                .migrate_runtime(),
54            Err(_) => GameState::default().migrate_runtime(),
55        },
56        4 => match serde_json::from_str::<v4::GameStateV4>(json) {
57            Ok(v) => v5::GameStateV5::from(v).into_current().migrate_runtime(),
58            Err(_) => GameState::default().migrate_runtime(),
59        },
60        5 => match serde_json::from_str::<v5::GameStateV5>(json) {
61            Ok(v) => v.into_current().migrate_runtime(),
62            Err(_) => GameState::default().migrate_runtime(),
63        },
64        _ => GameState::default().migrate_runtime(),
65    }
66}
67
68/// Serialize the live state to its on-disk JSON form. The `version` field
69/// is whatever the caller has on `state` — `GameState::default()` and the
70/// migration chain both stamp [`CURRENT_VERSION`], so the only way to write
71/// a wrong version is to mutate `state.version` by hand, which nothing does.
72///
73/// Sanitizes non-finite f64 fields (NaN / INFINITY) to 0.0 before
74/// serializing, since `serde_json` refuses to serialize non-finite f64
75/// and historically callers `let _ =`-swallowed the resulting Err — the
76/// player's progress would silently stop being written. Reaching a
77/// non-finite value normally is impossible, but a corrupted save loaded
78/// once can poison `cuques` / `lifetime_cuques` and we'd rather lose
79/// the corruption than lose subsequent saves.
80pub fn save_to_string(state: &GameState) -> serde_json::Result<String> {
81    // With Mag-typed counters, "non-finite" is no longer reachable —
82    // `Mag` stores log10 and saturates on impossible inputs. The clone+
83    // sanitize dance is preserved as belt-and-suspenders against any
84    // future buggy assignment that puts a NaN into the log10 field; if
85    // we ever see one, fall the affected counter back to zero.
86    let mut sanitized = state.clone();
87    if sanitized.cuques.log10.is_nan() {
88        sanitized.cuques = crate::bignum::Mag::ZERO;
89    }
90    if sanitized.lifetime_cuques.log10.is_nan() {
91        sanitized.lifetime_cuques = crate::bignum::Mag::ZERO;
92    }
93    if sanitized.best_fps.log10.is_nan() {
94        sanitized.best_fps = crate::bignum::Mag::ZERO;
95    }
96    serde_json::to_string_pretty(&sanitized)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn pre_versioned_json_loads_through_v1() {
105        // A save written by main (no `version` field, V1 shape) must load
106        // without losing data. V4 silently drops `upgrades_earned` (the
107        // 1.0.0 breaking change), so we only assert the surviving fields.
108        let legacy = r#"{
109            "cuques": 1234.5,
110            "total_clicks": 99,
111            "lifetime_cuques": 1234.5,
112            "best_fps": 0.0,
113            "golden_caught": 0,
114            "fingerers_owned": {"index_finger": 7},
115            "achievements_earned": ["first_finger"],
116            "upgrades_earned": ["click_mult_1"],
117            "prestige": 0,
118            "total_play_ticks": 0,
119            "buffs": []
120        }"#;
121        let s = load_from_str(legacy);
122        assert_eq!(s.version, CURRENT_VERSION);
123        assert_eq!(s.cuques, crate::bignum::Mag::from_f64(1234.5));
124        assert_eq!(s.total_clicks, 99);
125        assert_eq!(s.fingerer_count("index_finger"), 7);
126        assert!(s.has_achievement("first_finger"));
127        // V3→V4 dropped the old upgrades_earned. The tree starts with
128        // the anchor (origin) auto-owned — `migrate_runtime` inserts it
129        // for any save that didn't have it.
130        assert_eq!(s.tree.bought.len(), 1);
131        assert!(
132            s.tree
133                .bought
134                .contains(&crate::game::tree::coord::TreeCoord::ORIGIN)
135        );
136    }
137
138    #[test]
139    fn malformed_json_falls_back_to_default() {
140        let s = load_from_str("{ not valid json");
141        assert_eq!(s.cuques, crate::bignum::Mag::ZERO);
142        assert_eq!(s.version, CURRENT_VERSION);
143    }
144
145    #[test]
146    fn round_trip_through_save_to_string_preserves_state() {
147        let original = GameState {
148            cuques: crate::bignum::Mag::from_f64(4242.0),
149            total_clicks: 17,
150            ..GameState::default()
151        };
152        let json = save_to_string(&original).expect("serialize");
153        let loaded = load_from_str(&json);
154        assert_eq!(loaded.cuques, crate::bignum::Mag::from_f64(4242.0));
155        assert_eq!(loaded.total_clicks, 17);
156        assert_eq!(loaded.version, CURRENT_VERSION);
157    }
158
159    #[test]
160    fn round_trip_preserves_huge_mag_values_past_f64_range() {
161        // Regression for the V5-blocker: before the bump, `Mag` serialized
162        // as `{"log10": x}` once it cleared `f64`-finite range, but the
163        // V4 frozen schema's `cuques: f64` rejected the struct shape and
164        // `load_from_str` silently fell back to `default()` — wiping the
165        // entire save. With V5 declaring Mag-typed counters, the struct
166        // form is now part of the schema and the round-trip survives.
167        let original = GameState {
168            cuques: crate::bignum::Mag { log10: 600.0 },
169            lifetime_cuques: crate::bignum::Mag { log10: 1200.0 },
170            best_fps: crate::bignum::Mag { log10: 750.0 },
171            ..GameState::default()
172        };
173        let json = save_to_string(&original).expect("serialize");
174        let loaded = load_from_str(&json);
175        assert!((loaded.cuques.log10 - 600.0).abs() < 1e-9);
176        assert!((loaded.lifetime_cuques.log10 - 1200.0).abs() < 1e-9);
177        assert!((loaded.best_fps.log10 - 750.0).abs() < 1e-9);
178    }
179
180    #[test]
181    fn v4_json_with_plain_number_fields_still_loads_under_v5() {
182        // A V4-shaped save (`"cuques": 1234.5`) must still parse cleanly
183        // through the V5 reader. The `Mag` untagged-serde shim accepts
184        // bare JSON numbers exactly because of this requirement.
185        let v4_json = r#"{
186            "version": 4,
187            "cuques": 1234.5,
188            "total_clicks": 88,
189            "lifetime_cuques": 6789.0,
190            "best_fps": 12.0,
191            "golden_caught": 0,
192            "lucky_caught": 0,
193            "frenzy_caught": 0,
194            "buff_caught": 0,
195            "green_coin_caught": 0,
196            "fingerers_state": {},
197            "achievements_earned": [],
198            "prestige": 2,
199            "total_play_ticks": 4242,
200            "buffs": [],
201            "tree": { "bought": [], "cursor": {"x": 0, "y": 0}, "last_bought": null }
202        }"#;
203        let loaded = load_from_str(v4_json);
204        assert_eq!(loaded.version, CURRENT_VERSION);
205        assert!((loaded.cuques.to_f64() - 1234.5).abs() < 1e-9);
206        assert!((loaded.lifetime_cuques.to_f64() - 6789.0).abs() < 1e-9);
207        assert_eq!(loaded.total_clicks, 88);
208        assert_eq!(loaded.prestige, 2);
209    }
210}