Skip to main content

cuqueclicker_lib/save/versions/
v3.rs

1//! Save schema V3 — drops `goldens_since_green_coin`.
2//!
3//! V3's only persisted-shape change vs V2 is the removal of the Green Coin
4//! pity counter. The Vec-based powerup refactor (#25) replaced the pity
5//! mechanic with per-kind cooldown clocks (one for each `PowerupKind`),
6//! all of which are `#[serde(skip)]`. The pity counter became dead weight
7//! on disk — a field present in every V2 save that the live state doesn't
8//! consult anymore. V3 drops it.
9//!
10//! Once V3 is on `main` this file is FROZEN: subsequent schema changes
11//! go in `v4.rs` together with a `From<GameStateV3>` conversion.
12//!
13//! Each persisted enum/struct re-uses V2's frozen copies (modifier source,
14//! effect, duration, modifier, fingerer state, buff). The struct-level
15//! shape change is small enough that re-vendoring the inner types would
16//! be pure noise.
17
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet};
20
21use super::v2::{BuffV2, FingererStateV2, GameStateV2};
22use crate::game::state::GameState;
23
24fn default_v3_version() -> u32 {
25    3
26}
27
28#[derive(Clone, Serialize, Deserialize)]
29pub struct GameStateV3 {
30    #[serde(default = "default_v3_version")]
31    pub version: u32,
32    #[serde(default)]
33    pub cuques: f64,
34    #[serde(default)]
35    pub total_clicks: u64,
36    #[serde(default)]
37    pub lifetime_cuques: f64,
38    #[serde(default)]
39    pub best_fps: f64,
40    /// Lifetime grand total across every powerup kind. Strict rollup;
41    /// existing achievements (Golden Touch, Golden Hoarder) gate on this.
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 upgrades_earned: HashSet<String>,
58    #[serde(default)]
59    pub prestige: u64,
60    #[serde(default)]
61    pub total_play_ticks: u64,
62    #[serde(default)]
63    pub buffs: Vec<BuffV2>,
64}
65
66impl GameStateV3 {
67    /// Convert a V3 snapshot into the live `GameState`. Every persisted
68    /// field is copied verbatim; ephemeral state (`#[serde(skip)]` fields,
69    /// including `powerups`/`next_spawn_id`/`powerup_cooldowns`) stays at
70    /// its `Default` and gets seeded by `migrate_runtime` after the chain
71    /// finishes.
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            upgrades_earned: self.upgrades_earned,
93            prestige: self.prestige,
94            total_play_ticks: self.total_play_ticks,
95            buffs,
96            ..GameState::default()
97        }
98    }
99}
100
101/// V2 → V3 conversion. Drops `goldens_since_green_coin`. Every other field
102/// passes through verbatim; `version` is stamped to 3.
103impl From<GameStateV2> for GameStateV3 {
104    fn from(v2: GameStateV2) -> Self {
105        GameStateV3 {
106            version: 3,
107            cuques: v2.cuques,
108            total_clicks: v2.total_clicks,
109            lifetime_cuques: v2.lifetime_cuques,
110            best_fps: v2.best_fps,
111            golden_caught: v2.golden_caught,
112            lucky_caught: v2.lucky_caught,
113            frenzy_caught: v2.frenzy_caught,
114            buff_caught: v2.buff_caught,
115            green_coin_caught: v2.green_coin_caught,
116            fingerers_state: v2.fingerers_state,
117            achievements_earned: v2.achievements_earned,
118            upgrades_earned: v2.upgrades_earned,
119            prestige: v2.prestige,
120            total_play_ticks: v2.total_play_ticks,
121            buffs: v2.buffs,
122            // Pity counter dropped — Vec-based powerups use per-kind
123            // cooldown clocks instead, all of which are `#[serde(skip)]`.
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::save::versions::v2::{
132        BuffV2, FingererStateV2, ModifierDurationV2, ModifierEffectV2, ModifierSourceV2, ModifierV2,
133    };
134
135    fn empty_v2_with_pity(pity: u32) -> GameStateV2 {
136        GameStateV2 {
137            version: 2,
138            cuques: 0.0,
139            total_clicks: 0,
140            lifetime_cuques: 0.0,
141            best_fps: 0.0,
142            golden_caught: 0,
143            lucky_caught: 0,
144            frenzy_caught: 0,
145            buff_caught: 0,
146            green_coin_caught: 0,
147            fingerers_state: HashMap::new(),
148            achievements_earned: HashSet::new(),
149            upgrades_earned: HashSet::new(),
150            prestige: 0,
151            total_play_ticks: 0,
152            buffs: vec![],
153            goldens_since_green_coin: pity,
154        }
155    }
156
157    #[test]
158    fn v2_to_v3_drops_pity_counter() {
159        // V2 has `goldens_since_green_coin: 7`; the conversion must
160        // produce a V3 with no such field (verified by it not appearing
161        // in the V3 struct at all — the JSON round-trip below proves it
162        // doesn't sneak through serde) and stamp the new version.
163        let v2 = empty_v2_with_pity(7);
164        let v3: GameStateV3 = v2.into();
165        assert_eq!(v3.version, 3);
166        // Round-trip through JSON to verify the pity field is gone from
167        // the on-disk form too — not just absent from the struct.
168        let json = serde_json::to_string(&v3).expect("serialize");
169        assert!(
170            !json.contains("goldens_since_green_coin"),
171            "pity counter must not appear in V3 JSON: {json}"
172        );
173    }
174
175    #[test]
176    fn v2_to_v3_preserves_all_other_fields() {
177        let v2 = GameStateV2 {
178            version: 2,
179            cuques: 1234.5,
180            total_clicks: 99,
181            lifetime_cuques: 5_000.0,
182            best_fps: 12.0,
183            golden_caught: 17,
184            lucky_caught: 10,
185            frenzy_caught: 4,
186            buff_caught: 2,
187            green_coin_caught: 1,
188            fingerers_state: [(
189                "index_finger".to_string(),
190                FingererStateV2 {
191                    count: 9,
192                    modifiers: vec![ModifierV2 {
193                        source: ModifierSourceV2::GreenCoin,
194                        effects: vec![ModifierEffectV2::AddPercent(0.10)],
195                        duration: ModifierDurationV2::Permanent,
196                        created_at_tick: 0,
197                    }],
198                },
199            )]
200            .into_iter()
201            .collect(),
202            achievements_earned: ["first_finger".into()].into_iter().collect(),
203            upgrades_earned: ["click_mult_1".into()].into_iter().collect(),
204            prestige: 3,
205            total_play_ticks: 9_000,
206            buffs: vec![BuffV2::ClickFrenzy {
207                ticks_remaining: 100,
208                initial_ticks: 260,
209                mult: 777.0,
210            }],
211            goldens_since_green_coin: 5,
212        };
213
214        let v3: GameStateV3 = v2.into();
215
216        assert_eq!(v3.cuques, 1234.5);
217        assert_eq!(v3.total_clicks, 99);
218        assert_eq!(v3.lifetime_cuques, 5_000.0);
219        assert_eq!(v3.best_fps, 12.0);
220        assert_eq!(v3.golden_caught, 17);
221        assert_eq!(v3.lucky_caught, 10);
222        assert_eq!(v3.frenzy_caught, 4);
223        assert_eq!(v3.buff_caught, 2);
224        assert_eq!(v3.green_coin_caught, 1);
225        assert_eq!(v3.prestige, 3);
226        assert_eq!(v3.total_play_ticks, 9_000);
227        assert_eq!(v3.buffs.len(), 1);
228        let st = v3.fingerers_state.get("index_finger").unwrap();
229        assert_eq!(st.count, 9);
230        assert_eq!(st.modifiers.len(), 1);
231        assert!(v3.achievements_earned.contains("first_finger"));
232        assert!(v3.upgrades_earned.contains("click_mult_1"));
233    }
234
235    #[test]
236    fn v3_into_current_zero_inits_powerup_runtime_fields() {
237        // V3 doesn't persist any powerup-on-screen state; `into_current`
238        // must produce a live state whose `powerups` Vec is empty,
239        // `next_spawn_id` is 0, and every per-kind cooldown is seeded
240        // with a non-zero value (via `Default` → `next_cooldown`).
241        // Catching the third one is important: a kind with cooldown=0
242        // would spawn on tick 1 instead of waiting out an exponential
243        // sample.
244        let v3 = empty_v2_with_pity(0).into();
245        let live = GameStateV3::into_current(v3);
246        assert!(live.powerups.is_empty());
247        assert_eq!(live.next_spawn_id, 0);
248        for (i, &cd) in live.powerup_cooldowns.iter().enumerate() {
249            assert!(
250                cd > 0,
251                "powerup_cooldowns[{i}] must be seeded non-zero by Default, got {cd}"
252            );
253        }
254    }
255}