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>
154 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|