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:
drjones
2026-08-05 23:54:18 +00:00
parent ca39e8bad9
commit 8af6fd585e
8 changed files with 1155 additions and 18 deletions

View 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.

View 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;