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:
drjones
2026-08-05 02:48:17 +00:00
parent 9413537251
commit 8fccbb2a9a
4 changed files with 301 additions and 0 deletions

97
pkg/sim/crash_test.go Normal file
View 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)
}
}
}