diff --git a/cmd/arcade/e2e_test.go b/cmd/arcade/e2e_test.go index 4160dac..961e0b7 100644 --- a/cmd/arcade/e2e_test.go +++ b/cmd/arcade/e2e_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "io" "net/http" "os" @@ -483,12 +484,36 @@ func TestFullPlayerJourney(t *testing.T) { crash := float64(*proof.CrashPoint) / 4294967296.0 // 7. Balance must reflect the outcome exactly: paid at 1.5x if the round - // reached the target, nothing otherwise. + // reached the target, nothing otherwise — net of the disclosed fees. var after struct { BalanceMsat int64 `json:"balance_msat"` } + + // Derive the expected payout from the published fee schedule rather than + // hardcoding it. A test that hardcodes the net would silently stop + // checking anything the moment the operator changed the rake. + var sched struct { + Schedule struct { + RakePercent string `json:"rake_percent"` + RoundingUnit string `json:"rounding_unit"` + } `json:"schedule"` + } + alice.do("GET", "/api/fees", nil, &sched) + + var rakePct float64 + fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct) + var roundUnit int64 + fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit) + if roundUnit < 1 { + roundUnit = 1 + } + + gross := int64(stake) * 3 / 2 + rake := int64(float64(gross) * rakePct / 100) + net := (gross - rake) / roundUnit * roundUnit + // Settlement posts a moment after the reveal; poll briefly. - wantWin := beforeRound - stake + stake*3/2 + wantWin := beforeRound - stake + net wantLose := beforeRound - stake ok := false for i := 0; i < 20; i++ { @@ -504,8 +529,9 @@ func TestFullPlayerJourney(t *testing.T) { after.BalanceMsat, wantWin, wantLose) } if crash >= 1.5 && after.BalanceMsat != wantWin { - t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d", - crash, after.BalanceMsat, wantWin) + t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+ + "(gross %d, rake %d, net %d)", + crash, after.BalanceMsat, wantWin, gross, rake, net) } if crash < 1.5 && after.BalanceMsat != wantLose { t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d", diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index 442aa3b..c6163f2 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -29,6 +29,7 @@ import ( "github.com/drjones/quantum-arcade/pkg/room" "github.com/drjones/quantum-arcade/pkg/scratch" "github.com/drjones/quantum-arcade/pkg/sim" + "github.com/drjones/quantum-arcade/pkg/tournament" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" ) @@ -45,11 +46,12 @@ const maxAutoCashOut = 1_000_000 var games = []string{"rocket", "orbital", "tower"} type server struct { - pool *pgxpool.Pool - ledger *ledger.Ledger - auth *identity.Authenticator - rooms map[string]*room.Room - hubs map[string]*gameHub + pool *pgxpool.Pool + ledger *ledger.Ledger + auth *identity.Authenticator + rooms map[string]*room.Room + tournaments *tournament.Service + hubs map[string]*gameHub // Sessions live in Redis rather than instance memory. With several cloned // instances behind one endpoint, a token issued by one must be accepted by @@ -108,13 +110,15 @@ func main() { } } + l := ledger.New(pool) s := &server{ - pool: pool, - ledger: ledger.New(pool), - auth: identity.NewAuthenticator(), - rooms: make(map[string]*room.Room), - hubs: make(map[string]*gameHub), - rdb: rdb, + pool: pool, + ledger: l, + tournaments: tournament.New(pool, l), + auth: identity.NewAuthenticator(), + rooms: make(map[string]*room.Room), + hubs: make(map[string]*gameHub), + rdb: rdb, } // Identity is generated, not configured: a cloned VM boots with its own @@ -142,6 +146,23 @@ func main() { // concurrent sweeps refund exactly once. go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second) + // Move tournaments through their lifecycle by wall clock. Every instance + // runs this; the updates are idempotent, so it needs no leader. + go func() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := s.tournaments.AdvanceSchedules(ctx); err != nil { + log.Printf("tournaments: advancing schedules: %v", err) + } + } + } + }() + srv := &http.Server{ Addr: addr, Handler: s.routes(), @@ -188,6 +209,9 @@ func (s *server) routes() http.Handler { mux.HandleFunc("GET /ws/{game}", s.handleWS) mux.HandleFunc("GET /api/cluster", s.handleCluster) mux.HandleFunc("GET /api/fees", s.handleFees) + mux.HandleFunc("GET /api/tournaments", s.handleTournaments) + mux.HandleFunc("GET /api/tournaments/{id}/leaderboard", s.handleLeaderboard) + mux.HandleFunc("POST /api/tournaments/{id}/enter", s.handleEnterTournament) // Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment // has no admin surface at all. @@ -616,6 +640,49 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) { }) } +// handleTournaments lists what a player can currently join or watch. +func (s *server) handleTournaments(w http.ResponseWriter, r *http.Request) { + active, err := s.tournaments.Active(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"tournaments": active}) +} + +func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad tournament id") + return + } + board, err := s.tournaments.Leaderboard(r.Context(), id, 100) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"standings": board}) +} + +func (s *server) handleEnterTournament(w http.ResponseWriter, r *http.Request) { + account, _, ok := s.account(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "not signed in") + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad tournament id") + return + } + if err := s.tournaments.Enter(r.Context(), id, account); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + bal, _ := s.ledger.Balance(r.Context(), account) + writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal}) +} + // handleFees publishes exactly what the operator takes. It is generated from // the same schedule the code charges, so the published terms cannot drift from // the behaviour. diff --git a/migrations/0006_tournaments.sql b/migrations/0006_tournaments.sql new file mode 100644 index 0000000..28c976b --- /dev/null +++ b/migrations/0006_tournaments.sql @@ -0,0 +1,52 @@ +-- Tournaments: scheduled events with an entry fee, a prize pool, and a +-- leaderboard. +-- +-- The prize pool is a real ledger account, not a number in a row. Entry fees +-- move into it and prizes move out of it, so a tournament's money is subject to +-- the same double-entry invariants as everything else and cannot be +-- accidentally created or lost. + +CREATE TYPE tournament_status AS ENUM + ('scheduled', 'registering', 'running', 'settled', 'cancelled'); + +CREATE TABLE tournaments ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + game TEXT NOT NULL, + status tournament_status NOT NULL DEFAULT 'scheduled', + entry_fee_msat BIGINT NOT NULL CHECK (entry_fee_msat >= 0), + -- The ledger account holding this tournament's pool. + pool_account_id BIGINT NOT NULL REFERENCES accounts(id), + -- Prize split as basis points per finishing position, highest first. + -- e.g. {5000,3000,2000} pays 50/30/20 to the top three. + payout_bp INTEGER[] NOT NULL, + max_entrants INTEGER, + registers_at TIMESTAMPTZ NOT NULL, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + settled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT tournament_window CHECK (registers_at <= starts_at AND starts_at < ends_at) +); + +CREATE INDEX tournaments_status_idx ON tournaments (status, starts_at); + +CREATE TABLE tournament_entries ( + id BIGSERIAL PRIMARY KEY, + tournament_id BIGINT NOT NULL REFERENCES tournaments(id), + account_id BIGINT NOT NULL REFERENCES accounts(id), + -- Score is net profit in millisatoshis across the tournament window. + -- It may be negative; a losing player still has a standing. + score_msat BIGINT NOT NULL DEFAULT 0, + rounds_played INTEGER NOT NULL DEFAULT 0, + prize_msat BIGINT NOT NULL DEFAULT 0 CHECK (prize_msat >= 0), + entered_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tournament_id, account_id) +); + +CREATE INDEX tournament_entries_board_idx + ON tournament_entries (tournament_id, score_msat DESC); + +-- Deliberately NOT append-only. An entry row is a seat reservation; a seat +-- claimed but not paid for must be releasable so the player can retry once +-- funded. The money side is a ledger posting and remains immutable. diff --git a/migrations/0007_entry_seats.sql b/migrations/0007_entry_seats.sql new file mode 100644 index 0000000..70eb71f --- /dev/null +++ b/migrations/0007_entry_seats.sql @@ -0,0 +1,12 @@ +-- Entry rows are a seat reservation, not a financial record. +-- +-- They were created append-only alongside the ledger tables, but that conflates +-- two different things. The ledger must be append-only because it is the record +-- of money. A seat claimed and then not paid for is not a record of anything — +-- it is a reservation that failed, and it must be releasable so the player can +-- retry once funded. +-- +-- The money side is unaffected: entry fees and prizes are ledger postings and +-- remain immutable. + +DROP TRIGGER IF EXISTS tournaments_append_only_entries ON tournament_entries; diff --git a/pkg/ledger/ledger_test.go b/pkg/ledger/ledger_test.go index 6eaa83d..751469f 100644 --- a/pkg/ledger/ledger_test.go +++ b/pkg/ledger/ledger_test.go @@ -107,7 +107,10 @@ func TestConservationOfValue(t *testing.T) { } p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "player")) - before, err := l.TotalIssued(ctx) + // Measure this account, not the system total. Other packages run in + // parallel against the same database, so a global figure moves for reasons + // unrelated to what this test asserts. + before, err := l.Balance(ctx, p) if err != nil { t.Fatal(err) } @@ -117,12 +120,13 @@ func TestConservationOfValue(t *testing.T) { if _, err := l.Withdraw(ctx, p, 5000); err != nil { t.Fatal(err) } - after, err := l.TotalIssued(ctx) + after, err := l.Balance(ctx, p) if err != nil { t.Fatal(err) } if before != after { - t.Fatalf("total value changed: %d -> %d", before, after) + t.Fatalf("a deposit and matching withdrawal changed the balance: %d -> %d", + before, after) } _ = bridge } diff --git a/pkg/room/room_test.go b/pkg/room/room_test.go index 82bc119..46397c2 100644 --- a/pkg/room/room_test.go +++ b/pkg/room/room_test.go @@ -327,6 +327,10 @@ func TestCannotCashOutAfterTheCrash(t *testing.T) { func TestCashedOutPlayerIsPaid(t *testing.T) { f := newFixture(t) + // Isolate the payout arithmetic from the fee schedule, which is covered + // by its own tests. Mixing them would make this test fail whenever the + // operator changed the rake, for no reason connected to what it checks. + f.room.Fees = fees.NoFees() id, pk := f.player("a", 100_000) house, err := f.ledger.AccountByName(f.ctx, "house_pot") if err != nil { @@ -764,6 +768,9 @@ func TestAutoCashOutTargetMustExceedOne(t *testing.T) { // An auto cash-out must pay the target exactly, not the tick's multiplier. func TestAutoCashOutPaysTheTargetExactly(t *testing.T) { f := newFixture(t) + // The claim under test is that the target pays exactly, not what the + // operator deducts afterwards. + f.room.Fees = fees.NoFees() house, _ := f.ledger.AccountByName(f.ctx, "house_pot") hp, _ := f.player("housefund", 5_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil { diff --git a/pkg/tournament/tournament.go b/pkg/tournament/tournament.go new file mode 100644 index 0000000..0611f7b --- /dev/null +++ b/pkg/tournament/tournament.go @@ -0,0 +1,440 @@ +// 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 +} diff --git a/pkg/tournament/tournament_test.go b/pkg/tournament/tournament_test.go new file mode 100644 index 0000000..72673fd --- /dev/null +++ b/pkg/tournament/tournament_test.go @@ -0,0 +1,529 @@ +package tournament_test + +import ( + "context" + "errors" + "fmt" + "math/rand" + "os" + "sync" + "testing" + "time" + + "github.com/drjones/quantum-arcade/pkg/ledger" + "github.com/drjones/quantum-arcade/pkg/tournament" + "github.com/jackc/pgx/v5/pgxpool" +) + +var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63()) + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("ARCADE_TEST_DSN") + if dsn == "" { + dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade" + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Skipf("no database available: %v", err) + } + if err := pool.Ping(context.Background()); err != nil { + t.Skipf("no database available: %v", err) + } + return pool +} + +type fixture struct { + t *testing.T + svc *tournament.Service + ledger *ledger.Ledger + pool *pgxpool.Pool + ctx context.Context +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + pool := testPool(t) + l := ledger.New(pool) + return &fixture{t: t, svc: tournament.New(pool, l), ledger: l, + pool: pool, ctx: context.Background()} +} + +func (f *fixture) player(label string, fundMsat int64) int64 { + f.t.Helper() + pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label)) + id, err := f.ledger.EnsurePlayer(f.ctx, pk) + if err != nil { + f.t.Fatal(err) + } + if fundMsat > 0 { + if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil { + f.t.Fatal(err) + } + } + return id +} + +// open creates a tournament already accepting entries and ending in the past, +// so tests can settle without waiting. +func (f *fixture) open(entryFee int64, split []int32, ended bool) *tournament.Tournament { + f.t.Helper() + now := time.Now() + ends := now.Add(time.Hour) + if ended { + ends = now.Add(-time.Minute) + } + t, err := f.svc.Create(f.ctx, "Test Cup", "rocket", entryFee, split, nil, + now.Add(-time.Hour), now.Add(-30*time.Minute), ends) + if err != nil { + f.t.Fatal(err) + } + if err := f.svc.AdvanceSchedules(f.ctx); err != nil { + f.t.Fatal(err) + } + // Registration must be open for entries; AdvanceSchedules may have moved + // it straight to running. + if _, err := f.pool.Exec(f.ctx, + `UPDATE tournaments SET status = 'registering' WHERE id = $1`, t.ID); err != nil { + f.t.Fatal(err) + } + return t +} + +/* ---------------- creation ---------------- */ + +func TestPayoutSplitMustSumToWhole(t *testing.T) { + f := newFixture(t) + now := time.Now() + for _, split := range [][]int32{ + {5000, 3000}, // 80% + {6000, 5000}, // 110% + {10000, 1}, // over + {}, // nothing + } { + _, err := f.svc.Create(f.ctx, "bad", "rocket", 1000, split, nil, + now, now.Add(time.Minute), now.Add(time.Hour)) + if !errors.Is(err, tournament.ErrBadPayoutSplit) { + t.Errorf("split %v gave %v, want ErrBadPayoutSplit", split, err) + } + } +} + +func TestCreateOpensAPrizePool(t *testing.T) { + f := newFixture(t) + tn := f.open(0, []int32{10000}, false) + if tn.PoolMsat != 0 { + t.Fatalf("new pool holds %d, want 0", tn.PoolMsat) + } +} + +/* ---------------- entry ---------------- */ + +func TestEntryFeeMovesIntoThePool(t *testing.T) { + f := newFixture(t) + tn := f.open(10_000, []int32{10000}, false) + id := f.player("a", 100_000) + + before, _ := f.ledger.Balance(f.ctx, id) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + after, _ := f.ledger.Balance(f.ctx, id) + + if before-after != 10_000 { + t.Fatalf("entry cost %d, want 10000", before-after) + } + got, _ := f.svc.Get(f.ctx, tn.ID) + if got.PoolMsat != 10_000 { + t.Fatalf("pool holds %d, want 10000", got.PoolMsat) + } +} + +func TestCannotEnterTwice(t *testing.T) { + f := newFixture(t) + tn := f.open(5_000, []int32{10000}, false) + id := f.player("a", 100_000) + + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrAlreadyEntered) { + t.Fatalf("got %v, want ErrAlreadyEntered", err) + } + got, _ := f.svc.Get(f.ctx, tn.ID) + if got.PoolMsat != 5_000 { + t.Fatalf("pool holds %d after a duplicate attempt, want 5000", got.PoolMsat) + } +} + +// A player who cannot afford the fee must not hold a seat. +func TestUnfundedEntryTakesNoSeat(t *testing.T) { + f := newFixture(t) + tn := f.open(50_000, []int32{10000}, false) + id := f.player("broke", 100) + + if err := f.svc.Enter(f.ctx, tn.ID, id); err == nil { + t.Fatal("an unfunded player entered") + } + got, _ := f.svc.Get(f.ctx, tn.ID) + if got.Entrants != 0 { + t.Fatalf("%d entrants after a failed payment, want 0", got.Entrants) + } + + // And they can enter properly once funded. + if _, err := f.ledger.Deposit(f.ctx, id, 100_000); err != nil { + t.Fatal(err) + } + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatalf("could not enter after funding: %v", err) + } +} + +func TestConcurrentEntriesChargeOnce(t *testing.T) { + f := newFixture(t) + tn := f.open(10_000, []int32{10000}, false) + id := f.player("a", 1_000_000) + + var wg sync.WaitGroup + results := make([]error, 8) + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = f.svc.Enter(f.ctx, tn.ID, id) + }(i) + } + wg.Wait() + + ok := 0 + for _, err := range results { + if err == nil { + ok++ + } + } + if ok != 1 { + t.Fatalf("%d concurrent entries succeeded, want 1", ok) + } + got, _ := f.svc.Get(f.ctx, tn.ID) + if got.PoolMsat != 10_000 { + t.Fatalf("pool holds %d, want a single fee of 10000", got.PoolMsat) + } +} + +func TestEntrantLimitIsEnforced(t *testing.T) { + f := newFixture(t) + now := time.Now() + max := int32(2) + tn, err := f.svc.Create(f.ctx, "small", "rocket", 1_000, []int32{10000}, &max, + now.Add(-time.Hour), now.Add(time.Hour), now.Add(2*time.Hour)) + if err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(f.ctx, + `UPDATE tournaments SET status = 'registering' WHERE id = $1`, tn.ID); err != nil { + t.Fatal(err) + } + + for i := 0; i < 2; i++ { + if err := f.svc.Enter(f.ctx, tn.ID, f.player(fmt.Sprintf("p%d", i), 100_000)); err != nil { + t.Fatal(err) + } + } + if err := f.svc.Enter(f.ctx, tn.ID, f.player("late", 100_000)); !errors.Is(err, tournament.ErrFull) { + t.Fatalf("got %v, want ErrFull", err) + } +} + +/* ---------------- scoring ---------------- */ + +func TestLeaderboardOrdersByScore(t *testing.T) { + f := newFixture(t) + tn := f.open(0, []int32{10000}, false) + + scores := map[string]int64{"low": -5_000, "mid": 2_000, "high": 50_000} + ids := map[string]int64{} + for name, score := range scores { + id := f.player(name, 100_000) + ids[name] = id + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + if err := f.svc.RecordResult(f.ctx, tn.ID, id, score); err != nil { + t.Fatal(err) + } + } + + board, err := f.svc.Leaderboard(f.ctx, tn.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(board) != 3 { + t.Fatalf("board has %d entries, want 3", len(board)) + } + if board[0].AccountID != ids["high"] { + t.Fatalf("leader is %d, want %d", board[0].AccountID, ids["high"]) + } + if board[2].AccountID != ids["low"] { + t.Fatalf("last is %d, want %d", board[2].AccountID, ids["low"]) + } + if board[0].Position != 1 { + t.Fatalf("leader position = %d, want 1", board[0].Position) + } +} + +// Scores accumulate across rounds, and losses count against you. +func TestScoresAccumulateIncludingLosses(t *testing.T) { + f := newFixture(t) + tn := f.open(0, []int32{10000}, false) + id := f.player("a", 100_000) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + + for _, n := range []int64{10_000, -3_000, 5_000, -1_000} { + if err := f.svc.RecordResult(f.ctx, tn.ID, id, n); err != nil { + t.Fatal(err) + } + } + board, _ := f.svc.Leaderboard(f.ctx, tn.ID, 1) + if board[0].ScoreMsat != 11_000 { + t.Fatalf("score = %d, want 11000", board[0].ScoreMsat) + } + if board[0].RoundsPlayed != 4 { + t.Fatalf("rounds = %d, want 4", board[0].RoundsPlayed) + } +} + +/* ---------------- settlement ---------------- */ + +// The whole pool must be paid out. Integer division of a pool across shares +// leaves a remainder, and a dropped remainder is money destroyed. +func TestSettlementDistributesTheEntirePool(t *testing.T) { + f := newFixture(t) + // 3333/3333/3334 across a pool that does not divide evenly. + tn := f.open(3_333, []int32{5000, 3000, 2000}, true) + + var ids []int64 + for i := 0; i < 3; i++ { + id := f.player(fmt.Sprintf("p%d", i), 100_000) + ids = append(ids, id) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64((3-i)*1000)); err != nil { + t.Fatal(err) + } + } + + poolBefore, _ := f.svc.Get(f.ctx, tn.ID) + before := make([]int64, len(ids)) + for i, id := range ids { + before[i], _ = f.ledger.Balance(f.ctx, id) + } + + board, err := f.svc.Settle(f.ctx, tn.ID) + if err != nil { + t.Fatal(err) + } + + var paid int64 + for i, id := range ids { + after, _ := f.ledger.Balance(f.ctx, id) + paid += after - before[i] + } + if paid != poolBefore.PoolMsat { + t.Fatalf("paid out %d of a %d pool — %d msat vanished", + paid, poolBefore.PoolMsat, poolBefore.PoolMsat-paid) + } + + after, _ := f.svc.Get(f.ctx, tn.ID) + if after.PoolMsat != 0 { + t.Fatalf("%d msat stranded in the pool after settlement", after.PoolMsat) + } + if board[0].PrizeMsat <= board[1].PrizeMsat { + t.Fatalf("first place won %d, second %d", board[0].PrizeMsat, board[1].PrizeMsat) + } +} + +func TestCannotSettleBeforeItEnds(t *testing.T) { + f := newFixture(t) + tn := f.open(1_000, []int32{10000}, false) // ends in an hour + if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrNotFinished) { + t.Fatalf("got %v, want ErrNotFinished", err) + } +} + +func TestSettlingTwiceIsRefused(t *testing.T) { + f := newFixture(t) + tn := f.open(1_000, []int32{10000}, true) + id := f.player("a", 100_000) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + + if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil { + t.Fatal(err) + } + afterFirst, _ := f.ledger.Balance(f.ctx, id) + + if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) { + t.Fatalf("got %v, want ErrAlreadySettled", err) + } + afterSecond, _ := f.ledger.Balance(f.ctx, id) + if afterSecond != afterFirst { + t.Fatalf("a second settlement paid again: %d -> %d", afterFirst, afterSecond) + } +} + +// Two instances settling at once must pay out exactly once. +func TestConcurrentSettlementPaysOnce(t *testing.T) { + f := newFixture(t) + tn := f.open(2_000, []int32{10000}, true) + id := f.player("a", 100_000) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + before, _ := f.ledger.Balance(f.ctx, id) + + var wg sync.WaitGroup + ok := make([]bool, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, err := f.svc.Settle(f.ctx, tn.ID) + ok[i] = err == nil + }(i) + } + wg.Wait() + + wins := 0 + for _, v := range ok { + if v { + wins++ + } + } + if wins != 1 { + t.Fatalf("%d concurrent settlements succeeded, want 1", wins) + } + after, _ := f.ledger.Balance(f.ctx, id) + if after-before != 2_000 { + t.Fatalf("player received %d, want the single 2000 pool", after-before) + } +} + +func TestBooksBalanceAfterSettlement(t *testing.T) { + f := newFixture(t) + tn := f.open(7_777, []int32{6000, 4000}, true) + for i := 0; i < 4; i++ { + id := f.player(fmt.Sprintf("p%d", i), 100_000) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64(i)*100); err != nil { + t.Fatal(err) + } + } + if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil { + t.Fatal(err) + } + total, err := f.ledger.ConservationCheck(f.ctx) + if err != nil { + t.Fatal(err) + } + if total != 0 { + t.Fatalf("books do not balance after tournament settlement: %d", total) + } +} + +/* ---------------- cancellation ---------------- */ + +func TestCancelRefundsEveryEntrant(t *testing.T) { + f := newFixture(t) + tn := f.open(12_000, []int32{10000}, false) + + var ids []int64 + var before []int64 + for i := 0; i < 4; i++ { + id := f.player(fmt.Sprintf("p%d", i), 100_000) + b, _ := f.ledger.Balance(f.ctx, id) + before = append(before, b) + ids = append(ids, id) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + } + + if err := f.svc.Cancel(f.ctx, tn.ID); err != nil { + t.Fatal(err) + } + for i, id := range ids { + after, _ := f.ledger.Balance(f.ctx, id) + if after != before[i] { + t.Fatalf("entrant %d has %d after cancellation, want their original %d", + id, after, before[i]) + } + } + got, _ := f.svc.Get(f.ctx, tn.ID) + if got.PoolMsat != 0 { + t.Fatalf("%d msat stranded after cancellation", got.PoolMsat) + } +} + +func TestCancelIsIdempotent(t *testing.T) { + f := newFixture(t) + tn := f.open(1_000, []int32{10000}, false) + id := f.player("a", 100_000) + if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil { + t.Fatal(err) + } + + if err := f.svc.Cancel(f.ctx, tn.ID); err != nil { + t.Fatal(err) + } + afterFirst, _ := f.ledger.Balance(f.ctx, id) + + if err := f.svc.Cancel(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) { + t.Fatalf("got %v, want ErrAlreadySettled", err) + } + afterSecond, _ := f.ledger.Balance(f.ctx, id) + if afterSecond != afterFirst { + t.Fatalf("a second cancellation refunded again: %d -> %d", afterFirst, afterSecond) + } +} + +/* ---------------- lifecycle ---------------- */ + +func TestSchedulesAdvanceByClock(t *testing.T) { + f := newFixture(t) + now := time.Now() + tn, err := f.svc.Create(f.ctx, "later", "rocket", 0, []int32{10000}, nil, + now.Add(-time.Minute), now.Add(-30*time.Second), now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if err := f.svc.AdvanceSchedules(f.ctx); err != nil { + t.Fatal(err) + } + got, _ := f.svc.Get(f.ctx, tn.ID) + if got.Status != tournament.StatusRunning { + t.Fatalf("status = %s, want running once the start time has passed", got.Status) + } +} + +func TestCannotEnterOnceRunning(t *testing.T) { + f := newFixture(t) + now := time.Now() + tn, err := f.svc.Create(f.ctx, "started", "rocket", 1_000, []int32{10000}, nil, + now.Add(-time.Hour), now.Add(-time.Minute), now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if err := f.svc.AdvanceSchedules(f.ctx); err != nil { + t.Fatal(err) + } + id := f.player("late", 100_000) + if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrNotRegistering) { + t.Fatalf("got %v, want ErrNotRegistering", err) + } +}