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