Files
casino/pkg/room/room.go
drjones 48a9120fe4 feat: auto cash-out targets, 1% house edge, terminal aesthetic
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>
2026-08-05 16:24:31 +00:00

484 lines
13 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
// AutoCashOutAt is an optional target set before the round starts. When
// the multiplier reaches it the position closes automatically at exactly
// that value — not at whatever the next tick happens to show — so the
// player gets the number they chose. Zero means no target.
AutoCashOutAt fixed.F
}
// 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"`
// Auto is true when the position closed on its own target rather than a tap.
Auto bool `json:"auto,omitempty"`
}
// 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)
// Close any positions whose target has been met. This happens before
// the crash check so a target at or below the crash point always pays,
// regardless of where tick boundaries happen to fall.
r.triggerAutoCashOutsLocked(reached)
// 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
}
// triggerAutoCashOutsLocked closes positions whose target the multiplier has
// reached. Callers must hold the lock.
//
// A target above the crash point never fires: the round is already over at
// that value. A target at or below it always fires, at exactly the target.
func (r *Room) triggerAutoCashOutsLocked(reached fixed.F) {
for _, b := range r.bets {
if b.CashedOutAt != 0 || b.AutoCashOutAt == 0 {
continue
}
if b.AutoCashOutAt > r.crashPoint {
continue // the round ends before this target is reached
}
if reached >= b.AutoCashOutAt {
b.CashedOutAt = b.AutoCashOutAt
}
}
}
// 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, autoCashOutAt fixed.F) error {
if stakeMsat <= 0 {
return ledger.ErrNonPositiveAmount
}
// A target at or below 1.0 would close instantly for no gain.
if autoCashOutAt != 0 && autoCashOutAt <= fixed.One {
return fmt.Errorf("auto cash-out target must be above 1.00")
}
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,
AutoCashOutAt: autoCashOutAt,
}
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()
p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt
}
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
}