Skip to main content

cuqueclicker_lib/
bignum.rs

1//! `Mag` — a non-negative real represented as `log10(value)`.
2//!
3//! The whole point: gameplay-side quantities (cuques, FPS, tree multipliers,
4//! per-fingerer modifier mults) compound multiplicatively across thousands
5//! of bought nodes and powerup catches. Stored as `f64`, the product
6//! reaches `Infinity` after a long-haul session — the headline bug from
7//! the wild "broken save" triage. Stored as `log10`, the same product
8//! fits comfortably inside an `f64` for any value the player could
9//! conceivably observe; the dynamic range is `10^(±1.8e308)`, i.e.
10//! "truly infinite" for any practical gameplay.
11//!
12//! ## Representation
13//!
14//! `Mag { log10: f64 }`, where `log10` is the base-10 logarithm of the
15//! value. Zero is the sentinel `log10 == f64::NEG_INFINITY`; everything
16//! else is finite. Negatives are not representable (the only place the
17//! game produced one was an underflow that signalled a bug anyway —
18//! `try_sub` returns `Option`, callers handle the deficit explicitly).
19//!
20//! ## Operations
21//!
22//! - `mul` / `div`: addition / subtraction in log space — cheap.
23//! - `add`: requires un-logging the smaller addend; uses
24//!   `log10(a + b) = log10(a) + log10(1 + 10^(b - a))` for `a >= b`.
25//! - `try_sub`: same trick. Returns `None` if the result would be
26//!   negative.
27//! - `cmp`: direct comparison of the log values.
28//! - `to_f64`: useful at display sites (for the legacy `format::big`
29//!   path) and inside formulas that need a real `f64` (e.g. RNG range
30//!   bounds, multiplier caps from the procgen).
31
32use serde::{Deserialize, Deserializer, Serialize, Serializer};
33
34/// Non-negative log-magnitude. `Mag::ZERO == Mag { log10: -inf }`,
35/// `Mag::ONE == Mag { log10: 0.0 }`. All other instances carry a
36/// finite `log10`.
37///
38/// ## Save compatibility
39///
40/// Serializes as a plain `f64` when the value fits inside one
41/// (`log10 < 300`, well clear of overflow); otherwise as
42/// `{"log10": <f64>}`. Deserialization accepts BOTH shapes — so existing
43/// V4 saves with `"cuques": 1.234e10` and `"MulFactor": 7.0` continue
44/// to load correctly, no schema bump required. New saves use the
45/// struct form only when the value exceeds the `f64` finite range.
46#[derive(Clone, Copy, Debug)]
47pub struct Mag {
48    pub log10: f64,
49}
50
51impl Serialize for Mag {
52    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
53        // Below the `f64` overflow horizon, emit a plain number so saves
54        // stay human-readable and old-format-compatible. Above it, fall
55        // back to the explicit struct so the log-magnitude survives the
56        // round-trip exactly.
57        if self.log10 == f64::NEG_INFINITY {
58            return 0.0_f64.serialize(s);
59        }
60        if self.log10 < 300.0 {
61            return self.to_f64().serialize(s);
62        }
63        // Struct form for truly-huge values.
64        #[derive(Serialize)]
65        struct Wide {
66            log10: f64,
67        }
68        Wide { log10: self.log10 }.serialize(s)
69    }
70}
71
72impl<'de> Deserialize<'de> for Mag {
73    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
74        // Accept either a plain JSON number (legacy + small new saves)
75        // or the explicit `{"log10": ...}` struct (large new saves). The
76        // untagged enum gives serde the freedom to try both without us
77        // hand-writing a visitor.
78        #[derive(Deserialize)]
79        #[serde(untagged)]
80        enum Either {
81            Number(f64),
82            Struct { log10: f64 },
83        }
84        match Either::deserialize(d)? {
85            Either::Number(v) => Ok(Mag::from_f64(v)),
86            Either::Struct { log10 } => Ok(Mag { log10 }),
87        }
88    }
89}
90
91impl Mag {
92    pub const ZERO: Mag = Mag {
93        log10: f64::NEG_INFINITY,
94    };
95    pub const ONE: Mag = Mag { log10: 0.0 };
96
97    /// Construct from an `f64`. Non-finite / negative inputs collapse to
98    /// `ZERO` — there is no honest log-magnitude for them and silently
99    /// turning a `NaN` into a zero is friendlier than panicking on a
100    /// codepath the player never asked for.
101    pub fn from_f64(v: f64) -> Mag {
102        if !v.is_finite() || v <= 0.0 {
103            Mag::ZERO
104        } else {
105            Mag { log10: v.log10() }
106        }
107    }
108
109    /// Decode back to an `f64`. Clamps very-large `log10` values to
110    /// `f64::MAX` — losing precision in the high end is the price of
111    /// asking for an `f64`; the `Mag` representation itself stays
112    /// faithful. Use this at the display boundary or wherever a downstream
113    /// API truly demands `f64`. Internal math should chain `Mag` ops.
114    pub fn to_f64(self) -> f64 {
115        if self.log10.is_nan() {
116            return 0.0;
117        }
118        if self.log10 == f64::NEG_INFINITY {
119            return 0.0;
120        }
121        if self.log10 >= f64::MAX.log10() {
122            return f64::MAX;
123        }
124        10f64.powf(self.log10)
125    }
126
127    pub fn is_zero(self) -> bool {
128        self.log10 == f64::NEG_INFINITY
129    }
130
131    // `clippy::should_implement_trait` would have us drop the inherent
132    // method in favor of the `std::ops::Mul` impl below. We keep both
133    // because the call sites read more naturally as `a.mul(b)` than
134    // `Mag::Mul::mul(a, b)` and the `*` operator alone obscures that
135    // Mag isn't a primitive `f64`. The trait impl exists so `*` works
136    // and so the inherent method is provably aligned with std semantics.
137    #[allow(clippy::should_implement_trait)]
138    pub fn mul(self, rhs: Mag) -> Mag {
139        if self.is_zero() || rhs.is_zero() {
140            return Mag::ZERO;
141        }
142        Mag {
143            log10: self.log10 + rhs.log10,
144        }
145    }
146
147    #[allow(clippy::should_implement_trait)]
148    pub fn div(self, rhs: Mag) -> Mag {
149        if self.is_zero() {
150            return Mag::ZERO;
151        }
152        if rhs.is_zero() {
153            // Defining 1/0 as ZERO here is a deliberate, defensive
154            // choice: nothing in gameplay should ever divide by zero,
155            // but if it does we'd rather collapse to "no contribution"
156            // than panic or produce an `Infinity` that re-introduces
157            // the bug class this whole module exists to kill.
158            return Mag::ZERO;
159        }
160        Mag {
161            log10: self.log10 - rhs.log10,
162        }
163    }
164
165    #[allow(clippy::should_implement_trait)]
166    pub fn add(self, rhs: Mag) -> Mag {
167        if self.is_zero() {
168            return rhs;
169        }
170        if rhs.is_zero() {
171            return self;
172        }
173        let (hi, lo) = if self.log10 >= rhs.log10 {
174            (self.log10, rhs.log10)
175        } else {
176            (rhs.log10, self.log10)
177        };
178        let diff = lo - hi;
179        if diff < -16.0 {
180            // 10^-16 is past f64 precision relative to 1.0 — the smaller
181            // addend's contribution rounds away. Skip the (1.0 + 10^diff)
182            // dance and return the larger.
183            return Mag { log10: hi };
184        }
185        Mag {
186            log10: hi + (1.0 + 10f64.powf(diff)).log10(),
187        }
188    }
189
190    /// Saturating subtraction: returns `Some(self - rhs)` when
191    /// `self >= rhs`, otherwise `None`. Cuque spending uses this on
192    /// purchase — the caller already gated on `affordable`, so `None`
193    /// means a logic bug, not a regular game state.
194    pub fn try_sub(self, rhs: Mag) -> Option<Mag> {
195        if rhs.is_zero() {
196            return Some(self);
197        }
198        if self.log10 < rhs.log10 {
199            return None;
200        }
201        if self.is_zero() {
202            return Some(Mag::ZERO);
203        }
204        let diff = rhs.log10 - self.log10;
205        if diff < -16.0 {
206            return Some(self);
207        }
208        let factor = 1.0 - 10f64.powf(diff);
209        if factor <= 0.0 {
210            return Some(Mag::ZERO);
211        }
212        Some(Mag {
213            log10: self.log10 + factor.log10(),
214        })
215    }
216
217    /// Saturating subtraction with floor at zero. Convenience for paths
218    /// that don't care about the underflow case (e.g. visual tween
219    /// targets).
220    pub fn saturating_sub(self, rhs: Mag) -> Mag {
221        self.try_sub(rhs).unwrap_or(Mag::ZERO)
222    }
223
224    /// Raise to an integer power. `pow(0) == ONE`, `pow(n) = self * self * …`.
225    /// Cheap: just multiplies the log by `n`.
226    ///
227    /// `0^n` for any `n != 0` returns `ZERO`. Mathematically `0^negative` is
228    /// undefined, but returning ZERO is the conservative choice for this
229    /// codebase: it propagates "no contribution" downstream rather than
230    /// producing an `Infinity`, which would reintroduce the bug class
231    /// the `Mag` type exists to kill.
232    pub fn pow_i(self, n: i32) -> Mag {
233        if n == 0 {
234            return Mag::ONE;
235        }
236        if self.is_zero() {
237            return Mag::ZERO;
238        }
239        Mag {
240            log10: self.log10 * (n as f64),
241        }
242    }
243
244    /// Floored `to_f64`, useful for "how many of these can the player
245    /// afford" math. Clamps to `u64::MAX`.
246    pub fn floor_u64(self) -> u64 {
247        let v = self.to_f64().floor();
248        if v <= 0.0 {
249            0
250        } else if v >= u64::MAX as f64 {
251            u64::MAX
252        } else {
253            v as u64
254        }
255    }
256}
257
258impl Default for Mag {
259    fn default() -> Self {
260        Mag::ZERO
261    }
262}
263
264impl PartialEq for Mag {
265    fn eq(&self, other: &Self) -> bool {
266        // NaN-safe: treat any NaN log as ZERO for equality purposes.
267        let a = if self.log10.is_nan() {
268            f64::NEG_INFINITY
269        } else {
270            self.log10
271        };
272        let b = if other.log10.is_nan() {
273            f64::NEG_INFINITY
274        } else {
275            other.log10
276        };
277        a == b
278    }
279}
280
281impl PartialOrd for Mag {
282    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
283        self.log10.partial_cmp(&other.log10)
284    }
285}
286
287// Convenience: ergonomic conversion from common literal types so call
288// sites can keep reading naturally ("Mag::from(0.05)" inside a test
289// fixture).
290impl From<f64> for Mag {
291    fn from(v: f64) -> Self {
292        Mag::from_f64(v)
293    }
294}
295
296impl From<u32> for Mag {
297    fn from(v: u32) -> Self {
298        Mag::from_f64(v as f64)
299    }
300}
301
302impl From<u64> for Mag {
303    fn from(v: u64) -> Self {
304        Mag::from_f64(v as f64)
305    }
306}
307
308// Operator traits forward to the inherent methods so `a * b` / `a + b` /
309// `a / b` work alongside the explicit `.mul(b)` / `.add(b)` / `.div(b)`
310// the rest of the codebase uses. The trait impls also silence clippy's
311// `method_can_be_confused_for_std_trait` warning — the methods *are*
312// the standard operations now.
313//
314// Subtraction deliberately doesn't get a `Sub` impl: the only honest
315// answer for `a - b` when `b > a` is "underflow", and `try_sub →
316// Option` keeps every spend site forced to acknowledge that case.
317
318impl std::ops::Mul for Mag {
319    type Output = Mag;
320    fn mul(self, rhs: Mag) -> Mag {
321        Mag::mul(self, rhs)
322    }
323}
324
325impl std::ops::Div for Mag {
326    type Output = Mag;
327    fn div(self, rhs: Mag) -> Mag {
328        Mag::div(self, rhs)
329    }
330}
331
332impl std::ops::Add for Mag {
333    type Output = Mag;
334    fn add(self, rhs: Mag) -> Mag {
335        Mag::add(self, rhs)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn approx_eq(a: f64, b: f64) -> bool {
344        if a == b {
345            return true;
346        }
347        (a - b).abs() / b.abs().max(1e-12) < 1e-10
348    }
349
350    #[test]
351    fn from_f64_roundtrip() {
352        for v in [1.0, 1000.0, 1e20, 1e100, 1e300] {
353            let m = Mag::from_f64(v);
354            assert!(approx_eq(m.to_f64(), v), "roundtrip {v} got {}", m.to_f64());
355        }
356    }
357
358    #[test]
359    fn zero_and_negative_collapse_to_zero() {
360        assert_eq!(Mag::from_f64(0.0), Mag::ZERO);
361        assert_eq!(Mag::from_f64(-5.0), Mag::ZERO);
362        assert_eq!(Mag::from_f64(f64::NAN), Mag::ZERO);
363        assert_eq!(Mag::from_f64(f64::INFINITY), Mag::ZERO);
364    }
365
366    #[test]
367    fn mul_compounds_huge() {
368        let a = Mag::from_f64(1e200);
369        let b = Mag::from_f64(1e200);
370        let c = a.mul(b);
371        // 10^400 — well past f64::MAX.
372        assert!(approx_eq(c.log10, 400.0));
373        // to_f64 saturates rather than producing infinity.
374        assert_eq!(c.to_f64(), f64::MAX);
375    }
376
377    #[test]
378    fn pow_i_handles_grindy_compounding() {
379        // 1.005^100_000 — a stress test for late-late-game stacks.
380        // Original f64 path would overflow long before this.
381        let m = Mag::from_f64(1.005);
382        let p = m.pow_i(100_000);
383        let expected_log = (1.005_f64).log10() * 100_000.0;
384        assert!(approx_eq(p.log10, expected_log));
385    }
386
387    #[test]
388    fn add_handles_disparate_magnitudes() {
389        // 10^100 + 10^50 ≈ 10^100 (within f64 precision).
390        let a = Mag::from_f64(1e100);
391        let b = Mag::from_f64(1e50);
392        let s = a.add(b);
393        assert!(approx_eq(s.log10, a.log10));
394    }
395
396    #[test]
397    fn add_handles_equal_magnitudes() {
398        let a = Mag::from_f64(1e100);
399        let s = a.add(a);
400        // 2 * 10^100 -> log10 = 100 + log10(2)
401        assert!(approx_eq(s.log10, 100.0 + 2.0_f64.log10()));
402    }
403
404    #[test]
405    fn try_sub_returns_none_on_underflow() {
406        let a = Mag::from_f64(100.0);
407        let b = Mag::from_f64(200.0);
408        assert!(a.try_sub(b).is_none());
409    }
410
411    #[test]
412    fn try_sub_handles_close_values() {
413        let a = Mag::from_f64(100.0);
414        let b = Mag::from_f64(99.0);
415        let s = a.try_sub(b).expect("should fit");
416        assert!(approx_eq(s.to_f64(), 1.0));
417    }
418
419    #[test]
420    fn try_sub_zero_is_identity() {
421        let a = Mag::from_f64(50.0);
422        assert_eq!(a.try_sub(Mag::ZERO).unwrap(), a);
423    }
424
425    #[test]
426    fn comparison_order_matches_value_order() {
427        assert!(Mag::from_f64(10.0) < Mag::from_f64(100.0));
428        assert!(Mag::ZERO < Mag::from_f64(1.0));
429        // Truly huge values still order correctly.
430        assert!(Mag::from_f64(1e200).mul(Mag::from_f64(1e200)) > Mag::from_f64(1e300));
431    }
432
433    #[test]
434    fn mul_by_zero_is_zero() {
435        let a = Mag::from_f64(1e100);
436        assert_eq!(a.mul(Mag::ZERO), Mag::ZERO);
437    }
438
439    #[test]
440    fn one_is_multiplicative_identity() {
441        let a = Mag::from_f64(42.0);
442        assert!(approx_eq(a.mul(Mag::ONE).to_f64(), a.to_f64()));
443        assert!(approx_eq(Mag::ONE.mul(a).to_f64(), a.to_f64()));
444    }
445
446    #[test]
447    fn floor_u64_clamps_at_u64_max() {
448        let huge = Mag::from_f64(1e25);
449        // 10^25 > u64::MAX (~1.8e19), should clamp.
450        assert_eq!(huge.floor_u64(), u64::MAX);
451        let small = Mag::from_f64(42.7);
452        assert_eq!(small.floor_u64(), 42);
453        assert_eq!(Mag::ZERO.floor_u64(), 0);
454    }
455}