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>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
52
migrations/0006_tournaments.sql
Normal file
52
migrations/0006_tournaments.sql
Normal file
@@ -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.
|
||||
12
migrations/0007_entry_seats.sql
Normal file
12
migrations/0007_entry_seats.sql
Normal file
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
440
pkg/tournament/tournament.go
Normal file
440
pkg/tournament/tournament.go
Normal file
@@ -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
|
||||
}
|
||||
529
pkg/tournament/tournament_test.go
Normal file
529
pkg/tournament/tournament_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user