At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the rarest and most valuable outcome silently became an instant 1.00x loss. At u=2 it produced a 2.1-billion-times payout the house could never cover, which would have left settlement failing and the player unpaid. The crash point is now capped at the largest multiplier the curve can express, which is unreachable anyway since the round hits its tick ceiling first. FromInt now panics outside the Q32.32 integer range instead of wrapping a positive input into a negative value. Raises coverage to 88% overall; adds a Makefile with db-reset, since the append-only ledger steadily consumes bridge headroom across test runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
3.8 KiB
Go
116 lines
3.8 KiB
Go
package sim
|
|
|
|
import (
|
|
"math/bits"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
|
)
|
|
|
|
// HouseEdgeBP is the house edge in basis points (200 = 2.00%).
|
|
const HouseEdgeBP int64 = 200
|
|
|
|
// TickHz is the simulation rate. Rounds advance in whole ticks only.
|
|
const TickHz = 60
|
|
|
|
// RoundTicks is the hard ceiling on a round's length: 60 seconds at 60Hz.
|
|
//
|
|
// The multiplier follows a hyperbolic curve that diverges at exactly this
|
|
// tick, so no round can run longer no matter how extreme the crash point.
|
|
// An exponential curve has no such bound — a 275x round on one takes over two
|
|
// and a half minutes, which is unplayable when a dozen people are waiting.
|
|
const RoundTicks = 60 * TickHz
|
|
|
|
// MaxMultiplier is the largest value the curve expresses, reached on the final
|
|
// tick. Crash points at or above it settle when the round hits its ceiling.
|
|
func MaxMultiplier() fixed.F { return MultiplierAt(RoundTicks - 1) }
|
|
|
|
// CrashPoint derives the multiplier at which a round ends, as a pure function of
|
|
// the seed.
|
|
//
|
|
// The distribution is the inverse-uniform curve scaled by the house edge:
|
|
//
|
|
// crash = (1 - edge) / u, u uniform over (0, 1]
|
|
//
|
|
// which yields the same expected return of (1 - edge) at every cash-out target.
|
|
// No target is smarter than any other, so there is nothing to grind out.
|
|
func CrashPoint(seed [32]byte) fixed.F {
|
|
r := NewRNG(seed)
|
|
|
|
// u is uniform over [1, 2^32], giving a resolution of one part in 4 billion.
|
|
u := (r.Uint64() >> 32) + 1
|
|
|
|
// payoutRatio is (1 - edge) in Q32.32, e.g. 0.98.
|
|
payoutRatio := uint64((10000 - HouseEdgeBP) << 32 / 10000)
|
|
|
|
// crash = payoutRatio / (u / 2^32), computed as (payoutRatio * 2^32) / u
|
|
// through a 128-bit intermediate so no precision is lost.
|
|
hi, lo := bits.Mul64(payoutRatio, 1<<32)
|
|
q, _ := bits.Div64(hi, lo, u)
|
|
|
|
// The quotient can exceed int64 for the very smallest u — at u=1 it wraps
|
|
// negative, which would silently turn the rarest and most valuable outcome
|
|
// into an instant loss. Compare in unsigned space before converting.
|
|
//
|
|
// The cap is the largest multiplier the curve can express. Anything above
|
|
// it is unreachable anyway: the round would hit its tick ceiling first.
|
|
// It also bounds the maximum payout, so a single round cannot demand more
|
|
// than the house can hold.
|
|
maxCP := MaxMultiplier()
|
|
if q >= uint64(maxCP) {
|
|
return maxCP
|
|
}
|
|
|
|
cp := fixed.F(q)
|
|
if cp < fixed.One {
|
|
cp = fixed.One
|
|
}
|
|
return cp
|
|
}
|
|
|
|
// MultiplierAt returns the multiplier displayed at a given tick.
|
|
//
|
|
// m(t) = 1 / (1 - t/T)^2
|
|
//
|
|
// It starts at 1.0, rises slowly at first, and accelerates without bound as t
|
|
// approaches T. That acceleration is the tension: the longer you hold, the
|
|
// faster the number moves away from you, and the less time you have to react.
|
|
// It is also O(1), so a long round costs no more per tick than a short one.
|
|
func MultiplierAt(tick int) fixed.F {
|
|
if tick <= 0 {
|
|
return fixed.One
|
|
}
|
|
// Clamp the tick, not the value: clamping the value would make the curve
|
|
// step backwards at the boundary if rounding put the last computed point
|
|
// above the nominal ceiling.
|
|
if tick >= RoundTicks {
|
|
tick = RoundTicks - 1
|
|
}
|
|
// remaining = 1 - tick/T, always in (0, 1].
|
|
remaining := fixed.One - fixed.FromInt(int64(tick)).Div(fixed.FromInt(RoundTicks))
|
|
return fixed.One.Div(remaining.Mul(remaining))
|
|
}
|
|
|
|
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m,
|
|
// inverting the curve: t = T * (1 - 1/sqrt(m)).
|
|
func TicksToMultiplier(m fixed.F) int {
|
|
if m <= fixed.One {
|
|
return 0
|
|
}
|
|
if m >= MaxMultiplier() {
|
|
return RoundTicks
|
|
}
|
|
inv := fixed.One.Div(fixed.Sqrt(m))
|
|
t := fixed.FromInt(RoundTicks).Mul(fixed.One - inv).Int()
|
|
|
|
// Rounding in fixed point can land a tick early; step forward to the first
|
|
// tick that genuinely reaches the target.
|
|
tick := int(t)
|
|
for tick > 0 && MultiplierAt(tick-1) >= m {
|
|
tick--
|
|
}
|
|
for tick < RoundTicks && MultiplierAt(tick) < m {
|
|
tick++
|
|
}
|
|
return tick
|
|
}
|