Files
casino/pkg/sim/crash_test.go
drjones dee3becd47 fix(sim): cap crash point so extreme seeds cannot overflow or bankrupt
At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the
rarest and most valuable outcome silently became an instant 1.00x loss.
At u=2 it produced a 2.1-billion-times payout the house could never
cover, which would have left settlement failing and the player unpaid.
The crash point is now capped at the largest multiplier the curve can
express, which is unreachable anyway since the round hits its tick
ceiling first.

FromInt now panics outside the Q32.32 integer range instead of wrapping
a positive input into a negative value.

Raises coverage to 88% overall; adds a Makefile with db-reset, since the
append-only ledger steadily consumes bridge headroom across test runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:52:50 +00:00

166 lines
4.5 KiB
Go

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)
}
}
}
// No round may outlast the ceiling, however extreme the crash point.
func TestRoundLengthIsBounded(t *testing.T) {
if got := MultiplierAt(RoundTicks); got != MaxMultiplier() {
t.Fatalf("curve past the ceiling = %v, want %v", got, MaxMultiplier())
}
// Even the most extreme crash point settles within the ceiling.
if tick := TicksToMultiplier(MaxMultiplier()); tick > RoundTicks {
t.Fatalf("extreme crash point needs %d ticks, ceiling is %d", tick, RoundTicks)
}
}
// Timings that matter for how the game feels.
func TestCurveTimings(t *testing.T) {
for _, c := range []struct {
multiplier int64
maxSeconds float64
}{
{2, 20}, // the common case should arrive quickly
{10, 45},
{100, 56},
} {
tick := TicksToMultiplier(fixed.FromInt(c.multiplier))
secs := float64(tick) / TickHz
if secs > c.maxSeconds {
t.Errorf("%dx takes %.1fs, want under %.0fs", c.multiplier, secs, c.maxSeconds)
}
}
}
// The crash point must never be negative or below 1.0, at any seed. An
// unsigned quotient exceeding int64 previously wrapped negative here.
func TestCrashPointNeverOverflows(t *testing.T) {
// Drive the derivation across seeds chosen to produce very small u, which
// is where the quotient is largest.
for i := 0; i < 200000; i++ {
var seed [32]byte
for j := 0; j < 32; j++ {
seed[j] = byte(i >> (8 * (j % 4)))
}
cp := CrashPoint(seed)
if cp < fixed.One {
t.Fatalf("seed %d produced crash point %v, below 1.0", i, cp)
}
if cp > MaxMultiplier() {
t.Fatalf("seed %d produced crash point %v, above the ceiling %v",
i, cp, MaxMultiplier())
}
}
}
// The payout a single round can demand must be bounded, so settlement can
// always be covered.
func TestMaximumPayoutIsBounded(t *testing.T) {
max := MaxMultiplier()
if max <= 0 {
t.Fatalf("ceiling is not positive: %v", max)
}
// A 1000-sat stake at the ceiling must stay well inside int64.
const stakeMsat = int64(1_000_000)
payout := stakeMsat * int64(max) / int64(fixed.One)
if payout <= 0 {
t.Fatalf("payout at the ceiling overflowed: %d", payout)
}
if payout > 1<<62 {
t.Fatalf("payout at the ceiling is %d, unreasonably large", payout)
}
}