feat(sim): add deterministic RNG and crash curve
Seed expansion uses SplitMix64 so all 32 seed bytes affect the stream; copying the seed directly into xoshiro state left the first draw dependent only on bytes 8-15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
78
pkg/sim/crash.go
Normal file
78
pkg/sim/crash.go
Normal file
@@ -0,0 +1,78 @@
|
||||
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
|
||||
|
||||
// growthPerTickBP is multiplier growth per tick, in basis points of the current
|
||||
// value. At 6bp and 60Hz the multiplier reaches 2x in roughly 19 seconds, which
|
||||
// is long enough to feel the climb and short enough to keep rounds moving.
|
||||
const growthPerTickBP int64 = 6
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// step is the per-tick growth factor, 1 + growthPerTickBP/10000, in Q32.32.
|
||||
func step() fixed.F {
|
||||
return fixed.One + fixed.F(growthPerTickBP<<32/10000)
|
||||
}
|
||||
|
||||
// MultiplierAt returns the multiplier displayed at a given tick of the round,
|
||||
// compounding from 1.0.
|
||||
func MultiplierAt(tick int) fixed.F {
|
||||
m := fixed.One
|
||||
s := step()
|
||||
for i := 0; i < tick; i++ {
|
||||
m = m.Mul(s)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m.
|
||||
func TicksToMultiplier(m fixed.F) int {
|
||||
cur := fixed.One
|
||||
s := step()
|
||||
for tick := 0; tick < 1_000_000; tick++ {
|
||||
if cur >= m {
|
||||
return tick
|
||||
}
|
||||
cur = cur.Mul(s)
|
||||
}
|
||||
return 1_000_000
|
||||
}
|
||||
Reference in New Issue
Block a user