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` by chaining through
68    /// V4. The live shape no longer carries `upgrades_earned`; V4's frozen
69    /// `From<GameStateV3>` impl drops it. (Per `CLAUDE.md`, this delegation
70    /// is the one mutation a frozen file is allowed: keeping the chain
71    /// reachable as later versions land.)
72    pub fn into_current(self) -> GameState {
73        // Walk through V4 → V5 → GameState. V5 introduced Mag-typed
74        // counters; the previous "v4.into_current" shortcut moved its
75        // body into v5.
76        super::v5::GameStateV5::from(super::v4::GameStateV4::from(self)).into_current()
77    }
78}
79
80/// V2 → V3 conversion. Drops `goldens_since_green_coin`. Every other field
81/// passes through verbatim; `version` is stamped to 3.
82impl From<GameStateV2> for GameStateV3 {
83    fn from(v2: GameStateV2) -> Self {
84        GameStateV3 {
85            version: 3,
86            cuques: v2.cuques,
87            total_clicks: v2.total_clicks,
88            lifetime_cuques: v2.lifetime_cuques,
89            best_fps: v2.best_fps,
90            golden_caught: v2.golden_caught,
91            lucky_caught: v2.lucky_caught,
92            frenzy_caught: v2.frenzy_caught,
93            buff_caught: v2.buff_caught,
94            green_coin_caught: v2.green_coin_caught,
95            fingerers_state: v2.fingerers_state,
96            achievements_earned: v2.achievements_earned,
97            upgrades_earned: v2.upgrades_earned,
98            prestige: v2.prestige,
99            total_play_ticks: v2.total_play_ticks,
100            buffs: v2.buffs,
101            // Pity counter dropped — Vec-based powerups use per-kind
102            // cooldown clocks instead, all of which are `#[serde(skip)]`.
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::save::versions::v2::{
111        BuffV2, FingererStateV2, ModifierDurationV2, ModifierEffectV2, ModifierSourceV2, ModifierV2,
112    };
113
114    fn empty_v2_with_pity(pity: u32) -> GameStateV2 {
115        GameStateV2 {
116            version: 2,
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            goldens_since_green_coin: pity,
133        }
134    }
135
136    #[test]
137    fn v2_to_v3_drops_pity_counter() {
138        // V2 has `goldens_since_green_coin: 7`; the conversion must
139        // produce a V3 with no such field (verified by it not appearing
140        // in the V3 struct at all — the JSON round-trip below proves it
141        // doesn't sneak through serde) and stamp the new version.
142        let v2 = empty_v2_with_pity(7);
143        let v3: GameStateV3 = v2.into();
144        assert_eq!(v3.version, 3);
145        // Round-trip through JSON to verify the pity field is gone from
146        // the on-disk form too — not just absent from the struct.
147        let json = serde_json::to_string(&v3).expect("serialize");
148        assert!(
149            !json.contains("goldens_since_green_coin"),
150            "pity counter must not appear in V3 JSON: {json}"
151        );
152    }
153
154    #[test]
155    fn v2_to_v3_preserves_all_other_fields() {
156        let v2 = GameStateV2 {
157            version: 2,
158            cuques: 1234.5,
159            total_clicks: 99,
160            lifetime_cuques: 5_000.0,
161            best_fps: 12.0,
162            golden_caught: 17,
163            lucky_caught: 10,
164            frenzy_caught: 4,
165            buff_caught: 2,
166            green_coin_caught: 1,
167            fingerers_state: [(
168                "index_finger".to_string(),
169                FingererStateV2 {
170                    count: 9,
171                    modifiers: vec![ModifierV2 {
172                        source: ModifierSourceV2::GreenCoin,
173                        effects: vec![ModifierEffectV2::AddPercent(0.10)],
174                        duration: ModifierDurationV2::Permanent,
175                        created_at_tick: 0,
176                    }],
177                },
178            )]
179            .into_iter()
180            .collect(),
181            achievements_earned: ["first_finger".into()].into_iter().collect(),
182            upgrades_earned: ["click_mult_1".into()].into_iter().collect(),
183            prestige: 3,
184            total_play_ticks: 9_000,
185            buffs: vec![BuffV2::ClickFrenzy {
186                ticks_remaining: 100,
187                initial_ticks: 260,
188                mult: 777.0,
189            }],
190            goldens_since_green_coin: 5,
191        };
192
193        let v3: GameStateV3 = v2.into();
194
195        assert_eq!(v3.cuques, 1234.5);
196        assert_eq!(v3.total_clicks, 99);
197        assert_eq!(v3.lifetime_cuques, 5_000.0);
198        assert_eq!(v3.best_fps, 12.0);
199        assert_eq!(v3.golden_caught, 17);
200        assert_eq!(v3.lucky_caught, 10);
201        assert_eq!(v3.frenzy_caught, 4);
202        assert_eq!(v3.buff_caught, 2);
203        assert_eq!(v3.green_coin_caught, 1);
204        assert_eq!(v3.prestige, 3);
205        assert_eq!(v3.total_play_ticks, 9_000);
206        assert_eq!(v3.buffs.len(), 1);
207        let st = v3.fingerers_state.get("index_finger").unwrap();
208        assert_eq!(st.count, 9);
209        assert_eq!(st.modifiers.len(), 1);
210        assert!(v3.achievements_earned.contains("first_finger"));
211        assert!(v3.upgrades_earned.contains("click_mult_1"));
212    }
213
214    #[test]
215    fn v3_into_current_zero_inits_powerup_runtime_fields() {
216        // V3 doesn't persist any powerup-on-screen state; `into_current`
217        // must produce a live state whose `powerups` Vec is empty,
218        // `next_spawn_id` is 0, and every per-kind cooldown is seeded
219        // with a non-zero value (via `Default` → `next_cooldown`).
220        // Catching the third one is important: a kind with cooldown=0
221        // would spawn on tick 1 instead of waiting out an exponential
222        // sample.
223        let v3 = empty_v2_with_pity(0).into();
224        let live = GameStateV3::into_current(v3);
225        assert!(live.powerups.is_empty());
226        assert_eq!(live.next_spawn_id, 0);
227        for (i, &cd) in live.powerup_cooldowns.iter().enumerate() {
228            assert!(
229                cd > 0,
230                "powerup_cooldowns[{i}] must be seeded non-zero by Default, got {cd}"
231            );
232        }
233    }
234}