The cost curve, in closed form.
costOfNext = b · r^k
bulkCost = b · r^k · (r^n − 1) / (r − 1)
maxBuyable = floor( log_r( c(r−1) / (b·r^k) + 1 ) )
b = base, r = growth, k = owned, n = how many, c = the budget.
Closed form on day one, not as an optimization. "Buy max" at 4,000 owned is 4,000 iterations
on a hot path, run once per frame to render a button's label. The naive loop is a legitimate
oracle in a test — and this package's tests use one — and a performance bug in a build.
Determinism
b · r^k is the most important arithmetic in an idle game and ** makes it Tier B. owned is
an integer, so the price is a chain of multiplications instead: exponentiation by squaring,
which is Tier A and bit-identical everywhere. ** would be at most one ulp more accurate and
not reproducible, and for a number a player is charged, reproducible wins.
maxBuyable is the one Tier B call left, and it is a seed: Math.log proposes an
integer and a bounded correction verifies it with Tier A comparisons. Two engines can only
disagree if their logarithms differ by enough to move the answer four whole steps. They do not.
A persisted price is not portable. Recompute costs; never store one and compare it later for
equality. And affordability is compared exactly — bulkCost <= budget, never with an
epsilon — because an epsilon there lets a player buy something they cannot afford.
What floating point does to this, stated as a boundary rather than a defense
A double holds every integer exactly up to 2⁵³. Past that the spacing is 2, then 4, then 128 by
2⁶⁰. Relative precision never degrades, so a cost of 1e300 is still good to fifteen significant
figures: the magnitude is fine and the integers are not. Two places it bites, and only two —
a balance of 1e17 minus a cost of 3 is the balance again, so the player buys forever; and at
growth = 1.07 a price crosses 2⁵³ at about 520 owned and reaches Infinity at about 10,500.
The kit does nothing about it, deliberately: no BigInt, no Decimal, no mantissa/exponent
pair. It would infect every signature in the kit, allocate per operation on the hot path this
package exists to protect, cost most of a package's whole size budget, and be less reproducible
than IEEE-754, which is bit-identical across platforms by specification. What it does instead is
refuse rather than lie: bulkCost returns Infinity on overflow, which compares correctly
against any finite balance and refuses the purchase rather than silently making it free. The
design answer is the real one — a game whose numbers approach 9e15 has a prestige problem, not
an arithmetic problem.
Isomorphic: no clock, no randomness, no platform.
function bulkCost(curve: CostCurve, owned: number, count: number): number
b · r^k · (r^n − 1)/(r − 1) — the price of count more, starting from owned. Tier A.
A fixed batch is all or nothing: a ×10 button with funds for six buys nothing, not six. Only
maxBuyable resolves a purchase against the balance, and that asymmetry is the point —
a partial batch is a different transaction from the one the player pressed.
Returns0 for count <= 0 or a free curve; Infinity if the geometric term overflows, which
compares correctly against any finite balance and therefore refuses the purchase rather than
silently making it free.
ThrowsRangeError on a non-integer count, a negative owned, or a non-finite parameter.
function maxBuyable(curve: CostCurve, owned: number, budget: number, cap: number): number
floor( log_r( c(r−1)/(b·r^k) + 1 ) ), corrected for float rounding, clamped to cap.
The guarantee callers rely on is two-sided, and both halves hold on the engine that computed
them: bulkCost(curve, owned, maxBuyable(...)) <= budget — a max purchase can never drive a
balance negative — and bulkCost(curve, owned, maxBuyable(...) + 1) > budget unless the answer
is cap, so the button says what it does.
The correction after the logarithm is at most four steps in each direction. It is a rounding fix,
not a search: cap bounds arithmetic, never CPU, and 4,000 owned costs the same as 4.
This result is advisory. Do not persist it and do not send it anywhere.
The seed is Math.log, which ECMA-262 does not require to be correctly rounded. Two
conforming engines can therefore disagree in the last bit, and on a cost curve that disagreement
can land exactly on a floor boundary: same save, same balance, two clients, two different
answers to "how many can I afford" — differing by exactly one. That is bounded and it is the
residual the design accepts, because the alternative is a pow-free integer search or an
epsilon, and an epsilon here would let a player buy something they cannot afford.
What follows for a caller:
- Use it for a label and for the size of the purchase you are about to make, on the engine that computed it. Within one client it is exactly consistent with
bulkCost. - Never store it, never checksum it, and never put it in a replay log or on a wire. Store the inputs — owned count and balance — and recompute. A persisted count computed on Firefox and verified on Safari can differ by one and will look like tampering.
- The authoritative check is the balance test at purchase time, and it is
bulkCost(curve, owned, n) <= budget compared exactly. A verifier that re-derives n and demands equality will reject honest clients on a browser update; a verifier that charges the bulkCost of the n it was handed and refuses when the balance will not cover it cannot.
Parameters
ThrowsRangeError on a non-integer or negative owned or cap, or a non-finite curve parameter.
function milestoneMultiplier(owned: number, milestones: Milestones): number
The multiplier from milestone bonuses at an owned count. Repeated multiplication, so Tier A.
Feed it purchased counts, never effective ones. This is the subtlest bug in the package: a
multiplier keyed on a count the flow itself produces changes the rate inside an integral, so a
client integrating at 10 Hz places the discontinuity somewhere different from a catch-up
integrating once — same save, two answers, neither reproducible. Purchased counts change only at
actions, which is exactly the property the closed form needs.
It is a pure function of a number so a game can also use it on a shop card, which is where
players actually learn the mechanic exists.
ThrowsRangeError if owned, multiplier or any threshold is not finite, naming the index.