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}