Files
casino/pkg/room/room.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

447 lines
11 KiB
Go

// Package room runs the shared crash rounds.
//
// A round moves through four states: betting_open, locked, running, settled.
// The server seed is committed before betting opens and revealed only at
// settlement, so no one — including the operator — can know the crash point
// while bets are still being placed.
//
// All money movement goes through the ledger in a single transaction per
// settlement, which is what keeps the books balanced under load.
package room
import (
"context"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/drjones/quantum-arcade/pkg/fair"
"github.com/drjones/quantum-arcade/pkg/fixed"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/drjones/quantum-arcade/pkg/sim"
"github.com/jackc/pgx/v5/pgxpool"
)
// State is the phase of a round.
type State string
const (
StateBetting State = "betting_open"
StateLocked State = "locked"
StateRunning State = "running"
StateSettled State = "settled"
)
// Timings. The betting window is deliberately generous: at a party, people are
// walking up to their phones mid-round.
const (
BettingWindow = 20 * time.Second
LockedPause = 3 * time.Second
SettledPause = 7 * time.Second
TickInterval = time.Second / sim.TickHz
)
// Bet is one player's position in the current round.
type Bet struct {
AccountID int64
Pubkey []byte
Nickname string
StakeMsat int64
CashedOutAt fixed.F // zero until they cash out
PayoutMsat int64
}
// Snapshot is what clients render. It carries the seed inputs so a client can
// verify the round the moment it settles.
type Snapshot struct {
RoundID int64 `json:"round_id"`
Game string `json:"game"`
State State `json:"state"`
Tick int `json:"tick"`
Multiplier string `json:"multiplier"`
Commitment string `json:"commitment"`
ServerSeed string `json:"server_seed,omitempty"` // only once settled
CrashPoint string `json:"crash_point,omitempty"` // only once settled
Players []Player `json:"players"`
HousePotMsat int64 `json:"house_pot_msat"`
NextPhaseIn float64 `json:"next_phase_in_seconds"`
}
// Player is the public view of a participant.
type Player struct {
Nickname string `json:"nickname"`
PubkeyHex string `json:"pubkey"`
StakeMsat int64 `json:"stake_msat"`
CashedOut string `json:"cashed_out,omitempty"`
PayoutMsat int64 `json:"payout_msat"`
}
// Room runs one game's round loop.
type Room struct {
Game string
pool *pgxpool.Pool
ledger *ledger.Ledger
mu sync.RWMutex
roundID int64
state State
tick int
nonce uint64
serverSeed fair.ServerSeed
commitment [32]byte
crashPoint fixed.F
bets map[int64]*Bet
order [][]byte // participant pubkeys in join order
phaseEnds time.Time
subscribers map[chan Snapshot]struct{}
subMu sync.Mutex
}
func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
return &Room{
Game: game,
pool: pool,
ledger: l,
state: StateSettled,
bets: make(map[int64]*Bet),
subscribers: make(map[chan Snapshot]struct{}),
phaseEnds: time.Now(),
}
}
// Subscribe returns a channel of snapshots. The channel is buffered and drops
// updates rather than blocking the round loop: a slow phone must never stall
// the game for everyone else.
func (r *Room) Subscribe() (<-chan Snapshot, func()) {
ch := make(chan Snapshot, 8)
r.subMu.Lock()
r.subscribers[ch] = struct{}{}
r.subMu.Unlock()
return ch, func() {
r.subMu.Lock()
delete(r.subscribers, ch)
close(ch)
r.subMu.Unlock()
}
}
func (r *Room) broadcast() {
snap := r.Snapshot()
r.subMu.Lock()
defer r.subMu.Unlock()
for ch := range r.subscribers {
select {
case ch <- snap:
default: // subscriber is behind; skip this frame
}
}
}
// Run drives the round loop until the context is cancelled.
func (r *Room) Run(ctx context.Context) error {
ticker := time.NewTicker(TickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := r.step(ctx); err != nil {
// A failed step must not kill the room; log-and-continue keeps
// the arcade running even if the database blips.
fmt.Printf("room %s: step error: %v\n", r.Game, err)
}
}
}
}
func (r *Room) step(ctx context.Context) error {
r.mu.Lock()
state, phaseEnds := r.state, r.phaseEnds
r.mu.Unlock()
now := time.Now()
switch state {
case StateSettled:
if now.After(phaseEnds) {
return r.openRound(ctx)
}
case StateBetting:
if now.After(phaseEnds) {
r.mu.Lock()
r.state = StateLocked
r.phaseEnds = now.Add(LockedPause)
r.mu.Unlock()
r.broadcast()
}
case StateLocked:
if now.After(phaseEnds) {
return r.startRunning(ctx)
}
case StateRunning:
r.mu.Lock()
r.tick++
reached := sim.MultiplierAt(r.tick)
// A crash point beyond what the curve expresses would otherwise never
// be reached, so the tick ceiling also ends the round.
crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks
r.mu.Unlock()
if crashed {
return r.settle(ctx)
}
r.broadcast()
}
return nil
}
// openRound commits to a fresh server seed and opens betting.
func (r *Room) openRound(ctx context.Context) error {
seed := fair.NewServerSeed()
commitment := seed.Commitment()
r.mu.Lock()
r.nonce++
nonce := r.nonce
r.mu.Unlock()
var roundID int64
err := r.pool.QueryRow(ctx,
`INSERT INTO rounds (game, nonce, commitment) VALUES ($1, $2, $3) RETURNING id`,
r.Game, int64(nonce), commitment[:]).Scan(&roundID)
if err != nil {
return fmt.Errorf("opening round: %w", err)
}
r.mu.Lock()
r.roundID = roundID
r.serverSeed = seed
r.commitment = commitment
r.crashPoint = 0
r.tick = 0
r.bets = make(map[int64]*Bet)
r.order = nil
r.state = StateBetting
r.phaseEnds = time.Now().Add(BettingWindow)
r.mu.Unlock()
r.broadcast()
return nil
}
// startRunning derives the crash point from the committed seed and the
// participant set, then begins the climb.
func (r *Room) startRunning(ctx context.Context) error {
r.mu.Lock()
clientSeed := fair.ClientSeed(r.order)
roundSeed := fair.RoundSeed(r.serverSeed, clientSeed, r.nonce)
r.crashPoint = sim.CrashPoint(roundSeed)
r.state = StateRunning
r.tick = 0
roundID := r.roundID
crash := r.crashPoint
r.mu.Unlock()
if _, err := r.pool.Exec(ctx,
`UPDATE rounds SET locked_at = now(), client_seed = $2, crash_point = $3
WHERE id = $1`,
roundID, clientSeed[:], int64(crash)); err != nil {
return fmt.Errorf("locking round: %w", err)
}
r.broadcast()
return nil
}
// settle pays out everyone who cashed out in time and reveals the seed.
// Payouts are written as one ledger transaction so the books cannot be left
// half-updated.
func (r *Room) settle(ctx context.Context) error {
r.mu.Lock()
roundID := r.roundID
seed := r.serverSeed
crash := r.crashPoint
bets := make([]*Bet, 0, len(r.bets))
for _, b := range r.bets {
bets = append(bets, b)
}
r.state = StateSettled
r.phaseEnds = time.Now().Add(SettledPause)
r.mu.Unlock()
house, err := r.ledger.AccountByName(ctx, "house_pot")
if err != nil {
return err
}
var postings []ledger.Posting
var housePays int64
for _, b := range bets {
if b.CashedOutAt == 0 {
continue // rode it into the crash; the stake already sits with the house
}
payout := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
b.PayoutMsat = payout
if payout > 0 {
postings = append(postings, ledger.Posting{AccountID: b.AccountID, AmountMsat: payout})
housePays += payout
}
if _, err := r.pool.Exec(ctx,
`UPDATE bets SET payout_msat = $2, settled_at = now()
WHERE round_id = $1 AND account_id = $3`,
roundID, payout, b.AccountID); err != nil {
return fmt.Errorf("recording payout: %w", err)
}
}
if housePays > 0 {
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays})
rid := roundID
if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil {
return fmt.Errorf("settling round %d: %w", roundID, err)
}
}
// pgx encodes byte slices, not fixed-size arrays, so the seed is sliced.
seedBytes := seed.Bytes()
if _, err := r.pool.Exec(ctx,
`UPDATE rounds SET settled_at = now(), server_seed = $2 WHERE id = $1`,
roundID, seedBytes[:]); err != nil {
return fmt.Errorf("revealing seed: %w", err)
}
_ = crash
r.broadcast()
return nil
}
// PlaceBet takes a stake during the betting window. The stake moves to the
// house immediately, so a player can never bet money they do not have.
func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64) error {
if stakeMsat <= 0 {
return ledger.ErrNonPositiveAmount
}
r.mu.Lock()
if r.state != StateBetting {
r.mu.Unlock()
return fmt.Errorf("betting is closed")
}
if _, exists := r.bets[accountID]; exists {
r.mu.Unlock()
return fmt.Errorf("already in this round")
}
roundID := r.roundID
r.mu.Unlock()
house, err := r.ledger.AccountByName(ctx, "house_pot")
if err != nil {
return err
}
rid := roundID
if _, err := r.ledger.Post(ctx, "bet", &rid, []ledger.Posting{
{AccountID: accountID, AmountMsat: -stakeMsat},
{AccountID: house, AmountMsat: stakeMsat},
}); err != nil {
return err
}
if _, err := r.pool.Exec(ctx,
`INSERT INTO bets (round_id, account_id, stake_msat) VALUES ($1, $2, $3)`,
roundID, accountID, stakeMsat); err != nil {
return err
}
r.mu.Lock()
// Re-check state: the window may have closed while we were in the database.
if r.state != StateBetting || r.roundID != roundID {
r.mu.Unlock()
return fmt.Errorf("betting closed while placing bet")
}
r.bets[accountID] = &Bet{
AccountID: accountID, Pubkey: pubkey,
Nickname: nickname, StakeMsat: stakeMsat,
}
r.order = append(r.order, pubkey)
r.mu.Unlock()
r.broadcast()
return nil
}
// CashOut locks in the current multiplier. It is rejected once the round has
// passed the crash point, which the tick loop enforces by settling first.
func (r *Room) CashOut(accountID int64) (fixed.F, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.state != StateRunning {
return 0, fmt.Errorf("round is not running")
}
b, ok := r.bets[accountID]
if !ok {
return 0, fmt.Errorf("no bet in this round")
}
if b.CashedOutAt != 0 {
return 0, fmt.Errorf("already cashed out")
}
at := sim.MultiplierAt(r.tick)
if at >= r.crashPoint {
return 0, fmt.Errorf("too late")
}
b.CashedOutAt = at
go func() {
if _, err := r.pool.Exec(context.Background(),
`UPDATE bets SET cashout_at = $3 WHERE round_id = $1 AND account_id = $2`,
r.roundID, accountID, int64(at)); err != nil {
fmt.Printf("room %s: recording cashout: %v\n", r.Game, err)
}
}()
return at, nil
}
// Snapshot renders the current state for clients.
func (r *Room) Snapshot() Snapshot {
r.mu.RLock()
defer r.mu.RUnlock()
players := make([]Player, 0, len(r.bets))
for _, b := range r.bets {
p := Player{
Nickname: b.Nickname,
PubkeyHex: hex.EncodeToString(b.Pubkey),
StakeMsat: b.StakeMsat,
PayoutMsat: b.PayoutMsat,
}
if b.CashedOutAt != 0 {
p.CashedOut = b.CashedOutAt.String()
}
players = append(players, p)
}
s := Snapshot{
RoundID: r.roundID,
Game: r.Game,
State: r.state,
Tick: r.tick,
Multiplier: sim.MultiplierAt(r.tick).String(),
Commitment: hex.EncodeToString(r.commitment[:]),
Players: players,
NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
}
// The seed is revealed only once the round is over.
if r.state == StateSettled && r.crashPoint != 0 {
s.ServerSeed = r.serverSeed.Hex()
s.CrashPoint = r.crashPoint.String()
}
return s
}