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