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