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