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) } }