Auto cash-out closes a position at exactly the chosen target rather than the next tick's multiplier, and fires whenever the target is at or below the crash point. This is the feature that makes the game playable over a network, where manual timing is at the mercy of latency. House edge drops from 2% to 1% across crash and scratch. Scratch prize tables retuned so the published 99% RTP is exact. Adds docs/API.md: the client uses no private endpoints, so anyone can write a bot against the same API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
4.5 KiB
Go
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 < 48.5 || pct > 51.0 {
|
|
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49.5%%", 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 < 97.0 || rtp > 101.0 {
|
|
t.Fatalf("RTP at %dx = %.2f%%, want ~99%%", 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)
|
|
}
|
|
}
|