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