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