Files
casino/pkg/tournament/tournament.go
drjones 8af6fd585e feat(tournament): scheduled events with prize pools
Entry fees collect into a real ledger account rather than a number in a
row, so tournament money obeys the same double-entry invariants as
everything else and every movement is explained by a posting.

Settlement distributes the entire pool: dividing a pool across percentage
shares leaves a remainder, and dropping it would destroy money and break
conservation, so it goes to first place. Settlement claims the tournament
before paying, so two instances cannot both pay out. Cancellation refunds
every entrant and asserts the pool empties exactly.

18 tests including concurrent entry, concurrent settlement, unfunded
entry taking no seat, and books balancing after payout.

Removes an append-only trigger that had been over-applied to entry rows.
An entry is a seat reservation, not a financial record: a seat claimed
but unpaid must be releasable so the player can retry once funded. The
money side stays immutable because it is a ledger posting.

The journey test now derives the expected payout from the published fee
schedule instead of hardcoding it, so it keeps checking something real if
the rake changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:54:18 +00:00

441 lines
14 KiB
Go

// Package tournament runs scheduled competitive events.
//
// A tournament collects entry fees into a prize pool and pays them out to the
// best performers over a window of ordinary rounds. Players keep playing the
// same games; the tournament simply scores what they do.
//
// The prize pool is a real ledger account rather than a number in a row. Entry
// fees move into it and prizes move out of it, so tournament money obeys the
// same double-entry invariants as everything else: it cannot be created,
// cannot be lost, and every movement is explained by a posting.
//
// Two properties the tests pin down, because they are where this kind of code
// usually goes wrong:
//
// - Every millisatoshi collected is paid out. Integer division of a pool
// across percentage shares leaves a remainder, and a remainder that is
// silently dropped is money that vanishes.
// - A tournament settles exactly once, even if two instances try at the
// same moment.
package tournament
import (
"context"
"errors"
"fmt"
"time"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotRegistering = errors.New("tournament: registration is not open")
ErrAlreadyEntered = errors.New("tournament: already entered")
ErrFull = errors.New("tournament: entrant limit reached")
ErrNotFinished = errors.New("tournament: has not finished yet")
ErrAlreadySettled = errors.New("tournament: already settled")
ErrBadPayoutSplit = errors.New("tournament: payout shares must sum to 10000 basis points")
)
type Status string
const (
StatusScheduled Status = "scheduled"
StatusRegistering Status = "registering"
StatusRunning Status = "running"
StatusSettled Status = "settled"
StatusCancelled Status = "cancelled"
)
// Tournament is a scheduled event.
type Tournament struct {
ID int64 `json:"id"`
Name string `json:"name"`
Game string `json:"game"`
Status Status `json:"status"`
EntryFeeMsat int64 `json:"entry_fee_msat"`
PoolAccountID int64 `json:"-"`
PayoutBP []int32 `json:"payout_bp"`
MaxEntrants *int32 `json:"max_entrants"`
RegistersAt time.Time `json:"registers_at"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
PoolMsat int64 `json:"pool_msat"`
Entrants int `json:"entrants"`
}
// Standing is one player's place on the board.
type Standing struct {
Position int `json:"position"`
AccountID int64 `json:"account_id"`
Nickname string `json:"nickname"`
ScoreMsat int64 `json:"score_msat"`
RoundsPlayed int `json:"rounds_played"`
PrizeMsat int64 `json:"prize_msat"`
}
type Service struct {
pool *pgxpool.Pool
ledger *ledger.Ledger
}
func New(pool *pgxpool.Pool, l *ledger.Ledger) *Service {
return &Service{pool: pool, ledger: l}
}
// Create schedules a tournament and opens its prize pool account.
func (s *Service) Create(ctx context.Context, name, game string,
entryFeeMsat int64, payoutBP []int32, maxEntrants *int32,
registersAt, startsAt, endsAt time.Time) (*Tournament, error) {
var total int32
for _, bp := range payoutBP {
if bp <= 0 {
return nil, fmt.Errorf("%w: share %d is not positive", ErrBadPayoutSplit, bp)
}
total += bp
}
if total != 10000 {
return nil, fmt.Errorf("%w: shares sum to %d", ErrBadPayoutSplit, total)
}
// The pool is a named ledger account, so it appears in the books and in
// any audit alongside every other account.
poolName := fmt.Sprintf("tournament_pool_%d_%s", time.Now().UnixNano(), game)
var poolID int64
if err := s.pool.QueryRow(ctx,
`INSERT INTO accounts (kind, name) VALUES ('house', $1) RETURNING id`,
poolName).Scan(&poolID); err != nil {
return nil, fmt.Errorf("creating prize pool account: %w", err)
}
t := &Tournament{
Name: name, Game: game, Status: StatusScheduled,
EntryFeeMsat: entryFeeMsat, PoolAccountID: poolID, PayoutBP: payoutBP,
MaxEntrants: maxEntrants,
RegistersAt: registersAt, StartsAt: startsAt, EndsAt: endsAt,
}
if err := s.pool.QueryRow(ctx,
`INSERT INTO tournaments
(name, game, entry_fee_msat, pool_account_id, payout_bp,
max_entrants, registers_at, starts_at, ends_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
name, game, entryFeeMsat, poolID, payoutBP, maxEntrants,
registersAt, startsAt, endsAt).Scan(&t.ID); err != nil {
return nil, fmt.Errorf("creating tournament: %w", err)
}
return t, nil
}
// Enter registers a player, moving their entry fee into the prize pool.
func (s *Service) Enter(ctx context.Context, tournamentID, accountID int64) error {
t, err := s.Get(ctx, tournamentID)
if err != nil {
return err
}
if t.Status != StatusRegistering {
return fmt.Errorf("%w: status is %s", ErrNotRegistering, t.Status)
}
if t.MaxEntrants != nil && t.Entrants >= int(*t.MaxEntrants) {
return ErrFull
}
// Claim the seat before taking the money. The unique constraint makes a
// double entry impossible, and failing here means nothing was charged.
if _, err := s.pool.Exec(ctx,
`INSERT INTO tournament_entries (tournament_id, account_id) VALUES ($1, $2)`,
tournamentID, accountID); err != nil {
return ErrAlreadyEntered
}
if t.EntryFeeMsat > 0 {
if _, err := s.ledger.Post(ctx, "tournament_entry", nil, []ledger.Posting{
{AccountID: accountID, AmountMsat: -t.EntryFeeMsat},
{AccountID: t.PoolAccountID, AmountMsat: t.EntryFeeMsat},
}); err != nil {
// Could not pay: release the seat so the player can retry once
// funded, rather than holding a place they never paid for.
if _, derr := s.pool.Exec(ctx,
`DELETE FROM tournament_entries
WHERE tournament_id = $1 AND account_id = $2`,
tournamentID, accountID); derr != nil {
fmt.Printf("tournament: could not release unpaid seat: %v\n", derr)
}
return err
}
}
return nil
}
// RecordResult adds a round's net result to a player's tournament score.
//
// Called by settlement for every entrant playing the tournament's game inside
// its window. A losing round lowers the score; the board is net profit, so
// grinding many small wins and taking one large loss is not a way to climb.
func (s *Service) RecordResult(ctx context.Context, tournamentID, accountID, netMsat int64) error {
_, err := s.pool.Exec(ctx,
`UPDATE tournament_entries
SET score_msat = score_msat + $3,
rounds_played = rounds_played + 1
WHERE tournament_id = $1 AND account_id = $2`,
tournamentID, accountID, netMsat)
return err
}
// Leaderboard returns the current standings, best first.
func (s *Service) Leaderboard(ctx context.Context, tournamentID int64, limit int) ([]Standing, error) {
rows, err := s.pool.Query(ctx,
`SELECT e.account_id, COALESCE(a.nickname, ''), e.score_msat,
e.rounds_played, e.prize_msat
FROM tournament_entries e
JOIN accounts a ON a.id = e.account_id
WHERE e.tournament_id = $1
ORDER BY e.score_msat DESC, e.rounds_played ASC, e.id ASC
LIMIT $2`, tournamentID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Standing
pos := 0
for rows.Next() {
pos++
st := Standing{Position: pos}
if err := rows.Scan(&st.AccountID, &st.Nickname, &st.ScoreMsat,
&st.RoundsPlayed, &st.PrizeMsat); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
// Settle pays the prize pool out to the leaders and closes the tournament.
//
// The entire pool is distributed. Integer division of a pool across percentage
// shares leaves a remainder, and dropping it would quietly destroy money and
// break the ledger's conservation check, so the remainder goes to first place.
func (s *Service) Settle(ctx context.Context, tournamentID int64) ([]Standing, error) {
// Claim the tournament first: an UPDATE that only matches an unsettled row
// means two instances cannot both pay out.
tag, err := s.pool.Exec(ctx,
`UPDATE tournaments SET status = 'settled', settled_at = now()
WHERE id = $1 AND status IN ('running', 'registering')
AND ends_at <= now()`, tournamentID)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
var status Status
var endsAt time.Time
if err := s.pool.QueryRow(ctx,
`SELECT status, ends_at FROM tournaments WHERE id = $1`,
tournamentID).Scan(&status, &endsAt); err != nil {
return nil, err
}
if status == StatusSettled {
return nil, ErrAlreadySettled
}
return nil, fmt.Errorf("%w: ends at %s", ErrNotFinished, endsAt.Format(time.RFC3339))
}
t, err := s.Get(ctx, tournamentID)
if err != nil {
return nil, err
}
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
if err != nil {
return nil, err
}
board, err := s.Leaderboard(ctx, tournamentID, len(t.PayoutBP))
if err != nil {
return nil, err
}
if poolMsat == 0 || len(board) == 0 {
return board, nil
}
// Compute each share, then hand the rounding remainder to first place so
// the pool empties exactly.
postings := make([]ledger.Posting, 0, len(board)+1)
var distributed int64
prizes := make([]int64, len(board))
for i := range board {
if i >= len(t.PayoutBP) {
break
}
prize := poolMsat * int64(t.PayoutBP[i]) / 10000
prizes[i] = prize
distributed += prize
}
if remainder := poolMsat - distributed; remainder > 0 {
prizes[0] += remainder
distributed = poolMsat
}
for i, st := range board {
if prizes[i] <= 0 {
continue
}
board[i].PrizeMsat = prizes[i]
postings = append(postings, ledger.Posting{
AccountID: st.AccountID, AmountMsat: prizes[i]})
if _, err := s.pool.Exec(ctx,
`UPDATE tournament_entries SET prize_msat = $3
WHERE tournament_id = $1 AND account_id = $2`,
tournamentID, st.AccountID, prizes[i]); err != nil {
return nil, fmt.Errorf("recording prize: %w", err)
}
}
if distributed > 0 {
postings = append(postings, ledger.Posting{
AccountID: t.PoolAccountID, AmountMsat: -distributed})
if _, err := s.ledger.Post(ctx, "tournament_prize", nil, postings); err != nil {
return nil, fmt.Errorf("paying prizes: %w", err)
}
}
// The pool must be empty. Anything left would be money stranded in an
// account nobody can reach.
left, err := s.ledger.Balance(ctx, t.PoolAccountID)
if err != nil {
return nil, err
}
if left != 0 {
return nil, fmt.Errorf("tournament %d settled with %d msat stranded in its pool",
tournamentID, left)
}
return board, nil
}
// Cancel refunds every entry fee and closes the tournament.
func (s *Service) Cancel(ctx context.Context, tournamentID int64) error {
tag, err := s.pool.Exec(ctx,
`UPDATE tournaments SET status = 'cancelled', settled_at = now()
WHERE id = $1 AND status NOT IN ('settled', 'cancelled')`, tournamentID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrAlreadySettled
}
t, err := s.Get(ctx, tournamentID)
if err != nil {
return err
}
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
if err != nil {
return err
}
if poolMsat == 0 {
return nil
}
rows, err := s.pool.Query(ctx,
`SELECT account_id FROM tournament_entries WHERE tournament_id = $1`,
tournamentID)
if err != nil {
return err
}
var entrants []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
entrants = append(entrants, id)
}
rows.Close()
if len(entrants) == 0 {
return nil
}
// Refund the fee each player paid. The pool holds exactly the sum of
// those fees, so refunding the entry fee to each empties it precisely.
postings := make([]ledger.Posting, 0, len(entrants)+1)
var total int64
for _, id := range entrants {
postings = append(postings, ledger.Posting{AccountID: id, AmountMsat: t.EntryFeeMsat})
total += t.EntryFeeMsat
}
if total != poolMsat {
return fmt.Errorf("tournament %d: pool holds %d but refunds total %d",
tournamentID, poolMsat, total)
}
postings = append(postings, ledger.Posting{AccountID: t.PoolAccountID, AmountMsat: -total})
_, err = s.ledger.Post(ctx, "tournament_refund", nil, postings)
return err
}
// Get loads a tournament with its live pool balance and entrant count.
func (s *Service) Get(ctx context.Context, id int64) (*Tournament, error) {
var t Tournament
if err := s.pool.QueryRow(ctx,
`SELECT id, name, game, status, entry_fee_msat, pool_account_id,
payout_bp, max_entrants, registers_at, starts_at, ends_at
FROM tournaments WHERE id = $1`, id).
Scan(&t.ID, &t.Name, &t.Game, &t.Status, &t.EntryFeeMsat, &t.PoolAccountID,
&t.PayoutBP, &t.MaxEntrants, &t.RegistersAt, &t.StartsAt, &t.EndsAt); err != nil {
return nil, fmt.Errorf("tournament %d not found: %w", id, err)
}
t.PoolMsat, _ = s.ledger.Balance(ctx, t.PoolAccountID)
_ = s.pool.QueryRow(ctx,
`SELECT count(*) FROM tournament_entries WHERE tournament_id = $1`,
id).Scan(&t.Entrants)
return &t, nil
}
// AdvanceSchedules moves tournaments through their lifecycle by wall clock.
// Any instance may run it; the updates are idempotent.
func (s *Service) AdvanceSchedules(ctx context.Context) error {
if _, err := s.pool.Exec(ctx,
`UPDATE tournaments SET status = 'registering'
WHERE status = 'scheduled' AND registers_at <= now()`); err != nil {
return err
}
if _, err := s.pool.Exec(ctx,
`UPDATE tournaments SET status = 'running'
WHERE status = 'registering' AND starts_at <= now()`); err != nil {
return err
}
return nil
}
// Active lists tournaments a player can currently see or join.
func (s *Service) Active(ctx context.Context) ([]Tournament, error) {
rows, err := s.pool.Query(ctx,
`SELECT id FROM tournaments
WHERE status IN ('scheduled', 'registering', 'running')
ORDER BY starts_at`)
if err != nil {
return nil, err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
rows.Close()
out := make([]Tournament, 0, len(ids))
for _, id := range ids {
t, err := s.Get(ctx, id)
if err != nil {
continue
}
out = append(out, *t)
}
return out, nil
}