Files
casino/pkg/room/room.go
drjones 2a2a1db8de feat: playable arcade — rooms, identity, client, deployment
Round length is now bounded: the multiplier follows a hyperbolic curve
diverging at 60s, replacing an exponential one where a 275x crash point
produced a two-and-a-half minute round.

Fixes seed reveal, which silently failed every round because pgx cannot
encode a fixed-size byte array as bytea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:34:52 +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
}