From 8fccbb2a9a903cf4011bfbf611aa1b44b6ad5db1 Mon Sep 17 00:00:00 2001 From: drjones Date: Wed, 5 Aug 2026 02:48:17 +0000 Subject: [PATCH] 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 --- pkg/sim/crash.go | 78 ++++++++++++++++++++++++++++++++++ pkg/sim/crash_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++ pkg/sim/rng.go | 75 +++++++++++++++++++++++++++++++++ pkg/sim/rng_test.go | 51 +++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 pkg/sim/crash.go create mode 100644 pkg/sim/crash_test.go create mode 100644 pkg/sim/rng.go create mode 100644 pkg/sim/rng_test.go diff --git a/pkg/sim/crash.go b/pkg/sim/crash.go new file mode 100644 index 0000000..b672a22 --- /dev/null +++ b/pkg/sim/crash.go @@ -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 +} diff --git a/pkg/sim/crash_test.go b/pkg/sim/crash_test.go new file mode 100644 index 0000000..44c46ab --- /dev/null +++ b/pkg/sim/crash_test.go @@ -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) + } + } +} diff --git a/pkg/sim/rng.go b/pkg/sim/rng.go new file mode 100644 index 0000000..6a8be9f --- /dev/null +++ b/pkg/sim/rng.go @@ -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) +} diff --git a/pkg/sim/rng_test.go b/pkg/sim/rng_test.go new file mode 100644 index 0000000..00f70f7 --- /dev/null +++ b/pkg/sim/rng_test.go @@ -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) + } + } +}