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 }