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