arbitrary-numbers 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,11 +15,23 @@
15
15
  Numbers are stored as a normalized `coefficient × 10^exponent` pair. That makes arithmetic across wildly different scales fast and predictable — exactly what idle games and simulations need when values span from `1` to `10^300` in the same loop.
16
16
 
17
17
  - **Immutable by default** — every operation returns a new instance, no surprise mutations
18
- - **Fused operations** (`mulAdd`, `subMul`, ...) — reduce allocations in hot loops
18
+ - **Fused operations** (`mulAdd`, `subMul`, `mulDiv`, ...) — reduce allocations in hot loops
19
19
  - **Formula pipelines** — define an expression once, apply it to any number of values
20
20
  - **Pluggable display** — swap between scientific, unit (K/M/B/T), and letter notation without touching game logic
21
+ - **Save / load built-in** — `toJSON()` / `fromJSON()` / `parse()` for idle-game persistence
21
22
  - **Zero dependencies** — nothing to audit, nothing to break
22
23
 
24
+ ## How it compares
25
+
26
+ | Library | Strengths | Limitations | Pick when |
27
+ |---|---|---|---|
28
+ | **arbitrary-numbers** | TypeScript-first, fused ops, `mulDiv`, pluggable notation, serialization, zero deps | Newer; coefficient is always float64 | You want types, ergonomics, and notation flexibility |
29
+ | `break_infinity.js` | Very fast, large incremental-game community, battle-tested | JS only, no types, plugin system is bolt-on | Max speed and community examples matter most |
30
+ | `break_eternity.js` | Handles super-exponent range up to `e(9e15)` | Heavier, more complex API | You genuinely need values beyond `10^(10^15)` |
31
+ | `decimal.js` | Arbitrary *precision* (not just range) | 4–14× slower for game math | Financial math, exact decimal arithmetic |
32
+
33
+ `arbitrary-numbers` stores coefficients as float64, giving ~15 significant digits of precision — the same as a plain JS `number`. If you need exact decimal arithmetic, use `decimal.js`. If you need exponents beyond ~10^(10^15), use `break_eternity`.
34
+
23
35
  ## Install
24
36
 
25
37
  ```sh
@@ -33,46 +45,70 @@ Requires TypeScript `"strict": true`.
33
45
  ```typescript
34
46
  import { an, chain, formula, unitNotation } from "arbitrary-numbers";
35
47
 
36
- // Exact at any scale, no overflow or silent precision loss
37
- const base = an(1, 9); // 1,000,000,000
38
- const armor = an(2, 6); // 2,000,000
39
- let gold = an(5, 6); // 5,000,000
48
+ // JavaScript range limits
49
+ const jsHuge = Number("1e500"); // Infinity
50
+ const jsTiny = Number("1e-500"); // 0
40
51
 
41
- // Damage formula: (base - armor) * 0.75
42
- const damage = chain(base)
43
- .subMul(armor, an(7.5, -1))
52
+ // Arbitrary range in both directions
53
+ const huge = an(1, 500);
54
+ const tiny = an(1, -500);
55
+
56
+ // One-off pipeline with chain(): (6.2e15 - 8.5e13) * 0.75
57
+ const damage = chain(an(6.2, 15))
58
+ .subMul(an(8.5, 13), an(7.5, -1))
44
59
  .floor()
45
60
  .done();
46
61
 
47
- damage.toString(unitNotation) // "748.50 M"
62
+ // Reusable per-tick formula: gold = (gold * 1.08) + 2_500_000
63
+ const tick = formula("tick").mulAdd(an(1.08), an(2.5, 6));
48
64
 
49
- // Per-tick update: fused op, one allocation instead of two
50
- gold = gold.mulAdd(an(1.05), an(1, 4)); // (gold * 1.05) + 10,000
65
+ let gold = an(7.5, 12);
66
+ for (let i = 0; i < 3; i += 1) {
67
+ gold = tick.apply(gold);
68
+ }
51
69
 
52
- // Reusable formula pipeline applied to multiple values
53
- const applyArmor = formula("Armor").subMul(armor, an(7.5, -1)).floor();
54
- const physDamage = applyArmor.apply(base);
55
- const magDamage = applyArmor.apply(an(8, 8));
70
+ console.log("=== Range limits (JS vs arbitrary-numbers) ===");
71
+ console.log(`JS Number('1e500') -> ${jsHuge}`);
72
+ console.log(`AN an(1, 500) -> ${huge.toString()}`);
73
+ console.log(`JS Number('1e-500') -> ${jsTiny}`);
74
+ console.log(`AN an(1, -500) -> ${tiny.toString()}`);
56
75
 
57
- // Formatting adapts at every scale automatically
58
- an(1.5, 3).toString(unitNotation) // "1.50 K"
59
- an(1.5, 6).toString(unitNotation) // "1.50 M"
60
- an(1.5, 9).toString(unitNotation) // "1.50 B"
76
+ console.log("");
77
+ console.log("=== Game math helpers ===");
78
+ console.log(`Damage (chain + fused subMul) -> ${damage.toString(unitNotation)}`);
79
+ console.log(`Gold after 3 ticks (formula) -> ${gold.toString(unitNotation)}`);
80
+ ```
81
+
82
+ Example output when running this in a repository checkout (for example with `npx tsx examples/quickstart.ts`):
83
+
84
+ ```text
85
+ === Range limits (JS vs arbitrary-numbers) ===
86
+ JS Number('1e500') -> Infinity
87
+ AN an(1, 500) -> 1.00e+500
88
+ JS Number('1e-500') -> 0
89
+ AN an(1, -500) -> 1.00e-500
90
+
91
+ === Game math helpers ===
92
+ Damage (chain + fused subMul) -> 4.59 Qa
93
+ Gold after 3 ticks (formula) -> 9.45 T
61
94
  ```
62
95
 
63
96
  ## Table of contents
64
97
 
98
+ - [How it compares](#how-it-compares)
65
99
  - [Install](#install)
66
100
  - [Quick start](#quick-start)
67
101
  - [Table of contents](#table-of-contents)
68
102
  - [Creating numbers](#creating-numbers)
69
103
  - [Arithmetic](#arithmetic)
104
+ - [Negative numbers](#negative-numbers)
70
105
  - [Fused operations](#fused-operations)
71
106
  - [Fluent builder - `chain()`](#fluent-builder---chain)
72
107
  - [Reusable formulas - `formula()`](#reusable-formulas---formula)
73
108
  - [chain() vs formula()](#chain-vs-formula)
74
109
  - [Comparison and predicates](#comparison-and-predicates)
75
110
  - [Rounding and math](#rounding-and-math)
111
+ - [Serialization and save-load](#serialization-and-save-load)
76
112
  - [Display and formatting](#display-and-formatting)
77
113
  - [scientificNotation (default)](#scientificnotation-default)
78
114
  - [unitNotation - K, M, B, T...](#unitnotation---k-m-b-t)
@@ -136,6 +172,30 @@ a.negate() // -3,000,000
136
172
  a.abs() // 3,000,000
137
173
  ```
138
174
 
175
+ ## Negative numbers
176
+
177
+ All operations support negative coefficients. The sign is carried in the coefficient — the exponent is always the magnitude.
178
+
179
+ ```typescript
180
+ const debt = an(-5, 6); // -5,000,000
181
+ const income = an(2, 6); // 2,000,000
182
+
183
+ debt.add(income) // -3,000,000
184
+ debt.abs() // 5,000,000
185
+ debt.negate() // 5,000,000
186
+ debt.sign() // -1
187
+ debt.isNegative() // true
188
+
189
+ // Notation plugins preserve the sign
190
+ import { unitNotation, letterNotation, scientificNotation } from "arbitrary-numbers";
191
+
192
+ an(-1.5, 6).toString(scientificNotation) // "-1.50e+6"
193
+ an(-1.5, 6).toString(unitNotation) // "-1.50 M"
194
+ an(-1.5, 6).toString(letterNotation) // "-1.50b"
195
+ ```
196
+
197
+ Negative numbers are less common in idle games (resources can't go below zero), but they are useful for delta-income tracking, balance sheets, and damage-over-time effects. All arithmetic, comparison, rounding, and formatting methods handle negatives correctly across the full exponent range.
198
+
139
199
  ## Fused operations
140
200
 
141
201
  Fused methods compute a two-step expression in one normalisation pass, saving one intermediate allocation per call. Use them in per-tick update loops.
@@ -145,15 +205,21 @@ Fused methods compute a two-step expression in one normalisation pass, saving on
145
205
  gold = gold.mulAdd(prestigeRate, prestigeBonus);
146
206
 
147
207
  // Other fused pairs
148
- base.addMul(bonus, multiplier); // (base + bonus) * multiplier
149
- income.mulSub(rate, upkeep); // (income * rate) - upkeep
150
- raw.subMul(reduction, boost); // (raw - reduction) * boost
151
- damage.divAdd(speed, flat); // (damage / speed) + flat
208
+ base.addMul(bonus, multiplier); // (base + bonus) * multiplier
209
+ income.mulSub(rate, upkeep); // (income * rate) - upkeep
210
+ raw.subMul(reduction, boost); // (raw - reduction) * boost
211
+ damage.divAdd(speed, flat); // (damage / speed) + flat
212
+ production.mulDiv(deltaTime, cost); // (production * deltaTime) / cost
152
213
 
153
214
  // Sum an array in one pass, ~9x faster than .reduce((a, b) => a.add(b))
154
215
  const total = ArbitraryNumber.sumArray(incomeSources);
216
+
217
+ // Multiply all elements in one pass
218
+ const product = ArbitraryNumber.productArray(multipliers);
155
219
  ```
156
220
 
221
+ `mulDiv` is the idle-tick workhorse: `(production * deltaTime) / cost` without allocating an intermediate value for the product.
222
+
157
223
  ## Fluent builder - `chain()`
158
224
 
159
225
  `chain()` wraps an `ArbitraryNumber` in a thin accumulator. Each method mutates the accumulated value and returns `this`. No expression tree, no deferred execution.
@@ -246,22 +312,112 @@ ArbitraryNumber.lerp(a, b, 0.5) // 9,500
246
312
  ```typescript
247
313
  const n = an(1.75, 0); // 1.75
248
314
 
249
- n.floor() // 1
250
- n.ceil() // 2
251
- n.round() // 2
315
+ n.floor() // 1 (toward -∞)
316
+ n.ceil() // 2 (toward +∞)
317
+ n.round() // 2 (half-up)
318
+ n.trunc() // 1 (toward 0)
319
+
320
+ an(-1.75, 0).floor() // -2 (toward -∞)
321
+ an(-1.75, 0).trunc() // -1 (toward 0, unlike floor)
252
322
 
253
323
  an(4, 0).sqrt() // 2 (1.18x faster than .pow(0.5))
254
324
  an(1, 4).sqrt() // 100
255
325
  an(-4, 0).sqrt() // throws ArbitraryNumberDomainError
256
326
 
327
+ an(8, 0).cbrt() // 2
328
+ an(1, 9).cbrt() // 1e3 (= 1,000)
329
+ an(-27, 0).cbrt() // -3 (cube root supports negatives)
330
+
257
331
  an(1, 3).log10() // 3
258
332
  an(1.5, 3).log10() // 3.176...
333
+ an(1024, 0).log(2) // 10
334
+ an(Math.E, 0).ln() // ≈ 1
335
+
336
+ ArbitraryNumber.exp10(6) // 1e6 (inverse of log10)
337
+ ArbitraryNumber.exp10(3.5) // ≈ 3162.3
338
+
259
339
  ArbitraryNumber.Zero.log10() // throws ArbitraryNumberDomainError
260
340
 
261
341
  an(1.5, 3).toNumber() // 1500
262
342
  an(1, 400).toNumber() // Infinity (exponent beyond float64 range)
343
+
344
+ // Batch operations
345
+ ArbitraryNumber.productArray([an(2), an(3), an(4)]) // 24
346
+ ArbitraryNumber.maxOfArray([an(1), an(3), an(2)]) // an(3)
347
+ ArbitraryNumber.minOfArray([an(3), an(1), an(2)]) // an(1)
348
+ ```
349
+
350
+ ## Serialization and save-load
351
+
352
+ Idle games need to persist numbers across sessions. `arbitrary-numbers` provides three serialization paths:
353
+
354
+ ### JSON (recommended for save files)
355
+
356
+ ```typescript
357
+ import { ArbitraryNumber, an } from "arbitrary-numbers";
358
+
359
+ const gold = an(1.5, 6);
360
+
361
+ // Serialize — produces { c: number, e: number }
362
+ const blob = gold.toJSON(); // { c: 1.5, e: 6 }
363
+ const json = JSON.stringify(gold); // '{"c":1.5,"e":6}'
364
+
365
+ // Deserialize
366
+ const restored = ArbitraryNumber.fromJSON(JSON.parse(json));
367
+ restored.equals(gold); // true
368
+ ```
369
+
370
+ `toJSON()` uses short keys (`c`/`e`) to keep save blobs small. The shape is stable — renaming internal fields will never silently break your saves.
371
+
372
+ ### Compact string (URL params, cookies)
373
+
374
+ ```typescript
375
+ const raw = gold.toRaw(); // "1.5|6"
376
+ const restored = ArbitraryNumber.parse("1.5|6");
377
+ restored.equals(gold); // true
378
+ ```
379
+
380
+ ### Parsing arbitrary strings
381
+
382
+ `ArbitraryNumber.parse()` accepts multiple formats:
383
+
384
+ ```typescript
385
+ ArbitraryNumber.parse("1.5|6") // pipe format (exact round-trip)
386
+ ArbitraryNumber.parse("1.5e+6") // scientific notation
387
+ ArbitraryNumber.parse("1500000") // plain decimal
388
+ ArbitraryNumber.parse("-0.003") // negative decimal
389
+ ArbitraryNumber.parse("1.5E6") // uppercase E
263
390
  ```
264
391
 
392
+ Unrecognised or non-finite strings throw `ArbitraryNumberInputError`.
393
+
394
+ ### Save-every-60s pattern
395
+
396
+ ```typescript
397
+ // Save
398
+ function saveGame(state: GameState): string {
399
+ return JSON.stringify({
400
+ gold: state.gold.toJSON(),
401
+ gps: state.gps.toJSON(),
402
+ tick: state.tick,
403
+ });
404
+ }
405
+
406
+ // Load
407
+ function loadGame(json: string): GameState {
408
+ const raw = JSON.parse(json);
409
+ return {
410
+ gold: ArbitraryNumber.fromJSON(raw.gold),
411
+ gps: ArbitraryNumber.fromJSON(raw.gps),
412
+ tick: raw.tick,
413
+ };
414
+ }
415
+
416
+ setInterval(() => localStorage.setItem("save", saveGame(state)), 60_000);
417
+ ```
418
+
419
+ > **Precision note:** `toJSON()` / `fromJSON()` round-trip is exact. `toRaw()` / `parse()` with the pipe format is also exact. Parsing a number from a notation string (e.g. `"1.50 M"`) is not provided — it would be lossy because the suffix format discards precision beyond `decimals`.
420
+
265
421
  ## Display and formatting
266
422
 
267
423
  `toString(plugin?, decimals?)` accepts any `NotationPlugin`. Three plugins are included.
@@ -330,7 +486,7 @@ When two numbers differ in exponent by more than `PrecisionCutoff` (default `15`
330
486
  const huge = an(1, 20); // 10^20
331
487
  const tiny = an(1, 3); // 1,000
332
488
 
333
- huge.add(tiny) // returns huge unchanged
489
+ huge.add(tiny) // returns huge unchanged — tiny is negligible at 10^20 scale
334
490
  ```
335
491
 
336
492
  Override globally or for a single scoped block:
@@ -342,6 +498,34 @@ ArbitraryNumber.PrecisionCutoff = 50; // global
342
498
  const result = ArbitraryNumber.withPrecision(50, () => a.add(b));
343
499
  ```
344
500
 
501
+ ### When things drift — precision troubleshooting
502
+
503
+ **"Why doesn't `a.add(b).sub(b).equals(a)` return true when `b` is much larger than `a`?"**
504
+
505
+ ```typescript
506
+ const a = an(1, 3); // 1,000
507
+ const b = an(1, 20); // 10^20
508
+
509
+ a.add(b) // returns b — a is negligible, discarded
510
+ a.add(b).sub(b) // returns b.sub(b) = 0, not a
511
+ a.add(b).sub(b).equals(a) // false — a was lost when added to b
512
+ ```
513
+
514
+ This is the fundamental float64 limitation: `1,000 + 10^20 = 10^20` in any number system with ~15 significant digits. `arbitrary-numbers` does not hide this — it is exact for the scale at which each value is stored.
515
+
516
+ **Game patterns and their typical precision needs:**
517
+
518
+ | Pattern | Exponent diff | Precision loss |
519
+ |---|---|---|
520
+ | Same-tier resources (gold + gold) | 0–3 | None |
521
+ | Upgrade costs vs balance | 0–8 | None |
522
+ | Prestige multipliers | 15–25 | < 0.0001% |
523
+ | Idle accumulation over time | 20–50 | ~0.1% (acceptable for display) |
524
+
525
+ If exact addition across large exponent gaps matters (e.g. financial ledger), raise `PrecisionCutoff` to `50` and use `withPrecision`.
526
+
527
+ > **Warning:** `PrecisionCutoff` is a global static. Changing it affects all arithmetic library-wide, including in async callbacks that run concurrently. Use `withPrecision` for scoped overrides.
528
+
345
529
  ## Errors
346
530
 
347
531
  All errors thrown by the library extend `ArbitraryNumberError`, so you can distinguish them from your own errors.
@@ -441,67 +625,117 @@ an(3.2, 6).toString(new TierNotation({ separator: " " })) // "3.20 M"
441
625
 
442
626
  ## Idle game example
443
627
 
444
- A self-contained simulation showing `an()`, fused ops, `chain()`, helpers, and `unitNotation` working together.
628
+ A self-contained simulation showing hyper-growth, fused ops, helpers, and where plain JS `number` overflows while `ArbitraryNumber` keeps working.
445
629
 
446
630
  ```typescript
447
631
  import {
448
- ArbitraryNumber, an, chain,
449
- unitNotation, ArbitraryNumberHelpers as helpers,
632
+ an, chain,
633
+ UnitNotation,
634
+ CLASSIC_UNITS,
635
+ letterNotation,
636
+ ArbitraryNumberHelpers as helpers,
450
637
  } from "arbitrary-numbers";
638
+ import type { ArbitraryNumber } from "arbitrary-numbers";
451
639
 
452
- let gold = ArbitraryNumber.Zero;
453
- let goldPerSec = an(1);
640
+ let gold = an(5, 6); // 5,000,000
641
+ let gps = an(2, 5); // 200,000 per tick
642
+ let reactorCost = an(1, 9);
643
+ let reactors = 0;
454
644
 
455
- const UPGRADES = [
456
- { label: "Copper Pick ", cost: an(50), mult: an(5) },
457
- { label: "Iron Mine ", cost: an(1, 3), mult: an(20) },
458
- { label: "Gold Refinery", cost: an(5, 6), mult: an(1, 4) },
459
- ] as const;
645
+ const display = new UnitNotation({
646
+ units: CLASSIC_UNITS,
647
+ fallback: letterNotation,
648
+ });
460
649
 
461
- function tick(): void {
462
- gold = gold.add(goldPerSec);
650
+ function fmt(value: ArbitraryNumber, decimals = 2): string {
651
+ return value.toString(display, decimals);
463
652
  }
464
653
 
465
- function tryBuyAll(): void {
466
- for (const u of UPGRADES) {
467
- if (!helpers.meetsOrExceeds(gold, u.cost)) continue;
468
- gold = gold.sub(u.cost);
469
- goldPerSec = goldPerSec.mul(u.mult);
470
- console.log(` bought ${u.label} GPS: ${goldPerSec.toString(unitNotation)}`);
471
- }
654
+ function snapshot(tick: number): void {
655
+ console.log(
656
+ `[t=${String(tick).padStart(4)}] SNAPSHOT `
657
+ + `gold=${fmt(gold, 2).padStart(12)} gps=${fmt(gps, 2).padStart(12)}`,
658
+ );
472
659
  }
473
660
 
474
- function prestige(multiplier: ArbitraryNumber): void {
475
- goldPerSec = chain(goldPerSec)
476
- .mulAdd(multiplier, an(1)) // (gps * mult) + 1, fused
477
- .floor()
478
- .done();
479
- console.log(` prestige! new GPS: ${goldPerSec.toString(unitNotation)}`);
480
- }
661
+ console.log("=== Hyper-growth idle loop (720 ticks) ===");
662
+ console.log(`start gold=${fmt(gold)} gps=${fmt(gps)} reactorCost=${fmt(reactorCost)}`);
663
+
664
+ for (let t = 1; t <= 720; t += 1) {
665
+ // Core growth: gold = (gold * 1.12) + gps
666
+ gold = gold.mulAdd(an(1.12), gps);
667
+
668
+ if (t % 60 === 0 && helpers.meetsOrExceeds(gold, reactorCost)) {
669
+ const before = gps;
670
+ gold = gold.sub(reactorCost);
671
+ gps = chain(gps).mul(an(1, 25)).floor().done();
672
+ reactorCost = reactorCost.mul(an(8));
673
+ reactors += 1;
674
+
675
+ console.log(
676
+ `[t=${String(t).padStart(4)}] REACTOR #${String(reactors).padStart(2)} `
677
+ + `gps ${fmt(before)} -> ${fmt(gps)} `
678
+ + `nextCost=${fmt(reactorCost)}`,
679
+ );
680
+ }
481
681
 
482
- for (let t = 1; t <= 1_000_000; t++) {
483
- tick();
484
- if (t % 10 === 0) tryBuyAll();
485
- if (t === 51_000) prestige(an(1.5));
486
- if (t % 250_000 === 0) {
487
- const g = gold.toString(unitNotation, 3);
488
- const gps = goldPerSec.toString(unitNotation, 3);
489
- console.log(`[t=${String(t).padStart(9)}] gold: ${g.padStart(12)} gps: ${gps}`);
682
+ if (t === 240 || t === 480) {
683
+ const before = gps;
684
+ gps = chain(gps)
685
+ .mul(an(1, 4))
686
+ .add(an(7.5, 6))
687
+ .floor()
688
+ .done();
689
+ console.log(`[t=${String(t).padStart(4)}] PRESTIGE gps ${fmt(before)} -> ${fmt(gps)}`);
690
+ }
691
+
692
+ if (t % 120 === 0) {
693
+ snapshot(t);
490
694
  }
491
695
  }
696
+
697
+ console.log("\n=== Final scale check ===");
698
+ console.log(`reactors bought: ${reactors}`);
699
+ console.log(`final gold (unit+letter): ${fmt(gold)}`);
700
+ console.log(`final gps (unit+letter): ${fmt(gps)}`);
701
+ console.log(`final gold as JS Number: ${gold.toNumber()}`);
702
+ console.log(`final gps as JS Number : ${gps.toNumber()}`);
703
+ console.log("If JS shows Infinity while unit+letter output stays finite, the library is doing its job.");
492
704
  ```
493
705
 
494
706
  Output:
495
707
 
496
- ```
497
- bought Copper Pick GPS: 5.00
498
- bought Iron Mine GPS: 100.00
499
- bought Gold Refinery GPS: 1.00 M
500
- prestige! new GPS: 1.50 M
501
- [t= 250000] gold: 199.750 B gps: 1.500 M
502
- [t= 500000] gold: 574.750 B gps: 1.500 M
503
- [t= 750000] gold: 949.750 B gps: 1.500 M
504
- [t= 1000000] gold: 1.325 T gps: 1.500 M
708
+ ```text
709
+ === Hyper-growth idle loop (720 ticks) ===
710
+ start gold=5.00 M gps=200.00 K reactorCost=1.00 B
711
+ [t= 60] REACTOR # 1 gps 200.00 K -> 2.00 No nextCost=8.00 B
712
+ [t= 120] REACTOR # 2 gps 2.00 No -> 20.00 SpDc nextCost=64.00 B
713
+ [t= 120] SNAPSHOT gold= 14.94 Dc gps= 20.00 SpDc
714
+ [t= 180] REACTOR # 3 gps 20.00 SpDc -> 200.00 QiVg nextCost=512.00 B
715
+ [t= 240] REACTOR # 4 gps 200.00 QiVg -> 2.00 ai nextCost=4.10 T
716
+ [t= 240] PRESTIGE gps 2.00 ai -> 20.00 aj
717
+ [t= 240] SNAPSHOT gold= 1.49 SpVg gps= 20.00 aj
718
+ [t= 300] REACTOR # 5 gps 20.00 aj -> 200.00 ar nextCost=32.77 T
719
+ [t= 360] REACTOR # 6 gps 200.00 ar -> 2.00 ba nextCost=262.14 T
720
+ [t= 360] SNAPSHOT gold= 1.49 at gps= 2.00 ba
721
+ [t= 420] REACTOR # 7 gps 2.00 ba -> 20.00 bi nextCost=2.10 Qa
722
+ [t= 480] REACTOR # 8 gps 20.00 bi -> 200.00 bq nextCost=16.78 Qa
723
+ [t= 480] PRESTIGE gps 200.00 bq -> 2.00 bs
724
+ [t= 480] SNAPSHOT gold= 149.43 bj gps= 2.00 bs
725
+ [t= 540] REACTOR # 9 gps 2.00 bs -> 20.00 ca nextCost=134.22 Qa
726
+ [t= 600] REACTOR #10 gps 20.00 ca -> 200.00 ci nextCost=1.07 Qi
727
+ [t= 600] SNAPSHOT gold= 149.43 cb gps= 200.00 ci
728
+ [t= 660] REACTOR #11 gps 200.00 ci -> 2.00 cr nextCost=8.59 Qi
729
+ [t= 720] REACTOR #12 gps 2.00 cr -> 20.00 cz nextCost=68.72 Qi
730
+ [t= 720] SNAPSHOT gold= 14.94 cs gps= 20.00 cz
731
+
732
+ === Final scale check ===
733
+ reactors bought: 12
734
+ final gold (unit+letter): 14.94 cs
735
+ final gps (unit+letter): 20.00 cz
736
+ final gold as JS Number: 1.494328222485101e+292
737
+ final gps as JS Number : Infinity
738
+ If JS shows Infinity while unit+letter output stays finite, the library is doing its job.
505
739
  ```
506
740
 
507
741
  ## Performance
@@ -512,10 +746,10 @@ Quick reference (Node 22.16, Intel i5-13600KF):
512
746
 
513
747
  | Operation | Time |
514
748
  |---|---|
515
- | `add` / `sub` (typical) | ~20-28 ns |
516
- | `mul` / `div` | ~10-11 ns |
517
- | Fused ops (`mulAdd`, `mulSub`, ...) | ~27-29 ns, 1.5-1.6x faster than chained |
518
- | `sumArray(50 items)` | ~200 ns, 8.4-8.7x faster than `.reduce` |
519
- | `compareTo` (same exponent) | ~0.6 ns |
520
- | `sqrt()` | ~10 ns |
521
- | `pow(0.5)` | ~7 ns |
749
+ | `add` / `sub` (typical) | ~270-275 ns |
750
+ | `mul` / `div` | ~250-255 ns |
751
+ | Fused ops (`mulAdd`, `mulSub`, ...) | ~275 ns, ~1.9x faster than chained |
752
+ | `sumArray(50 items)` | ~435 ns, ~31x faster than `.reduce` |
753
+ | `compareTo` (same exponent) | ~3 ns |
754
+ | `sqrt()` | ~252 ns |
755
+ | `pow(0.5)` | ~20 ns |