Skip to main content

cuqueclicker_lib/save/versions/
v4.rs

1//! Save schema V4 — drops the old hardcoded `upgrades_earned` set entirely
2//! and introduces the infinite procedural upgrade tree.
3//!
4//! V3 → V4 is a **breaking change**. The old `UPGRADES` catalog is gone;
5//! every entry the player had earned under V3 is silently dropped. In
6//! exchange the player's V4 save carries an `UpgradeTreeState` whose
7//! `bought` set is empty — they start fresh on the new tree at lot
8//! `(0, 0)`.
9//!
10//! Once V4 is on `main` this file is FROZEN. Subsequent schema changes go
11//! in `v5.rs` together with a `From<GameStateV4> for GameStateV5`
12//! conversion.
13//!
14//! Each persisted enum/struct re-uses V2's frozen copies (modifier source,
15//! effect, duration, modifier, fingerer state, buff). Only the top-level
16//! `GameState` shape changes between V3 and V4.
17
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet};
20
21use super::v2::{BuffV2, FingererStateV2};
22use super::v3::GameStateV3;
23use crate::game::state::GameState;
24use crate::game::tree::UpgradeTreeState;
25
26fn default_v4_version() -> u32 {
27    4
28}
29
30#[derive(Clone, Serialize, Deserialize)]
31pub struct GameStateV4 {
32    #[serde(default = "default_v4_version")]
33    pub version: u32,
34    #[serde(default)]
35    pub cuques: f64,
36    #[serde(default)]
37    pub total_clicks: u64,
38    #[serde(default)]
39    pub lifetime_cuques: f64,
40    #[serde(default)]
41    pub best_fps: f64,
42    #[serde(default)]
43    pub golden_caught: u64,
44    #[serde(default)]
45    pub lucky_caught: u64,
46    #[serde(default)]
47    pub frenzy_caught: u64,
48    #[serde(default)]
49    pub buff_caught: u64,
50    #[serde(default)]
51    pub green_coin_caught: u64,
52    #[serde(default)]
53    pub fingerers_state: HashMap<String, FingererStateV2>,
54    #[serde(default)]
55    pub achievements_earned: HashSet<String>,
56    #[serde(default)]
57    pub prestige: u64,
58    #[serde(default)]
59    pub total_play_ticks: u64,
60    #[serde(default)]
61    pub buffs: Vec<BuffV2>,
62    /// V4 addition: the infinite upgrade tree state. Replaces the old
63    /// hardcoded `upgrades_earned: HashSet<String>` entirely.
64    #[serde(default)]
65    pub tree: UpgradeTreeState,
66}
67
68impl GameStateV4 {
69    /// Convert a V4 snapshot into the live `GameState`. Persisted fields
70    /// copy verbatim; ephemeral state (`#[serde(skip)]` fields) stays at
71    /// `Default` and is seeded by `migrate_runtime`.
72    pub fn into_current(self) -> GameState {
73        let fingerers_state = self
74            .fingerers_state
75            .into_iter()
76            .map(|(id, st)| (id, st.into()))
77            .collect();
78        let buffs = self.buffs.into_iter().map(Into::into).collect();
79        GameState {
80            version: crate::save::CURRENT_VERSION,
81            cuques: self.cuques,
82            total_clicks: self.total_clicks,
83            lifetime_cuques: self.lifetime_cuques,
84            best_fps: self.best_fps,
85            golden_caught: self.golden_caught,
86            lucky_caught: self.lucky_caught,
87            frenzy_caught: self.frenzy_caught,
88            buff_caught: self.buff_caught,
89            green_coin_caught: self.green_coin_caught,
90            fingerers_state,
91            achievements_earned: self.achievements_earned,
92            prestige: self.prestige,
93            total_play_ticks: self.total_play_ticks,
94            buffs,
95            tree: self.tree,
96            ..GameState::default()
97        }
98    }
99}
100
101/// V3 → V4 conversion. Drops `upgrades_earned` (old hardcoded UPGRADES
102/// catalog is retired); inits a fresh empty `UpgradeTreeState`. Every
103/// other field passes through verbatim; `version` is stamped to 4.
104///
105/// The breaking change is intentional — coordinated with the game's
106/// 1.0.0 release. Players who upgrade lose their old hand-curated
107/// upgrade purchases but keep cuques, fingerers, achievements, prestige,
108/// and active buffs.
109impl From<GameStateV3> for GameStateV4 {
110    fn from(v3: GameStateV3) -> Self {
111        GameStateV4 {
112            version: 4,
113            cuques: v3.cuques,
114            total_clicks: v3.total_clicks,
115            lifetime_cuques: v3.lifetime_cuques,
116            best_fps: v3.best_fps,
117            golden_caught: v3.golden_caught,
118            lucky_caught: v3.lucky_caught,
119            frenzy_caught: v3.frenzy_caught,
120            buff_caught: v3.buff_caught,
121            green_coin_caught: v3.green_coin_caught,
122            fingerers_state: v3.fingerers_state,
123            achievements_earned: v3.achievements_earned,
124            // upgrades_earned silently dropped — old catalog is gone.
125            prestige: v3.prestige,
126            total_play_ticks: v3.total_play_ticks,
127            buffs: v3.buffs,
128            tree: UpgradeTreeState::default(),
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::game::tree::coord::TreeCoord;
137    use crate::save::versions::v2::{
138        FingererStateV2, ModifierDurationV2, ModifierEffectV2, ModifierSourceV2, ModifierV2,
139    };
140
141    fn empty_v3() -> GameStateV3 {
142        GameStateV3 {
143            version: 3,
144            cuques: 0.0,
145            total_clicks: 0,
146            lifetime_cuques: 0.0,
147            best_fps: 0.0,
148            golden_caught: 0,
149            lucky_caught: 0,
150            frenzy_caught: 0,
151            buff_caught: 0,
152            green_coin_caught: 0,
153            fingerers_state: HashMap::new(),
154            achievements_earned: HashSet::new(),
155            upgrades_earned: HashSet::new(),
156            prestige: 0,
157            total_play_ticks: 0,
158            buffs: vec![],
159        }
160    }
161
162    #[test]
163    fn v3_to_v4_drops_upgrades_earned_silently() {
164        let mut v3 = empty_v3();
165        v3.upgrades_earned.insert("click_mult_1".into());
166        v3.upgrades_earned.insert("hand_of_god_mult_3".into());
167        let v4: GameStateV4 = v3.into();
168        assert_eq!(v4.version, 4);
169        // V4 has no upgrades_earned field at all — the JSON round-trip
170        // proves it doesn't sneak through.
171        let json = serde_json::to_string(&v4).expect("serialize");
172        assert!(
173            !json.contains("upgrades_earned"),
174            "old upgrades field must not appear in V4 JSON: {json}"
175        );
176    }
177
178    #[test]
179    fn v3_to_v4_inits_empty_tree() {
180        let v4: GameStateV4 = empty_v3().into();
181        assert!(v4.tree.bought.is_empty());
182        assert_eq!(v4.tree.cursor, TreeCoord::ORIGIN);
183        assert_eq!(v4.tree.last_bought, None);
184    }
185
186    #[test]
187    fn v3_to_v4_preserves_all_other_fields() {
188        let v3 = GameStateV3 {
189            version: 3,
190            cuques: 1234.5,
191            total_clicks: 99,
192            lifetime_cuques: 5_000.0,
193            best_fps: 12.0,
194            golden_caught: 17,
195            lucky_caught: 10,
196            frenzy_caught: 4,
197            buff_caught: 2,
198            green_coin_caught: 1,
199            fingerers_state: [(
200                "index_finger".to_string(),
201                FingererStateV2 {
202                    count: 9,
203                    modifiers: vec![ModifierV2 {
204                        source: ModifierSourceV2::GreenCoin,
205                        effects: vec![ModifierEffectV2::AddPercent(0.10)],
206                        duration: ModifierDurationV2::Permanent,
207                        created_at_tick: 0,
208                    }],
209                },
210            )]
211            .into_iter()
212            .collect(),
213            achievements_earned: ["first_finger".into()].into_iter().collect(),
214            upgrades_earned: ["click_mult_1".into()].into_iter().collect(),
215            prestige: 3,
216            total_play_ticks: 9_000,
217            buffs: vec![BuffV2::ClickFrenzy {
218                ticks_remaining: 100,
219                initial_ticks: 260,
220                mult: 777.0,
221            }],
222        };
223
224        let v4: GameStateV4 = v3.into();
225
226        assert_eq!(v4.cuques, 1234.5);
227        assert_eq!(v4.total_clicks, 99);
228        assert_eq!(v4.lifetime_cuques, 5_000.0);
229        assert_eq!(v4.best_fps, 12.0);
230        assert_eq!(v4.golden_caught, 17);
231        assert_eq!(v4.lucky_caught, 10);
232        assert_eq!(v4.frenzy_caught, 4);
233        assert_eq!(v4.buff_caught, 2);
234        assert_eq!(v4.green_coin_caught, 1);
235        assert_eq!(v4.prestige, 3);
236        assert_eq!(v4.total_play_ticks, 9_000);
237        assert_eq!(v4.buffs.len(), 1);
238        let st = v4.fingerers_state.get("index_finger").unwrap();
239        assert_eq!(st.count, 9);
240        assert_eq!(st.modifiers.len(), 1);
241        assert!(v4.achievements_earned.contains("first_finger"));
242    }
243
244    #[test]
245    fn v4_into_current_rebuilds_tree_aggregate() {
246        // A V4 save with one bought node at origin should arrive in live
247        // state with the TreeAggregate already populated. The migrate_runtime
248        // step is what does this rebuild; here we just confirm the bought
249        // set survives the chain.
250        let mut v4 = GameStateV4 {
251            version: 4,
252            cuques: 0.0,
253            total_clicks: 0,
254            lifetime_cuques: 0.0,
255            best_fps: 0.0,
256            golden_caught: 0,
257            lucky_caught: 0,
258            frenzy_caught: 0,
259            buff_caught: 0,
260            green_coin_caught: 0,
261            fingerers_state: HashMap::new(),
262            achievements_earned: HashSet::new(),
263            prestige: 0,
264            total_play_ticks: 0,
265            buffs: vec![],
266            tree: UpgradeTreeState::default(),
267        };
268        v4.tree.bought.insert(TreeCoord::ORIGIN);
269
270        let live = v4.into_current();
271        assert!(live.tree.bought.contains(&TreeCoord::ORIGIN));
272    }
273}