Round length is now bounded: the multiplier follows a hyperbolic curve diverging at 60s, replacing an exponential one where a 275x crash point produced a two-and-a-half minute round. Fixes seed reveal, which silently failed every round because pgx cannot encode a fixed-size byte array as bytea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
3.3 KiB
Go
128 lines
3.3 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.
|
|
worst := fixed.FromInt(4_000_000_000)
|
|
if tick := TicksToMultiplier(worst); 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)
|
|
}
|
|
}
|
|
}
|