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
|
||||||
|
}
|
||||||
97
pkg/sim/crash_test.go
Normal file
97
pkg/sim/crash_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrashPointNeverBelowOne(t *testing.T) {
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0], seed[1] = byte(i), byte(i>>8)
|
||||||
|
if cp := CrashPoint(seed); cp < 1<<32 {
|
||||||
|
t.Fatalf("seed %d: crash point %v below 1.0", i, cp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrashPointIsDeterministic(t *testing.T) {
|
||||||
|
var seed [32]byte
|
||||||
|
copy(seed[:], "repeatable")
|
||||||
|
first := CrashPoint(seed)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if got := CrashPoint(seed); got != first {
|
||||||
|
t.Fatalf("run %d: %v != %v", i, got, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a 2% house edge, a player cashing out at exactly 2.00x should win
|
||||||
|
// slightly under half the time. This pins the payout distribution.
|
||||||
|
func TestHouseEdgeAtTwoX(t *testing.T) {
|
||||||
|
const n = 200000
|
||||||
|
target := int64(2) << 32
|
||||||
|
wins := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0], seed[1], seed[2] = byte(i), byte(i>>8), byte(i>>16)
|
||||||
|
if int64(CrashPoint(seed)) >= target {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pct := float64(wins) * 100 / n
|
||||||
|
if pct < 47.5 || pct > 50.5 {
|
||||||
|
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49%%", pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expected return at any cash-out target should be about 98%.
|
||||||
|
func TestExpectedReturnMatchesEdge(t *testing.T) {
|
||||||
|
const n = 200000
|
||||||
|
for _, targetX := range []int64{2, 3, 5} {
|
||||||
|
target := targetX << 32
|
||||||
|
var returned float64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0], seed[1], seed[2], seed[3] = byte(i), byte(i>>8), byte(i>>16), byte(targetX)
|
||||||
|
if int64(CrashPoint(seed)) >= target {
|
||||||
|
returned += float64(targetX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rtp := returned * 100 / n
|
||||||
|
if rtp < 96.0 || rtp > 100.0 {
|
||||||
|
t.Fatalf("RTP at %dx = %.2f%%, want ~98%%", targetX, rtp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiplierStartsAtOne(t *testing.T) {
|
||||||
|
if got := MultiplierAt(0); got != 1<<32 {
|
||||||
|
t.Fatalf("MultiplierAt(0) = %v, want 1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiplierIsMonotonic(t *testing.T) {
|
||||||
|
prev := MultiplierAt(0)
|
||||||
|
for tick := 1; tick < 5000; tick++ {
|
||||||
|
cur := MultiplierAt(tick)
|
||||||
|
if cur < prev {
|
||||||
|
t.Fatalf("tick %d: multiplier decreased %v -> %v", tick, prev, cur)
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicksToMultiplierRoundTrips(t *testing.T) {
|
||||||
|
for _, m := range []int64{2, 5, 10} {
|
||||||
|
target := fixed.FromInt(m)
|
||||||
|
tick := TicksToMultiplier(target)
|
||||||
|
if MultiplierAt(tick) < target {
|
||||||
|
t.Fatalf("tick %d does not reach %dx", tick, m)
|
||||||
|
}
|
||||||
|
if tick > 0 && MultiplierAt(tick-1) >= target {
|
||||||
|
t.Fatalf("tick %d is not the first to reach %dx", tick, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
pkg/sim/rng.go
Normal file
75
pkg/sim/rng.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RNG is a deterministic xoshiro256** generator seeded from 32 bytes.
|
||||||
|
// It uses only integer operations, so a browser replaying a round reproduces
|
||||||
|
// the server's stream exactly.
|
||||||
|
//
|
||||||
|
// This is the expansion function, not the entropy source: the seed itself comes
|
||||||
|
// from the commit-reveal protocol, which is what makes outcomes unriggable.
|
||||||
|
type RNG struct {
|
||||||
|
state [4]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRNG creates a reproducible generator from a 32-byte seed.
|
||||||
|
//
|
||||||
|
// The seed is run through SplitMix64 rather than copied into the state
|
||||||
|
// directly. Copying directly leaves the first output depending only on
|
||||||
|
// state[1], so seeds differing in other bytes produce identical first draws —
|
||||||
|
// which would make CrashPoint blind to most of its own seed.
|
||||||
|
func NewRNG(seed [32]byte) *RNG {
|
||||||
|
// Fold every seed byte into a single accumulator first, so all 32 bytes
|
||||||
|
// influence all four state words.
|
||||||
|
acc := uint64(0x9E3779B97F4A7C15)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
acc ^= binary.LittleEndian.Uint64(seed[i*8 : i*8+8])
|
||||||
|
acc = splitMix64(&acc)
|
||||||
|
}
|
||||||
|
r := &RNG{}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
r.state[i] = splitMix64(&acc)
|
||||||
|
}
|
||||||
|
// An all-zero state is a fixed point of the recurrence. SplitMix64 makes
|
||||||
|
// this vanishingly unlikely, but the guard costs nothing.
|
||||||
|
if r.state[0]|r.state[1]|r.state[2]|r.state[3] == 0 {
|
||||||
|
r.state[0] = 0x9E3779B97F4A7C15
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitMix64 advances x and returns a well-mixed 64-bit value. Every input bit
|
||||||
|
// affects every output bit, which is the property the state expansion needs.
|
||||||
|
func splitMix64(x *uint64) uint64 {
|
||||||
|
*x += 0x9E3779B97F4A7C15
|
||||||
|
z := *x
|
||||||
|
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
|
||||||
|
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
|
||||||
|
return z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uint64 returns the next 64 bits of the stream.
|
||||||
|
func (r *RNG) Uint64() uint64 {
|
||||||
|
s := &r.state
|
||||||
|
result := rotl(s[1]*5, 7) * 9
|
||||||
|
t := s[1] << 17
|
||||||
|
s[2] ^= s[0]
|
||||||
|
s[3] ^= s[1]
|
||||||
|
s[1] ^= s[2]
|
||||||
|
s[0] ^= s[3]
|
||||||
|
s[2] ^= t
|
||||||
|
s[3] = rotl(s[3], 45)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotl(x uint64, k uint) uint64 { return (x << k) | (x >> (64 - k)) }
|
||||||
|
|
||||||
|
// Unit returns a fixed-point value uniformly distributed over [0, 1).
|
||||||
|
// Taking the top 32 bits places them exactly in the fractional field.
|
||||||
|
func (r *RNG) Unit() fixed.F {
|
||||||
|
return fixed.F(r.Uint64() >> 32)
|
||||||
|
}
|
||||||
51
pkg/sim/rng_test.go
Normal file
51
pkg/sim/rng_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package sim
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRNGIsDeterministic(t *testing.T) {
|
||||||
|
var seed [32]byte
|
||||||
|
copy(seed[:], "quantum-arcade-test-seed")
|
||||||
|
a, b := NewRNG(seed), NewRNG(seed)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
if x, y := a.Uint64(), b.Uint64(); x != y {
|
||||||
|
t.Fatalf("iteration %d: %d != %d", i, x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDifferentSeedsDiverge(t *testing.T) {
|
||||||
|
var s1, s2 [32]byte
|
||||||
|
copy(s1[:], "seed-one")
|
||||||
|
copy(s2[:], "seed-two")
|
||||||
|
a, b := NewRNG(s1), NewRNG(s2)
|
||||||
|
same := 0
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if a.Uint64() == b.Uint64() {
|
||||||
|
same++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same > 1 {
|
||||||
|
t.Fatalf("streams collided %d times in 100 draws", same)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroSeedDoesNotDegenerate(t *testing.T) {
|
||||||
|
var seed [32]byte // all zeros
|
||||||
|
r := NewRNG(seed)
|
||||||
|
first := r.Uint64()
|
||||||
|
if first == 0 && r.Uint64() == 0 {
|
||||||
|
t.Fatal("zero seed produced a degenerate all-zero stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnitInRange(t *testing.T) {
|
||||||
|
var seed [32]byte
|
||||||
|
seed[0] = 9
|
||||||
|
r := NewRNG(seed)
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
u := r.Unit()
|
||||||
|
if u < 0 || u >= 1<<32 {
|
||||||
|
t.Fatalf("Unit() = %v out of [0,1)", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user