arbitrary-numbers 1.1.0 → 2.0.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
@@ -10,27 +10,71 @@
10
10
  [![Zero dependencies](https://img.shields.io/badge/dependencies-zero-6366f1?labelColor=0c0c0e)](package.json)
11
11
  </div>
12
12
 
13
- `arbitrary-numbers` fills a specific gap: JavaScript's `Number` type silently loses precision above `Number.MAX_SAFE_INTEGER`, and `BigInt` can't represent decimals, so working with extremely large or small magnitudes often requires manual scaling into very large integers.
13
+ **arbitrary-numbers** is a fast, TypeScript-first big-number library for idle games and incremental simulators. It stores numbers as `coefficient × 10^exponent`, mutates in-place for zero allocation on the hot path, and ships with notation plugins and serialization built in.
14
14
 
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.
15
+ ```
16
+ gold.add(income).sub(cost).mul(multiplier) // mutates gold, returns gold — zero allocations
17
+ ```
18
+
19
+ - **Mutable by default** — no allocations on the steady-state path
20
+ - **Opt-in immutability** — `.freeze()` returns a `FrozenArbitraryNumber` that throws on any mutation
21
+ - **Fused operations** — `mulAdd`, `subMul`, `mulDiv`, … compute two steps in one normalisation pass
22
+ - **Reusable formulas** — define a pipeline once, `apply()` to any value
23
+ - **Notation plugins** — scientific, unit (K/M/B/T), letter (a/b/c), or write your own in 5 lines
24
+ - **Save/load built-in** — `toJSON()` / `fromJSON()` for idle-game persistence
25
+ - **Zero dependencies**
26
+
27
+ ---
16
28
 
17
- - **Immutable by default** — every operation returns a new instance, no surprise mutations
18
- - **Fused operations** (`mulAdd`, `subMul`, `mulDiv`, ...) — reduce allocations in hot loops
19
- - **Formula pipelines** — define an expression once, apply it to any number of values
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
22
- - **Zero dependencies** — nothing to audit, nothing to break
29
+ ## Table of contents
30
+
31
+ - [How it compares](#how-it-compares)
32
+ - [Install](#install)
33
+ - [Quick start](#quick-start)
34
+ - [The mutable API](#the-mutable-api)
35
+ - [Creating numbers](#creating-numbers)
36
+ - [Arithmetic](#arithmetic)
37
+ - [Fused operations](#fused-operations)
38
+ - [Reusable formulas](#reusable-formulas)
39
+ - [Frozen numbers](#frozen-numbers)
40
+ - [Comparison and predicates](#comparison-and-predicates)
41
+ - [Rounding and math](#rounding-and-math)
42
+ - [Display and notation](#display-and-notation)
43
+ - [Custom notation plugins](#custom-notation-plugins)
44
+ - [Serialization](#serialization)
45
+ - [Precision control](#precision-control)
46
+ - [Utilities](#utilities)
47
+ - [Errors](#errors)
48
+ - [Idle game example](#idle-game-example)
49
+ - [Performance](#performance)
50
+ - [Migration from v1](#migration-from-v1)
51
+
52
+ ---
23
53
 
24
54
  ## How it compares
25
55
 
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 |
56
+ | Library | Pick when |
57
+ |---|---|
58
+ | **arbitrary-numbers** | You want TypeScript types, mutable-fast arithmetic, fused ops, notation plugins, and serialization |
59
+ | `break_infinity.js` | Community ecosystem matters most — widely used, battle-tested, but JS-only and immutable |
60
+ | `break_eternity.js` | You genuinely need values beyond `10^(10^15)` |
61
+ | `decimal.js` | You need arbitrary *precision* (financial math, exact decimals) not just range |
62
+
63
+ Performance on Node 22.16, Intel i5-13600KF:
32
64
 
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`.
65
+ | Operation | arbitrary-numbers v2 | break_infinity.js | break_eternity.js | decimal.js |
66
+ |---|---|---|---|---|
67
+ | `add` / `sub` | **~13 ns** | ~28–32 ns | ~150–195 ns | ~134–163 ns |
68
+ | `mul` | **~11 ns** | ~15 ns | ~159 ns | ~380 ns |
69
+ | `div` | **~12 ns** | ~39–47 ns | ~200–249 ns | ~469–843 ns |
70
+ | `sqrt()` | **~11 ns** | ~14 ns | ~32 ns | ~4591 ns |
71
+ | `compareTo` | **~3.6 ns** | ~4.8 ns | ~20 ns | ~79 ns |
72
+ | `clone()` | **~6.7 ns** | ~61 ns | ~73 ns | ~260 ns |
73
+ | `sumArray(50)` | **~156 ns total** | no equivalent | no equivalent | no equivalent |
74
+
75
+ Full breakdown: [`benchmarks/COMPETITOR_BENCHMARKS.md`](benchmarks/COMPETITOR_BENCHMARKS.md).
76
+
77
+ ---
34
78
 
35
79
  ## Install
36
80
 
@@ -40,716 +84,614 @@ npm install arbitrary-numbers
40
84
 
41
85
  Requires TypeScript `"strict": true`.
42
86
 
87
+ ---
88
+
43
89
  ## Quick start
44
90
 
45
91
  ```typescript
46
- import { an, chain, formula, unitNotation } from "arbitrary-numbers";
92
+ import { an, formula, unitNotation, letterNotation, scientificNotation } from "arbitrary-numbers";
47
93
 
48
- // JavaScript range limits
49
- const jsHuge = Number("1e500"); // Infinity
50
- const jsTiny = Number("1e-500"); // 0
94
+ // JS overflows — arbitrary-numbers doesn't
95
+ Number("1e500") // Infinity
96
+ an(1, 500) // 1.00e+500 — tracked exactly
51
97
 
52
- // Arbitrary range in both directions
53
- const huge = an(1, 500);
54
- const tiny = an(1, -500);
98
+ // Mutable chaining all three ops mutate gold, no allocations
99
+ const gold = an(7.5, 12); // 7,500,000,000,000
100
+ const income = an(2.5, 9);
101
+ const cost = an(1.0, 9);
102
+ gold.add(income).sub(cost);
55
103
 
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))
59
- .floor()
60
- .done();
104
+ // clone() when you need to keep the original
105
+ const before = gold.clone();
106
+ gold.mul(an(1.1, 0));
107
+ // before is unchanged
61
108
 
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));
109
+ // Notation plugins swap at any call site
110
+ an(3.2, 15).toString(scientificNotation) // "3.20e+15"
111
+ an(3.2, 15).toString(unitNotation) // "3.20 Qa"
112
+ an(3.2, 15).toString(letterNotation) // "3.20e"
64
113
 
65
- let gold = an(7.5, 12);
66
- for (let i = 0; i < 3; i += 1) {
67
- gold = tick.apply(gold);
68
- }
114
+ // Reusable formula — define once, apply to many values
115
+ const tick = formula().mulAdd(an(1.08, 0), an(2.5, 6));
116
+ tick.applyInPlace(gold); // hot path — mutates gold in-place
117
+ const result = tick.apply(gold); // apply() clones first, gold unchanged
118
+ ```
119
+
120
+ ---
121
+
122
+ ## The mutable API
69
123
 
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()}`);
124
+ **Every arithmetic method mutates `this` and returns `this`.** This is the single most important thing to understand.
75
125
 
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)}`);
126
+ ```typescript
127
+ const a = an(3, 6); // 3,000,000
128
+ const b = an(1, 3); // 1,000
129
+
130
+ a.add(b); // a is now 3,001,000 — b is unchanged
80
131
  ```
81
132
 
82
- Example output when running this in a repository checkout (for example with `npx tsx examples/quickstart.ts`):
133
+ Chain operations naturally:
134
+
135
+ ```typescript
136
+ gold.add(income).sub(cost).mul(multiplier); // all ops mutate gold
137
+ ```
83
138
 
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
139
+ Use `clone()` to branch:
90
140
 
91
- === Game math helpers ===
92
- Damage (chain + fused subMul) -> 4.59 Qa
93
- Gold after 3 ticks (formula) -> 9.45 T
141
+ ```typescript
142
+ const snapshot = gold.clone();
143
+ gold.add(income);
144
+ // snapshot is still the old value
94
145
  ```
95
146
 
96
- ## Table of contents
147
+ **Static methods allocate, instance methods mutate:**
97
148
 
98
- - [How it compares](#how-it-compares)
99
- - [Install](#install)
100
- - [Quick start](#quick-start)
101
- - [Table of contents](#table-of-contents)
102
- - [Creating numbers](#creating-numbers)
103
- - [Arithmetic](#arithmetic)
104
- - [Negative numbers](#negative-numbers)
105
- - [Fused operations](#fused-operations)
106
- - [Fluent builder - `chain()`](#fluent-builder---chain)
107
- - [Reusable formulas - `formula()`](#reusable-formulas---formula)
108
- - [chain() vs formula()](#chain-vs-formula)
109
- - [Comparison and predicates](#comparison-and-predicates)
110
- - [Rounding and math](#rounding-and-math)
111
- - [Serialization and save-load](#serialization-and-save-load)
112
- - [Display and formatting](#display-and-formatting)
113
- - [scientificNotation (default)](#scientificnotation-default)
114
- - [unitNotation - K, M, B, T...](#unitnotation---k-m-b-t)
115
- - [AlphabetNotation - a, b, c... aa, ab...](#alphabetnotation---a-b-c-aa-ab)
116
- - [Precision control](#precision-control)
117
- - [Errors](#errors)
118
- - [Utilities](#utilities)
119
- - [ArbitraryNumberOps - mixed `number | ArbitraryNumber` input](#arbitrarynumberops---mixed-number--arbitrarynumber-input)
120
- - [ArbitraryNumberGuard - type guards](#arbitrarynumberguard---type-guards)
121
- - [ArbitraryNumberHelpers - game and simulation patterns](#arbitrarynumberhelpers---game-and-simulation-patterns)
122
- - [Writing a custom plugin](#writing-a-custom-plugin)
123
- - [Idle game example](#idle-game-example)
124
- - [Performance](#performance)
149
+ ```typescript
150
+ ArbitraryNumber.add(a, b) // returns a NEW instance — a and b are unchanged
151
+ a.add(b) // mutates a, returns a
152
+ ```
153
+
154
+ Watch out for aliasing:
155
+
156
+ ```typescript
157
+ const total = gold; // alias — NOT a copy
158
+ total.add(income); // mutates gold too!
159
+
160
+ const total = gold.clone(); // correct — independent copy
161
+ total.add(income); // gold is unchanged
162
+ ```
163
+
164
+ ---
125
165
 
126
166
  ## Creating numbers
127
167
 
128
168
  ```typescript
129
169
  import { ArbitraryNumber, an } from "arbitrary-numbers";
130
170
 
131
- // From a coefficient and exponent
132
- new ArbitraryNumber(1.5, 3) // 1,500 { coefficient: 1.5, exponent: 3 }
133
- new ArbitraryNumber(15, 3) // 15,000 -> { coefficient: 1.5, exponent: 4 } (normalised)
134
- new ArbitraryNumber(0, 99) // Zero -> { coefficient: 0, exponent: 0 }
171
+ new ArbitraryNumber(1.5, 3) // 1,500 { coefficient: 1.5, exponent: 3 }
172
+ new ArbitraryNumber(15, 3) // normalised → { coefficient: 1.5, exponent: 4 }
173
+ new ArbitraryNumber(0, 99) // zero → { coefficient: 0, exponent: 0 }
135
174
 
136
- // From a plain JS number
137
175
  ArbitraryNumber.from(1_500_000) // { coefficient: 1.5, exponent: 6 }
138
176
  ArbitraryNumber.from(0.003) // { coefficient: 3, exponent: -3 }
139
177
 
140
- // Shorthand
141
- an(1.5, 6) // same as new ArbitraryNumber(1.5, 6)
142
- an.from(1_500) // same as ArbitraryNumber.from(1500)
178
+ an(1.5, 6) // shorthand for new ArbitraryNumber(1.5, 6)
179
+ an.from(1_500) // shorthand for ArbitraryNumber.from(1500)
143
180
 
144
- // Static constants
145
- ArbitraryNumber.Zero // 0
146
- ArbitraryNumber.One // 1
147
- ArbitraryNumber.Ten // 10
181
+ an(1.5, 6).clone() // fresh mutable copy
148
182
  ```
149
183
 
150
- Inputs must be finite. `NaN`, `Infinity`, and `-Infinity` throw `ArbitraryNumberInputError`:
184
+ Non-finite inputs throw `ArbitraryNumberInputError`:
151
185
 
152
186
  ```typescript
153
- ArbitraryNumber.from(Infinity) // throws ArbitraryNumberInputError { value: Infinity }
154
- new ArbitraryNumber(NaN, 0) // throws ArbitraryNumberInputError { value: NaN }
155
- new ArbitraryNumber(1, Infinity) // throws ArbitraryNumberInputError { value: Infinity }
187
+ ArbitraryNumber.from(Infinity) // throws
188
+ new ArbitraryNumber(NaN, 0) // throws
156
189
  ```
157
190
 
191
+ ---
192
+
158
193
  ## Arithmetic
159
194
 
160
- All methods return a new `ArbitraryNumber`. Instances are immutable.
195
+ All instance methods mutate `this` and return `this`. Static methods return a new instance.
161
196
 
162
197
  ```typescript
163
- const a = an(3, 6); // 3,000,000
164
- const b = an(1, 3); // 1,000
198
+ const a = an(3, 6);
199
+ const b = an(1, 3);
165
200
 
166
- a.add(b) // 3,001,000
167
- a.sub(b) // 2,999,000
168
- a.mul(b) // 3,000,000,000
169
- a.div(b) // 3,000
170
- a.pow(2) // 9 * 10^12
171
- a.negate() // -3,000,000
172
- a.abs() // 3,000,000
173
- ```
201
+ // instance — mutates a
202
+ a.add(b) a.sub(b) a.mul(b) a.div(b)
203
+ a.pow(2) a.negate() a.abs()
174
204
 
175
- ## Negative numbers
205
+ // static — new instance, a and b unchanged
206
+ ArbitraryNumber.add(a, b)
207
+ ArbitraryNumber.sub(a, b)
208
+ ArbitraryNumber.mul(a, b)
209
+ ArbitraryNumber.div(a, b)
210
+ ```
176
211
 
177
- All operations support negative coefficients. The sign is carried in the coefficient — the exponent is always the magnitude.
212
+ Negative numbers are fully supported the sign lives in the coefficient:
178
213
 
179
214
  ```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"
215
+ const debt = an(-5, 6); // -5,000,000
216
+ debt.clone().abs() // 5,000,000
217
+ debt.clone().negate() // 5,000,000
218
+ debt.sign() // -1
219
+ debt.isNegative() // true
195
220
  ```
196
221
 
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.
222
+ ---
198
223
 
199
224
  ## Fused operations
200
225
 
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.
226
+ Fused methods compute a two-step expression in one normalisation pass fewer intermediate steps, one less allocation compared to chaining two separate ops.
202
227
 
203
228
  ```typescript
204
- // (gold * rate) + bonus in one pass, ~1.5x faster than chained
205
- gold = gold.mulAdd(prestigeRate, prestigeBonus);
206
-
207
- // Other fused pairs
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
213
-
214
- // Sum an array in one pass, ~9x faster than .reduce((a, b) => a.add(b))
215
- const total = ArbitraryNumber.sumArray(incomeSources);
216
-
217
- // Multiply all elements in one pass
218
- const product = ArbitraryNumber.productArray(multipliers);
229
+ gold.mulAdd(rate, bonus) // (gold × rate) + bonus
230
+ base.addMul(bonus, multiplier) // (base + bonus) × multiplier
231
+ income.mulSub(rate, upkeep) // (income × rate) − upkeep
232
+ raw.subMul(reduction, boost) // (raw reduction) × boost
233
+ damage.divAdd(speed, flat) // (damage / speed) + flat
234
+ production.mulDiv(dt, cost) // (production × dt) / cost
219
235
  ```
220
236
 
221
- `mulDiv` is the idle-tick workhorse: `(production * deltaTime) / cost` without allocating an intermediate value for the product.
222
-
223
- ## Fluent builder - `chain()`
237
+ Operands are only read, never mutated. Only `this` changes.
224
238
 
225
- `chain()` wraps an `ArbitraryNumber` in a thin accumulator. Each method mutates the accumulated value and returns `this`. No expression tree, no deferred execution.
239
+ Batch operations that avoid intermediate instances entirely:
226
240
 
227
241
  ```typescript
228
- import { chain } from "arbitrary-numbers";
229
-
230
- const damage = chain(base)
231
- .subMul(armour, mitigation) // (base - armour) * mitigation, fused
232
- .add(flat)
233
- .floor()
234
- .done(); // returns the ArbitraryNumber result
242
+ ArbitraryNumber.sumArray(sources) // sum of an array — ~3.1 ns/element
243
+ ArbitraryNumber.productArray(multipliers) // product of an array
235
244
  ```
236
245
 
237
- All fused ops are available on the builder, so complex formulas do not sacrifice performance.
238
-
239
- Available methods: `add`, `sub`, `mul`, `div`, `pow`, `mulAdd`, `addMul`, `mulSub`, `subMul`, `divAdd`, `abs`, `neg`, `sqrt`, `floor`, `ceil`, `round`, `done`.
246
+ ---
240
247
 
241
- ## Reusable formulas - `formula()`
248
+ ## Reusable formulas
242
249
 
243
- `formula()` builds a deferred pipeline. Unlike `chain()`, a formula stores its operations and runs them only when `apply()` is called, so the same formula can be applied to any number of values.
250
+ `formula()` builds a chainable pipeline. Steps are stored as closures and replayed on each application.
244
251
 
245
252
  ```typescript
246
253
  import { formula, an } from "arbitrary-numbers";
247
254
 
248
- const armorReduction = formula("Armor Reduction")
249
- .subMul(armor, an(7.5, -1)) // (base - armor) * 0.75
255
+ // Build once
256
+ const armorReduction = formula()
257
+ .subMul(armor, an(0.75, 0)) // (base − armor) × 0.75
250
258
  .floor();
251
259
 
260
+ // apply() — clones the input, returns a new instance
252
261
  const physDamage = armorReduction.apply(physBase);
253
262
  const magDamage = armorReduction.apply(magBase);
263
+
264
+ // applyInPlace() — mutates the passed instance directly (hot path, no clone)
265
+ armorReduction.applyInPlace(enemyAtk);
254
266
  ```
255
267
 
256
- Each step returns a new `AnFormula`, leaving the original unchanged. Branching is safe:
268
+ Compose with `then()`:
257
269
 
258
270
  ```typescript
259
- const base = formula().mul(an(2));
260
- const withFloor = base.floor(); // new formula, base is unchanged
261
- const withCeil = base.ceil(); // another branch from the same base
271
+ const withCrit = armorReduction.then(formula().mul(critMult).ceil());
272
+ const result = withCrit.apply(baseDamage);
262
273
  ```
263
274
 
264
- Compose two formulas in sequence with `then()`:
275
+ Type a formula as a property:
265
276
 
266
277
  ```typescript
267
- const critBonus = formula("Crit Bonus").mul(an(1.5)).ceil();
268
- const full = armorReduction.then(critBonus);
269
- const result = full.apply(baseDamage);
270
- ```
278
+ import type { AnFormula } from "arbitrary-numbers";
271
279
 
272
- Available methods: `add`, `sub`, `mul`, `div`, `pow`, `mulAdd`, `addMul`, `mulSub`, `subMul`, `divAdd`, `abs`, `neg`, `sqrt`, `floor`, `ceil`, `round`, `then`, `named`, `apply`.
280
+ class DamageSystem {
281
+ private readonly formula: AnFormula;
282
+ constructor(armor: ArbitraryNumber) {
283
+ this.formula = formula().subMul(armor, an(0.75, 0)).floor();
284
+ }
285
+ calculate(base: ArbitraryNumber): ArbitraryNumber {
286
+ return this.formula.apply(base);
287
+ }
288
+ }
289
+ ```
273
290
 
274
- ### chain() vs formula()
291
+ ---
275
292
 
276
- | | `chain(value)` | `formula(name?)` |
277
- |---|---|---|
278
- | Execution | Immediate | Deferred, runs on `apply()` |
279
- | Input | Fixed at construction | Provided at `apply()` |
280
- | Reusable | No, one-shot | Yes, any number of times |
281
- | Composable | No | Yes, via `then()` |
282
- | Builder style | Stateful accumulator | Immutable, each step returns a new instance |
283
- | Terminal | `.done()` | `.apply(value)` |
293
+ ## Frozen numbers
284
294
 
285
- ## Comparison and predicates
295
+ `.freeze()` returns a `FrozenArbitraryNumber` — identical API, but every mutating method throws `ArbitraryNumberMutationError`.
286
296
 
287
297
  ```typescript
288
- const a = an(1, 4); // 10,000
289
- const b = an(9, 3); // 9,000
290
-
291
- a.compareTo(b) // 1 (compatible with Array.sort)
292
- a.greaterThan(b) // true
293
- a.lessThan(b) // false
294
- a.greaterThanOrEqual(b) // true
295
- a.lessThanOrEqual(b) // false
296
- a.equals(b) // false
298
+ import { ArbitraryNumber, FrozenArbitraryNumber } from "arbitrary-numbers";
297
299
 
298
- a.isZero() // false
299
- a.isPositive() // true
300
- a.isNegative() // false
301
- a.isInteger() // true
302
- a.sign() // 1 (-1 | 0 | 1)
300
+ const base = an(1.5, 6).freeze();
301
+ base.add(an(1)); // throws ArbitraryNumberMutationError: "add"
303
302
 
304
- ArbitraryNumber.min(a, b) // b (9,000)
305
- ArbitraryNumber.max(a, b) // a (10,000)
306
- ArbitraryNumber.clamp(an(5, 5), a, an(1, 5)) // an(1, 5) (clamped to max)
307
- ArbitraryNumber.lerp(a, b, 0.5) // 9,500
303
+ // Escape with clone()
304
+ const mutable = base.clone(); // plain ArbitraryNumber, fully mutable
305
+ mutable.add(an(1)); // ok
308
306
  ```
309
307
 
310
- ## Rounding and math
308
+ The three static constants are frozen — use `an(0)` / `an(1)` / `an(10)` when you need a mutable starting point:
311
309
 
312
310
  ```typescript
313
- const n = an(1.75, 0); // 1.75
314
-
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)
322
-
323
- an(4, 0).sqrt() // 2 (1.18x faster than .pow(0.5))
324
- an(1, 4).sqrt() // 100
325
- an(-4, 0).sqrt() // throws ArbitraryNumberDomainError
311
+ ArbitraryNumber.Zero // FrozenArbitraryNumber read only
312
+ ArbitraryNumber.One // FrozenArbitraryNumber — read only
313
+ ArbitraryNumber.Ten // FrozenArbitraryNumber — read only
326
314
 
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
-
331
- an(1, 3).log10() // 3
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
-
339
- ArbitraryNumber.Zero.log10() // throws ArbitraryNumberDomainError
340
-
341
- an(1.5, 3).toNumber() // 1500
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)
315
+ ArbitraryNumber.Zero.add(income) // throws!
316
+ an(0).add(income) // ok
348
317
  ```
349
318
 
350
- ## Serialization and save-load
319
+ ---
351
320
 
352
- Idle games need to persist numbers across sessions. `arbitrary-numbers` provides three serialization paths:
321
+ ## Comparison and predicates
353
322
 
354
- ### JSON (recommended for save files)
323
+ These never mutate.
355
324
 
356
325
  ```typescript
357
- import { ArbitraryNumber, an } from "arbitrary-numbers";
326
+ const a = an(1, 4); // 10,000
327
+ const b = an(9, 3); // 9,000
358
328
 
359
- const gold = an(1.5, 6);
329
+ a.compareTo(b) // 1 (compatible with Array.sort)
330
+ a.greaterThan(b) // true
331
+ a.lessThan(b) // false
332
+ a.greaterThanOrEqual(b) // true
333
+ a.equals(b) // false
360
334
 
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}'
335
+ a.isZero() // false
336
+ a.isPositive() // true
337
+ a.isNegative() // false
338
+ a.isInteger() // true
339
+ a.sign() // 1 (-1 | 0 | 1)
364
340
 
365
- // Deserialize
366
- const restored = ArbitraryNumber.fromJSON(JSON.parse(json));
367
- restored.equals(gold); // true
341
+ ArbitraryNumber.min(a, b) // b (returns the input reference — no clone)
342
+ ArbitraryNumber.max(a, b) // a
343
+ ArbitraryNumber.clamp(an(5, 5), a, b) // b (clamped to max)
344
+ ArbitraryNumber.lerp(a, b, 0.5) // new instance — midpoint
368
345
  ```
369
346
 
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.
347
+ `min`, `max`, `clamp` return one of the original referencesthey do not clone.
371
348
 
372
- ### Compact string (URL params, cookies)
349
+ ---
373
350
 
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
351
+ ## Rounding and math
381
352
 
382
- `ArbitraryNumber.parse()` accepts multiple formats:
353
+ These mutate `this` and return `this`.
383
354
 
384
355
  ```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
390
- ```
391
-
392
- Unrecognised or non-finite strings throw `ArbitraryNumberInputError`.
393
-
394
- ### Save-every-60s pattern
356
+ an(1.75, 0).clone().floor() // 1
357
+ an(1.75, 0).clone().ceil() // 2
358
+ an(1.75, 0).clone().round() // 2
359
+ an(1.75, 0).clone().trunc() // 1
360
+ an(-1.75, 0).clone().floor() // -2 (toward −∞)
361
+ an(-1.75, 0).clone().trunc() // -1 (toward 0)
395
362
 
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
- }
363
+ an(4, 0).clone().sqrt() // 2
364
+ an(8, 0).clone().cbrt() // 2
365
+ an(-27, 0).clone().cbrt() // -3 (cube root supports negatives)
405
366
 
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
- }
367
+ an(1, 3).log10() // 3
368
+ an(1024, 0).log(2) // 10
369
+ an(Math.E, 0).ln() // ≈ 1
370
+ ArbitraryNumber.exp10(6) // 1e6 (new instance)
415
371
 
416
- setInterval(() => localStorage.setItem("save", saveGame(state)), 60_000);
372
+ an(1, 400).toNumber() // Infinity (beyond float64)
417
373
  ```
418
374
 
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
-
421
- ## Display and formatting
375
+ ---
422
376
 
423
- `toString(plugin?, decimals?)` accepts any `NotationPlugin`. Three plugins are included.
377
+ ## Display and notation
424
378
 
425
- ### scientificNotation (default)
379
+ `toString(plugin?, decimals?)` accepts any `NotationPlugin`. Three are included:
426
380
 
427
381
  ```typescript
428
- import { scientificNotation } from "arbitrary-numbers";
382
+ import {
383
+ scientificNotation, // default
384
+ unitNotation, // K / M / B / T / Qa …
385
+ letterNotation, // a / b / c … aa / ab …
386
+ } from "arbitrary-numbers";
429
387
 
430
- an(1.5, 3).toString() // "1.50e+3"
431
- an(1.5, 3).toString(scientificNotation, 4) // "1.5000e+3"
432
- an(1.5, 0).toString() // "1.50"
388
+ const n = an(3.2, 15); // 3,200,000,000,000,000
389
+
390
+ n.toString() // "3.20e+15" (scientificNotation)
391
+ n.toString(scientificNotation, 4) // "3.2000e+15"
392
+ n.toString(unitNotation) // "3.20 Qa"
393
+ n.toString(letterNotation) // "3.20e"
433
394
  ```
434
395
 
435
- ### unitNotation - K, M, B, T...
396
+ **Unit notation** comes with two built-in unit lists:
436
397
 
437
398
  ```typescript
438
- import { unitNotation, UnitNotation, COMPACT_UNITS, letterNotation } from "arbitrary-numbers";
399
+ import { UnitNotation, CLASSIC_UNITS, COMPACT_UNITS, letterNotation } from "arbitrary-numbers";
439
400
 
440
- an(1.5, 3).toString(unitNotation) // "1.50 K"
441
- an(3.2, 6).toString(unitNotation) // "3.20 M"
442
- an(1.0, 9).toString(unitNotation) // "1.00 B"
401
+ // CLASSIC_UNITS: K, M, B, T, Qa, Qi … Ct (centillion)
402
+ // COMPACT_UNITS: k, M, B, T, Qa, Qi … No
403
+ // fallback kicks in for values beyond the list
443
404
 
444
- // Custom unit list with a fallback for values beyond the list
445
- const custom = new UnitNotation({ units: COMPACT_UNITS, fallback: letterNotation });
405
+ const display = new UnitNotation({ units: CLASSIC_UNITS, fallback: letterNotation });
406
+ an(3.2, 6).toString(display) // "3.20 M"
407
+ an(3.2, 303).toString(display) // "3.20 Ct"
408
+ an(3.2, 400).toString(display) // "3.20e" (falls back to letterNotation)
446
409
  ```
447
410
 
448
- ### AlphabetNotation - a, b, c... aa, ab...
411
+ **Letter notation** suffixes never run out (`a`–`z`, then `aa`–`zz`, then `aaa`, …):
449
412
 
450
413
  ```typescript
451
- import { letterNotation, AlphabetNotation, alphabetSuffix } from "arbitrary-numbers";
452
-
453
414
  an(1.5, 3).toString(letterNotation) // "1.50a"
454
415
  an(1.5, 6).toString(letterNotation) // "1.50b"
455
416
  an(1.5, 78).toString(letterNotation) // "1.50z"
456
417
  an(1.5, 81).toString(letterNotation) // "1.50aa"
457
418
  ```
458
419
 
459
- Suffixes never run out: `a-z`, then `aa-zz`, then `aaa`, and so on.
420
+ ---
460
421
 
461
- Pass a custom alphabet for any suffix sequence:
422
+ ## Custom notation plugins
423
+
424
+ Any object with `format(coefficient, exponent, decimals)` is a valid plugin — you have complete control over how numbers render:
462
425
 
463
426
  ```typescript
464
- const excelNotation = new AlphabetNotation({ alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" });
427
+ import type { NotationPlugin } from "arbitrary-numbers";
428
+
429
+ // Simple inline plugin — no class needed
430
+ const romanTiers: NotationPlugin = {
431
+ format(coefficient, exponent, decimals) {
432
+ const tiers = ["", "K", "M", "B", "T", "Qa", "Qi"];
433
+ const tier = Math.floor(exponent / 3);
434
+ const value = coefficient * 10 ** (exponent - tier * 3);
435
+ return `${value.toFixed(decimals)}${tiers[tier] ?? `e+${tier * 3}`}`;
436
+ },
437
+ };
465
438
 
466
- an(1.5, 3).toString(excelNotation) // "1.50A"
467
- an(1.5, 78).toString(excelNotation) // "1.50Z"
468
- an(1.5, 81).toString(excelNotation) // "1.50AA"
439
+ an(1.5, 3).toString(romanTiers) // "1.50K"
440
+ an(1.5, 6).toString(romanTiers) // "1.50M"
441
+ an(1.5, 21).toString(romanTiers) // "1.50e+21"
469
442
  ```
470
443
 
471
- `alphabetSuffix(tier, alphabet?)` exposes the suffix algorithm as a standalone function:
444
+ For tier-based suffix patterns, extend `SuffixNotationBase` it handles the `coefficient × 10^(exponent mod 3)` scaling for you and lets you focus on just the suffix:
472
445
 
473
446
  ```typescript
474
- import { alphabetSuffix } from "arbitrary-numbers";
447
+ import { SuffixNotationBase } from "arbitrary-numbers";
475
448
 
476
- alphabetSuffix(1) // "a"
477
- alphabetSuffix(27) // "aa"
478
- alphabetSuffix(27, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") // "AA"
479
- ```
449
+ class GameNotation extends SuffixNotationBase {
450
+ // Any suffix scheme you want — Japanese units, emoji, roman numerals, …
451
+ private static readonly TIERS = ["", "", "億", "", "京", "垓"];
452
+ getSuffix(tier: number): string {
453
+ return GameNotation.TIERS[tier] ?? `e+${tier * 3}`;
454
+ }
455
+ }
480
456
 
481
- ## Precision control
457
+ const jp = new GameNotation({ separator: "" });
458
+ an(1.5, 3).toString(jp) // "1.50万"
459
+ an(3.2, 6).toString(jp) // "3.20億"
460
+ ```
482
461
 
483
- When two numbers differ in exponent by more than `PrecisionCutoff` (default `15`), the smaller operand is silently discarded because its contribution is below floating-point resolution:
462
+ The `AlphabetNotation` class (backing `letterNotation`) is also customisable:
484
463
 
485
464
  ```typescript
486
- const huge = an(1, 20); // 10^20
487
- const tiny = an(1, 3); // 1,000
465
+ import { AlphabetNotation, alphabetSuffix } from "arbitrary-numbers";
466
+
467
+ const excelColumns = new AlphabetNotation({ alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" });
468
+ an(1.5, 3).toString(excelColumns) // "1.50A"
469
+ an(1.5, 78).toString(excelColumns) // "1.50Z"
470
+ an(1.5, 81).toString(excelColumns) // "1.50AA"
488
471
 
489
- huge.add(tiny) // returns huge unchanged — tiny is negligible at 10^20 scale
472
+ // The suffix algorithm is also available standalone
473
+ alphabetSuffix(1) // "a"
474
+ alphabetSuffix(27) // "aa"
490
475
  ```
491
476
 
492
- Override globally or for a single scoped block:
477
+ ---
493
478
 
494
- ```typescript
495
- ArbitraryNumber.PrecisionCutoff = 50; // global
479
+ ## Serialization
496
480
 
497
- // Scoped - PrecisionCutoff is restored after fn, even on throw
498
- const result = ArbitraryNumber.withPrecision(50, () => a.add(b));
499
- ```
481
+ Three paths for idle-game save files:
500
482
 
501
- ### When things drift — precision troubleshooting
483
+ ```typescript
484
+ const gold = an(1.5, 6);
502
485
 
503
- **"Why doesn't `a.add(b).sub(b).equals(a)` return true when `b` is much larger than `a`?"**
486
+ // JSON recommended, compact keys (c/e), stable across versions
487
+ gold.toJSON() // { c: 1.5, e: 6 }
488
+ JSON.stringify(gold) // '{"c":1.5,"e":6}'
489
+ ArbitraryNumber.fromJSON({ c: 1.5, e: 6 }) // restores
504
490
 
505
- ```typescript
506
- const a = an(1, 3); // 1,000
507
- const b = an(1, 20); // 10^20
491
+ // Pipe string — for URL params, cookies
492
+ gold.toRawString() // "1.5|6"
493
+ ArbitraryNumber.parse("1.5|6") // restores
508
494
 
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
495
+ // parse() accepts multiple input formats
496
+ ArbitraryNumber.parse("1.5e+6") // scientific
497
+ ArbitraryNumber.parse("1500000") // plain decimal
498
+ ArbitraryNumber.parse("-0.003") // negative
512
499
  ```
513
500
 
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.
501
+ Save-every-60s pattern:
515
502
 
516
- **Game patterns and their typical precision needs:**
503
+ ```typescript
504
+ // Save
505
+ function save(state: GameState): string {
506
+ return JSON.stringify({
507
+ gold: state.gold.toJSON(),
508
+ gps: state.gps.toJSON(),
509
+ });
510
+ }
517
511
 
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) |
512
+ // Load
513
+ function load(json: string): GameState {
514
+ const raw = JSON.parse(json);
515
+ return {
516
+ gold: ArbitraryNumber.fromJSON(raw.gold),
517
+ gps: ArbitraryNumber.fromJSON(raw.gps),
518
+ };
519
+ }
524
520
 
525
- If exact addition across large exponent gaps matters (e.g. financial ledger), raise `PrecisionCutoff` to `50` and use `withPrecision`.
521
+ setInterval(() => localStorage.setItem("save", save(state)), 60_000);
522
+ ```
526
523
 
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.
524
+ ---
528
525
 
529
- ## Errors
526
+ ## Precision control
530
527
 
531
- All errors thrown by the library extend `ArbitraryNumberError`, so you can distinguish them from your own errors.
528
+ When two numbers differ in exponent by more than `defaults.scaleCutoff` (default `15`), the smaller operand is discarded its contribution is below float64 resolution:
532
529
 
533
530
  ```typescript
534
- import {
535
- ArbitraryNumberError,
536
- ArbitraryNumberInputError,
537
- ArbitraryNumberDomainError,
538
- } from "arbitrary-numbers";
531
+ const huge = an(1, 20); // 10^20
532
+ const tiny = an(1, 3); // 1,000
539
533
 
540
- try {
541
- an(1).div(an(0));
542
- } catch (e) {
543
- if (e instanceof ArbitraryNumberDomainError) {
544
- console.log(e.context); // { dividend: 1 }
545
- }
546
- }
534
+ huge.clone().add(tiny) // unchanged — tiny is negligible at this scale
547
535
  ```
548
536
 
549
- | Class | Thrown when | Extra property |
550
- |---|---|---|
551
- | `ArbitraryNumberInputError` | Non-finite input to a constructor or factory | `.value: number` |
552
- | `ArbitraryNumberDomainError` | Mathematically undefined operation | `.context: Record<string, number>` |
553
-
554
- ## Utilities
555
-
556
- ### ArbitraryNumberOps - mixed `number | ArbitraryNumber` input
537
+ Override globally or in a scoped block:
557
538
 
558
539
  ```typescript
559
- import { ArbitraryNumberOps as ops } from "arbitrary-numbers";
540
+ ArbitraryNumber.defaults.scaleCutoff = 50; // global
560
541
 
561
- ops.from(1_500_000) // { coefficient: 1.5, exponent: 6 }
562
- ops.add(1500, an(2, 3)) // 3,500
563
- ops.mul(an(2, 0), 5) // 10
564
- ops.compare(5000, an(1, 4)) // -1 (5000 < 10,000)
565
- ops.clamp(500, 1000, 2000) // 1,000
542
+ // Scoped restored after fn, even on throw
543
+ const result = ArbitraryNumber.withPrecision(50, () => a.clone().add(b));
566
544
  ```
567
545
 
568
- ### ArbitraryNumberGuard - type guards
546
+ ---
547
+
548
+ ## Utilities
549
+
550
+ ### ArbitraryNumberGuard — type guards
569
551
 
570
552
  ```typescript
571
553
  import { ArbitraryNumberGuard as guard } from "arbitrary-numbers";
572
554
 
573
- guard.isArbitraryNumber(value) // true when value instanceof ArbitraryNumber
574
- guard.isNormalizedNumber(value) // true when value has numeric coefficient and exponent
575
- guard.isZero(value) // true when value is ArbitraryNumber with coefficient 0
555
+ guard.isArbitraryNumber(value) // true when value instanceof ArbitraryNumber
556
+ guard.isNormalizedNumber(value) // true when value has numeric coefficient + exponent
557
+ guard.isZero(value) // true when value is an ArbitraryNumber with coefficient 0
576
558
  ```
577
559
 
578
- ### ArbitraryNumberHelpers - game and simulation patterns
560
+ ### ArbitraryNumberHelpers game patterns
579
561
 
580
562
  ```typescript
581
563
  import { ArbitraryNumberHelpers as helpers } from "arbitrary-numbers";
582
564
 
583
- helpers.meetsOrExceeds(gold, upgradeCost) // true when gold >= upgradeCost
584
- helpers.wholeMultipleCount(gold, upgradeCost) // how many upgrades can you afford?
585
- helpers.subtractWithFloor(health, damage) // max(health - damage, 0)
586
- helpers.subtractWithFloor(health, damage, minHealth) // max(health - damage, minHealth)
565
+ helpers.meetsOrExceeds(gold, cost) // gold >= cost
566
+ helpers.wholeMultipleCount(gold, cost) // how many can you afford?
567
+ helpers.subtractWithFloor(health, damage) // max(health damage, 0)
568
+ helpers.subtractWithFloor(health, damage, min) // max(health damage, min)
587
569
  ```
588
570
 
589
- All helpers accept `number | ArbitraryNumber` as input.
571
+ All helpers accept `number | ArbitraryNumber` and never mutate their arguments.
590
572
 
591
- ## Writing a custom plugin
592
-
593
- Any object with a `format(coefficient, exponent, decimals)` method is a valid `NotationPlugin`:
594
-
595
- ```typescript
596
- import type { NotationPlugin } from "arbitrary-numbers";
573
+ ---
597
574
 
598
- const emojiNotation: NotationPlugin = {
599
- format(coefficient, exponent, decimals) {
600
- const tiers = ["", "K", "M", "B", "T", "Qa", "Qi"];
601
- const tier = Math.floor(exponent / 3);
602
- const display = coefficient * 10 ** (exponent - tier * 3);
603
- return `${display.toFixed(decimals)}${tiers[tier] ?? `e+${tier * 3}`}`;
604
- },
605
- };
575
+ ## Errors
606
576
 
607
- an(1.5, 3).toString(emojiNotation) // "1.50K"
608
- an(1.5, 6).toString(emojiNotation) // "1.50M"
609
- ```
577
+ All errors extend `ArbitraryNumberError`.
610
578
 
611
- For tier-based suffix patterns, extend `SuffixNotationBase`, which handles all coefficient and remainder math:
579
+ | Class | Thrown when |
580
+ |---|---|
581
+ | `ArbitraryNumberInputError` | Non-finite input (NaN, Infinity) to constructor or factory. `.value: number` |
582
+ | `ArbitraryNumberDomainError` | Mathematically undefined operation (div by zero, sqrt of negative). `.context: Record<string, number>` |
583
+ | `ArbitraryNumberMutationError` | Mutating method called on a frozen instance |
612
584
 
613
585
  ```typescript
614
- import { SuffixNotationBase } from "arbitrary-numbers";
586
+ import { ArbitraryNumberDomainError } from "arbitrary-numbers";
615
587
 
616
- class TierNotation extends SuffixNotationBase {
617
- private static readonly TIERS = ["", "K", "M", "B", "T", "Qa", "Qi"];
618
- getSuffix(tier: number): string {
619
- return TierNotation.TIERS[tier] ?? `e+${tier * 3}`;
588
+ try {
589
+ an(1).div(an(0));
590
+ } catch (e) {
591
+ if (e instanceof ArbitraryNumberDomainError) {
592
+ console.log(e.context); // { dividend: 1 }
620
593
  }
621
594
  }
622
-
623
- an(3.2, 6).toString(new TierNotation({ separator: " " })) // "3.20 M"
624
595
  ```
625
596
 
597
+ ---
598
+
626
599
  ## Idle game example
627
600
 
628
- A self-contained simulation showing hyper-growth, fused ops, helpers, and where plain JS `number` overflows while `ArbitraryNumber` keeps working.
601
+ Full source: [`examples/idle-game.ts`](examples/idle-game.ts)
629
602
 
630
603
  ```typescript
631
604
  import {
632
- an, chain,
633
- UnitNotation,
634
- CLASSIC_UNITS,
635
- letterNotation,
605
+ an,
606
+ UnitNotation, CLASSIC_UNITS, letterNotation, scientificNotation,
636
607
  ArbitraryNumberHelpers as helpers,
637
608
  } from "arbitrary-numbers";
638
- import type { ArbitraryNumber } from "arbitrary-numbers";
639
609
 
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;
610
+ const display = new UnitNotation({ units: CLASSIC_UNITS, fallback: letterNotation });
611
+ const fmt = (v) => v.exponent > 300
612
+ ? v.toString(scientificNotation)
613
+ : v.toString(display);
644
614
 
645
- const display = new UnitNotation({
646
- units: CLASSIC_UNITS,
647
- fallback: letterNotation,
648
- });
615
+ let gold = an(1, 0);
616
+ let gps = an(1, 0);
617
+ let upgradeCost = an(1, 2);
618
+ let upgrades = 0;
649
619
 
650
- function fmt(value: ArbitraryNumber, decimals = 2): string {
651
- return value.toString(display, decimals);
652
- }
620
+ for (let t = 1; t <= 350; t++) {
621
+ gold.mulAdd(an(1, 1), gps); // gold = (gold × 10) + gps — fused, zero alloc
653
622
 
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
- );
623
+ if (upgrades < 25 && helpers.meetsOrExceeds(gold, upgradeCost)) {
624
+ gold.sub(upgradeCost);
625
+ gps.mul(an(1, 3));
626
+ upgradeCost.mul(an(1, 6));
627
+ upgrades++;
628
+ }
659
629
  }
630
+ ```
660
631
 
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
- }
632
+ Output (selected lines):
681
633
 
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
- }
634
+ ```
635
+ === Idle game simulation (350 ticks) ===
636
+ start gold=1.00 gps=1.00 upgradeCost=100.00
691
637
 
692
- if (t % 120 === 0) {
693
- snapshot(t);
694
- }
695
- }
638
+ [t= 2] UPGRADE # 1 gps 1.00 → 1.00 K next cost: 1.00e+8
639
+ [t= 8] UPGRADE # 2 gps 1.00 K → 1.00 M next cost: 1.00e+14
640
+ [t= 15] UPGRADE # 3 gps 1.00 M → 1.00 B next cost: 1.00e+20
641
+ ...
642
+ [t= 67] UPGRADE #11 gps 1.00 No → 1.00 Dc next cost: 1.00e+68
643
+ [t= 70] snapshot AN: 112.22 Vg JS: 1.12e+65
644
+ [t= 80] UPGRADE #13 gps 1.00 UDc → 1.00 DDc next cost: 1.00e+80
645
+ ...
646
+ [t=140] snapshot AN: 1.21 JS: 1.21e+129
647
+ [t=280] snapshot AN: 1.22 JS: 1.22e+267
648
+ [t=350] snapshot AN: 1.22e+337 JS: Infinity ← beyond float64 max!
696
649
 
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.");
704
- ```
705
-
706
- Output:
707
-
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.
650
+ === Final state ===
651
+ Upgrades bought : 25
652
+ Final gold (AN) : 1.22e+337
653
+ Gold as JS num : Infinity
739
654
  ```
740
655
 
656
+ JS overflows at `~1e308`. ArbitraryNumber keeps tracking the exact value.
657
+
658
+ > **Note on `UnitNotation` display:** `CLASSIC_UNITS` only defines specific named illion tiers (K, M, B, T … Ct). Values at exponents between named tiers will show as a plain decimal. For fully continuous display across all exponents, use `letterNotation` or `scientificNotation` as the primary formatter.
659
+
660
+ ---
661
+
741
662
  ## Performance
742
663
 
743
- Benchmarks are in [`benchmarks/`](benchmarks/). Competitor comparison: [`benchmarks/COMPETITOR_BENCHMARKS.md`](benchmarks/COMPETITOR_BENCHMARKS.md).
664
+ Benchmarks: [`benchmarks/`](benchmarks/). Competitor comparison: [`benchmarks/COMPETITOR_BENCHMARKS.md`](benchmarks/COMPETITOR_BENCHMARKS.md).
744
665
 
745
- Quick reference (Node 22.16, Intel i5-13600KF):
666
+ Node 22.16, Intel i5-13600KF:
746
667
 
747
- | Operation | Time |
748
- |---|---|
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 |
668
+ | Operation | v2.0 | v1.1 |
669
+ |---|---|---|
670
+ | `new ArbitraryNumber(c, e)` | ~15.6 ns | ~13.5 ns |
671
+ | `clone()` | **~6.7 ns** | ~13.5 ns |
672
+ | `add` / `sub` | **~13 ns** | ~270 ns |
673
+ | `mul` / `div` | **~11–12 ns** | ~255 ns |
674
+ | `sqrt()` | **~11 ns** | ~252 ns |
675
+ | `compareTo` | ~3.6 ns | ~3 ns |
676
+ | `sumArray(50)` | **~156 ns** (~3.1 ns/elem) | N/A |
677
+
678
+ v2 arithmetic is **20× faster than v1** — the v1 `Object.create` path cost ~250 ns per result regardless of the math. v2 mutates in-place: steady-state ops are pure float arithmetic with zero allocation.
679
+
680
+ ---
681
+
682
+ ## Migration from v1
683
+
684
+ | v1 | v2 | Notes |
685
+ |---|---|---|
686
+ | `a.add(b)` → new instance | `a.add(b)` → mutates `a` | **Breaking.** Use `a.clone().add(b)` to keep old semantics. |
687
+ | `chain(a).add(b).done()` | `a.add(b)` | `chain` removed — direct chaining is native. |
688
+ | `formula(fn).apply(a)` | `formula().step1().step2().apply(a)` | Builder pattern replaces the callback. |
689
+ | `ArbitraryNumber.PrecisionCutoff` | `ArbitraryNumber.defaults.scaleCutoff` | Renamed + namespaced. |
690
+ | `ops.add(x, y)` | `ArbitraryNumber.add(x, y)` | Static on main class. |
691
+ | `ArbitraryNumberOps` export | removed | — |
692
+ | `ArbitraryNumberArithmetic` export | removed | — |
693
+ | `NormalizedNumber` export | removed | — |
694
+ | `AnChain`, `chain()` export | removed | — |
695
+ | `a.toRaw()` | `a.toRawString()` | Renamed for clarity. |
696
+ | `a.freeze()` | new | Returns `FrozenArbitraryNumber`. |
697
+ | `ArbitraryNumber.Zero.add(...)` | throws `ArbitraryNumberMutationError` | Use `an(0).add(...)`. |