Files
casino/pkg/scratch/scratch_test.go
drjones 41c1bb2fdf feat(fair,scratch): add commit-reveal fairness and scratch tickets
Scratch odds tables are derived from the same tier list that generates
outcomes, so the published odds cannot drift from reality. Tests assert
observed frequencies and empirical RTP against the published figures;
the initial prize tables claimed 98% but actually paid 56%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:28:27 +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 != 9800 {
t.Errorf("%s: RTP = %d bp, want 9800", 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)
}
}