Skip to main content

cuqueclicker_lib/save/versions/
v2.rs

1//! Save schema V2 — adds the `version` field, per-fingerer modifiers, and
2//! Green-Coin spawn pity counter.
3//!
4//! FROZEN. Subsequent schema changes go in `v3.rs` together with a
5//! `From<GameStateV2> for GameStateV3` conversion and a unit test. The
6//! only edit allowed here after freeze is making `into_current` chain
7//! through the next version, which we do because the live `GameState`
8//! shape no longer has `goldens_since_green_coin`.
9//!
10//! Each persisted enum/struct has a frozen V2 copy here so future changes
11//! to the live types in `crate::game::modifier` and `crate::game::state`
12//! can't retroactively reshape what V2 means on disk.
13
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16
17use super::v1::{BuffV1, GameStateV1};
18use crate::game::modifier::{
19    FingererAggregate, Modifier, ModifierDuration, ModifierEffect, ModifierSource,
20};
21use crate::game::state::{Buff, FingererState, GameState};
22
23#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
24pub enum ModifierSourceV2 {
25    GreenCoin,
26    PurpleCoin,
27}
28
29impl From<ModifierSourceV2> for ModifierSource {
30    fn from(s: ModifierSourceV2) -> Self {
31        match s {
32            ModifierSourceV2::GreenCoin => ModifierSource::GreenCoin,
33            ModifierSourceV2::PurpleCoin => ModifierSource::PurpleCoin,
34        }
35    }
36}
37
38#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
39pub enum ModifierEffectV2 {
40    FlatFps(f64),
41    AddPercent(f64),
42    MulFactor(f64),
43}
44
45impl From<ModifierEffectV2> for ModifierEffect {
46    fn from(e: ModifierEffectV2) -> Self {
47        match e {
48            ModifierEffectV2::FlatFps(v) => ModifierEffect::FlatFps(v),
49            ModifierEffectV2::AddPercent(v) => ModifierEffect::AddPercent(v),
50            ModifierEffectV2::MulFactor(v) => {
51                ModifierEffect::MulFactor(crate::bignum::Mag::from_f64(v))
52            }
53        }
54    }
55}
56
57#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub enum ModifierDurationV2 {
59    Permanent,
60    Ticks(u32),
61}
62
63impl From<ModifierDurationV2> for ModifierDuration {
64    fn from(d: ModifierDurationV2) -> Self {
65        match d {
66            ModifierDurationV2::Permanent => ModifierDuration::Permanent,
67            ModifierDurationV2::Ticks(n) => ModifierDuration::Ticks(n),
68        }
69    }
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct ModifierV2 {
74    pub source: ModifierSourceV2,
75    pub effects: Vec<ModifierEffectV2>,
76    pub duration: ModifierDurationV2,
77    #[serde(default)]
78    pub created_at_tick: u64,
79}
80
81impl From<ModifierV2> for Modifier {
82    fn from(m: ModifierV2) -> Self {
83        Modifier {
84            source: m.source.into(),
85            effects: m.effects.into_iter().map(Into::into).collect(),
86            duration: m.duration.into(),
87            created_at_tick: m.created_at_tick,
88        }
89    }
90}
91
92#[derive(Clone, Debug, Default, Serialize, Deserialize)]
93pub struct FingererStateV2 {
94    #[serde(default)]
95    pub count: u32,
96    #[serde(default)]
97    pub modifiers: Vec<ModifierV2>,
98}
99
100impl From<FingererStateV2> for FingererState {
101    fn from(v: FingererStateV2) -> Self {
102        let modifiers: Vec<Modifier> = v.modifiers.into_iter().map(Into::into).collect();
103        let aggregate = FingererAggregate::rebuild(&modifiers);
104        FingererState {
105            count: v.count,
106            modifiers,
107            aggregate,
108        }
109    }
110}
111
112/// V2 mirror of `Buff`. After Phase 3 of #21 the live `Buff` carries only
113/// click-side variants — per-fingerer multipliers live on the modifier
114/// system instead. V2's frozen shape matches that: the V1→V2 conversion
115/// absorbs `BuffV1::FingererBoost` into per-fingerer modifiers before
116/// `BuffV2` ever sees them.
117#[derive(Clone, Debug, Serialize, Deserialize)]
118pub enum BuffV2 {
119    ClickFrenzy {
120        ticks_remaining: u32,
121        initial_ticks: u32,
122        mult: f64,
123    },
124}
125
126impl From<BuffV2> for Buff {
127    fn from(b: BuffV2) -> Self {
128        match b {
129            BuffV2::ClickFrenzy {
130                ticks_remaining,
131                initial_ticks,
132                mult,
133            } => Buff::ClickFrenzy {
134                ticks_remaining,
135                initial_ticks,
136                mult,
137            },
138        }
139    }
140}
141
142fn default_v2_version() -> u32 {
143    2
144}
145
146#[derive(Clone, Serialize, Deserialize)]
147pub struct GameStateV2 {
148    #[serde(default = "default_v2_version")]
149    pub version: u32,
150    #[serde(default)]
151    pub cuques: f64,
152    #[serde(default)]
153    pub total_clicks: u64,
154    #[serde(default)]
155    pub lifetime_cuques: f64,
156    #[serde(default)]
157    pub best_fps: f64,
158    /// Lifetime grand total across every powerup variant. Strict rollup;
159    /// existing achievements (Golden Touch, Golden Hoarder) gate on this,
160    /// so it stays accurate even when migrating from a V1 that only had
161    /// the rollup.
162    #[serde(default)]
163    pub golden_caught: u64,
164    /// Per-variant catch counters introduced with V2. V1 saves had no
165    /// breakdown to recover, so these zero-init on V1→V2 — the *total*
166    /// stays accurate via `golden_caught`; only the *breakdown* is
167    /// post-V2.
168    #[serde(default)]
169    pub lucky_caught: u64,
170    #[serde(default)]
171    pub frenzy_caught: u64,
172    #[serde(default)]
173    pub buff_caught: u64,
174    #[serde(default)]
175    pub green_coin_caught: u64,
176    #[serde(default)]
177    pub fingerers_state: HashMap<String, FingererStateV2>,
178    #[serde(default)]
179    pub achievements_earned: HashSet<String>,
180    #[serde(default)]
181    pub upgrades_earned: HashSet<String>,
182    #[serde(default)]
183    pub prestige: u64,
184    #[serde(default)]
185    pub total_play_ticks: u64,
186    #[serde(default)]
187    pub buffs: Vec<BuffV2>,
188    /// Pity counter for the Green Coin spawn roll. Persisted so the timer
189    /// survives quit/restart. Pre-V2 saves default this to 0 — they had
190    /// no Green Coin mechanic, so starting fresh is correct.
191    #[serde(default)]
192    pub goldens_since_green_coin: u32,
193}
194
195impl GameStateV2 {
196    /// Convert a V2 snapshot into the live `GameState` by chaining through
197    /// the next-version conversion. The live state shape evolves with new
198    /// versions, so V2 doesn't try to construct it directly — it delegates
199    /// to `GameStateV3::into_current` (and so on, when V4 lands). This
200    /// keeps the V2 file frozen against live-shape drift while the
201    /// migration tests here continue to exercise the chain end-to-end.
202    pub fn into_current(self) -> GameState {
203        super::v3::GameStateV3::from(self).into_current()
204    }
205}
206
207/// V1 → V2 conversion. Shape changes:
208///   - `fingerers_owned: HashMap<String, u32>` →
209///     `fingerers_state: HashMap<String, FingererStateV2 { count, modifiers }>`
210///   - `BuffV1::FingererBoost` is **absorbed** into per-fingerer modifiers
211///     with `source: PurpleCoin`, `effects: [MulFactor(mult)]`, and
212///     `duration: Ticks(ticks_remaining)`. The `created_at_tick` field is
213///     reconstructed as `total_play_ticks - elapsed`, where elapsed is
214///     `initial_ticks - ticks_remaining` (saturating).
215///   - `BuffV1::ClickFrenzy` passes through as `BuffV2::ClickFrenzy`.
216///   - Adds `version: 2`.
217impl From<GameStateV1> for GameStateV2 {
218    fn from(v1: GameStateV1) -> Self {
219        let mut fingerers_state: HashMap<String, FingererStateV2> = v1
220            .fingerers_owned
221            .into_iter()
222            .map(|(id, count)| {
223                (
224                    id,
225                    FingererStateV2 {
226                        count,
227                        modifiers: vec![],
228                    },
229                )
230            })
231            .collect();
232        let mut buffs: Vec<BuffV2> = Vec::new();
233        for b in v1.buffs {
234            match b {
235                BuffV1::ClickFrenzy {
236                    ticks_remaining,
237                    initial_ticks,
238                    mult,
239                } => buffs.push(BuffV2::ClickFrenzy {
240                    ticks_remaining,
241                    initial_ticks,
242                    mult,
243                }),
244                BuffV1::FingererBoost {
245                    ticks_remaining,
246                    initial_ticks,
247                    fingerer_id,
248                    mult,
249                } => {
250                    // Reconstruct the buff's start tick from current
251                    // play-tick minus elapsed. saturating_sub guards against
252                    // weird/inconsistent saves where the elapsed math
253                    // doesn't fit (we lose age accuracy in that case but
254                    // never crash).
255                    let elapsed = initial_ticks.saturating_sub(ticks_remaining) as u64;
256                    let created_at_tick = v1.total_play_ticks.saturating_sub(elapsed);
257                    let st = fingerers_state.entry(fingerer_id).or_default();
258                    st.modifiers.push(ModifierV2 {
259                        source: ModifierSourceV2::PurpleCoin,
260                        effects: vec![ModifierEffectV2::MulFactor(mult)],
261                        duration: ModifierDurationV2::Ticks(ticks_remaining),
262                        created_at_tick,
263                    });
264                }
265            }
266        }
267        GameStateV2 {
268            version: 2,
269            cuques: v1.cuques,
270            total_clicks: v1.total_clicks,
271            lifetime_cuques: v1.lifetime_cuques,
272            best_fps: v1.best_fps,
273            golden_caught: v1.golden_caught,
274            // V1 had no per-variant breakdown — the four counters
275            // zero-init. The rollup `golden_caught` stays accurate.
276            lucky_caught: 0,
277            frenzy_caught: 0,
278            buff_caught: 0,
279            green_coin_caught: 0,
280            fingerers_state,
281            achievements_earned: v1.achievements_earned,
282            upgrades_earned: v1.upgrades_earned,
283            prestige: v1.prestige,
284            total_play_ticks: v1.total_play_ticks,
285            buffs,
286            // V1 saves predate the Green Coin mechanic; the pity counter
287            // simply starts fresh on first load into V2.
288            goldens_since_green_coin: 0,
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn v1_to_v2_preserves_fingerer_counts() {
299        let v1 = GameStateV1 {
300            cuques: 0.0,
301            total_clicks: 0,
302            lifetime_cuques: 0.0,
303            best_fps: 0.0,
304            golden_caught: 0,
305            fingerers_owned: [("index_finger".into(), 9), ("latex_glove".into(), 4)]
306                .into_iter()
307                .collect(),
308            achievements_earned: HashSet::new(),
309            upgrades_earned: HashSet::new(),
310            prestige: 0,
311            total_play_ticks: 0,
312            buffs: vec![],
313        };
314
315        let v2: GameStateV2 = v1.into();
316
317        assert_eq!(v2.version, 2);
318        assert_eq!(v2.fingerers_state.get("index_finger").unwrap().count, 9);
319        assert_eq!(v2.fingerers_state.get("latex_glove").unwrap().count, 4);
320        assert!(
321            v2.fingerers_state
322                .values()
323                .all(|st| st.modifiers.is_empty())
324        );
325    }
326
327    #[test]
328    fn v1_to_v2_absorbs_in_flight_fingerer_boost_into_modifier() {
329        // A V1 save mid-Buff-golden has `BuffV1::FingererBoost` running.
330        // Phase 3 routes that into the modifier system — Buff golden's live
331        // semantics now live there. Remaining time and mult must survive;
332        // the modifier's `created_at_tick` reconstructs from
333        // `total_play_ticks - elapsed`.
334        let v1 = GameStateV1 {
335            cuques: 0.0,
336            total_clicks: 0,
337            lifetime_cuques: 0.0,
338            best_fps: 0.0,
339            golden_caught: 0,
340            fingerers_owned: [("latex_glove".into(), 4)].into_iter().collect(),
341            achievements_earned: HashSet::new(),
342            upgrades_earned: HashSet::new(),
343            prestige: 0,
344            total_play_ticks: 1500,
345            buffs: vec![BuffV1::FingererBoost {
346                ticks_remaining: 600,
347                initial_ticks: 1200,
348                fingerer_id: "latex_glove".into(),
349                mult: 7.0,
350            }],
351        };
352
353        let v2: GameStateV2 = v1.into();
354
355        // The V2 buffs Vec no longer carries this — it's been moved.
356        assert!(v2.buffs.is_empty());
357
358        let st = v2
359            .fingerers_state
360            .get("latex_glove")
361            .expect("fingerer entry preserved");
362        assert_eq!(st.count, 4);
363        assert_eq!(st.modifiers.len(), 1);
364        let m = &st.modifiers[0];
365        assert!(matches!(m.source, ModifierSourceV2::PurpleCoin));
366        assert!(matches!(m.duration, ModifierDurationV2::Ticks(600)));
367        assert!(matches!(
368            m.effects[0],
369            ModifierEffectV2::MulFactor(v) if (v - 7.0).abs() < 1e-9
370        ));
371        // 1500 - (1200 - 600) = 900
372        assert_eq!(m.created_at_tick, 900);
373    }
374
375    #[test]
376    fn v1_to_v2_absorbed_modifier_attaches_to_unowned_fingerer_safely() {
377        // Edge case: the V1 save has a FingererBoost on a fingerer that
378        // wasn't in `fingerers_owned`. The migration must still preserve
379        // the modifier rather than silently dropping it. We create the
380        // entry with count=0 — same defensive behavior as live
381        // `attach_modifier`.
382        let v1 = GameStateV1 {
383            cuques: 0.0,
384            total_clicks: 0,
385            lifetime_cuques: 0.0,
386            best_fps: 0.0,
387            golden_caught: 0,
388            fingerers_owned: HashMap::new(),
389            achievements_earned: HashSet::new(),
390            upgrades_earned: HashSet::new(),
391            prestige: 0,
392            total_play_ticks: 0,
393            buffs: vec![BuffV1::FingererBoost {
394                ticks_remaining: 100,
395                initial_ticks: 100,
396                fingerer_id: "hand_of_god".into(),
397                mult: 7.0,
398            }],
399        };
400
401        let v2: GameStateV2 = v1.into();
402
403        let st = v2
404            .fingerers_state
405            .get("hand_of_god")
406            .expect("entry created");
407        assert_eq!(st.count, 0);
408        assert_eq!(st.modifiers.len(), 1);
409    }
410
411    #[test]
412    fn v1_to_v2_passes_through_click_frenzy() {
413        let v1 = GameStateV1 {
414            cuques: 0.0,
415            total_clicks: 0,
416            lifetime_cuques: 0.0,
417            best_fps: 0.0,
418            golden_caught: 0,
419            fingerers_owned: HashMap::new(),
420            achievements_earned: HashSet::new(),
421            upgrades_earned: HashSet::new(),
422            prestige: 0,
423            total_play_ticks: 0,
424            buffs: vec![BuffV1::ClickFrenzy {
425                ticks_remaining: 100,
426                initial_ticks: 260,
427                mult: 777.0,
428            }],
429        };
430
431        let v2: GameStateV2 = v1.into();
432
433        assert_eq!(v2.buffs.len(), 1);
434        assert!(matches!(
435            v2.buffs[0],
436            BuffV2::ClickFrenzy {
437                ticks_remaining: 100,
438                ..
439            }
440        ));
441    }
442
443    #[test]
444    fn v2_into_current_rebuilds_aggregate_from_modifiers() {
445        // A V2 save with one Green Coin (+10%) and one Purple Coin (x2) on
446        // a fingerer must arrive in live state with the aggregate already
447        // populated — the FPS hot path reads it without rebuilding.
448        let v2 = GameStateV2 {
449            version: 2,
450            cuques: 0.0,
451            total_clicks: 0,
452            lifetime_cuques: 0.0,
453            best_fps: 0.0,
454            golden_caught: 0,
455            lucky_caught: 0,
456            frenzy_caught: 0,
457            buff_caught: 0,
458            green_coin_caught: 0,
459            fingerers_state: [(
460                "latex_glove".to_string(),
461                FingererStateV2 {
462                    count: 5,
463                    modifiers: vec![
464                        ModifierV2 {
465                            source: ModifierSourceV2::GreenCoin,
466                            effects: vec![ModifierEffectV2::AddPercent(0.10)],
467                            duration: ModifierDurationV2::Permanent,
468                            created_at_tick: 0,
469                        },
470                        ModifierV2 {
471                            source: ModifierSourceV2::PurpleCoin,
472                            effects: vec![ModifierEffectV2::MulFactor(2.0)],
473                            duration: ModifierDurationV2::Ticks(600),
474                            created_at_tick: 0,
475                        },
476                    ],
477                },
478            )]
479            .into_iter()
480            .collect(),
481            achievements_earned: HashSet::new(),
482            upgrades_earned: HashSet::new(),
483            prestige: 0,
484            total_play_ticks: 0,
485            buffs: vec![],
486            goldens_since_green_coin: 0,
487        };
488
489        let live = v2.into_current();
490        let st = live.fingerers_state.get("latex_glove").unwrap();
491        assert_eq!(st.count, 5);
492        assert_eq!(st.modifiers.len(), 2);
493        assert!((st.aggregate.add_percent - 0.10).abs() < 1e-9);
494        assert!((st.aggregate.mul_factor.to_f64() - 2.0).abs() < 1e-9);
495    }
496
497    #[test]
498    fn v1_to_v2_zero_inits_per_variant_counters() {
499        // V1 had only the rollup `golden_caught`. V2 adds four per-variant
500        // counters; on V1→V2 they zero-init while the rollup is preserved.
501        // No data is lost — the *total* stays accurate; only the
502        // *breakdown* is post-V2.
503        let v1 = GameStateV1 {
504            cuques: 0.0,
505            total_clicks: 0,
506            lifetime_cuques: 0.0,
507            best_fps: 0.0,
508            golden_caught: 17,
509            fingerers_owned: HashMap::new(),
510            achievements_earned: HashSet::new(),
511            upgrades_earned: HashSet::new(),
512            prestige: 0,
513            total_play_ticks: 0,
514            buffs: vec![],
515        };
516
517        let v2: GameStateV2 = v1.into();
518
519        assert_eq!(v2.golden_caught, 17, "rollup carried forward");
520        assert_eq!(v2.lucky_caught, 0);
521        assert_eq!(v2.frenzy_caught, 0);
522        assert_eq!(v2.buff_caught, 0);
523        assert_eq!(v2.green_coin_caught, 0);
524    }
525
526    #[test]
527    fn v2_into_current_preserves_per_variant_counters() {
528        let v2 = GameStateV2 {
529            version: 2,
530            cuques: 0.0,
531            total_clicks: 0,
532            lifetime_cuques: 0.0,
533            best_fps: 0.0,
534            golden_caught: 100,
535            lucky_caught: 60,
536            frenzy_caught: 20,
537            buff_caught: 15,
538            green_coin_caught: 5,
539            fingerers_state: HashMap::new(),
540            achievements_earned: HashSet::new(),
541            upgrades_earned: HashSet::new(),
542            prestige: 0,
543            total_play_ticks: 0,
544            buffs: vec![],
545            goldens_since_green_coin: 0,
546        };
547
548        let live = v2.into_current();
549
550        assert_eq!(live.golden_caught, 100);
551        assert_eq!(live.lucky_caught, 60);
552        assert_eq!(live.frenzy_caught, 20);
553        assert_eq!(live.buff_caught, 15);
554        assert_eq!(live.green_coin_caught, 5);
555    }
556}