felt/.planning/phases/01-tournament-engine/01-PLAN-I.md
Mikkel Georgsen 21ff95068e docs(01): create Phase 1 plans (A-N) with research and feedback
14 plans in 6 waves covering all 68 requirements for the Tournament
Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5
runes, ICM complexity), plan verification (2 iterations), and user
feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics,
integration test, DKK defaults, JWT 7-day expiry, clock tap safety).

Wave structure:
  1: A (scaffold), B (schema)
  2: C (auth/audit), D (clock), E (templates), J (frontend scaffold)
  3: F (financial), H (seating), M (layout shell)
  4: G (player management)
  5: I (tournament lifecycle)
  6: K (overview/financials), L (players), N (tables/more)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:58:22 +01:00

334 lines
16 KiB
Markdown

# Plan I: Tournament Lifecycle + Multi-Tournament + Chop/Deal
---
wave: 5
depends_on: [01-PLAN-D, 01-PLAN-F, 01-PLAN-G, 01-PLAN-H]
files_modified:
- internal/tournament/tournament.go
- internal/tournament/state.go
- internal/tournament/multi.go
- internal/financial/icm.go
- internal/financial/chop.go
- internal/server/routes/tournaments.go
- internal/financial/icm_test.go
- internal/financial/chop_test.go
- internal/tournament/tournament_test.go
- internal/tournament/integration_test.go
autonomous: true
requirements: [MULTI-01, MULTI-02, FIN-11, SEAT-06]
---
## Goal
The tournament lifecycle — create from template, configure, start, run, and close — works end-to-end. Multiple simultaneous tournaments run with fully independent state (clocks, financials, players, tables). A tournament lobby shows all active tournaments. Chop/deal support (ICM, chip-chop, even-chop, custom) handles end-game scenarios. The tap-tap seat move flow from the seating engine is wired into the full tournament context.
## Context
- **Clock engine** — Plan D (running, independent per tournament)
- **Financial engine** — Plan F (transactions, prize pool, payouts)
- **Player management** — Plan G (buy-in, bust-out, rankings)
- **Seating engine** — Plan H (tables, balancing, break table)
- **Template-first creation** — CONTEXT.md: TD picks template, everything pre-fills
- **Tournament auto-closes** when one player remains (CONTEXT.md)
- **ICM calculator:** Malmuth-Harville exact for <=10 players, Monte Carlo for 11+ (01-RESEARCH.md Pitfall 3)
## User Decisions (from CONTEXT.md)
- **Template-first creation** — pick template → pre-fill → tweak → Start
- **Local changes by default** — tournament gets a copy of building blocks
- **Tournament auto-closes** when one player remains — no manual "end tournament" button
- **Multi-tournament switching** — tabs at top (phone) or split view (tablet landscape)
- **Flexible chop/deal support** — ICM, custom split, partial chop, any number of players
- **Prize money and league positions are independent** — money can be chopped but positions determined by play
- **Minimum player threshold** — Start button unavailable until met
## Tasks
<task id="I1" title="Implement tournament lifecycle and multi-tournament management">
**1. Tournament Service** (`internal/tournament/tournament.go`):
- `TournamentService` struct with db, clock registry, financial engine, player service, seating service, audit trail, hub
- `CreateFromTemplate(ctx, templateID string, overrides TournamentOverrides) (*Tournament, error)`:
- Load expanded template (all building blocks)
- Create tournament record with status "created"
- Copy all building block references (chip_set_id, blind_structure_id, etc.) — local copy semantics
- Apply overrides (name, min/max players, bonuses, PKO flag, etc.)
- Create tables from blueprint if specified, or empty table set
- Record audit entry
- Broadcast tournament.created event
- Return tournament
- `CreateManual(ctx, config TournamentConfig) (*Tournament, error)`:
- Create without a template — manually specify all config
- Same flow but no template reference
- `GetTournament(ctx, id string) (*TournamentDetail, error)`:
- Return full tournament state:
```go
type TournamentDetail struct {
Tournament Tournament
ClockSnapshot *ClockSnapshot
Tables []TableDetail
Players PlayerSummary // counts: registered, active, busted
PrizePool PrizePoolSummary
Rankings []PlayerRanking
RecentActivity []AuditEntry // last 20 audit entries
BalanceStatus *BalanceStatus
}
```
- `StartTournament(ctx, id string) error`:
- Validate minimum players met
- Validate at least one table exists with seats
- Set status to "registering" → "running"
- Start the clock engine (loads blind structure, starts countdown)
- Set started_at timestamp
- Record audit entry
- Broadcast tournament.started event
- `PauseTournament(ctx, id string) error`:
- Pause the clock
- Set status to "paused"
- Record audit entry, broadcast
- `ResumeTournament(ctx, id string) error`:
- Resume the clock
- Set status to "running"
- Record audit entry, broadcast
- `EndTournament(ctx, id string) error`:
- Called automatically when 1 player remains, or manually for deals
- Stop the clock
- Assign finishing positions to remaining active players
- Calculate and apply final payouts
- Set status to "completed", ended_at timestamp
- Record audit entry
- Broadcast tournament.ended event
- `CancelTournament(ctx, id string) error`:
- Set status to "cancelled"
- Stop clock if running
- Void all pending transactions (mark as cancelled, not deleted)
- Record audit entry, broadcast
- `CheckAutoClose(ctx, id string) error`:
- Called after every bust-out (from player management)
- Count remaining active players
- If 1 remaining: auto-close tournament
- If 0 remaining (edge case): cancel tournament
**2. Tournament State Aggregation** (`internal/tournament/state.go`):
- `GetTournamentState(ctx, id string) (*TournamentState, error)`:
- Aggregates all state for WebSocket snapshot:
- Clock state (from clock engine)
- Player counts (registered, active, busted)
- Table states (all tables with seats)
- Financial summary (prize pool, entries, rebuys)
- Rankings (current)
- Balance status
- This is what new WebSocket connections receive (replaces the stub from Plan A)
- Sent as a single JSON message on connect
- `BuildActivityFeed(ctx, tournamentID string, limit int) ([]ActivityEntry, error)`:
- Convert recent audit entries into human-readable activity items:
```go
type ActivityEntry struct {
Timestamp int64 `json:"timestamp"`
Type string `json:"type"` // "bust", "buyin", "rebuy", "clock", "seat", etc.
Title string `json:"title"` // "John Smith busted by Jane Doe"
Description string `json:"description"` // "Table 1, Seat 4 → 12th place"
Icon string `json:"icon"` // For frontend rendering
}
```
**3. Multi-Tournament Manager** (`internal/tournament/multi.go`) — MULTI-01:
- `MultiManager` struct — manages multiple tournament services (or uses tournament-scoped queries)
- `ListActiveTournaments(ctx) ([]TournamentSummary, error)` — MULTI-02:
- Return all tournaments with status in (registering, running, paused, final_table)
- Summary: ID, name, status, player count, current level, blind, remaining time
- This powers the tournament lobby view
- `GetTournamentSummary(ctx, id string) (*TournamentSummary, error)`:
- Lightweight summary for lobby display
- **Independence guarantee:** Every piece of state (clock, players, tables, financials) is scoped by tournament_id. No global singletons. Multiple tournaments run simultaneously with zero interference.
**4. API Routes** (`internal/server/routes/tournaments.go`):
- `POST /api/v1/tournaments` — create from template: body: `{"template_id": "...", "name": "Friday Night Turbo", "overrides": {...}}`
- `POST /api/v1/tournaments/manual` — create without template
- `GET /api/v1/tournaments` — list all (active and recent)
- `GET /api/v1/tournaments/active` — active only (lobby, MULTI-02)
- `GET /api/v1/tournaments/{id}` — full tournament state
- `GET /api/v1/tournaments/{id}/state` — WebSocket-compatible full state snapshot
- `POST /api/v1/tournaments/{id}/start` — start tournament
- `POST /api/v1/tournaments/{id}/pause` — pause
- `POST /api/v1/tournaments/{id}/resume` — resume
- `POST /api/v1/tournaments/{id}/cancel` — cancel
- `GET /api/v1/tournaments/{id}/activity` — recent activity feed
**5. WebSocket Integration:**
- Update the WebSocket hub (Plan A) to send full tournament state on connect
- Messages are typed: `{type: "clock.tick", data: {...}}`, `{type: "player.bust", data: {...}}`, etc.
- Client subscribes to a specific tournament by sending `{type: "subscribe", tournament_id: "..."}`
- Lobby clients subscribe without tournament_id to receive all tournament summaries
**Verification:**
- Create tournament from template with all config pre-filled
- Start tournament after minimum players met
- Multiple tournaments run simultaneously with independent state
- Tournament auto-closes when 1 player remains
- Lobby shows all active tournaments
- WebSocket sends full state on connect
</task>
<task id="I2" title="Implement chop/deal support with ICM calculator">
**1. ICM Calculator** (`internal/financial/icm.go`) — FIN-11:
- `Malmuth-Harville Algorithm` (exact, for <= 10 players):
- Calculate each player's equity in the prize pool based on chip stacks
- Recursively calculate probability of each player finishing in each position
- `CalculateICMExact(stacks []int64, payouts []int64) ([]int64, error)`:
- Input: chip stacks (int64) and payout amounts (int64 cents)
- Output: ICM value for each player (int64 cents)
- Algorithm: for each player, calculate P(finish in each position) using chip-proportion recursion
- Sum P(position) * payout(position) for all positions
- `Monte Carlo ICM` (approximate, for 11+ players):
- `CalculateICMMonteCarlo(stacks []int64, payouts []int64, iterations int) ([]int64, error)`:
- Default iterations: 100,000 (converges to <0.1% error per 01-RESEARCH.md)
- Simulate tournament outcomes based on chip probabilities
- For each iteration: randomly determine finishing order weighted by chip stacks
- Average results across all iterations
- Return ICM values (int64 cents)
- `CalculateICM(stacks []int64, payouts []int64) ([]int64, error)`:
- Dispatcher: use exact if <= 10 players, Monte Carlo otherwise
- Validate inputs: stacks and payouts must be non-empty, all positive
- ICM values must sum to total prize pool (validate before returning)
**2. Chop/Deal Engine** (`internal/financial/chop.go`):
- `ChopEngine` struct with financial engine, audit trail
- `ProposeDeal(ctx, tournamentID string, dealType string, params DealParams) (*DealProposal, error)`:
- Deal types:
- **ICM**: TD inputs chip stacks → system calculates ICM values → shows proposed payouts
- **Chip Chop**: Divide pool proportionally by chip count (simpler than ICM)
- **Even Chop**: Equal split among all remaining players
- **Custom**: TD enters specific amounts per player
- **Partial Chop**: Split some money, keep remainder + points in play (CONTEXT.md: "split some money, play on for remaining + league points")
- DealParams:
```go
type DealParams struct {
PlayerStacks map[string]int64 // playerID -> chip count (for ICM/chip chop)
CustomAmounts map[string]int64 // playerID -> custom payout (for custom)
PartialPool int64 // Amount to split (for partial chop)
RemainingPool int64 // Amount left in play (for partial chop)
}
```
- Returns proposal for all remaining players with calculated payouts:
```go
type DealProposal struct {
ID string
TournamentID string
DealType string
Payouts []DealPayout // {PlayerID, PlayerName, Amount, ChipStack, ICMValue}
TotalAmount int64 // Must equal prize pool (or partial pool for partial chop)
IsPartial bool
RemainingPool int64 // If partial, what's still in play
CreatedAt int64
}
```
- Validate: sum of proposed payouts == total pool (or partial pool)
- Does NOT apply yet — returns proposal for TD approval
- `ConfirmDeal(ctx, tournamentID, proposalID string) error`:
- Apply all payouts as transactions
- If full chop: set all players' status to "deal", assign finishing positions
- If partial chop: apply partial payouts, tournament continues with remaining pool
- **Prize money and league positions are independent** (CONTEXT.md): positions for league points are based on chip counts at deal time (or play on for partial chop)
- Record audit entry with full deal details
- If full chop: end tournament (set status "completed")
- Broadcast
- `CancelDeal(ctx, tournamentID, proposalID string) error`
**3. API Routes (add to tournaments.go):**
- `POST /api/v1/tournaments/{id}/deal/propose` — body: `{"type": "icm", "stacks": {"player1": 50000, "player2": 30000}}`
- `GET /api/v1/tournaments/{id}/deal/proposals` — list proposals
- `POST /api/v1/tournaments/{id}/deal/proposals/{proposalId}/confirm` — confirm deal
- `POST /api/v1/tournaments/{id}/deal/proposals/{proposalId}/cancel` — cancel
**4. Tests:**
- `internal/financial/icm_test.go`:
- Test ICM exact with 2 players: known expected values
- Test ICM exact with 3 players: verify against known ICM tables
- Test ICM exact with 5 players: verify sum equals prize pool
- Test ICM Monte Carlo with 15 players: verify result is within 1% of expected (statistical test)
- Test ICM with equal stacks: all players should get approximately equal ICM values
- Test ICM edge case: one player has 99% of chips
- Test performance: ICM for 10 players completes in < 1 second
- Test performance: Monte Carlo ICM for 20 players completes in < 2 seconds
- `internal/financial/chop_test.go`:
- Test chip chop: payouts proportional to chip stacks
- Test even chop: all players get equal amounts
- Test custom split: arbitrary amounts summing to pool
- Test partial chop: partial payouts + remaining pool for continuation
- Test deal sets finishing positions correctly
- Test full chop ends tournament
- `internal/tournament/tournament_test.go`:
- Test create from template pre-fills all config
- Test start requires minimum players
- Test auto-close when 1 player remains
- Test multiple tournaments run independently
- Test tournament state aggregation includes all components
- `internal/tournament/integration_test.go` — **End-to-end tournament lifecycle test:**
- Create tournament from template (standard 10-player, PKO)
- Register 10 players with buy-ins (verify prize pool, rake, seat assignments)
- Start tournament, advance clock through 3 levels
- Process 2 rebuys, 1 add-on (verify prize pool update, transaction log)
- Bust 5 players with bounty transfers (verify rankings, hitman chain, balance check)
- Undo 1 bust → verify re-ranking of all subsequent positions
- Run table balancing (verify suggestion → accept → move)
- Break a table (verify even redistribution)
- Propose and confirm ICM deal with remaining 5 players
- **Final assertions:**
- Sum of all payouts == prize pool (int64, zero deviation)
- All 10 players have correct finishing positions
- Audit trail contains every state-changing action
- All transactions are accounted for (buyin + rebuy + addon = contributions, payouts + rake = disbursements)
- Tournament status is "completed"
**Verification:**
- ICM calculation produces correct values for known test cases
- All chop types produce valid payouts summing to prize pool
- Partial chop allows tournament to continue
- Full deal ends the tournament and assigns positions
- Multiple tournaments run simultaneously without interference
- Tournament lobby shows all active tournaments
</task>
## Verification Criteria
1. Tournament creation from template pre-fills all configuration
2. Tournament start requires minimum players met
3. Tournament auto-closes when one player remains
4. Multiple simultaneous tournaments with independent clocks, financials, and players
5. Tournament lobby shows all active tournaments (MULTI-02)
6. ICM calculator works for 2-20+ players (exact for <=10, Monte Carlo for 11+)
7. All chop types (ICM, chip-chop, even-chop, custom, partial) work correctly
8. Prize money and league positions are independent in deal scenarios
9. Full tournament state sent on WebSocket connect
10. Activity feed shows recent actions in human-readable form
## Must-Haves (Goal-Backward)
- [ ] Template-first tournament creation with local copy semantics
- [ ] Tournament auto-closes when one player remains
- [ ] Multiple simultaneous tournaments with fully independent state
- [ ] Tournament lobby view for multi-tournament overview
- [ ] ICM calculator (exact <=10, Monte Carlo 11+) produces correct values
- [ ] Chop/deal support (ICM, chip-chop, even-chop, custom, partial)
- [ ] Prize money and league positions independent
- [ ] Full tournament state available on WebSocket connect