Round length is now bounded: the multiplier follows a hyperbolic curve diverging at 60s, replacing an exponential one where a 275x crash point produced a two-and-a-half minute round. Fixes seed reveal, which silently failed every round because pgx cannot encode a fixed-size byte array as bytea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
3.4 KiB
Go
104 lines
3.4 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 is zero because payoutRatio < 2^32, so the division cannot overflow.
|
|
hi, lo := bits.Mul64(payoutRatio, 1<<32)
|
|
q, _ := bits.Div64(hi, lo, u)
|
|
|
|
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
|
|
}
|