Files
casino/pkg/scratch/scratch_test.go
drjones 48a9120fe4 feat: auto cash-out targets, 1% house edge, terminal aesthetic
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>
2026-08-05 16:24:31 +00:00

154 lines
4.5 KiB
Go

package scratch_test
import (
"encoding/binary"
"testing"
"github.com/drjones/quantum-arcade/pkg/fair"
"github.com/drjones/quantum-arcade/pkg/scratch"
)
func TestCatalogIsCoherent(t *testing.T) {
for _, ticket := range scratch.Catalog {
if err := ticket.Validate(); err != nil {
t.Errorf("%s: %v", ticket.ID, err)
}
}
}
// The published RTP must be a genuine 98%, not a rounded claim.
func TestPublishedRTPIsHonest(t *testing.T) {
for _, ticket := range scratch.Catalog {
rtp := ticket.RTPBasisPoints()
if rtp != 9900 {
t.Errorf("%s: RTP = %d bp, want 9900", ticket.ID, rtp)
}
}
}
// The core honesty test: the odds table shown to players must match the
// frequencies the generator actually produces. If someone edits a weight to
// tighten the game without updating the table, this fails.
func TestObservedFrequenciesMatchPublishedOdds(t *testing.T) {
const trials = 2_000_000
for _, ticket := range scratch.Catalog {
counts := map[string]int{}
for i := 0; i < trials; i++ {
var seed [32]byte
binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
out := ticket.Play(seed, 1000)
counts[out.TierName]++
}
for _, row := range ticket.Odds() {
expected := float64(row.Weight) / float64(row.Denominator)
observed := float64(counts[row.TierName]) / trials
// Allow a relative tolerance that scales with rarity, since rare
// tiers have proportionally more sampling noise.
tolerance := 0.02 + 3/(expected*trials+1)
if expected > 0 {
diff := (observed - expected) / expected
if diff < -tolerance || diff > tolerance {
t.Errorf("%s/%s: published %.6f, observed %.6f (%.1f%% off)",
ticket.ID, row.TierName, expected, observed, diff*100)
}
}
}
}
}
// Empirical return must match the published RTP, which is the claim that
// actually matters to a player.
func TestEmpiricalReturnMatchesPublishedRTP(t *testing.T) {
const trials = 2_000_000
const stake = 10_000
for _, ticket := range scratch.Catalog {
var paid int64
for i := 0; i < trials; i++ {
var seed [32]byte
binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
paid += ticket.Play(seed, stake).PayoutMsat
}
staked := int64(trials) * stake
observedBP := paid * 10000 / staked
published := int64(ticket.RTPBasisPoints())
if observedBP < published-150 || observedBP > published+150 {
t.Errorf("%s: observed RTP %d bp, published %d bp",
ticket.ID, observedBP, published)
}
}
}
func TestOutcomeIsDeterministic(t *testing.T) {
ticket := scratch.Catalog[0]
var seed [32]byte
copy(seed[:], "a-fixed-seed-for-this-ticket-abc")
first := ticket.Play(seed, 5000)
for i := 0; i < 100; i++ {
if got := ticket.Play(seed, 5000); got.TierName != first.TierName ||
got.PayoutMsat != first.PayoutMsat {
t.Fatal("scratch outcome is not deterministic")
}
}
}
// The revealed cells must never contradict the payout: a winning ticket shows
// three of a kind, a losing ticket does not.
func TestRevealedCellsAgreeWithPayout(t *testing.T) {
for _, ticket := range scratch.Catalog {
for i := 0; i < 20000; i++ {
var seed [32]byte
binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
for j := 8; j < 32; j++ {
seed[j] = byte(i*j + j)
}
out := ticket.Play(seed, 1000)
counts := map[int]int{}
for _, c := range out.Cells {
counts[c]++
}
hasThree := false
for _, n := range counts {
if n >= 3 {
hasThree = true
break
}
}
if out.PayoutBP > 0 && !hasThree {
t.Fatalf("%s: winning ticket (%s) shows no three-of-a-kind: %v",
ticket.ID, out.TierName, out.Cells)
}
if out.PayoutBP == 0 && hasThree {
t.Fatalf("%s: losing ticket shows three-of-a-kind: %v",
ticket.ID, out.Cells)
}
}
}
}
// Scratch tickets must be verifiable by the same commit-reveal path as the
// crash games.
func TestPlayFromRoundIsVerifiable(t *testing.T) {
ticket := scratch.Catalog[0]
server := fair.NewServerSeed()
commitment := server.Commitment()
pubkey := []byte("player-pubkey")
out, proof := scratch.PlayFromRound(ticket, server, pubkey, 3, 1000)
if !fair.VerifyCommitment(commitment, server) {
t.Fatal("commitment does not verify")
}
// Independently recompute the outcome the way a client would.
seed := fair.RoundSeed(server, fair.ClientSeed([][]byte{pubkey}), 3)
if recomputed := ticket.Play(seed, 1000); recomputed.TierName != out.TierName {
t.Fatalf("recomputed %q, server said %q", recomputed.TierName, out.TierName)
}
if proof.Nonce != 3 {
t.Fatalf("proof nonce = %d, want 3", proof.Nonce)
}
}