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>
530 lines
14 KiB
Go
530 lines
14 KiB
Go
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)
|
|
}
|
|
}
|