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