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 = 4;
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::v4::GameStateV4::from(versions::v3::GameStateV3::from(
37                versions::v2::GameStateV2::from(v1),
38            ))
39            .into_current()
40            .migrate_runtime(),
41            Err(_) => GameState::default().migrate_runtime(),
42        },
43        2 => match serde_json::from_str::<versions::v2::GameStateV2>(json) {
44            Ok(v2) => versions::v4::GameStateV4::from(versions::v3::GameStateV3::from(v2))
45                .into_current()
46                .migrate_runtime(),
47            Err(_) => GameState::default().migrate_runtime(),
48        },
49        3 => match serde_json::from_str::<versions::v3::GameStateV3>(json) {
50            Ok(v3) => versions::v4::GameStateV4::from(v3)
51                .into_current()
52                .migrate_runtime(),
53            Err(_) => GameState::default().migrate_runtime(),
54        },
55        4 => match serde_json::from_str::<versions::v4::GameStateV4>(json) {
56            Ok(v4) => v4.into_current().migrate_runtime(),
57            Err(_) => GameState::default().migrate_runtime(),
58        },
59        _ => GameState::default().migrate_runtime(),
60    }
61}
62
63/// Serialize the live state to its on-disk JSON form. The `version` field
64/// is whatever the caller has on `state` — `GameState::default()` and the
65/// migration chain both stamp [`CURRENT_VERSION`], so the only way to write
66/// a wrong version is to mutate `state.version` by hand, which nothing does.
67///
68/// Sanitizes non-finite f64 fields (NaN / INFINITY) to 0.0 before
69/// serializing, since `serde_json` refuses to serialize non-finite f64
70/// and historically callers `let _ =`-swallowed the resulting Err — the
71/// player's progress would silently stop being written. Reaching a
72/// non-finite value normally is impossible, but a corrupted save loaded
73/// once can poison `cuques` / `lifetime_cuques` and we'd rather lose
74/// the corruption than lose subsequent saves.
75pub fn save_to_string(state: &GameState) -> serde_json::Result<String> {
76    let mut sanitized = state.clone();
77    if !sanitized.cuques.is_finite() {
78        sanitized.cuques = 0.0;
79    }
80    if !sanitized.lifetime_cuques.is_finite() {
81        sanitized.lifetime_cuques = 0.0;
82    }
83    if !sanitized.best_fps.is_finite() {
84        sanitized.best_fps = 0.0;
85    }
86    serde_json::to_string_pretty(&sanitized)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn pre_versioned_json_loads_through_v1() {
95        // A save written by main (no `version` field, V1 shape) must load
96        // without losing data. V4 silently drops `upgrades_earned` (the
97        // 1.0.0 breaking change), so we only assert the surviving fields.
98        let legacy = r#"{
99            "cuques": 1234.5,
100            "total_clicks": 99,
101            "lifetime_cuques": 1234.5,
102            "best_fps": 0.0,
103            "golden_caught": 0,
104            "fingerers_owned": {"index_finger": 7},
105            "achievements_earned": ["first_finger"],
106            "upgrades_earned": ["click_mult_1"],
107            "prestige": 0,
108            "total_play_ticks": 0,
109            "buffs": []
110        }"#;
111        let s = load_from_str(legacy);
112        assert_eq!(s.version, CURRENT_VERSION);
113        assert_eq!(s.cuques, 1234.5);
114        assert_eq!(s.total_clicks, 99);
115        assert_eq!(s.fingerer_count("index_finger"), 7);
116        assert!(s.has_achievement("first_finger"));
117        // V3→V4 dropped the old upgrades_earned. The tree starts with
118        // the anchor (origin) auto-owned — `migrate_runtime` inserts it
119        // for any save that didn't have it.
120        assert_eq!(s.tree.bought.len(), 1);
121        assert!(
122            s.tree
123                .bought
124                .contains(&crate::game::tree::coord::TreeCoord::ORIGIN)
125        );
126    }
127
128    #[test]
129    fn malformed_json_falls_back_to_default() {
130        let s = load_from_str("{ not valid json");
131        assert_eq!(s.cuques, 0.0);
132        assert_eq!(s.version, CURRENT_VERSION);
133    }
134
135    #[test]
136    fn round_trip_through_save_to_string_preserves_state() {
137        let original = GameState {
138            cuques: 4242.0,
139            total_clicks: 17,
140            ..GameState::default()
141        };
142        let json = save_to_string(&original).expect("serialize");
143        let loaded = load_from_str(&json);
144        assert_eq!(loaded.cuques, 4242.0);
145        assert_eq!(loaded.total_clicks, 17);
146        assert_eq!(loaded.version, CURRENT_VERSION);
147    }
148}