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