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>
This commit is contained in:
446
pkg/room/room.go
Normal file
446
pkg/room/room.go
Normal file
@@ -0,0 +1,446 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user