Fees now flow through settlement. The payout and the deduction are posted as separate ledger transactions rather than netted, so a player's history shows the full win and the charge as itemised lines instead of a quietly smaller win. The admin console shows treasury, liability, revenue, every posting, every round, and risk flags. Auth is a constant-time token compare and the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a default deployment has no admin endpoint to attack. The token lives in browser memory only. It is read-only over game outcomes by design: seeds show only after settlement and nothing can alter a crash point. A control that could would make the fairness proof a lie. The console immediately found a real bug: 343 unresolved rounds, because the reconciler only considered rounds with bets and abandoned empty ones accumulated forever, burying the signal. Now cleared automatically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
586 lines
17 KiB
Go
586 lines
17 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"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/fair"
|
|
"github.com/drjones/quantum-arcade/pkg/fees"
|
|
"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 is capped at MaxListedPlayers. Sending every player to every
|
|
// subscriber is O(n^2) in bandwidth and makes a large room impossible:
|
|
// 50k players broadcast to 50k phones is gigabytes per second. The full
|
|
// list is available on request; the feed carries the leaderboard.
|
|
Players []Player `json:"players"`
|
|
PlayerCount int `json:"player_count"`
|
|
PotMsat int64 `json:"pot_msat"`
|
|
CashedOut int `json:"cashed_out_count"`
|
|
HousePotMsat int64 `json:"house_pot_msat"`
|
|
NextPhaseIn float64 `json:"next_phase_in_seconds"`
|
|
|
|
// StartedUnixMilli is when the running phase began. Because the multiplier
|
|
// curve is deterministic, a client can compute the current value locally
|
|
// from this instead of being told it sixty times a second.
|
|
StartedUnixMilli int64 `json:"started_unix_milli,omitempty"`
|
|
}
|
|
|
|
// MaxListedPlayers bounds the per-frame player list.
|
|
const MaxListedPlayers = 24
|
|
|
|
// BroadcastHz is how often a running round pushes a frame. Clients compute the
|
|
// multiplier locally between frames, so this only has to be often enough to
|
|
// correct drift and deliver cash-out news.
|
|
const BroadcastHz = 5
|
|
|
|
// Player is the public view of a participant.
|
|
type Player struct {
|
|
Nickname string `json:"nickname"`
|
|
// Pubkey is omitted from the live feed: it is 64 hex characters, it is
|
|
// most of the frame, and nothing in the interface displays it. The full
|
|
// participant list, with keys, is served by the verification endpoint
|
|
// after settlement — which is where it actually matters.
|
|
PubkeyHex string `json:"pubkey,omitempty"`
|
|
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
|
|
|
|
// Fees is the operator's schedule. Deductions are posted as their own
|
|
// ledger transaction rather than folded into the payout, so a player's
|
|
// history shows the win and the fee as separate, itemised lines.
|
|
Fees fees.Schedule
|
|
|
|
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 []byte]struct{}
|
|
subMu sync.Mutex
|
|
|
|
// runStarted is when the current running phase began.
|
|
runStarted time.Time
|
|
}
|
|
|
|
func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
|
return &Room{
|
|
Game: game,
|
|
pool: pool,
|
|
ledger: l,
|
|
Fees: fees.DefaultSchedule(),
|
|
state: StateSettled,
|
|
bets: make(map[int64]*Bet),
|
|
subscribers: make(map[chan []byte]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 []byte, func()) {
|
|
ch := make(chan []byte, 4)
|
|
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()
|
|
}
|
|
}
|
|
|
|
// broadcast serialises the snapshot once and hands the same bytes to every
|
|
// subscriber.
|
|
//
|
|
// Letting each connection marshal its own copy costs ~355us per subscriber per
|
|
// frame, which at any real crowd size exceeds the tick interval by orders of
|
|
// magnitude. One marshal per frame turns fan-out into a pointer copy.
|
|
func (r *Room) broadcast() {
|
|
payload, err := json.Marshal(r.Snapshot())
|
|
if err != nil {
|
|
fmt.Printf("room %s: marshalling snapshot: %v\n", r.Game, err)
|
|
return
|
|
}
|
|
r.subMu.Lock()
|
|
defer r.subMu.Unlock()
|
|
for ch := range r.subscribers {
|
|
select {
|
|
case ch <- payload:
|
|
default: // subscriber is behind; drop this frame rather than stall
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
// The multiplier is a pure function of the tick, and the client has
|
|
// the same curve. So the feed does not need to carry it sixty times a
|
|
// second: clients interpolate locally from StartedUnixMilli and the
|
|
// server sends a correcting frame a few times a second.
|
|
//
|
|
// At 60Hz this fan-out was the single largest cost in the system. At
|
|
// BroadcastHz it is a rounding error, and the animation is smoother
|
|
// because it is no longer gated on network jitter.
|
|
if r.tick%(sim.TickHz/BroadcastHz) == 0 {
|
|
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
|
|
r.runStarted = time.Now()
|
|
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 payouts []ledger.Posting
|
|
var feeLines []ledger.Posting
|
|
var housePays, houseKeeps int64
|
|
|
|
for _, b := range bets {
|
|
if b.CashedOutAt == 0 {
|
|
continue // rode it into the crash; the stake already sits with the house
|
|
}
|
|
gross := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
|
|
split := r.Fees.Apply(gross)
|
|
b.PayoutMsat = split.NetMsat
|
|
|
|
if gross > 0 {
|
|
payouts = append(payouts, ledger.Posting{AccountID: b.AccountID, AmountMsat: gross})
|
|
housePays += gross
|
|
}
|
|
if split.HouseMsat() > 0 {
|
|
feeLines = append(feeLines, ledger.Posting{
|
|
AccountID: b.AccountID, AmountMsat: -split.HouseMsat()})
|
|
houseKeeps += split.HouseMsat()
|
|
}
|
|
|
|
if _, err := r.pool.Exec(ctx,
|
|
`UPDATE bets SET payout_msat = $2, rake_msat = $4, rounding_msat = $5,
|
|
settled_at = now()
|
|
WHERE round_id = $1 AND account_id = $3`,
|
|
roundID, split.NetMsat, b.AccountID,
|
|
split.RakeMsat, split.RoundingMsat); err != nil {
|
|
return fmt.Errorf("recording payout: %w", err)
|
|
}
|
|
}
|
|
|
|
rid := roundID
|
|
|
|
// Pay the full winnings first, then take the fee as its own transaction.
|
|
// Netting them into one posting would be arithmetically identical but
|
|
// would hide the deduction: the player would see a smaller win rather
|
|
// than a win and a charge.
|
|
if housePays > 0 {
|
|
payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays})
|
|
if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil {
|
|
return fmt.Errorf("settling round %d: %w", roundID, err)
|
|
}
|
|
}
|
|
if houseKeeps > 0 {
|
|
feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps})
|
|
if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil {
|
|
return fmt.Errorf("collecting fees for 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()
|
|
|
|
// Aggregate over every player, but only serialise the largest few.
|
|
var pot int64
|
|
cashed := 0
|
|
all := make([]*Bet, 0, len(r.bets))
|
|
for _, b := range r.bets {
|
|
pot += b.StakeMsat
|
|
if b.CashedOutAt != 0 {
|
|
cashed++
|
|
}
|
|
all = append(all, b)
|
|
}
|
|
// Partial ordering is enough: the list is a leaderboard, not a ledger.
|
|
sort.Slice(all, func(i, j int) bool { return all[i].StakeMsat > all[j].StakeMsat })
|
|
if len(all) > MaxListedPlayers {
|
|
all = all[:MaxListedPlayers]
|
|
}
|
|
|
|
players := make([]Player, 0, len(all))
|
|
for _, b := range all {
|
|
p := Player{
|
|
Nickname: b.Nickname,
|
|
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,
|
|
PlayerCount: len(r.bets),
|
|
PotMsat: pot,
|
|
CashedOut: cashed,
|
|
NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
|
|
}
|
|
if r.state == StateRunning {
|
|
s.StartedUnixMilli = r.runStarted.UnixMilli()
|
|
}
|
|
// 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
|
|
}
|