Files
casino/pkg/scratch/scratch.go
drjones dee3becd47 fix(sim): cap crash point so extreme seeds cannot overflow or bankrupt
At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the
rarest and most valuable outcome silently became an instant 1.00x loss.
At u=2 it produced a 2.1-billion-times payout the house could never
cover, which would have left settlement failing and the player unpaid.
The crash point is now capped at the largest multiplier the curve can
express, which is unreachable anyway since the round hits its tick
ceiling first.

FromInt now panics outside the Q32.32 integer range instead of wrapping
a positive input into a negative value.

Raises coverage to 88% overall; adds a Makefile with db-reset, since the
append-only ledger steadily consumes bridge headroom across test runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:52:50 +00:00

238 lines
7.1 KiB
Go

// Package scratch implements instant scratch-ticket games.
//
// The defining property here is that the odds table shown to the player is
// derived from the same data that generates outcomes. There is no separate
// "marketing" table that could drift from reality: Odds() and Play() read the
// identical tier list, and a test asserts that observed frequencies match the
// published ones.
package scratch
import (
"encoding/binary"
"fmt"
"github.com/drjones/quantum-arcade/pkg/fair"
)
// Denominator is the resolution of the odds table. Every tier's weight is
// expressed out of this, so odds are exact rationals rather than rounded
// percentages.
const Denominator uint64 = 1_000_000
// Tier is one prize band.
type Tier struct {
// Name is what the player sees when they win it.
Name string
// Weight is the number of outcomes out of Denominator that land here.
Weight uint64
// PayoutBP is the prize as basis points of the stake: 10000 = 1x stake.
PayoutBP uint64
}
// Ticket is a scratch game definition.
type Ticket struct {
ID string
Name string
Blurb string
// Cells is how many squares the player scratches, for presentation.
Cells int
Tiers []Tier
}
// Outcome is the result of one ticket.
type Outcome struct {
TicketID string `json:"ticket_id"`
TierName string `json:"tier_name"`
PayoutBP uint64 `json:"payout_bp"`
PayoutMsat int64 `json:"payout_msat"`
// Roll is the raw draw, published so the player can check the mapping.
Roll uint64 `json:"roll"`
// Cells are the revealed symbols, derived from the same seed.
Cells []int `json:"cells"`
}
// OddsRow is one line of the published odds table.
type OddsRow struct {
TierName string `json:"tier"`
PayoutBP uint64 `json:"payout_bp"`
Weight uint64 `json:"weight"`
Denominator uint64 `json:"denominator"`
// OneIn is the human-readable "1 in N" figure, zero for the losing tier.
OneIn uint64 `json:"one_in"`
}
// Validate reports whether the tier weights are coherent. A ticket whose
// weights do not sum to exactly Denominator would have undefined outcomes.
func (t Ticket) Validate() error {
var sum uint64
for _, tier := range t.Tiers {
sum += tier.Weight
}
if sum != Denominator {
return fmt.Errorf("scratch: ticket %q weights sum to %d, want %d",
t.ID, sum, Denominator)
}
return nil
}
// Odds returns the published odds table, derived from the generating tiers.
func (t Ticket) Odds() []OddsRow {
rows := make([]OddsRow, 0, len(t.Tiers))
for _, tier := range t.Tiers {
row := OddsRow{
TierName: tier.Name,
PayoutBP: tier.PayoutBP,
Weight: tier.Weight,
Denominator: Denominator,
}
if tier.Weight > 0 {
row.OneIn = Denominator / tier.Weight
}
rows = append(rows, row)
}
return rows
}
// RTPBasisPoints is the return to player in basis points, computed from the
// same tiers that generate outcomes. 9800 means 98%.
func (t Ticket) RTPBasisPoints() uint64 {
var total uint64
for _, tier := range t.Tiers {
total += tier.Weight * tier.PayoutBP
}
return total / Denominator
}
// Play resolves one ticket from a round seed.
func (t Ticket) Play(seed [32]byte, stakeMsat int64) Outcome {
// The first 8 bytes select the tier; later bytes decorate the cells, so
// presentation can never alter the prize.
roll := binary.BigEndian.Uint64(seed[0:8]) % Denominator
var chosen Tier
var cumulative uint64
for _, tier := range t.Tiers {
cumulative += tier.Weight
if roll < cumulative {
chosen = tier
break
}
}
return Outcome{
TicketID: t.ID,
TierName: chosen.Name,
PayoutBP: chosen.PayoutBP,
PayoutMsat: stakeMsat * int64(chosen.PayoutBP) / 10000,
Roll: roll,
Cells: t.revealCells(seed, chosen),
}
}
// symbolCount is the number of distinct symbols on a ticket face. With six
// symbols, a losing face can always be filled without an accidental
// three-of-a-kind for any ticket up to twelve cells.
const symbolCount = 6
// revealCells produces the symbols the player scratches off.
//
// The prize is already fixed by the roll; the cells are presentation. But they
// must never contradict the result, so a winning face always shows a genuine
// three-of-a-kind and a losing face never does.
func (t Ticket) revealCells(seed [32]byte, won Tier) []int {
cells := make([]int, t.Cells)
for i := range cells {
cells[i] = -1
}
counts := make([]int, symbolCount)
// A winning face gets its match placed first, on three adjacent positions
// so they cannot collide with one another.
winSym := -1
if won.PayoutBP > 0 && t.Cells >= 3 {
winSym = int(seed[31]) % symbolCount
start := int(seed[30]) % t.Cells
for k := 0; k < 3; k++ {
cells[(start+k)%t.Cells] = winSym
counts[winSym]++
}
}
// Fill the remainder, never letting a non-winning symbol reach three.
for i := range cells {
if cells[i] != -1 {
continue
}
pick := int(seed[(i+8)%32]) % symbolCount
for attempts := 0; attempts < symbolCount; attempts++ {
if pick != winSym && counts[pick] >= 2 {
pick = (pick + 1) % symbolCount
continue
}
if pick == winSym && winSym == -1 {
pick = (pick + 1) % symbolCount
continue
}
break
}
cells[i] = pick
counts[pick]++
}
return cells
}
// Catalog is the set of tickets offered. Each is validated at startup.
var Catalog = []Ticket{
{
ID: "nebula-nine",
Name: "Nebula Nine",
Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.",
Cells: 9,
// Weights are out of 1,000,000 and sum to it exactly. The weighted
// payout sums to 9.8e9, which is an RTP of exactly 98%.
Tiers: []Tier{
{Name: "No win", Weight: 463_400, PayoutBP: 0},
{Name: "Stake back", Weight: 350_000, PayoutBP: 10_000},
{Name: "Double", Weight: 150_000, PayoutBP: 20_000},
{Name: "Five times", Weight: 30_000, PayoutBP: 50_000},
{Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000},
{Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000},
},
},
{
ID: "singularity",
Name: "Singularity",
Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.",
Cells: 6,
// Same 98% RTP as Nebula Nine, but concentrated in the rare tiers:
// you lose far more often, and the top prize is 500x.
Tiers: []Tier{
{Name: "No win", Weight: 866_530, PayoutBP: 0},
{Name: "Stake back", Weight: 60_000, PayoutBP: 10_000},
{Name: "Triple", Weight: 45_000, PayoutBP: 30_000},
{Name: "Ten times", Weight: 25_000, PayoutBP: 100_000},
{Name: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000},
{Name: "Singularity", Weight: 470, PayoutBP: 5_000_000},
},
},
}
// ByID looks up a ticket in the catalog.
func ByID(id string) (Ticket, bool) {
for _, t := range Catalog {
if t.ID == id {
return t, true
}
}
return Ticket{}, false
}
// PlayFromRound is the entry point used by the server: it derives the seed
// through the same commit-reveal machinery the crash games use, so scratch
// tickets are verifiable by exactly the same method.
func PlayFromRound(t Ticket, server fair.ServerSeed, pubkey []byte, nonce uint64, stakeMsat int64) (Outcome, fair.Proof) {
client := fair.ClientSeed([][]byte{pubkey})
seed := fair.RoundSeed(server, client, nonce)
return t.Play(seed, stakeMsat), fair.BuildProof(server, [][]byte{pubkey}, nonce)
}