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::tree::UpgradeTreeState;
24
25fn default_v4_version() -> u32 {
26    4
27}
28
29#[derive(Clone, Serialize, Deserialize)]
30pub struct GameStateV4 {
31    #[serde(default = "default_v4_version")]
32    pub version: u32,
33    #[serde(default)]
34    pub cuques: f64,
35    #[serde(default)]
36    pub total_clicks: u64,
37    #[serde(default)]
38    pub lifetime_cuques: f64,
39    #[serde(default)]
40    pub best_fps: f64,
41    #[serde(default)]
42    pub golden_caught: u64,
43    #[serde(default)]
44    pub lucky_caught: u64,
45    #[serde(default)]
46    pub frenzy_caught: u64,
47    #[serde(default)]
48    pub buff_caught: u64,
49    #[serde(default)]
50    pub green_coin_caught: u64,
51    #[serde(default)]
52    pub fingerers_state: HashMap<String, FingererStateV2>,
53    #[serde(default)]
54    pub achievements_earned: HashSet<String>,
55    #[serde(default)]
56    pub prestige: u64,
57    #[serde(default)]
58    pub total_play_ticks: u64,
59    #[serde(default)]
60    pub buffs: Vec<BuffV2>,
61    /// V4 addition: the infinite upgrade tree state. Replaces the old
62    /// hardcoded `upgrades_earned: HashSet<String>` entirely.
63    #[serde(default)]
64    pub tree: UpgradeTreeState,
65}
66
67// V4's old `into_current` lived here when V4 was the current schema. The
68// V5 bump moved the live-state conversion to `v5::GameStateV5::into_current`
69// (the live `GameState` shape now requires `Mag` counters, which the V4
70// frozen schema doesn't carry). V4 saves on disk continue to load — the
71// chain now walks `v4 → v5 → GameState` — but the V4 module itself only
72// owns the persisted shape and the `From<GameStateV3>` step.
73
74/// V3 → V4 conversion. Drops `upgrades_earned` (old hardcoded UPGRADES
75/// catalog is retired); inits a fresh empty `UpgradeTreeState`. Every
76/// other field passes through verbatim; `version` is stamped to 4.
77///
78/// The breaking change is intentional — coordinated with the game's
79/// 1.0.0 release. Players who upgrade lose their old hand-curated
80/// upgrade purchases but keep cuques, fingerers, achievements, prestige,
81/// and active buffs.
82impl From<GameStateV3> for GameStateV4 {
83    fn from(v3: GameStateV3) -> Self {
84        GameStateV4 {
85            version: 4,
86            cuques: v3.cuques,
87            total_clicks: v3.total_clicks,
88            lifetime_cuques: v3.lifetime_cuques,
89            best_fps: v3.best_fps,
90            golden_caught: v3.golden_caught,
91            lucky_caught: v3.lucky_caught,
92            frenzy_caught: v3.frenzy_caught,
93            buff_caught: v3.buff_caught,
94            green_coin_caught: v3.green_coin_caught,
95            fingerers_state: v3.fingerers_state,
96            achievements_earned: v3.achievements_earned,
97            // upgrades_earned silently dropped — old catalog is gone.
98            prestige: v3.prestige,
99            total_play_ticks: v3.total_play_ticks,
100            buffs: v3.buffs,
101            tree: UpgradeTreeState::default(),
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::game::tree::coord::TreeCoord;
110    use crate::save::versions::v2::{
111        FingererStateV2, ModifierDurationV2, ModifierEffectV2, ModifierSourceV2, ModifierV2,
112    };
113
114    fn empty_v3() -> GameStateV3 {
115        GameStateV3 {
116            version: 3,
117            cuques: 0.0,
118            total_clicks: 0,
119            lifetime_cuques: 0.0,
120            best_fps: 0.0,
121            golden_caught: 0,
122            lucky_caught: 0,
123            frenzy_caught: 0,
124            buff_caught: 0,
125            green_coin_caught: 0,
126            fingerers_state: HashMap::new(),
127            achievements_earned: HashSet::new(),
128            upgrades_earned: HashSet::new(),
129            prestige: 0,
130            total_play_ticks: 0,
131            buffs: vec![],
132        }
133    }
134
135    #[test]
136    fn v3_to_v4_drops_upgrades_earned_silently() {
137        let mut v3 = empty_v3();
138        v3.upgrades_earned.insert("click_mult_1".into());
139        v3.upgrades_earned.insert("hand_of_god_mult_3".into());
140        let v4: GameStateV4 = v3.into();
141        assert_eq!(v4.version, 4);
142        // V4 has no upgrades_earned field at all — the JSON round-trip
143        // proves it doesn't sneak through.
144        let json = serde_json::to_string(&v4).expect("serialize");
145        assert!(
146            !json.contains("upgrades_earned"),
147            "old upgrades field must not appear in V4 JSON: {json}"
148        );
149    }
150
151    #[test]
152    fn v3_to_v4_inits_empty_tree() {
153        let v4: GameStateV4 = empty_v3().into();
154        assert!(v4.tree.bought.is_empty());
155        assert_eq!(v4.tree.cursor, TreeCoord::ORIGIN);
156        assert_eq!(v4.tree.last_bought, None);
157    }
158
159    #[test]
160    fn v3_to_v4_preserves_all_other_fields() {
161        let v3 = GameStateV3 {
162            version: 3,
163            cuques: 1234.5,
164            total_clicks: 99,
165            lifetime_cuques: 5_000.0,
166            best_fps: 12.0,
167            golden_caught: 17,
168            lucky_caught: 10,
169            frenzy_caught: 4,
170            buff_caught: 2,
171            green_coin_caught: 1,
172            fingerers_state: [(
173                "index_finger".to_string(),
174                FingererStateV2 {
175                    count: 9,
176                    modifiers: vec![ModifierV2 {
177                        source: ModifierSourceV2::GreenCoin,
178                        effects: vec![ModifierEffectV2::AddPercent(0.10)],
179                        duration: ModifierDurationV2::Permanent,
180                        created_at_tick: 0,
181                    }],
182                },
183            )]
184            .into_iter()
185            .collect(),
186            achievements_earned: ["first_finger".into()].into_iter().collect(),
187            upgrades_earned: ["click_mult_1".into()].into_iter().collect(),
188            prestige: 3,
189            total_play_ticks: 9_000,
190            buffs: vec![BuffV2::ClickFrenzy {
191                ticks_remaining: 100,
192                initial_ticks: 260,
193                mult: 777.0,
194            }],
195        };
196
197        let v4: GameStateV4 = v3.into();
198
199        assert_eq!(v4.cuques, 1234.5);
200        assert_eq!(v4.total_clicks, 99);
201        assert_eq!(v4.lifetime_cuques, 5_000.0);
202        assert_eq!(v4.best_fps, 12.0);
203        assert_eq!(v4.golden_caught, 17);
204        assert_eq!(v4.lucky_caught, 10);
205        assert_eq!(v4.frenzy_caught, 4);
206        assert_eq!(v4.buff_caught, 2);
207        assert_eq!(v4.green_coin_caught, 1);
208        assert_eq!(v4.prestige, 3);
209        assert_eq!(v4.total_play_ticks, 9_000);
210        assert_eq!(v4.buffs.len(), 1);
211        let st = v4.fingerers_state.get("index_finger").unwrap();
212        assert_eq!(st.count, 9);
213        assert_eq!(st.modifiers.len(), 1);
214        assert!(v4.achievements_earned.contains("first_finger"));
215    }
216
217    #[test]
218    fn v4_through_v5_preserves_tree_state() {
219        // V4-on-disk → live state goes through V5 now. Round the V4
220        // fixture through the chain and confirm the bought set survives.
221        let mut v4 = GameStateV4 {
222            version: 4,
223            cuques: 0.0,
224            total_clicks: 0,
225            lifetime_cuques: 0.0,
226            best_fps: 0.0,
227            golden_caught: 0,
228            lucky_caught: 0,
229            frenzy_caught: 0,
230            buff_caught: 0,
231            green_coin_caught: 0,
232            fingerers_state: HashMap::new(),
233            achievements_earned: HashSet::new(),
234            prestige: 0,
235            total_play_ticks: 0,
236            buffs: vec![],
237            tree: UpgradeTreeState::default(),
238        };
239        v4.tree.bought.insert(TreeCoord::ORIGIN);
240
241        let live = crate::save::versions::v5::GameStateV5::from(v4).into_current();
242        assert!(live.tree.bought.contains(&TreeCoord::ORIGIN));
243    }
244}