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>
This commit is contained in:
Mikkel Georgsen 2026-03-01 02:58:22 +01:00
parent 5cdd7b0fa7
commit 21ff95068e
18 changed files with 3494 additions and 273 deletions

View file

@ -91,10 +91,10 @@ Requirements for Phase 1 (Development Focus: Live Tournament Management). Each m
- [ ] **SEAT-03**: Dealer button tracking - [ ] **SEAT-03**: Dealer button tracking
- [ ] **SEAT-04**: Random initial seating on buy-in (fills tables evenly) - [ ] **SEAT-04**: Random initial seating on buy-in (fills tables evenly)
- [ ] **SEAT-05**: Automatic balancing suggestions with operator confirmation required (size difference threshold, move fairness, button awareness, locked players, break short tables first — dry-run preview, never auto-apply) - [ ] **SEAT-05**: Automatic balancing suggestions with operator confirmation required (size difference threshold, move fairness, button awareness, locked players, break short tables first — dry-run preview, never auto-apply)
- [ ] **SEAT-06**: Drag-and-drop manual moves on touch interface - [ ] **SEAT-06**: Tap-tap manual seat moves on touch interface (tap source seat, tap destination seat)
- [ ] **SEAT-07**: Break Table action (dissolve and distribute) - [ ] **SEAT-07**: Break Table action (dissolve and distribute)
- [ ] **SEAT-08**: Visual top-down table layout (player names in seats), list view, movement screen - [ ] **SEAT-08**: Visual top-down table layout (player names in seats), list view, movement screen
- [ ] **SEAT-09**: Hand-for-hand mode (stop timer, per-hand deduction) - [ ] **SEAT-09**: Hand-for-hand mode (clock pauses, per-table completion tracking, all tables complete → next hand)
### Multi-Tournament ### Multi-Tournament
@ -270,165 +270,177 @@ Deferred to Development Phases 2-4. Tracked but not in current roadmap.
## Traceability ## Traceability
Which phases cover which requirements. Updated during roadmap creation. Which phases cover which requirements. Updated during roadmap reorganization.
| Requirement | Phase | Status | | Requirement | Phase | Status |
|-------------|-------|--------| |-------------|-------|--------|
| ARCH-01 | Phase 1 | Pending | | ARCH-01 | Phase 1 | Pending |
| ARCH-02 | Phase 1 | Pending | | ARCH-02 | Phase 3 | Pending |
| ARCH-03 | Phase 1 | Pending | | ARCH-03 | Phase 1 | Pending |
| ARCH-04 | Phase 1 | Pending | | ARCH-04 | Phase 1 | Pending |
| ARCH-05 | Phase 1 | Pending | | ARCH-05 | Phase 1 | Pending |
| ARCH-06 | Phase 1 | Pending | | ARCH-06 | Phase 1 | Pending |
| ARCH-07 | Phase 1 | Pending | | ARCH-07 | Phase 1 | Pending |
| ARCH-08 | Phase 1 | Pending | | ARCH-08 | Phase 1 | Pending |
| ARCH-09 | Phase 7 | Pending |
| ARCH-10 | Phase 7 | Pending |
| AUTH-01 | Phase 1 | Pending | | AUTH-01 | Phase 1 | Pending |
| AUTH-02 | Phase 1 | Pending | | AUTH-02 | Phase 7 | Pending |
| AUTH-03 | Phase 1 | Pending | | AUTH-03 | Phase 1 | Pending |
| AUTH-04 | Phase 1 | Pending | | AUTH-04 | Phase 3 | Pending |
| AUTH-05 | Phase 1 | Pending | | AUTH-05 | Phase 2 | Pending |
| AUTH-06 | Phase 1 | Pending | | AUTH-06 | Phase 3 | Pending |
| AUTH-07 | Phase 1 | Pending | | AUTH-07 | Phase 7 | Pending |
| AUTH-08 | Phase 1 | Pending | | AUTH-08 | Phase 3 | Pending |
| AUTH-09 | Phase 1 | Pending | | AUTH-09 | Phase 3 | Pending |
| NET-01 | Phase 1 | Pending | | NET-01 | Phase 7 | Pending |
| NET-02 | Phase 1 | Pending | | NET-02 | Phase 7 | Pending |
| NET-03 | Phase 1 | Pending | | NET-03 | Phase 7 | Pending |
| NET-04 | Phase 1 | Pending | | NET-04 | Phase 7 | Pending |
| NET-05 | Phase 1 | Pending | | NET-05 | Phase 7 | Pending |
| NET-06 | Phase 1 | Pending | | NET-06 | Phase 7 | Pending |
| NET-07 | Phase 1 | Pending | | NET-07 | Phase 7 | Pending |
| PLAT-01 | Phase 2 | Pending | | PLAT-01 | Phase 3 | Pending |
| PLAT-02 | Phase 2 | Pending | | PLAT-02 | Phase 3 | Pending |
| PLAT-03 | Phase 2 | Pending | | PLAT-03 | Phase 3 | Pending |
| PLAT-04 | Phase 2 | Pending | | PLAT-04 | Phase 3 | Pending |
| PLAT-05 | Phase 2 | Pending | | PLAT-05 | Phase 3 | Pending |
| PLAT-06 | Phase 2 | Pending | | PLAT-06 | Phase 3 | Pending |
| SYNC-01 | Phase 2 | Pending | | SYNC-01 | Phase 3 | Pending |
| SYNC-02 | Phase 2 | Pending | | SYNC-02 | Phase 3 | Pending |
| SYNC-03 | Phase 2 | Pending | | SYNC-03 | Phase 3 | Pending |
| SYNC-04 | Phase 2 | Pending | | SYNC-04 | Phase 3 | Pending |
| EXPORT-01 | Phase 2 | Pending | | EXPORT-01 | Phase 2 | Pending |
| EXPORT-02 | Phase 2 | Pending | | EXPORT-02 | Phase 2 | Pending |
| EXPORT-03 | Phase 2 | Pending | | EXPORT-03 | Phase 2 | Pending |
| EXPORT-04 | Phase 2 | Pending | | EXPORT-04 | Phase 2 | Pending |
| CLOCK-01 | Phase 3 | Pending | | CLOCK-01 | Phase 1 | Pending |
| CLOCK-02 | Phase 3 | Pending | | CLOCK-02 | Phase 1 | Pending |
| CLOCK-03 | Phase 3 | Pending | | CLOCK-03 | Phase 1 | Pending |
| CLOCK-04 | Phase 3 | Pending | | CLOCK-04 | Phase 1 | Pending |
| CLOCK-05 | Phase 3 | Pending | | CLOCK-05 | Phase 1 | Pending |
| CLOCK-06 | Phase 3 | Pending | | CLOCK-06 | Phase 1 | Pending |
| CLOCK-07 | Phase 3 | Pending | | CLOCK-07 | Phase 1 | Pending |
| CLOCK-08 | Phase 3 | Pending | | CLOCK-08 | Phase 1 | Pending |
| CLOCK-09 | Phase 3 | Pending | | CLOCK-09 | Phase 1 | Pending |
| BLIND-01 | Phase 3 | Pending | | BLIND-01 | Phase 1 | Pending |
| BLIND-02 | Phase 3 | Pending | | BLIND-02 | Phase 1 | Pending |
| BLIND-03 | Phase 3 | Pending | | BLIND-03 | Phase 1 | Pending |
| BLIND-04 | Phase 3 | Pending | | BLIND-04 | Phase 1 | Pending |
| BLIND-05 | Phase 3 | Pending | | BLIND-05 | Phase 1 | Pending |
| BLIND-06 | Phase 3 | Pending | | BLIND-06 | Phase 1 | Pending |
| CHIP-01 | Phase 3 | Pending | | CHIP-01 | Phase 1 | Pending |
| CHIP-02 | Phase 3 | Pending | | CHIP-02 | Phase 1 | Pending |
| CHIP-03 | Phase 3 | Pending | | CHIP-03 | Phase 1 | Pending |
| CHIP-04 | Phase 3 | Pending | | CHIP-04 | Phase 1 | Pending |
| MULTI-01 | Phase 3 | Pending | | MULTI-01 | Phase 1 | Pending |
| MULTI-02 | Phase 3 | Pending | | MULTI-02 | Phase 1 | Pending |
| FIN-01 | Phase 4 | Pending | | FIN-01 | Phase 1 | Pending |
| FIN-02 | Phase 4 | Pending | | FIN-02 | Phase 1 | Pending |
| FIN-03 | Phase 4 | Pending | | FIN-03 | Phase 1 | Pending |
| FIN-04 | Phase 4 | Pending | | FIN-04 | Phase 1 | Pending |
| FIN-05 | Phase 4 | Pending | | FIN-05 | Phase 1 | Pending |
| FIN-06 | Phase 4 | Pending | | FIN-06 | Phase 1 | Pending |
| FIN-07 | Phase 4 | Pending | | FIN-07 | Phase 1 | Pending |
| FIN-08 | Phase 4 | Pending | | FIN-08 | Phase 1 | Pending |
| FIN-09 | Phase 4 | Pending | | FIN-09 | Phase 1 | Pending |
| FIN-10 | Phase 4 | Pending | | FIN-10 | Phase 1 | Pending |
| FIN-11 | Phase 4 | Pending | | FIN-11 | Phase 1 | Pending |
| FIN-12 | Phase 4 | Pending | | FIN-12 | Phase 1 | Pending |
| FIN-13 | Phase 4 | Pending | | FIN-13 | Phase 1 | Pending |
| FIN-14 | Phase 4 | Pending | | FIN-14 | Phase 1 | Pending |
| PLYR-01 | Phase 5 | Pending | | PLYR-01 | Phase 1 | Pending |
| PLYR-02 | Phase 5 | Pending | | PLYR-02 | Phase 1 | Pending |
| PLYR-03 | Phase 5 | Pending | | PLYR-03 | Phase 1 | Pending |
| PLYR-04 | Phase 5 | Pending | | PLYR-04 | Phase 1 | Pending |
| PLYR-05 | Phase 5 | Pending | | PLYR-05 | Phase 1 | Pending |
| PLYR-06 | Phase 5 | Pending | | PLYR-06 | Phase 1 | Pending |
| PLYR-07 | Phase 5 | Pending | | PLYR-07 | Phase 1 | Pending |
| SEAT-01 | Phase 5 | Pending | | SEAT-01 | Phase 1 | Pending |
| SEAT-02 | Phase 5 | Pending | | SEAT-02 | Phase 1 | Pending |
| SEAT-03 | Phase 5 | Pending | | SEAT-03 | Phase 1 | Pending |
| SEAT-04 | Phase 5 | Pending | | SEAT-04 | Phase 1 | Pending |
| SEAT-05 | Phase 5 | Pending | | SEAT-05 | Phase 1 | Pending |
| SEAT-06 | Phase 5 | Pending | | SEAT-06 | Phase 1 | Pending |
| SEAT-07 | Phase 5 | Pending | | SEAT-07 | Phase 1 | Pending |
| SEAT-08 | Phase 5 | Pending | | SEAT-08 | Phase 1 | Pending |
| SEAT-09 | Phase 5 | Pending | | SEAT-09 | Phase 1 | Pending |
| UI-01 | Phase 6 | Pending | | UI-01 | Phase 1 | Pending |
| UI-02 | Phase 6 | Pending | | UI-02 | Phase 1 | Pending |
| UI-03 | Phase 6 | Pending | | UI-03 | Phase 1 | Pending |
| UI-04 | Phase 6 | Pending | | UI-04 | Phase 1 | Pending |
| UI-05 | Phase 6 | Pending | | UI-05 | Phase 1 | Pending |
| UI-06 | Phase 6 | Pending | | UI-06 | Phase 1 | Pending |
| UI-07 | Phase 6 | Pending | | UI-07 | Phase 1 | Pending |
| UI-08 | Phase 6 | Pending | | UI-08 | Phase 1 | Pending |
| DISP-01 | Phase 7 | Pending | | DISP-01 | Phase 2 | Pending |
| DISP-02 | Phase 7 | Pending | | DISP-02 | Phase 2 | Pending |
| DISP-03 | Phase 7 | Pending | | DISP-03 | Phase 2 | Pending |
| DISP-04 | Phase 7 | Pending | | DISP-04 | Phase 2 | Pending |
| DISP-05 | Phase 7 | Pending | | DISP-05 | Phase 2 | Pending |
| DISP-06 | Phase 7 | Pending | | DISP-06 | Phase 2 | Pending |
| DISP-07 | Phase 7 | Pending | | DISP-07 | Phase 2 | Pending |
| DISP-08 | Phase 7 | Pending | | DISP-08 | Phase 2 | Pending |
| DISP-09 | Phase 7 | Pending | | DISP-09 | Phase 2 | Pending |
| DISP-10 | Phase 7 | Pending | | DISP-10 | Phase 2 | Pending |
| PWA-01 | Phase 8 | Pending | | DISP-11 | Phase 7 | Pending |
| PWA-02 | Phase 8 | Pending | | PWA-01 | Phase 2 | Pending |
| PWA-03 | Phase 8 | Pending | | PWA-02 | Phase 2 | Pending |
| PWA-04 | Phase 8 | Pending | | PWA-03 | Phase 2 | Pending |
| PWA-05 | Phase 8 | Pending | | PWA-04 | Phase 2 | Pending |
| PWA-06 | Phase 8 | Pending | | PWA-05 | Phase 2 | Pending |
| PWA-07 | Phase 8 | Pending | | PWA-06 | Phase 2 | Pending |
| PWA-08 | Phase 8 | Pending | | PWA-07 | Phase 2 | Pending |
| PWA-09 | Phase 8 | Pending | | PWA-08 | Phase 2 | Pending |
| PWA-10 | Phase 8 | Pending | | PWA-09 | Phase 2 | Pending |
| SIGN-01 | Phase 9 | Pending | | PWA-10 | Phase 2 | Pending |
| SIGN-02 | Phase 9 | Pending | | SIGN-01 | Phase 4 | Pending |
| SIGN-03 | Phase 9 | Pending | | SIGN-02 | Phase 4 | Pending |
| SIGN-04 | Phase 9 | Pending | | SIGN-03 | Phase 4 | Pending |
| SIGN-05 | Phase 9 | Pending | | SIGN-04 | Phase 4 | Pending |
| SIGN-06 | Phase 9 | Pending | | SIGN-05 | Phase 4 | Pending |
| SIGN-07 | Phase 9 | Pending | | SIGN-06 | Phase 4 | Pending |
| SIGN-08 | Phase 9 | Pending | | SIGN-07 | Phase 4 | Pending |
| SIGN-09 | Phase 9 | Pending | | SIGN-08 | Phase 4 | Pending |
| SIGN-10 | Phase 9 | Pending | | SIGN-09 | Phase 4 | Pending |
| EVENT-01 | Phase 9 | Pending | | SIGN-10 | Phase 4 | Pending |
| EVENT-02 | Phase 9 | Pending | | EVENT-01 | Phase 4 | Pending |
| EVENT-03 | Phase 9 | Pending | | EVENT-02 | Phase 4 | Pending |
| EVENT-04 | Phase 9 | Pending | | EVENT-03 | Phase 4 | Pending |
| LEAGUE-01 | Phase 10 | Pending | | EVENT-04 | Phase 4 | Pending |
| LEAGUE-02 | Phase 10 | Pending | | LEAGUE-01 | Phase 5 | Pending |
| LEAGUE-03 | Phase 10 | Pending | | LEAGUE-02 | Phase 5 | Pending |
| LEAGUE-04 | Phase 10 | Pending | | LEAGUE-03 | Phase 5 | Pending |
| LEAGUE-05 | Phase 10 | Pending | | LEAGUE-04 | Phase 5 | Pending |
| LEAGUE-06 | Phase 10 | Pending | | LEAGUE-05 | Phase 5 | Pending |
| REGION-01 | Phase 10 | Pending | | LEAGUE-06 | Phase 5 | Pending |
| REGION-02 | Phase 10 | Pending | | REGION-01 | Phase 5 | Pending |
| REGION-03 | Phase 10 | Pending | | REGION-02 | Phase 5 | Pending |
| REGION-04 | Phase 10 | Pending | | REGION-03 | Phase 5 | Pending |
| REGION-05 | Phase 10 | Pending | | REGION-04 | Phase 5 | Pending |
| TDD-01 | Phase 11 | Pending | | REGION-05 | Phase 5 | Pending |
| TDD-02 | Phase 11 | Pending | | TDD-01 | Phase 6 | Pending |
| TDD-03 | Phase 11 | Pending | | TDD-02 | Phase 6 | Pending |
| TDD-04 | Phase 11 | Pending | | TDD-03 | Phase 6 | Pending |
| TDD-05 | Phase 11 | Pending | | TDD-04 | Phase 6 | Pending |
| TDD-06 | Phase 11 | Pending | | TDD-05 | Phase 6 | Pending |
| TDD-07 | Phase 11 | Pending | | TDD-06 | Phase 6 | Pending |
| TDD-07 | Phase 6 | Pending |
**Coverage:** **Coverage:**
- v1 requirements: 126 total - v1 requirements: 152 total
- Mapped to phases: 126 - Mapped to phases: 152
- Unmapped: 0 - Unmapped: 0
**Per-phase breakdown:**
- Phase 1 (Tournament Engine): 68
- Phase 2 (Display Views + Player PWA): 25
- Phase 3 (Core Sync + Platform Identity): 15
- Phase 4 (Digital Signage + Events Engine): 14
- Phase 5 (Leagues, Seasons + Regional Tournaments): 11
- Phase 6 (TDD Migration): 7
- Phase 7 (Hardware Leaf): 12
--- ---
*Requirements defined: 2026-02-28* *Requirements defined: 2026-02-28*
*Last updated: 2026-02-28 — traceability populated after roadmap creation* *Last updated: 2026-03-01 — roadmap reorganized from 11 phases to 7 (vertical slices, software-first/hardware-later, dev environment is x86 LXC not ARM64)*

View file

@ -2,7 +2,11 @@
## Overview ## Overview
Felt is built in 11 phases, all scoped to Development Phase 1 (Live Tournament Management). The build order is constraint-driven: infrastructure and data model correctness must be established before any domain logic, domain logic before frontend, frontend before displays, and the TDD migration weapon last — delivered just before launch when there is a complete system to migrate into. Each phase delivers one coherent, independently verifiable capability. Felt is built in 7 phases, all scoped to Development Phase 1 (Live Tournament Management). The build order follows two principles: **vertical slices** (backend + frontend together so every phase is demoable) and **software first, hardware later** (ARM64, LUKS, Netbird, Pi Zero kiosk are all Phase 7 packaging concerns).
All development and testing happens in an x86 LXC container on Proxmox. The Go binary running there is functionally identical to the Virtual Leaf that runs the free tier in production on Hetzner. Hardware Leaf is a later deployment target for the Offline/Pro tier.
**Development environment:** x86_64 LXC container (Debian/Ubuntu), Go binary + LibSQL + NATS JetStream + SvelteKit. No Docker, no ARM cross-compilation, no Netbird. Display views are browser windows pointed at the container. Player phones connect on the local network.
## Phases ## Phases
@ -12,143 +16,87 @@ Felt is built in 11 phases, all scoped to Development Phase 1 (Live Tournament M
Decimal phases appear between their surrounding integers in numeric order. Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 1: Foundation** - Go monorepo, Leaf binary, embedded NATS + LibSQL, Netbird mesh, Authentik OIDC, CI with ARM64 cross-compilation - [ ] **Phase 1: Tournament Engine** - Go binary in LXC, full vertical slice: clock, blinds, financials, players, tables, seating, operator UI — everything to run a complete tournament
- [ ] **Phase 2: Platform Identity & Sync** - Platform-level player profiles, NATS Leaf-to-Core sync, Core PostgreSQL with RLS, export formats - [ ] **Phase 2: Display Views + Player PWA** - Browser-based display views, player mobile PWA, export formats — the full venue experience
- [ ] **Phase 3: Tournament Engine** - Clock, blind structures, chip management, multi-tournament support - [ ] **Phase 3: Core Sync + Platform Identity** - PostgreSQL on Core, NATS sync, platform-level player profiles, Virtual Leaf, GDPR compliance
- [ ] **Phase 4: Financial Engine** - Buy-ins, rebuys, add-ons, bounties, prize pool, payouts, chop/deal, receipts - [ ] **Phase 4: Digital Signage + Events Engine** - WYSIWYG content editor, AI generation, scheduling, visual rule builder, automation triggers
- [ ] **Phase 5: Player & Table Management** - Player database, registration flows, bust-out, seating, auto-balancing, hand-for-hand - [ ] **Phase 5: Leagues, Seasons + Regional Tournaments** - Point formulas, standings, cross-venue tournaments, finals management
- [ ] **Phase 6: Operator UI** - Mobile-first SvelteKit interface, dark theme, FAB, data tables, toast notifications - [ ] **Phase 6: TDD Migration** - Full TDD XML import, player database migration, tournament history, wizard, zero data loss
- [ ] **Phase 7: Display System** - Wireless display node registry, view assignment, tournament views, theming, screen cycling - [ ] **Phase 7: Hardware Leaf (ARM64 + Offline Hardening)** - ARM64 cross-compilation, LUKS encryption, Pi Zero display nodes, Netbird mesh, provisioning, chaos testing
- [ ] **Phase 8: Player Mobile PWA** - QR code access, live clock, rankings, personal stats, WebSocket real-time, add-to-home
- [ ] **Phase 9: Digital Signage & Events Engine** - Content editor, AI assist, scheduling, event rules, automation triggers
- [ ] **Phase 10: League, Season & Regional Tournaments** - Point formulas, standings, cross-venue tournaments, finals management
- [ ] **Phase 11: TDD Migration** - Full TDD XML import, player database migration, tournament history, wizard, zero data loss
## Phase Details ## Phase Details
### Phase 1: Foundation ### Phase 1: Tournament Engine
**Goal**: The Leaf binary runs on ARM64, passes CI, and provides the infrastructure skeleton every subsequent phase builds on **Goal**: A complete tournament runs from start to finish in an x86 LXC container with a working touch-friendly operator UI — demoable at a venue by pointing any device at the container
**Depends on**: Nothing (first phase) **Depends on**: Nothing (first phase)
**Requirements**: ARCH-01, ARCH-02, ARCH-03, ARCH-04, ARCH-05, ARCH-06, ARCH-07, ARCH-08, AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06, AUTH-07, AUTH-08, AUTH-09, NET-01, NET-02, NET-03, NET-04, NET-05, NET-06, NET-07 **Requirements**: ARCH-01, ARCH-03, ARCH-04, ARCH-05, ARCH-06, ARCH-07, ARCH-08, AUTH-01, AUTH-03, CLOCK-01, CLOCK-02, CLOCK-03, CLOCK-04, CLOCK-05, CLOCK-06, CLOCK-07, CLOCK-08, CLOCK-09, BLIND-01, BLIND-02, BLIND-03, BLIND-04, BLIND-05, BLIND-06, CHIP-01, CHIP-02, CHIP-03, CHIP-04, FIN-01, FIN-02, FIN-03, FIN-04, FIN-05, FIN-06, FIN-07, FIN-08, FIN-09, FIN-10, FIN-11, FIN-12, FIN-13, FIN-14, PLYR-01, PLYR-02, PLYR-03, PLYR-04, PLYR-05, PLYR-06, PLYR-07, SEAT-01, SEAT-02, SEAT-03, SEAT-04, SEAT-05, SEAT-06, SEAT-07, SEAT-08, SEAT-09, MULTI-01, MULTI-02, UI-01, UI-02, UI-03, UI-04, UI-05, UI-06, UI-07, UI-08
**Note**: Basic sound infrastructure (play_sound action for level change, break start, bubble) ships with the clock engine here. The full visual rule builder (EVENT-01 through EVENT-04) remains in Phase 4.
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. A single Go binary (`cmd/leaf`) builds for ARM64 via CI with CGO enabled and LibSQL linked, with zero manual steps 1. A Go binary starts in the LXC container, serves HTTP, embeds NATS JetStream and LibSQL, and WebSocket-connected clients receive state changes within 100ms
2. The Leaf binary starts on an Orange Pi 5 Plus, serves HTTP, connects embedded NATS JetStream and LibSQL, and survives a simulated power failure without data loss (NATS sync_interval: always verified) 2. The clock counts down each level, transitions automatically, emits configurable warnings; an operator can pause, resume, jump to any level; two tournaments run simultaneously with independent state
3. An operator can log in with a PIN (offline) or OIDC via Authentik (online) and receive a JWT with their role (Admin/Floor/Viewer) 3. A blind structure with mixed-game rotation, big-blind antes, and chip-up breaks can be saved as a template; the structure wizard produces a playable structure from inputs
4. A display node and a player browser can reach the Leaf from outside the local network via HTTPS through the Netbird reverse proxy 4. A complete buy-in, rebuy, add-on, and bounty flow produces a transaction log; a CI gate test verifies sum of payouts always equals prize pool (int64 cents, zero floating-point deviation)
5. Every state-changing action writes an append-only audit trail entry readable from the database 5. An operator can search by name with typeahead, process a buy-in with auto-seating, bust a player with hitman selection and auto-balancing — all from a mobile-first touch UI with FAB, persistent header, Catppuccin Mocha dark theme, and 48px touch targets
6. Every state-changing action writes an append-only audit trail entry; any financial transaction or bust-out can be undone with full re-ranking
**Plans** (12 plans, 5 waves):
- Wave 1: A (Project Scaffold + Infrastructure), B (Database Schema + Migrations)
- Wave 2: C (Auth + Audit Trail + Undo), D (Clock Engine), E (Blind Structure + Templates), J (SvelteKit Frontend Scaffold)
- Wave 3: F (Financial Engine), G (Player Management), H (Table & Seating Engine)
- Wave 4: I (Tournament Lifecycle + Multi-Tournament + Chop/Deal)
- Wave 5: K (Frontend — Overview + Clock + Financials), L (Frontend — Players + Tables + More)
### Phase 2: Display Views + Player PWA
**Goal**: The full venue experience — operator UI on phone, TV browsers showing tournament data fullscreen, players scanning QR codes on their phones — all connecting to the LXC container
**Depends on**: Phase 1
**Requirements**: DISP-01, DISP-02, DISP-03, DISP-04, DISP-05, DISP-06, DISP-07, DISP-08, DISP-09, DISP-10, PWA-01, PWA-02, PWA-03, PWA-04, PWA-05, PWA-06, PWA-07, PWA-08, PWA-09, PWA-10, AUTH-05, EXPORT-01, EXPORT-02, EXPORT-03, EXPORT-04
**Success Criteria** (what must be TRUE):
1. All tournament views (Clock, Rankings, Seating Chart, Blind Schedule, Final Table, Prize Pool, Lobby) render in browser windows connected via WebSocket and are readable from 10+ feet with auto font-scaling
2. Screen cycling with configurable rotation runs automatically; the operator can assign views, override, or lock any screen from the operator UI
3. A player scans a QR code and sees live clock, current blinds, and next level within 3 seconds — no account required; the PWA prompts "Add to Home Screen" on iOS and Android
4. A player can claim personal status (seat, points) by entering a 6-digit PIN; WebSocket updates arrive within 100ms with auto-reconnect and polling fallback
5. A completed tournament exports to CSV, JSON, and HTML with correct data and venue branding; a player can request a JSON export of their personal data (GDPR portability)
**Plans**: TBD **Plans**: TBD
### Phase 2: Platform Identity & Sync ### Phase 3: Core Sync + Platform Identity
**Goal**: Players are platform-level Felt entities with cross-venue portable profiles, and tournament events flow from Leaf to Core with guaranteed delivery **Goal**: Players are platform-level entities with cross-venue portable profiles, Leaf-to-Core sync works with guaranteed delivery, Virtual Leaf provides free-tier access, and Core Admin is secured with MFA
**Depends on**: Phase 1 **Depends on**: Phase 1
**Requirements**: PLAT-01, PLAT-02, PLAT-03, PLAT-04, PLAT-05, PLAT-06, SYNC-01, SYNC-02, SYNC-03, SYNC-04, EXPORT-01, EXPORT-02, EXPORT-03, EXPORT-04 **Requirements**: ARCH-02, PLAT-01, PLAT-02, PLAT-03, PLAT-04, PLAT-05, PLAT-06, SYNC-01, SYNC-02, SYNC-03, SYNC-04, AUTH-04, AUTH-06, AUTH-08, AUTH-09
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. A player created on one Leaf appears on Core with the same UUID and profile data after reconnect, with no duplicates 1. A player created on one Leaf appears on Core with the same UUID and profile data after reconnect, with no duplicates
2. Tournament events published on Leaf while offline queue in NATS JetStream and replay to Core in order when connectivity is restored 2. Tournament events published on Leaf while offline queue in NATS JetStream and replay to Core in order when connectivity is restored; Core never overwrites Leaf data for a running tournament
3. Core never overwrites Leaf data for a tournament that is currently running 3. Reverse sync delivers player profiles, league config, tournament templates, and branding from Core to Leaf
4. A player can request and download a JSON export of all their personal data (GDPR portability) 4. A Virtual Leaf instance starts on Core infrastructure, runs the same tournament logic as a physical Leaf, and syncs data to Core PostgreSQL
5. A completed tournament exports to CSV, JSON, and HTML with correct data and venue branding applied 5. Core Admin authenticates via OIDC with mandatory MFA; NATS subjects are namespaced by venue ID with PostgreSQL RLS multi-tenant isolation; Leaf-Core sync uses mTLS + API key per venue
6. Player data export (GDPR portability) and deletion (anonymize tournament entries, remove PII, propagate via NATS) work correctly
**Plans**: TBD **Plans**: TBD
### Phase 3: Tournament Engine ### Phase 4: Digital Signage + Events Engine
**Goal**: A complete tournament clock and blind structure runs from start to finish with all operational controls an operator needs **Goal**: Venue screens show scheduled content between tournaments and the events engine automates visual and audio responses to tournament moments — all configured via the operator UI without code
**Depends on**: Phase 1 **Depends on**: Phase 2
**Requirements**: CLOCK-01, CLOCK-02, CLOCK-03, CLOCK-04, CLOCK-05, CLOCK-06, CLOCK-07, CLOCK-08, CLOCK-09, BLIND-01, BLIND-02, BLIND-03, BLIND-04, BLIND-05, BLIND-06, CHIP-01, CHIP-02, CHIP-03, CHIP-04, MULTI-01, MULTI-02
**Success Criteria** (what must be TRUE):
1. The clock counts down each level with second-granularity display, transitions automatically to the next level or break, and emits warning alerts at configurable thresholds
2. An operator can pause, resume, jump to any level, and advance forward or backward; all connected clients reflect the change within 100ms
3. A blind structure with mixed-game rotation, big-blind antes, and chip-up breaks can be saved as a template and loaded into any new tournament
4. The structure wizard produces a playable blind structure from player count, duration, starting chips, and denomination inputs
5. Two tournaments run simultaneously on the same Leaf with independent clocks, financials, and player tracking
**Plans**: TBD
### Phase 4: Financial Engine
**Goal**: All tournament money flows are tracked with integer-only arithmetic, full receipts, and a complete payout calculation from first buy-in to final chop
**Depends on**: Phase 3
**Requirements**: FIN-01, FIN-02, FIN-03, FIN-04, FIN-05, FIN-06, FIN-07, FIN-08, FIN-09, FIN-10, FIN-11, FIN-12, FIN-13, FIN-14
**Success Criteria** (what must be TRUE):
1. A CI gate test verifies that the sum of individual payouts always equals the prize pool total — zero floating-point deviation possible
2. A complete buy-in, rebuy, add-on, and bounty flow produces a transaction log where every event is a receipt with operator, timestamp, and previous/new state
3. Prize pool auto-calculates correctly from all financial inputs including guaranteed pot shortfall and end-of-season withholding
4. The ICM calculator, chip-chop, and custom deal-making flows produce payout splits that sum exactly to the prize pool
5. An operator can edit a financial transaction with a reason; the audit trail shows both the original and corrected entries
**Plans**: TBD
### Phase 5: Player & Table Management
**Goal**: Operators can register, seat, track, and bust players through a complete tournament with automatic balancing and full action history
**Depends on**: Phase 4
**Requirements**: PLYR-01, PLYR-02, PLYR-03, PLYR-04, PLYR-05, PLYR-06, PLYR-07, SEAT-01, SEAT-02, SEAT-03, SEAT-04, SEAT-05, SEAT-06, SEAT-07, SEAT-08, SEAT-09
**Success Criteria** (what must be TRUE):
1. An operator can search by name with typeahead, select a player, process a buy-in, and have the player auto-seated — all within a single touch flow
2. Busting a player triggers hitman selection, bounty transfer, auto-ranking, and a rebalancing proposal; the operator confirms before any seats move
3. The auto-balancing algorithm never produces invalid seating (validated against TDA Rules 25-28 edge cases: last 2-player table, button position, blinds position, locked players)
4. Any bust-out, rebuy, or buy-in can be undone with full re-ranking and table state restoration
5. A QR code per player enables self-check-in; scanning it opens the player's registration flow
**Plans**: TBD
### Phase 6: Operator UI
**Goal**: The complete operator experience is a mobile-first, dark-room-ready SvelteKit interface that surfaces all engine capabilities with TDD-depth and modern UX
**Depends on**: Phase 5
**Requirements**: UI-01, UI-02, UI-03, UI-04, UI-05, UI-06, UI-07, UI-08
**Success Criteria** (what must be TRUE):
1. An operator running a tournament on a phone can access every runtime action (bust, buy-in, rebuy, add-on, pause/resume) without navigating away from the current screen, using the FAB
2. The persistent header shows current clock, level, blinds, and player count at all times across all tabs
3. All touch targets are at least 48px; the interface is usable in a dark room without eye strain (Catppuccin Mocha)
4. On a laptop or desktop, the sidebar navigation is visible and the content area is wider — same codebase, responsive layout
5. Data tables support sort, sticky headers, and search/filter; swipe actions work on mobile
**Plans**: TBD
### Phase 7: Display System
**Goal**: Wireless display nodes connect, receive view assignments, and show tournament data readable from 10+ feet — without HDMI cables
**Depends on**: Phase 6
**Requirements**: DISP-01, DISP-02, DISP-03, DISP-04, DISP-05, DISP-06, DISP-07, DISP-08, DISP-09, DISP-10
**Success Criteria** (what must be TRUE):
1. A Pi Zero 2W display node connects to the Leaf via WebSocket, appears in the node registry with name, status, and current view, and the operator can reassign its view without touching the device
2. All tournament views (Clock, Rankings, Seating, Blind Schedule, Final Table, Prize Pool, Lobby) render correctly and are readable from 10+ feet at common TV resolutions
3. Display nodes auto-scale font size to resolution; no overflow or truncation on any supported resolution
4. Screen cycling with configurable rotation runs automatically; the operator can override or lock any screen to a specific view
5. A Pi Zero 2W running all display views stays within 450MB memory for 8+ hours without crash or restart (validated on actual hardware)
**Plans**: TBD
### Phase 8: Player Mobile PWA
**Goal**: Any player with a phone can scan a QR code and get live tournament data — clock, rankings, blinds, prize pool — with no app install and no login
**Depends on**: Phase 7
**Requirements**: PWA-01, PWA-02, PWA-03, PWA-04, PWA-05, PWA-06, PWA-07, PWA-08, PWA-09, PWA-10
**Success Criteria** (what must be TRUE):
1. A player scans a QR code at the venue entrance and sees live clock, current blinds, and next level within 3 seconds — no account required
2. The PWA stays live during a tournament: WebSocket updates arrive within 100ms; if disconnected, the PWA auto-reconnects and falls back to polling
3. A player can claim personal status (their seat, points) by entering a 6-digit PIN — no email or app account needed
4. The PWA prompts "Add to Home Screen" on both iOS and Android; launching from home screen works offline for the last-known state
5. Players can view league standings and upcoming tournaments without any authentication
**Plans**: TBD
### Phase 9: Digital Signage & Events Engine
**Goal**: Venue screens show scheduled content between tournaments and the events engine automates visual and audio responses to tournament moments
**Depends on**: Phase 7
**Requirements**: SIGN-01, SIGN-02, SIGN-03, SIGN-04, SIGN-05, SIGN-06, SIGN-07, SIGN-08, SIGN-09, SIGN-10, EVENT-01, EVENT-02, EVENT-03, EVENT-04 **Requirements**: SIGN-01, SIGN-02, SIGN-03, SIGN-04, SIGN-05, SIGN-06, SIGN-07, SIGN-08, SIGN-09, SIGN-10, EVENT-01, EVENT-02, EVENT-03, EVENT-04
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. An operator creates a promo card with the WYSIWYG editor, uses AI text generation to produce content, schedules it to run on specific screens at specific times, and it appears without any manual intervention 1. An operator creates a promo card with the WYSIWYG editor, uses AI text generation to produce content, schedules it to run on specific screens at specific times, and it appears without manual intervention
2. Screens automatically switch to tournament clock view when a tournament starts and revert to the signage playlist when it ends — without operator action 2. Screens automatically switch to tournament clock view when a tournament starts and revert to the signage playlist when it ends — without operator action
3. An event rule triggers a sound and a display message when the final table is reached; the operator created the rule via the visual builder without writing code 3. An event rule triggers a sound and a display message when the final table is reached; the operator created the rule via the visual builder without writing code
4. Different screens show different content simultaneously (e.g., screen 1 shows sponsor ad, screen 2 shows league table) 4. Different screens show different content simultaneously (e.g., screen 1 shows sponsor ad, screen 2 shows league table)
5. All signage content bundles are stored on Leaf as static HTML/CSS/JS and render in Chromium kiosk without internet 5. All signage content bundles are stored as static HTML/CSS/JS and render in any browser without internet
**Plans**: TBD **Plans**: TBD
### Phase 10: League, Season & Regional Tournaments ### Phase 5: Leagues, Seasons + Regional Tournaments
**Goal**: Venues can run structured leagues with configurable point formulas and season standings, and anyone can create cross-venue tournaments using the free-tier regional organizer **Goal**: Venues run structured leagues with configurable point formulas and season standings, and anyone can create cross-venue tournaments using the free-tier regional organizer
**Depends on**: Phase 8 **Depends on**: Phase 3
**Requirements**: LEAGUE-01, LEAGUE-02, LEAGUE-03, LEAGUE-04, LEAGUE-05, LEAGUE-06, REGION-01, REGION-02, REGION-03, REGION-04, REGION-05 **Requirements**: LEAGUE-01, LEAGUE-02, LEAGUE-03, LEAGUE-04, LEAGUE-05, LEAGUE-06, REGION-01, REGION-02, REGION-03, REGION-04, REGION-05
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. An operator configures a league with a custom point formula, tests it against sample placements with the formula tester, and sees the distribution graph before saving 1. An operator configures a league with a custom point formula, tests it against sample placements with the formula tester, and sees the distribution graph before saving
2. Season standings update automatically after each tournament result; the operator can configure best-N-of-M counting and minimum attendance requirements 2. Season standings update automatically after each tournament result; the operator can configure best-N-of-M counting and minimum attendance requirements
3. League standings display on a dedicated display node view, updating live as tournament results arrive 3. League standings display as a dedicated view, updating live as tournament results arrive
4. A regional organizer (free-tier user) creates a cross-venue tournament, adds qualifying events at participating venues, and the unified leaderboard updates automatically from each venue's results 4. A regional organizer (free-tier user) creates a cross-venue tournament, adds qualifying events at participating venues, and the unified leaderboard updates automatically from each venue's results
5. A finals event is managed through the platform with aggregated qualifying results feeding directly into the finals seeding 5. A finals event is managed through the platform with aggregated qualifying results feeding directly into the finals seeding
**Plans**: TBD **Plans**: TBD
### Phase 11: TDD Migration ### Phase 6: TDD Migration
**Goal**: Any venue running The Tournament Director can import their complete history — blind structures, players, results, leagues — and start using Felt on day one with zero data loss **Goal**: Any venue running The Tournament Director can import their complete history and start using Felt on day one with zero data loss
**Depends on**: Phase 10 **Depends on**: Phase 5
**Requirements**: TDD-01, TDD-02, TDD-03, TDD-04, TDD-05, TDD-06, TDD-07 **Requirements**: TDD-01, TDD-02, TDD-03, TDD-04, TDD-05, TDD-06, TDD-07
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. An operator drops a TDD XML export file into the import wizard and sees a preview of all detected blind structures, players, tournaments, and leagues before committing 1. An operator drops a TDD XML export file into the import wizard and sees a preview of all detected blind structures, players, tournaments, and leagues before committing
@ -158,21 +106,38 @@ Decimal phases appear between their surrounding integers in numeric order.
5. A venue that has used TDD for 5 years can import their full history and see complete league standings with accurate historical point calculations on day one 5. A venue that has used TDD for 5 years can import their full history and see complete league standings with accurate historical point calculations on day one
**Plans**: TBD **Plans**: TBD
### Phase 7: Hardware Leaf (ARM64 + Offline Hardening)
**Goal**: A pre-configured ARM64 Leaf node boots, connects to Core via Netbird, serves everything wirelessly, survives power failures, and runs completely offline — this is the Offline/Pro tier product
**Depends on**: Phase 3
**Requirements**: ARCH-09, ARCH-10, AUTH-02, AUTH-07, NET-01, NET-02, NET-03, NET-04, NET-05, NET-06, NET-07, DISP-11
**Success Criteria** (what must be TRUE):
1. A single Go binary (`cmd/leaf`) builds for ARM64 via CI with CGO enabled and LibSQL linked, with zero manual steps
2. The Leaf binary starts on an Orange Pi 5 Plus with LUKS full-disk encryption on NVMe, serves HTTP, and connects to Core via Netbird WireGuard mesh
3. An operator can authenticate via OIDC through Authentik when the Leaf has internet; display nodes and player browsers reach the Leaf from outside the local network via HTTPS through the Netbird reverse proxy
4. Pi Zero 2W display nodes run Chromium kiosk with systemd watchdog, zram, and memory limits; all display views stay under 350MB RSS for 4+ hours (soak tested on actual hardware)
5. The Leaf survives 10 hard power cycles without data loss (WAL checkpoint on shutdown, verified by chaos testing); automated daily backup to USB or cloud with documented recovery
6. Custom domain support works (venue CNAME → felt subdomain); lazy connections scale to 500+ venues; Netbird DNS, SSH, and firewall policies are enforced
**Plans**: TBD
## Key Principles
1. **Build software first, package for hardware later.** Every feature is developed and tested in an x86 LXC container. ARM64 is a deployment target, not a development concern.
2. **Vertical slices, not horizontal layers.** Each phase delivers something visible and demoable. No headless engine phases followed by a monolithic UI phase.
3. **The LXC container IS the product.** The binary running in the dev container is functionally the same as the Virtual Leaf that runs the free tier in production. Hardware Leaf is a later packaging step for paid tiers.
4. **Revenue-gated complexity.** Netbird, LUKS, ARM cross-compilation, Pi Zero kiosk — all Offline/Pro tier. Don't build until venues pay for it.
5. **Demo-driven development.** After Phase 1, demo a full tournament at the venue. After Phase 2, the full experience with displays and player phones. Every phase adds visible value.
## Progress ## Progress
**Execution Order:** **Execution Order:**
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Foundation | 0/TBD | Not started | - | | 1. Tournament Engine | 0/12 | Planning complete | - |
| 2. Platform Identity & Sync | 0/TBD | Not started | - | | 2. Display Views + Player PWA | 0/TBD | Not started | - |
| 3. Tournament Engine | 0/TBD | Not started | - | | 3. Core Sync + Platform Identity | 0/TBD | Not started | - |
| 4. Financial Engine | 0/TBD | Not started | - | | 4. Digital Signage + Events Engine | 0/TBD | Not started | - |
| 5. Player & Table Management | 0/TBD | Not started | - | | 5. Leagues, Seasons + Regional Tournaments | 0/TBD | Not started | - |
| 6. Operator UI | 0/TBD | Not started | - | | 6. TDD Migration | 0/TBD | Not started | - |
| 7. Display System | 0/TBD | Not started | - | | 7. Hardware Leaf (ARM64 + Offline Hardening) | 0/TBD | Not started | - |
| 8. Player Mobile PWA | 0/TBD | Not started | - |
| 9. Digital Signage & Events Engine | 0/TBD | Not started | - |
| 10. League, Season & Regional Tournaments | 0/TBD | Not started | - |
| 11. TDD Migration | 0/TBD | Not started | - |

View file

@ -9,10 +9,10 @@ See: .planning/PROJECT.md (updated 2026-02-28)
## Current Position ## Current Position
Phase: 1 of 11 (Foundation) Phase: 1 of 7 (Tournament Engine)
Plan: 0 of TBD in current phase Plan: 0 of 12 in current phase
Status: Ready to plan Status: Planning complete — ready to execute
Last activity: 2026-02-28 — Roadmap created (11 phases, 126 v1 requirements mapped) Last activity: 2026-03-01 — Phase 1 planned (12 plans, 5 waves, 68 requirements covered)
Progress: [░░░░░░░░░░] 0% Progress: [░░░░░░░░░░] 0%
@ -61,6 +61,6 @@ None yet.
## Session Continuity ## Session Continuity
Last session: 2026-02-28 Last session: 2026-03-01
Stopped at: Roadmap created, STATE.md initialized — ready to begin Phase 1 planning Stopped at: Phase 1 planning complete (12 plans, 5 waves) — ready to begin execution with Plan A (Wave 1)
Resume file: None Resume file: None

View file

@ -28,7 +28,9 @@ Note: The operator throughout this system is the **Tournament Director (TD)**
- **Built-in starter templates** ship with the app (Turbo, Standard, Deep Stack, WSOP-style) so day one has something to pick from - **Built-in starter templates** ship with the app (Turbo, Standard, Deep Stack, WSOP-style) so day one has something to pick from
- **Structure wizard** lives in template management — input player count, starting chips, desired duration, denominations → generates a blind structure to save as a block - **Structure wizard** lives in template management — input player count, starting chips, desired duration, denominations → generates a blind structure to save as a block
- **Minimum player threshold** — configured in tournament metadata (typically 8-9), Start button unavailable until met - **Minimum player threshold** — configured in tournament metadata (typically 8-9), Start button unavailable until met
- **Chip bonuses** — configurable per tournament: early signup bonus (before a date/time or first X players) and punctuality bonus (showed up on time) - **Chip bonuses** — configurable per tournament:
- Early signup bonus: bonus chips awarded to the first X players who buy in (configurable cutoff count)
- Punctuality bonus: bonus chips awarded to players who complete buy-in before the tournament starts (status transitions from 'registering' to 'running') — deterministic, automatic, no TD judgment call
- **Late registration soft lock with admin override** — when cutoff hits, registration locks but admin can push through a late entry (logged in audit trail). Some tournaments have no late reg at all (registration closes at start) - **Late registration soft lock with admin override** — when cutoff hits, registration locks but admin can push through a late entry (logged in audit trail). Some tournaments have no late reg at all (registration closes at start)
### Payout Structure ### Payout Structure
@ -70,6 +72,15 @@ Note: The operator throughout this system is the **Tournament Director (TD)**
- **Break Table is fully automatic** — system knows all open seats, distributes players evenly, TD just sees the result ("Player X → Table 1 Seat 4") - **Break Table is fully automatic** — system knows all open seats, distributes players evenly, TD just sees the result ("Player X → Table 1 Seat 4")
- **No drag-and-drop in Phase 1** — tap-tap flow for all moves - **No drag-and-drop in Phase 1** — tap-tap flow for all moves
### Hand-for-Hand Mode
- Activated by TD when tournament reaches bubble (remaining players = paid positions + 1)
- Clock pauses, each table plays one hand at a time
- TD view: table grid showing completion status per table (completed / in progress)
- TD taps "Hand Complete" per table as dealers report
- When all tables complete: next hand starts automatically, or if someone busted the bubble is resolved
- If bust during hand-for-hand: check if bubble burst, if yes exit hand-for-hand mode and resume clock
- Hand-for-hand should be prominent (visible mode indicator on all clients) because players watch this closely
### End-Game & Payouts ### End-Game & Payouts
- **Flexible chop/deal support** — all remaining players participate in a deal, nobody leaves while others play on. Supported scenarios: - **Flexible chop/deal support** — all remaining players participate in a deal, nobody leaves while others play on. Supported scenarios:
- ICM calculation (TD inputs chip stacks, system calculates each player's share) - ICM calculation (TD inputs chip stacks, system calculates each player's share)
@ -127,7 +138,6 @@ Note: The operator throughout this system is the **Tournament Director (TD)**
- **Drag-and-drop seat moves** — UI polish, add after core tap-tap flow works (future Phase 1 enhancement or later) - **Drag-and-drop seat moves** — UI polish, add after core tap-tap flow works (future Phase 1 enhancement or later)
- **PWA seat move notifications** — when a player is moved (balancing or table break), notify them in the PWA with new table/seat — Phase 2 - **PWA seat move notifications** — when a player is moved (balancing or table break), notify them in the PWA with new table/seat — Phase 2
- **"Keep apart" player marking** — TD marks players who shouldn't be at the same table (couples, etc.), auto-seating respects it — evaluate for Phase 1 or later - **"Keep apart" player marking** — TD marks players who shouldn't be at the same table (couples, etc.), auto-seating respects it — evaluate for Phase 1 or later
- **Chip bonus for early signup/punctuality** — captured in decisions, evaluate complexity during planning
</deferred> </deferred>

View file

@ -0,0 +1,148 @@
# Plan A: Project Scaffold + Core Infrastructure
---
wave: 1
depends_on: []
files_modified:
- go.mod
- go.sum
- cmd/leaf/main.go
- internal/server/server.go
- internal/server/ws/hub.go
- internal/server/middleware/auth.go
- internal/server/middleware/role.go
- internal/nats/embedded.go
- internal/nats/publisher.go
- internal/store/db.go
- frontend/embed.go
- Makefile
autonomous: true
requirements: [ARCH-01, ARCH-04, ARCH-05, ARCH-06, ARCH-07]
---
## Goal
A Go binary starts in the LXC container, embeds NATS JetStream (sync_interval: always) and LibSQL, serves HTTP via chi, hosts a WebSocket hub that broadcasts to connected clients within 100ms, and serves a SvelteKit SPA stub via go:embed. This is the skeleton that every subsequent plan builds on.
## Context
- **Greenfield project** — no existing code
- **Dev environment:** x86_64 LXC container (Debian/Ubuntu), Go 1.23+
- **go-libsql has no tagged releases** — pin to commit hash in go.mod with comment
- **NATS JetStream sync_interval: always** is mandatory (Jepsen 2025 finding for single-node)
- **coder/websocket** v1.8.14 for WebSocket hub (not gorilla/websocket)
- See 01-RESEARCH.md Pattern 1 (Embedded NATS), Pattern 2 (WebSocket Hub), Pattern 3 (SvelteKit Embed)
## User Decisions (from CONTEXT.md)
- The operator is the **Tournament Director (TD)** — use this term consistently
- Multi-tournament support means all state must be tournament-scoped from day one (MULTI-01)
- WebSocket hub must support tournament-scoped broadcasting
## Tasks
<task id="A1" title="Initialize Go module and dependency tree">
Create the Go module at the project root. Install all core dependencies pinned to specific versions:
- `github.com/tursodatabase/go-libsql` — pin to commit hash (check latest stable commit, add comment in go.mod)
- `github.com/nats-io/nats-server/v2` @ v2.12.4
- `github.com/nats-io/nats.go` @ latest
- `github.com/coder/websocket` @ v1.8.14
- `github.com/go-chi/chi/v5` @ v5.2.5
- `github.com/golang-jwt/jwt/v5` @ latest
- `golang.org/x/crypto` @ latest
- `github.com/google/uuid` @ latest
- `github.com/skip2/go-qrcode` @ latest
Create the directory structure per 01-RESEARCH.md Recommended Project Structure. Include empty `.go` files with package declarations where needed to make the module compile.
Create a Makefile with targets:
- `make build` — builds `cmd/leaf` binary
- `make run` — builds and runs with default data dir
- `make test` — runs all tests
- `make frontend` — builds the SvelteKit frontend (placeholder — just creates build dir with stub index.html)
- `make all` — frontend + build
The binary must compile and run (serving HTTP on :8080) with `make run`.
**Verification:** `go build ./cmd/leaf/...` succeeds. `go vet ./...` passes. Directory structure matches the recommended layout.
</task>
<task id="A2" title="Implement core infrastructure: NATS, LibSQL, WebSocket hub, HTTP server">
Wire up all core infrastructure in `cmd/leaf/main.go` and the `internal/` packages:
**1. Embedded NATS Server** (`internal/nats/embedded.go`):
- Start NATS server in-process with `DontListen: true` (no TCP listener)
- JetStream enabled with `JetStreamSyncInterval: 0` (sync_interval: always — MANDATORY per Jepsen finding)
- Memory limit: 64MB, Disk limit: 1GB
- Wait for ready with 5-second timeout
- Clean shutdown on context cancellation
- Create initial JetStream streams: `AUDIT` (subject: `tournament.*.audit`), `STATE` (subject: `tournament.*.state.*`)
**2. NATS Publisher** (`internal/nats/publisher.go`):
- Publish function that writes to JetStream with subject routing
- Helper to publish tournament-scoped events: `Publish(ctx, tournamentID, subject, data)`
**3. LibSQL Database** (`internal/store/db.go`):
- Open LibSQL with `sql.Open("libsql", "file:"+dbPath)`
- Enable WAL mode: `PRAGMA journal_mode=WAL`
- Enable foreign keys: `PRAGMA foreign_keys=ON`
- Configurable data directory (flag or env var)
- Close on context cancellation
**4. WebSocket Hub** (`internal/server/ws/hub.go`):
- Connection registry with mutex protection
- Client struct: conn, tournamentID (subscription scope), send channel (buffered 256)
- `Register(client)` / `Unregister(client)`
- `Broadcast(tournamentID, messageType, data)` — sends to all clients subscribed to that tournament (or all if tournamentID is empty)
- On connect: accept WebSocket, register client, send initial state snapshot (stub for now — just a "connected" message)
- Read pump and write pump goroutines per client
- Drop messages for slow consumers (non-blocking channel send)
- Graceful shutdown: close all connections
**5. HTTP Server** (`internal/server/server.go`):
- chi router with middleware: logger, recoverer, request ID, CORS (permissive for dev)
- Route groups:
- `/api/v1/` — REST API endpoints (stub 200 responses for now)
- `/ws` — WebSocket upgrade endpoint → Hub.HandleConnect
- `/*` — SvelteKit SPA fallback handler
- Configurable listen address (default `:8080`)
- Graceful shutdown with context
**6. SvelteKit Embed Stub** (`frontend/embed.go`):
- `//go:embed all:build` directive
- `Handler()` function that serves static files with SPA fallback
- For now, create a `frontend/build/index.html` stub file with minimal HTML that says "Felt — Loading..."
**7. Main** (`cmd/leaf/main.go`):
- Parse flags: `--data-dir` (default `./data`), `--addr` (default `:8080`)
- Startup order: LibSQL → NATS → Hub → HTTP Server
- Signal handling (SIGINT, SIGTERM) with graceful shutdown in reverse order
- Log startup and shutdown events
**Verification:**
- `make run` starts the binary, prints startup log showing LibSQL opened, NATS ready, HTTP listening on :8080
- `curl http://localhost:8080/` returns the stub HTML
- `curl http://localhost:8080/api/v1/health` returns 200 with JSON showing all subsystems operational (LibSQL: `SELECT 1` succeeds, NATS server ready, WebSocket hub accepting connections)
- A WebSocket client connecting to `ws://localhost:8080/ws` receives a connected message
- CTRL+C triggers graceful shutdown with log messages
</task>
## Verification Criteria
1. `go build ./cmd/leaf/...` succeeds with zero warnings
2. `go vet ./...` passes
3. `make run` starts the binary and serves HTTP on :8080
4. LibSQL database file created in data directory with WAL mode
5. NATS JetStream streams (AUDIT, STATE) exist after startup
6. WebSocket connection to `/ws` succeeds and receives initial message
7. SPA fallback serves index.html for unknown routes
8. Graceful shutdown on SIGINT/SIGTERM completes without errors
## Must-Haves (Goal-Backward)
- [ ] Go binary compiles and runs as a single process with all infrastructure embedded
- [ ] NATS JetStream uses sync_interval: always (data durability guarantee)
- [ ] WebSocket hub supports tournament-scoped broadcasting
- [ ] SvelteKit SPA served via go:embed with fallback routing
- [ ] All state is tournament-scoped from day one (no global singletons)

View file

@ -0,0 +1,277 @@
# Plan B: Database Schema + Migrations
---
wave: 1
depends_on: []
files_modified:
- internal/store/migrations/001_initial_schema.sql
- internal/store/migrations/002_fts_indexes.sql
- internal/store/migrate.go
- internal/store/db.go
autonomous: true
requirements: [ARCH-03, ARCH-08, PLYR-01, PLYR-07, SEAT-01, SEAT-02]
---
## Goal
The complete LibSQL database schema for Phase 1 is defined in migration files and auto-applied on startup. Every table, index, and FTS5 virtual table needed by the tournament engine exists. All financial columns use INTEGER (int64 cents, never float/real). The migration system is simple (sequential numbered SQL files, applied once, tracked in a migrations table).
## Context
- **LibSQL is SQLite-compatible** — standard SQL DDL, PRAGMA, triggers, FTS5 all work
- **All financial values are int64 cents** (ARCH-03) — every money column is INTEGER, never REAL
- **Append-only audit trail** (ARCH-08) — audit_entries table is INSERT only, never UPDATE/DELETE
- **Player database persistent on Leaf** (PLYR-01) — players table with UUID PK
- **FTS5 for typeahead search** (PLYR-02) — virtual table on player names
- **Tables and seats** (SEAT-01, SEAT-02) — tables, seats, blueprints
- Schema must support multi-tournament (all tournament-specific tables reference tournament_id)
## User Decisions (from CONTEXT.md)
- **Templates are compositions of reusable building blocks** — chip_sets, blind_structures, payout_structures, buyin_configs, points_formulas are all independent entities
- **Entry count = unique entries only** — not rebuys or add-ons (affects how we count entries for payout bracket selection)
- **Receipts configurable per venue** — venue_settings table needed
- **Chip bonuses** — early signup bonus, punctuality bonus fields in tournament config
- **Minimum player threshold** — field in tournament metadata
## Tasks
<task id="B1" title="Design and write the initial schema migration">
Create `internal/store/migrations/001_initial_schema.sql` containing all Phase 1 tables. Use `IF NOT EXISTS` on all CREATE statements. Every table gets `created_at` and `updated_at` timestamps (INTEGER, Unix epoch seconds).
**Venue & Settings:**
```sql
-- venue_settings: singleton row for venue-level config
-- Fields: id, venue_name, currency_code, currency_symbol, rounding_denomination (INTEGER cents),
-- receipt_mode (off/digital/print/both), timezone, created_at, updated_at
```
**Building Blocks (venue-level reusable):**
```sql
-- chip_sets: id, name, is_builtin, created_at, updated_at
-- chip_denominations: id, chip_set_id FK, value (INTEGER cents), color_hex, label, sort_order
-- blind_structures: id, name, is_builtin, game_type_default, notes, created_at, updated_at
-- blind_levels: id, structure_id FK, position (sort order), level_type (round/break),
-- game_type, small_blind (INTEGER), big_blind (INTEGER), ante (INTEGER), bb_ante (INTEGER),
-- duration_seconds, chip_up_denomination_value (INTEGER, nullable), notes
-- payout_structures: id, name, is_builtin, created_at, updated_at
-- payout_brackets: id, structure_id FK, min_entries, max_entries
-- payout_tiers: id, bracket_id FK, position, percentage_basis_points (INTEGER, e.g. 5000 = 50.00%)
-- buyin_configs: id, name, buyin_amount (INTEGER cents), starting_chips (INTEGER),
-- rake_total (INTEGER cents), bounty_amount (INTEGER cents), bounty_chip (INTEGER),
-- rebuy_allowed BOOLEAN, rebuy_cost (INTEGER), rebuy_chips (INTEGER), rebuy_rake (INTEGER),
-- rebuy_limit (INTEGER, 0=unlimited), rebuy_level_cutoff (INTEGER nullable),
-- rebuy_time_cutoff_seconds (INTEGER nullable), rebuy_chip_threshold (INTEGER nullable),
-- addon_allowed BOOLEAN, addon_cost (INTEGER), addon_chips (INTEGER), addon_rake (INTEGER),
-- addon_level_start (INTEGER nullable), addon_level_end (INTEGER nullable),
-- reentry_allowed BOOLEAN, reentry_limit (INTEGER),
-- late_reg_level_cutoff (INTEGER nullable), late_reg_time_cutoff_seconds (INTEGER nullable),
-- created_at, updated_at
-- rake_splits: id, buyin_config_id FK, category (house/staff/league/season_reserve), amount (INTEGER cents)
-- points_formulas: id, name, expression TEXT, variables TEXT (JSON), is_builtin, created_at, updated_at
```
**Tournament Templates:**
```sql
-- tournament_templates: id, name, description, chip_set_id FK, blind_structure_id FK,
-- payout_structure_id FK, buyin_config_id FK, points_formula_id FK (nullable),
-- min_players (INTEGER), max_players (INTEGER nullable),
-- early_signup_bonus_chips (INTEGER, default 0), early_signup_cutoff TEXT (nullable, datetime or player count),
-- punctuality_bonus_chips (INTEGER, default 0),
-- is_pko BOOLEAN DEFAULT FALSE,
-- is_builtin BOOLEAN DEFAULT FALSE,
-- created_at, updated_at
```
**Tournaments (runtime):**
```sql
-- tournaments: id (UUID), name, template_id FK (nullable, reference only),
-- status (created/registering/running/paused/final_table/completed/cancelled),
-- -- Copied config (local changes don't affect template):
-- chip_set_id FK, blind_structure_id FK, payout_structure_id FK, buyin_config_id FK,
-- points_formula_id FK (nullable),
-- min_players, max_players,
-- early_signup_bonus_chips, early_signup_cutoff,
-- punctuality_bonus_chips,
-- is_pko BOOLEAN,
-- -- Runtime state:
-- current_level INTEGER DEFAULT 0,
-- clock_state TEXT DEFAULT 'stopped', -- stopped/running/paused
-- clock_remaining_ns INTEGER DEFAULT 0,
-- total_elapsed_ns INTEGER DEFAULT 0,
-- hand_for_hand BOOLEAN DEFAULT FALSE,
-- started_at INTEGER (nullable), ended_at INTEGER (nullable),
-- created_at, updated_at
```
**Players:**
```sql
-- players: id (UUID), name, nickname (nullable), email (nullable), phone (nullable),
-- photo_url (nullable), notes TEXT (nullable), custom_fields TEXT (nullable, JSON),
-- created_at, updated_at
-- tournament_players: id, tournament_id FK, player_id FK, status (registered/active/busted/deal),
-- seat_table_id FK (nullable), seat_position INTEGER (nullable),
-- buy_in_at INTEGER (nullable), bust_out_at INTEGER (nullable),
-- bust_out_order INTEGER (nullable), -- position when busted (derived from bust order, not stored permanently)
-- finishing_position INTEGER (nullable), -- final position (set at tournament end or deal)
-- current_chips INTEGER DEFAULT 0,
-- rebuys INTEGER DEFAULT 0, addons INTEGER DEFAULT 0, reentries INTEGER DEFAULT 0,
-- bounty_value (INTEGER cents, for PKO — starts at half of bounty_amount),
-- bounties_collected INTEGER DEFAULT 0,
-- prize_amount (INTEGER cents, default 0),
-- points_awarded INTEGER DEFAULT 0,
-- early_signup_bonus_applied BOOLEAN DEFAULT FALSE,
-- punctuality_bonus_applied BOOLEAN DEFAULT FALSE,
-- hitman_player_id (nullable, FK players — who busted them),
-- created_at, updated_at
-- UNIQUE(tournament_id, player_id) -- one entry per player per tournament.
-- Re-entry reactivates same row: status → 'active', reentries += 1,
-- bust_out_at/bust_out_order/finishing_position/hitman_player_id are CLEARED (previous values preserved in audit trail).
-- Only the final bust matters for ranking purposes.
```
**Tables & Seating:**
```sql
-- tables: id, tournament_id FK, name TEXT, seat_count INTEGER (6-10),
-- dealer_button_position INTEGER (nullable), is_active BOOLEAN DEFAULT TRUE,
-- created_at, updated_at
-- table_blueprints: id, name, table_configs TEXT (JSON array of {name, seat_count}),
-- created_at, updated_at
-- balance_suggestions: id, tournament_id FK, status (pending/accepted/cancelled/expired),
-- from_table_id FK, to_table_id FK,
-- player_id FK (nullable — set when suggestion specifies a player),
-- from_seat INTEGER (nullable), to_seat INTEGER (nullable),
-- reason TEXT, created_at, resolved_at INTEGER (nullable)
```
**Financial Transactions:**
```sql
-- transactions: id (UUID), tournament_id FK, player_id FK,
-- type (buyin/rebuy/addon/reentry/bounty_collected/bounty_paid/payout/rake/chop/bubble_prize),
-- amount (INTEGER cents), chips (INTEGER, chips given/removed),
-- operator_id TEXT, -- who performed the action
-- receipt_data TEXT (nullable, JSON),
-- undone BOOLEAN DEFAULT FALSE, undone_by TEXT (nullable, FK audit_entries.id),
-- metadata TEXT (nullable, JSON — for bounty chain info, chop details, etc),
-- created_at
-- bubble_prizes: id, tournament_id FK, amount (INTEGER cents),
-- funded_from TEXT (JSON — array of {position, reduction_amount}),
-- status (proposed/confirmed/cancelled), created_at
```
**Audit Trail:**
```sql
-- audit_entries: id (UUID), tournament_id TEXT (nullable — some are venue-level),
-- timestamp INTEGER (UnixNano), operator_id TEXT,
-- action TEXT (e.g. 'player.bust', 'financial.buyin', 'clock.pause', 'seat.move'),
-- target_type TEXT, target_id TEXT,
-- previous_state TEXT (JSON), new_state TEXT (JSON),
-- metadata TEXT (nullable, JSON),
-- undone_by TEXT (nullable, references audit_entries.id)
-- -- NO UPDATE OR DELETE — append only
```
**Operators:**
```sql
-- operators: id (UUID), name TEXT, pin_hash TEXT (bcrypt), role TEXT (admin/floor/viewer),
-- created_at, updated_at
```
Add indexes on all foreign keys and common query patterns:
- `tournament_players(tournament_id, status)` for active player lookups
- `tournament_players(tournament_id, bust_out_order)` for rankings
- `transactions(tournament_id, type)` for financial summaries
- `transactions(tournament_id, player_id)` for player transaction history
- `audit_entries(tournament_id, timestamp)` for audit log browsing
- `audit_entries(action)` for action filtering
- `tables(tournament_id, is_active)` for active table lookups
**Verification:** The SQL file is valid and can be executed against a fresh LibSQL database without errors.
</task>
<task id="B2" title="Implement migration runner and FTS5 indexes">
**1. FTS5 Migration** (`internal/store/migrations/002_fts_indexes.sql`):
Create FTS5 virtual table for player search:
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS players_fts USING fts5(
name, nickname, email,
content='players',
content_rowid='rowid'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER IF NOT EXISTS players_ai AFTER INSERT ON players BEGIN
INSERT INTO players_fts(rowid, name, nickname, email)
VALUES (new.rowid, new.name, new.nickname, new.email);
END;
CREATE TRIGGER IF NOT EXISTS players_ad AFTER DELETE ON players BEGIN
INSERT INTO players_fts(players_fts, rowid, name, nickname, email)
VALUES ('delete', old.rowid, old.name, old.nickname, old.email);
END;
CREATE TRIGGER IF NOT EXISTS players_au AFTER UPDATE ON players BEGIN
INSERT INTO players_fts(players_fts, rowid, name, nickname, email)
VALUES ('delete', old.rowid, old.name, old.nickname, old.email);
INSERT INTO players_fts(rowid, name, nickname, email)
VALUES (new.rowid, new.name, new.nickname, new.email);
END;
```
**2. Migration Runner** (`internal/store/migrate.go`):
- Create a `_migrations` table to track applied migrations: `(id INTEGER PRIMARY KEY, name TEXT, applied_at INTEGER)`
- On startup, read all `*.sql` files from the embedded migrations directory (use `//go:embed migrations/*.sql`)
- Sort by filename (numeric prefix ensures order)
- For each migration not yet applied: execute within a transaction, record in _migrations table
- Log each migration applied
- Return error if any migration fails (don't apply partial migrations)
**3. Wire into db.go:**
- After opening the database and setting PRAGMAs, call `RunMigrations(db)` automatically
- Add a `//go:embed migrations/*.sql` directive in migrate.go to embed migration SQL files
**4. Seed data:**
Create `internal/store/migrations/003_seed_data.sql`:
- Insert a default admin operator (name: "Admin", PIN hash for "1234", role: "admin")
- Insert a default venue_settings row (currency: DKK, symbol: kr, rounding: 5000 = 50 kr, receipt_mode: digital)
- Insert a default chip set ("Standard") with common denominations:
- 25 (white, #FFFFFF), 100 (red, #FF0000), 500 (green, #00AA00), 1000 (black, #000000), 5000 (blue, #0000FF)
- Insert a second chip set ("Copenhagen") with DKK-friendly denominations:
- 100 (white), 500 (red), 1000 (green), 5000 (black), 10000 (blue)
**Verification:**
- `make run` starts the binary and auto-applies all 3 migrations
- Second startup skips already-applied migrations (idempotent)
- `SELECT * FROM _migrations` shows all 3 rows
- `SELECT * FROM operators` shows the default admin
- `SELECT * FROM chip_denominations` shows 5 denominations
- FTS5 search works: insert a player, query `SELECT * FROM players_fts WHERE players_fts MATCH 'searchterm'`
</task>
## Verification Criteria
1. All migration SQL files are syntactically valid
2. Migrations auto-apply on first startup, skip on subsequent startups
3. All financial columns use INTEGER type (grep for REAL/FLOAT returns zero hits in schema)
4. FTS5 virtual table syncs with players table via triggers
5. Default seed data (admin operator, venue settings, chip set) exists after first startup
6. Foreign key constraints are enforced (test by inserting invalid FK)
7. Schema supports multi-tournament (tournament_id FK on all tournament-specific tables)
## Must-Haves (Goal-Backward)
- [ ] Every money column is INTEGER (int64 cents) — zero float64 in the schema
- [ ] Audit trail table is append-only by design (no UPDATE trigger needed — enforce in application code)
- [ ] Player table exists with UUID PK for cross-venue portability
- [ ] FTS5 enables typeahead search on player names
- [ ] All tournament-specific tables reference tournament_id for multi-tournament support
- [ ] Migration system is embedded and runs automatically on startup

View file

@ -0,0 +1,186 @@
# Plan C: Authentication + Audit Trail + Undo Engine
---
wave: 2
depends_on: [01-PLAN-A, 01-PLAN-B]
files_modified:
- internal/auth/pin.go
- internal/auth/jwt.go
- internal/server/middleware/auth.go
- internal/server/middleware/role.go
- internal/audit/trail.go
- internal/audit/undo.go
- internal/server/routes/auth.go
- internal/auth/pin_test.go
- internal/audit/trail_test.go
- internal/audit/undo_test.go
autonomous: true
requirements: [AUTH-01, AUTH-03, ARCH-08, PLYR-06]
---
## Goal
Operators authenticate with a PIN that produces a local JWT with role claims. Every state-changing action writes an immutable audit trail entry to LibSQL and publishes to NATS JetStream. Any financial action or bust-out can be undone with full state reversal and re-ranking. This is the security and accountability foundation for the entire system.
## Context
- **PIN login flow:** Operator enters PIN → bcrypt compare against all operators → JWT issued with role claim (admin/floor/viewer)
- **Rate limiting:** Exponential backoff after 5 failures, lockout after 10 (AUTH-05 spec, applied here to operator PIN too)
- **JWT:** HS256 signed, 7-day expiry (local system, not internet-exposed — prevents mid-tournament logouts during 12+ hour sessions), claims: sub (operator ID), role, iat, exp
- **Audit trail:** Every mutation writes to `audit_entries` table AND publishes to NATS `tournament.{id}.audit`
- **Undo:** Creates a NEW audit entry that reverses the original — never deletes/modifies existing entries
- See 01-RESEARCH.md Pattern 4 (Event-Sourced Audit Trail), PIN Authentication Flow
## User Decisions (from CONTEXT.md)
- **Undo is critical** — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking
- **Late registration soft lock with admin override** — logged in audit trail
- **Operator is the Tournament Director (TD)**
- **Roles:** Admin (full control), Floor (runtime actions), Viewer (read-only)
## Tasks
<task id="C1" title="Implement PIN authentication with JWT issuance">
**1. PIN Service** (`internal/auth/pin.go`):
- `AuthService` struct with db, signing key, rate limiter
- `Login(ctx, pin string) (token string, operator Operator, err error)`:
- Check rate limiter — return `ErrTooManyAttempts` if blocked
- Load all operators from DB
- Try bcrypt.CompareHashAndPassword against each operator's pin_hash
- On match: reset failure counter, issue JWT, return token + operator
- On no match: record failure, return `ErrInvalidPIN`
- Rate limiter (in-memory, per-IP or global for simplicity):
- Track consecutive failures
- After 5 failures: 30-second delay
- After 8 failures: 5-minute delay
- After 10 failures: 30-minute lockout
- Reset on successful login
- `HashPIN(pin string) (string, error)` — bcrypt with cost 12 (for seed data and operator management)
- `CreateOperator(ctx, name, pin, role string) error` — insert operator with hashed PIN
- `ListOperators(ctx) ([]Operator, error)` — for admin management
- `UpdateOperator(ctx, id, name, pin, role string) error`
**2. JWT Service** (`internal/auth/jwt.go`):
- `NewToken(operatorID, role string) (string, error)` — creates HS256-signed JWT with claims: sub, role, iat, exp (24h)
- `ValidateToken(tokenString string) (*Claims, error)` — parses and validates JWT, returns claims
- Claims struct: `OperatorID string`, `Role string` (admin/floor/viewer)
- Signing key: generated randomly on first startup, stored in LibSQL `_config` table (persisted across restarts)
**3. Auth Middleware** (`internal/server/middleware/auth.go`):
- Extract JWT from `Authorization: Bearer <token>` header
- Validate token, extract claims
- Store claims in request context (`context.WithValue`)
- Return 401 if missing/invalid/expired
- Public routes (health, static files, WebSocket initial connect) bypass auth
**4. Role Middleware** (`internal/server/middleware/role.go`):
- `RequireRole(roles ...string)` middleware factory
- Extract claims from context, check if role is in allowed list
- Return 403 if insufficient role
- Role hierarchy: admin > floor > viewer (admin can do everything floor can do)
**5. Auth Routes** (`internal/server/routes/auth.go`):
- `POST /api/v1/auth/login` — body: `{"pin": "1234"}` → response: `{"token": "...", "operator": {...}}`
- `GET /api/v1/auth/me` — returns current operator from JWT claims
- `POST /api/v1/auth/logout` — client-side only (JWT is stateless), but endpoint exists for audit logging
**6. Tests** (`internal/auth/pin_test.go`):
- Test successful login returns valid JWT
- Test wrong PIN returns ErrInvalidPIN
- Test rate limiting kicks in after 5 failures
- Test lockout after 10 failures
- Test successful login resets failure counter
- Test JWT validation with expired token returns error
- Test role middleware blocks insufficient roles
**Verification:**
- `POST /api/v1/auth/login` with PIN "1234" returns a JWT token
- The token can be used to access protected endpoints
- Wrong PIN returns 401
- Rate limiting activates after 5 rapid failures
- Role middleware blocks floor from admin-only endpoints
</task>
<task id="C2" title="Implement audit trail and undo engine">
**1. Audit Trail** (`internal/audit/trail.go`):
- `AuditTrail` struct with db, nats publisher
- `Record(ctx, entry AuditEntry) error`:
- Generate UUID for entry ID
- Set timestamp to `time.Now().UnixNano()`
- Extract operator ID from context (set by auth middleware)
- Insert into `audit_entries` table in LibSQL
- Publish to NATS JetStream subject `tournament.{tournament_id}.audit`
- If tournament_id is empty (venue-level action), publish to `venue.audit`
- `AuditEntry` struct per 01-RESEARCH.md Pattern 4:
- ID, TournamentID, Timestamp, OperatorID, Action, TargetType, TargetID
- PreviousState (json.RawMessage), NewState (json.RawMessage)
- Metadata (json.RawMessage, optional)
- UndoneBy (*string, nullable)
- `GetEntries(ctx, tournamentID string, limit, offset int) ([]AuditEntry, error)` — paginated audit log
- `GetEntry(ctx, entryID string) (*AuditEntry, error)` — single entry lookup
- Action constants: define all action strings as constants:
- `player.buyin`, `player.bust`, `player.rebuy`, `player.addon`, `player.reentry`
- `financial.buyin`, `financial.rebuy`, `financial.addon`, `financial.payout`, `financial.chop`, `financial.bubble_prize`
- `clock.start`, `clock.pause`, `clock.resume`, `clock.advance`, `clock.rewind`, `clock.jump`
- `seat.assign`, `seat.move`, `seat.balance`, `seat.break_table`
- `tournament.create`, `tournament.start`, `tournament.end`, `tournament.cancel`
- `template.create`, `template.update`, `template.delete`
- `operator.login`, `operator.logout`
- `undo.*` — mirrors each action type
**2. Undo Engine** (`internal/audit/undo.go`):
- `UndoEngine` struct with db, audit trail, nats publisher
- `Undo(ctx, auditEntryID string) error`:
- Load the original audit entry
- Verify it hasn't already been undone (check `undone_by` field)
- Create a NEW audit entry with:
- Action: `undo.{original_action}` (e.g., `undo.player.bust`)
- PreviousState: original's NewState
- NewState: original's PreviousState
- Metadata: `{"undone_entry_id": "original-id"}`
- Update the original entry's `undone_by` field to point to the new entry (this is the ONE exception to append-only — it marks an entry as undone, not deleting it)
- Return the undo entry for the caller to perform the actual state reversal
- `CanUndo(ctx, auditEntryID string) (bool, error)` — checks if entry is undoable (not already undone, action type is undoable)
- Undoable actions list: `player.bust`, `player.buyin`, `player.rebuy`, `player.addon`, `player.reentry`, `financial.*`, `seat.move`, `seat.balance`
- Non-undoable actions: `clock.*`, `tournament.start`, `tournament.end`, `operator.*`
**3. Integration with existing infrastructure:**
- Add `AuditTrail` to the server struct, pass to all route handlers
- Audit trail is a cross-cutting concern — every route handler that mutates state calls `audit.Record()`
- Wire up NATS publisher to audit trail
**4. Tests** (`internal/audit/trail_test.go`, `internal/audit/undo_test.go`):
- Test audit entry is persisted to LibSQL with all fields
- Test audit entry is published to NATS JetStream
- Test undo creates new entry and marks original as undone
- Test double-undo returns error
- Test non-undoable action returns error
- Test GetEntries pagination works correctly
- Test operator ID is extracted from context
**Verification:**
- Every state mutation creates an audit_entries row with correct previous/new state
- NATS JetStream receives the audit event on the correct subject
- Undo creates a reversal entry and marks the original
- Double-undo is rejected
- Audit log is queryable by tournament, action type, and time range
</task>
## Verification Criteria
1. PIN login produces valid JWT with role claims
2. Auth middleware rejects requests without valid JWT
3. Role middleware enforces admin/floor/viewer hierarchy
4. Rate limiting activates after 5 failed login attempts
5. Every state mutation produces an audit_entries row
6. Undo creates a new audit entry (never deletes the original)
7. Double-undo is rejected with clear error
8. All tests pass
## Must-Haves (Goal-Backward)
- [ ] Operator PIN → local JWT works offline (no external IdP dependency)
- [ ] Three roles (Admin, Floor, Viewer) enforced on all API endpoints
- [ ] Every state-changing action writes an append-only audit trail entry
- [ ] Financial transactions and bust-outs can be undone with audit trail reversal
- [ ] Undo never deletes or overwrites existing audit entries (only marks them and creates new ones)

View file

@ -0,0 +1,237 @@
# Plan D: Clock Engine
---
wave: 2
depends_on: [01-PLAN-A, 01-PLAN-B]
files_modified:
- internal/clock/engine.go
- internal/clock/ticker.go
- internal/clock/warnings.go
- internal/server/routes/clock.go
- internal/clock/engine_test.go
- internal/clock/warnings_test.go
autonomous: true
requirements: [CLOCK-01, CLOCK-02, CLOCK-03, CLOCK-04, CLOCK-05, CLOCK-06, CLOCK-07, CLOCK-08, CLOCK-09]
---
## Goal
A server-authoritative tournament clock counts down each level with millisecond precision, transitions automatically between levels (rounds and breaks), supports pause/resume/advance/rewind/jump, emits configurable warnings, and broadcasts state to all WebSocket clients at 1/sec (10/sec in final 10s). Reconnecting clients receive a full clock snapshot immediately. Multiple clock engines run independently for multi-tournament support.
## Context
- **Server-authoritative clock** — clients NEVER run their own timer. Server sends absolute state (level, remaining_ms, is_paused, server_timestamp), clients calculate display from server time
- **Tick rates:** 1/sec normal, 10/sec in final 10 seconds of each level (CLOCK-08)
- **Clock state machine:** stopped → running ↔ paused → stopped (CLOCK-03)
- **Level transitions are automatic** — when a level's time expires, advance to next level (CLOCK-01)
- **Breaks have distinct treatment** — levels can be round or break type (CLOCK-02)
- See 01-RESEARCH.md: Clock Engine State Machine code example, Pitfall 5 (Clock Drift)
## User Decisions (from CONTEXT.md)
- **Overview tab priority:** Clock & current level is the biggest element (top priority)
- **Multi-tournament switching** — each tournament has its own independent clock (MULTI-01)
- **Hand-for-hand mode** (SEAT-09) — clock pauses, per-hand deduction. The clock engine must support this mode
## Tasks
<task id="D1" title="Implement clock engine state machine and ticker">
**1. Clock Engine** (`internal/clock/engine.go`):
- `ClockEngine` struct:
- `mu sync.RWMutex`
- `tournamentID string`
- `state ClockState` (stopped/running/paused)
- `levels []Level` — the blind structure levels for this tournament
- `currentLevel int` — index into levels
- `remainingNs int64` — nanoseconds remaining in current level
- `lastTick time.Time` — monotonic clock reference for drift-free timing
- `totalElapsedNs int64` — total tournament time (excludes paused time)
- `pausedAt *time.Time` — when pause started (for elapsed tracking)
- `handForHand bool` — hand-for-hand mode flag
- `hub *ws.Hub` — WebSocket hub for broadcasting
- `auditTrail *audit.AuditTrail` — for recording clock actions
- `Level` struct:
- `Position int`
- `LevelType string` — "round" or "break"
- `GameType string` — e.g., "nlhe", "plo", "horse" (for mixed game rotation)
- `SmallBlind int64` (cents)
- `BigBlind int64` (cents)
- `Ante int64` (cents)
- `BBAnte int64` (cents, big blind ante)
- `DurationSeconds int`
- `ChipUpDenomination *int64` (nullable)
- `Notes string`
- State machine methods:
- `Start()` — transition from stopped → running, set lastTick, record audit entry
- `Pause()` — transition from running → paused, record remaining time, record audit entry
- `Resume()` — transition from paused → running, reset lastTick, record audit entry
- `Stop()` — transition to stopped (tournament end)
- `AdvanceLevel()` — move to next level (CLOCK-04 forward)
- `RewindLevel()` — move to previous level (CLOCK-04 backward)
- `JumpToLevel(levelIndex int)` — jump to any level by index (CLOCK-05)
- `SetHandForHand(enabled bool)` — toggle hand-for-hand flag on the clock snapshot (called by seating engine, SEAT-09). When enabled, clock pauses. When disabled, clock resumes.
- `advanceLevel()` (internal, called when timer expires):
- If current level is the last level, enter overtime (repeat last level or stop — configurable)
- Otherwise, increment currentLevel, set remainingNs from new level's DurationSeconds
- Emit level_change event via WebSocket broadcast
- If new level is a break, emit break_start event
- If previous level was a break, emit break_end event
- `Snapshot() ClockSnapshot` — returns current state for reconnecting clients (CLOCK-09):
```go
type ClockSnapshot struct {
TournamentID string `json:"tournament_id"`
State string `json:"state"` // "stopped", "running", "paused"
CurrentLevel int `json:"current_level"`
Level Level `json:"level"` // Current level details
NextLevel *Level `json:"next_level"` // Preview of next level (nullable if last)
RemainingMs int64 `json:"remaining_ms"`
TotalElapsedMs int64 `json:"total_elapsed_ms"`
ServerTimeMs int64 `json:"server_time_ms"` // For client drift correction
HandForHand bool `json:"hand_for_hand"`
LevelCount int `json:"level_count"` // Total levels in structure
Warnings []Warning `json:"warnings"` // Active warning thresholds
}
```
- `LoadLevels(levels []Level)` — load blind structure levels into the engine
**2. Ticker** (`internal/clock/ticker.go`):
- `StartTicker(ctx context.Context, engine *ClockEngine)`:
- Run in a goroutine
- Normal mode: tick every 100ms (check if second changed, broadcast at 1/sec)
- Final 10s mode: broadcast every 100ms (10/sec effective)
- On each tick:
1. Lock engine mutex
2. If state is not running, skip
3. Calculate elapsed since lastTick using monotonic clock
4. Subtract from remainingNs
5. Update totalElapsedNs
6. Check for level transition (remainingNs <= 0)
7. Determine if broadcast is needed (1/sec or 10/sec)
8. Build ClockSnapshot
9. Unlock mutex
10. Broadcast via hub (outside the lock)
- Use `time.NewTicker(100 * time.Millisecond)` for consistent 100ms checks
- Stop ticker on context cancellation
**3. Clock Registry** (in engine.go or separate):
- `ClockRegistry` — manages multiple clock engines (one per tournament)
- `GetOrCreate(tournamentID string, hub *Hub) *ClockEngine`
- `Get(tournamentID string) *ClockEngine`
- `Remove(tournamentID string)` — cleanup after tournament ends
- Thread-safe with sync.RWMutex
**Verification:**
- Create a clock engine, load 3 levels (2 rounds + 1 break), start it
- Clock counts down, advancing through levels automatically
- Pause stops the countdown, resume continues from where it left
- Snapshot returns correct remaining time at any moment
- Multiple clock engines run independently with different levels
</task>
<task id="D2" title="Implement clock warnings and API routes">
**1. Warning System** (`internal/clock/warnings.go`):
- `WarningThreshold` struct: `Seconds int`, `Type string` (audio/visual/both), `SoundID string`, `Message string`
- Default warning thresholds: 60s, 30s, 10s (configurable per tournament)
- `checkWarnings()` method on ClockEngine:
- Check if remainingNs just crossed a threshold (was above, now below)
- If crossed, emit warning event via WebSocket: `{type: "clock.warning", seconds: 60, sound: "warning_60s"}`
- Track which warnings have been emitted for current level (reset on level change)
- Don't re-emit if already emitted (prevent duplicate warnings on tick boundary)
- Level change sound event: when level changes, emit `{type: "clock.level_change", sound: "level_change"}`
- Break start/end sounds: emit `{type: "clock.break_start", sound: "break_start"}` and `break_end`
**2. Clock API Routes** (`internal/server/routes/clock.go`):
All routes require auth middleware. Mutation routes require admin or floor role.
- `POST /api/v1/tournaments/{id}/clock/start` — start the clock
- Load blind structure from DB, create/get clock engine, call Start()
- Response: ClockSnapshot
- `POST /api/v1/tournaments/{id}/clock/pause` — pause
- Response: ClockSnapshot
- `POST /api/v1/tournaments/{id}/clock/resume` — resume
- Response: ClockSnapshot
- `POST /api/v1/tournaments/{id}/clock/advance` — advance to next level
- Response: ClockSnapshot
- `POST /api/v1/tournaments/{id}/clock/rewind` — go back to previous level
- Response: ClockSnapshot
- `POST /api/v1/tournaments/{id}/clock/jump` — body: `{"level": 5}`
- Validate level index is within range
- Response: ClockSnapshot
- `GET /api/v1/tournaments/{id}/clock` — get current clock state
- Response: ClockSnapshot
- This is also what WebSocket clients receive on connect
- `PUT /api/v1/tournaments/{id}/clock/warnings` — body: `{"warnings": [{"seconds": 60, "type": "both", "sound": "warning"}]}`
- Update warning thresholds for this tournament
- Response: updated warnings config
All mutation endpoints:
- Record audit entry (action, previous state, new state)
- Persist clock state to tournament record in DB (currentLevel, remainingNs, state)
- Broadcast updated state via WebSocket hub
**3. Clock State Persistence:**
- On every meaningful state change (pause, resume, level advance, jump), persist to DB:
- `UPDATE tournaments SET current_level = ?, clock_state = ?, clock_remaining_ns = ?, total_elapsed_ns = ? WHERE id = ?`
- On startup, if a tournament was "running" when the server stopped, resume with adjusted remaining time (or pause it — safer for crash recovery)
**4. Tests** (`internal/clock/engine_test.go`, `internal/clock/warnings_test.go`):
- Test clock counts down correctly over simulated time
- Test pause preserves remaining time exactly
- Test resume continues from paused position
- Test level auto-advance when time expires
- Test jump to specific level sets correct remaining time
- Test rewind to previous level
- Test warning threshold detection (crossing boundary)
- Test warning not re-emitted for same level
- Test hand-for-hand mode pauses clock
- Test multiple independent engines don't interfere
- Test crash recovery: clock persisted as "running" resumes correctly on startup
- Test snapshot includes all required fields (CLOCK-09)
- Test total elapsed time excludes paused periods
**Verification:**
- Clock API endpoints work via curl
- Clock ticks appear on WebSocket connection
- Warning events fire at correct thresholds
- Level transitions happen automatically
- Clock state survives server restart
</task>
## Verification Criteria
1. Clock counts down each level with second-granularity display and transitions automatically
2. Breaks display with distinct visual treatment (level_type: "break" in snapshot)
3. Pause/resume works with correct remaining time preservation
4. Forward/backward level advance works
5. Jump to any level by number works
6. Total elapsed time displays correctly (excludes paused time)
7. Warning events fire at configured thresholds (60s, 30s, 10s default)
8. WebSocket clients receive clock ticks at 1/sec (10/sec in final 10s)
9. Reconnecting WebSocket clients receive full clock snapshot immediately
10. Multiple tournament clocks run independently
11. Clock state persists to DB and survives server restart
## Must-Haves (Goal-Backward)
- [ ] Server-authoritative clock — clients never run their own timer
- [ ] Automatic level transitions (rounds and breaks)
- [ ] Pause/resume with visual indicator data in snapshot
- [ ] Jump to any level by number
- [ ] Configurable warning thresholds with audio/visual events
- [ ] 1/sec normal ticks, 10/sec final 10 seconds
- [ ] Full clock snapshot on WebSocket connect (reconnection)
- [ ] Independent clock per tournament (multi-tournament)
- [ ] Clock state persisted to DB (crash recovery)

View file

@ -0,0 +1,252 @@
# Plan E: Blind Structure + Chip Sets + Templates
---
wave: 2
depends_on: [01-PLAN-A, 01-PLAN-B]
files_modified:
- internal/blind/structure.go
- internal/blind/wizard.go
- internal/blind/templates.go
- internal/template/chipset.go
- internal/template/payout.go
- internal/template/buyin.go
- internal/template/tournament.go
- internal/server/routes/templates.go
- internal/blind/wizard_test.go
- internal/template/tournament_test.go
autonomous: true
requirements: [BLIND-01, BLIND-02, BLIND-03, BLIND-04, BLIND-05, BLIND-06, CHIP-01, CHIP-02, CHIP-03, CHIP-04, FIN-01, FIN-02, FIN-05, FIN-06, FIN-10]
---
## Goal
All reusable building blocks — chip sets, blind structures, payout structures, buy-in configs — have full CRUD with API endpoints. Built-in templates (Turbo, Standard, Deep Stack, WSOP-style) ship as seed data. The structure wizard generates a blind structure from inputs (player count, starting chips, duration, denominations). Tournament templates compose these building blocks. Template management is a dedicated area.
## Context
- **Templates are compositions of building blocks** — not monolithic configs (CONTEXT.md locked decision)
- **Building blocks are venue-level** — shared across tournaments
- **Local changes by default** — tournament gets a copy, edits don't affect the template
- **Structure wizard** lives in template management (CONTEXT.md locked decision)
- **Built-in templates** ship with the app (BLIND-05)
- **Big Blind Ante** support alongside standard ante (BLIND-02)
- **Mixed game rotation** (HORSE, 8-Game) via game type per level (BLIND-03)
- See 01-RESEARCH.md for blind wizard algorithm description
## User Decisions (from CONTEXT.md)
- **Template-first creation** — TD picks a template, everything pre-fills, tweak for tonight, Start
- **Building blocks feel like LEGO** — pick chip set, pick blind structure, pick payout table, name it, done
- **Dedicated template management area** — create from scratch, duplicate/edit existing, save tournament config as new template
- **Entry count = unique entries only** — for payout bracket selection
- **Prize rounding** — round down to nearest venue-configured denomination
## Tasks
<task id="E1" title="Implement building block CRUD: chip sets, blind structures, payout structures, buy-in configs">
**1. Chip Set Service** (`internal/template/chipset.go`):
- `ChipSet` struct: ID, Name, IsBuiltin, CreatedAt, UpdatedAt
- `ChipDenomination` struct: ID, ChipSetID, Value (int64 cents), ColorHex, Label, SortOrder
- CRUD operations:
- `CreateChipSet(ctx, name string, denominations []ChipDenomination) (*ChipSet, error)`
- `GetChipSet(ctx, id string) (*ChipSet, error)` — includes denominations
- `ListChipSets(ctx) ([]ChipSet, error)`
- `UpdateChipSet(ctx, id, name string, denominations []ChipDenomination) error`
- `DeleteChipSet(ctx, id string) error` — fail if referenced by active tournament
- `DuplicateChipSet(ctx, id string, newName string) (*ChipSet, error)`
- Built-in chip sets cannot be deleted (is_builtin = true), but can be duplicated
**2. Blind Structure Service** (`internal/blind/structure.go`):
- `BlindStructure` struct: ID, Name, IsBuiltin, GameTypeDefault, Notes, CreatedAt, UpdatedAt
- `BlindLevel` struct per BLIND-01:
- Position, LevelType (round/break), GameType
- SmallBlind, BigBlind, Ante, BBAnte (all int64) — BLIND-02
- DurationSeconds, ChipUpDenominationValue (*int64 nullable) — CHIP-02
- Notes
- CRUD operations:
- `CreateStructure(ctx, name string, levels []BlindLevel) (*BlindStructure, error)`
- `GetStructure(ctx, id string) (*BlindStructure, error)` — includes levels ordered by position
- `ListStructures(ctx) ([]BlindStructure, error)`
- `UpdateStructure(ctx, id string, name string, levels []BlindLevel) error`
- `DeleteStructure(ctx, id string) error`
- `DuplicateStructure(ctx, id string, newName string) (*BlindStructure, error)`
- Validation:
- At least one round level required
- Small blind < big blind
- Duration > 0 for all levels
- Positions must be contiguous starting from 0
**3. Payout Structure Service** (`internal/template/payout.go`):
- `PayoutStructure` struct: ID, Name, IsBuiltin, CreatedAt, UpdatedAt
- `PayoutBracket` struct: ID, StructureID, MinEntries, MaxEntries
- `PayoutTier` struct: ID, BracketID, Position, PercentageBasisPoints (int64, 5000 = 50.00%)
- CRUD with brackets and tiers as nested entities
- Validation:
- Brackets must cover contiguous ranges (no gaps)
- Tier percentages per bracket must sum to exactly 10000 (100.00%)
- At least one bracket required
**4. Buy-in Config Service** (`internal/template/buyin.go`):
- `BuyinConfig` struct with all fields from schema: buyin amount, starting chips, rake, bounty, rebuy config, addon config, reentry config, late reg config
- `RakeSplit` struct: Category (house/staff/league/season_reserve), Amount (int64 cents)
- CRUD operations
- Validation:
- Rake splits sum must equal rake_total
- All amounts must be non-negative
- Rebuy/addon limits must be non-negative
- If bounty_amount > 0, bounty_chip must be > 0
**5. API Routes** (`internal/server/routes/templates.go`):
All routes require auth. Create/update/delete require admin role. List/get are floor+ accessible.
Chip Sets:
- `GET /api/v1/chip-sets` — list all
- `GET /api/v1/chip-sets/{id}` — get with denominations
- `POST /api/v1/chip-sets` — create
- `PUT /api/v1/chip-sets/{id}` — update
- `DELETE /api/v1/chip-sets/{id}` — delete
- `POST /api/v1/chip-sets/{id}/duplicate` — duplicate
Blind Structures:
- `GET /api/v1/blind-structures` — list all
- `GET /api/v1/blind-structures/{id}` — get with levels
- `POST /api/v1/blind-structures` — create
- `PUT /api/v1/blind-structures/{id}` — update
- `DELETE /api/v1/blind-structures/{id}` — delete
- `POST /api/v1/blind-structures/{id}/duplicate` — duplicate
Payout Structures:
- `GET /api/v1/payout-structures` — list all
- `GET /api/v1/payout-structures/{id}` — get with brackets and tiers
- `POST /api/v1/payout-structures` — create
- `PUT /api/v1/payout-structures/{id}` — update
- `DELETE /api/v1/payout-structures/{id}` — delete
- `POST /api/v1/payout-structures/{id}/duplicate` — duplicate
Buy-in Configs:
- `GET /api/v1/buyin-configs` — list all
- `GET /api/v1/buyin-configs/{id}` — get
- `POST /api/v1/buyin-configs` — create
- `PUT /api/v1/buyin-configs/{id}` — update
- `DELETE /api/v1/buyin-configs/{id}` — delete
- `POST /api/v1/buyin-configs/{id}/duplicate` — duplicate
All mutations record audit entries.
**Verification:**
- Full CRUD cycle for each building block type via curl
- Validation rejects invalid inputs (e.g., payout tiers not summing to 100%)
- Built-in items cannot be deleted
- Duplicate creates independent copy
</task>
<task id="E2" title="Implement tournament templates, built-in seed data, and structure wizard">
**1. Tournament Template Service** (`internal/template/tournament.go`):
- `TournamentTemplate` struct: ID, Name, Description, ChipSetID, BlindStructureID, PayoutStructureID, BuyinConfigID, PointsFormulaID (nullable), MinPlayers, MaxPlayers, EarlySignupBonusChips, EarlySignupCutoff, PunctualityBonusChips, IsPKO, IsBuiltin, CreatedAt, UpdatedAt
- CRUD operations:
- `CreateTemplate(ctx, template TournamentTemplate) (*TournamentTemplate, error)` — validates all FK references exist
- `GetTemplate(ctx, id string) (*TournamentTemplate, error)` — returns template with populated building block summaries (names, not full data)
- `GetTemplateExpanded(ctx, id string) (*ExpandedTemplate, error)` — returns template with ALL building block data (for tournament creation)
- `ListTemplates(ctx) ([]TournamentTemplate, error)`
- `UpdateTemplate(ctx, template TournamentTemplate) error`
- `DeleteTemplate(ctx, id string) error`
- `DuplicateTemplate(ctx, id string, newName string) (*TournamentTemplate, error)`
- `SaveAsTemplate(ctx, tournamentID string, name string) (*TournamentTemplate, error)` — creates a new template from a tournament's current config
- API Routes:
- `GET /api/v1/tournament-templates` — list all
- `GET /api/v1/tournament-templates/{id}` — get with building block summaries
- `GET /api/v1/tournament-templates/{id}/expanded` — get with full building block data
- `POST /api/v1/tournament-templates` — create
- `PUT /api/v1/tournament-templates/{id}` — update
- `DELETE /api/v1/tournament-templates/{id}` — delete
- `POST /api/v1/tournament-templates/{id}/duplicate` — duplicate
**2. Built-in Seed Data** (`internal/blind/templates.go`):
Create built-in templates that ship with the app. Add to seed migration or boot logic (skip if already exist).
Built-in Blind Structures:
- **Turbo** (~2hr for 20 players): 15-minute levels, aggressive blind jumps, 1 break
- Levels: 25/50, 50/100, 75/150, 100/200, break, 150/300, 200/400, 300/600, 400/800, break, 600/1200, 800/1600, 1000/2000, 1500/3000, 2000/4000
- Starting chips: 10,000
- **Standard** (~3-4hr for 20 players): 20-minute levels, moderate progression, 2 breaks
- Levels: 25/50, 50/100, 75/150, 100/200, 150/300, break, 200/400, 300/600, 400/800, 500/1000, break, 600/1200, 800/1600, 1000/2000, 1500/3000, 2000/4000, 3000/6000
- Starting chips: 15,000
- **Deep Stack** (~5-6hr for 20 players): 30-minute levels, slow progression, 3 breaks
- Starting chips: 25,000, wider level range
- **WSOP-style**: 60-minute levels, with antes starting at level 4, BB ante option
- Starting chips: 50,000, slow progression
Built-in Payout Structures:
- **Standard**: 8-20 entries (3 prizes: 50/30/20), 21-30 (4 prizes: 45/26/17/12), 31-40 (5 prizes), 41+ (6 prizes)
Built-in Tournament Templates (compose the above):
- Turbo template (Turbo blinds + Standard payout + default chip set + basic buy-in)
- Standard template
- Deep Stack template
- WSOP-style template
Each built-in has `is_builtin = true` — cannot be deleted, but can be duplicated.
**3. Structure Wizard** (`internal/blind/wizard.go`):
Algorithm to generate a blind structure from inputs:
- Inputs: `playerCount int`, `startingChips int64`, `targetDurationMinutes int`, `chipSetID string` (for denomination alignment)
- Algorithm (from 01-RESEARCH.md):
1. Calculate target number of levels: `targetDuration / levelDuration` (default 20-minute levels)
2. Calculate target final big blind: `startingChips * playerCount / 10` (roughly — at the end, average stack = 10 BB)
3. Calculate geometric progression ratio: `(finalBB / initialBB)^(1/numLevels)`
4. Generate levels with geometric blind progression
5. Snap each blind to nearest chip denomination from the chip set
6. Ensure SB = BB/2 (or closest denomination)
7. Add antes starting at ~level 4-5 (standard is ante = BB at higher levels)
8. Insert breaks every 4-5 levels (10-minute breaks)
9. Mark chip-up breaks when lower denominations are no longer needed
- Output: `[]BlindLevel` ready to save as a blind structure
- API Route:
- `POST /api/v1/blind-structures/wizard` — body: `{"player_count": 20, "starting_chips": 15000, "target_duration_minutes": 240, "chip_set_id": "..."}`
- Response: generated `[]BlindLevel` (NOT saved — preview only, TD can then save)
**4. Tests:**
- `internal/blind/wizard_test.go`:
- Test wizard generates sensible structure for various inputs (10, 20, 40, 80 players)
- Test blind values align with chip denominations
- Test breaks are inserted at reasonable intervals
- Test generated structure has increasing blinds
- Test edge case: very short tournament (1 hour), very long tournament (8 hours)
- `internal/template/tournament_test.go`:
- Test template creation with valid FK references
- Test template creation with invalid FK reference returns error
- Test SaveAsTemplate from running tournament
- Test GetTemplateExpanded returns all building block data
**Verification:**
- All 4 built-in templates exist after first startup
- Wizard generates a blind structure from sample inputs
- Generated blind values align with chip denominations
- Full CRUD for tournament templates works
- Template expanded endpoint returns complete building block data
</task>
## Verification Criteria
1. Unlimited configurable levels with all fields (round/break, game type, SB/BB, ante, BB ante, duration, chip-up, notes)
2. Big Blind Ante field exists alongside standard ante
3. Mixed game rotation via game_type per level
4. Blind structures can be saved/loaded as reusable templates
5. 4 built-in blind structures + 4 built-in tournament templates exist on first boot
6. Structure wizard produces a playable structure from inputs
7. Chip sets with denominations, colors, and values fully manageable
8. Chip-up tracking via chip_up_denomination field per level
9. Payout structures with entry-count brackets work correctly
10. All building blocks compose into tournament templates
## Must-Haves (Goal-Backward)
- [ ] Building blocks are independent reusable entities (not embedded in templates)
- [ ] Templates compose building blocks by reference (LEGO pattern)
- [ ] Built-in templates ship with the app and cannot be deleted
- [ ] Structure wizard generates playable blind structures from inputs
- [ ] Big Blind Ante and mixed game rotation are supported in the level definition
- [ ] Payout structure tiers always sum to exactly 100% per bracket
- [ ] Chip denominations have colors for display rendering

View file

@ -0,0 +1,268 @@
# Plan F: Financial Engine
---
wave: 3
depends_on: [01-PLAN-C, 01-PLAN-E]
files_modified:
- internal/financial/engine.go
- internal/financial/payout.go
- internal/financial/receipt.go
- internal/server/routes/financials.go
- internal/financial/engine_test.go
- internal/financial/payout_test.go
autonomous: true
requirements: [FIN-03, FIN-04, FIN-07, FIN-08, FIN-09, FIN-12, FIN-13, FIN-14]
---
## Goal
The financial engine processes buy-ins, rebuys, add-ons, re-entries, and bounty transfers. It calculates prize pools from all inputs, handles guaranteed pots, tracks rake splits (including season reserve), and generates receipts for every transaction. All math uses int64 cents. Every transaction writes an audit entry and can be undone. A CI gate test proves sum of payouts always equals prize pool.
## Context
- **All money is int64 cents** (ARCH-03) — already enforced in schema (Plan B)
- **Buy-in config is a building block** — already created in Plan E
- **Payout structures with brackets** — already created in Plan E
- **Audit trail + undo** — already created in Plan C
- **Late registration soft lock with admin override** — logged in audit trail (CONTEXT.md)
- See 01-RESEARCH.md Pattern 5 (Int64 Financial Math), Pitfall 4 (Payout Rounding)
## User Decisions (from CONTEXT.md)
- **Entry count = unique entries only** — not rebuys or add-ons (for payout bracket selection)
- **Prize rounding** — round down to nearest venue-configured denomination
- **Bubble prize** — fast and prominent, funded by shaving top prizes
- **PKO (Progressive Knockout)** — half bounty to hitman, half added to own bounty
- **Receipts configurable per venue** — off / digital / print / both
- **Late registration soft lock with admin override**
## Tasks
<task id="F1" title="Implement financial transaction engine">
**1. Financial Engine** (`internal/financial/engine.go`):
- `FinancialEngine` struct with db, audit trail, nats publisher
- `ProcessBuyIn(ctx, tournamentID, playerID string, override bool) (*Transaction, error)`:
- Load tournament's buyin config
- Check late registration eligibility:
- Load tournament clock state (current level, elapsed time)
- If past cutoff AND not override → return `ErrLateRegistrationClosed`
- If past cutoff AND override → allow, log admin override in audit trail (CONTEXT.md: admin override logged)
- If no cutoff → always allow
- Check max players limit
- Create transaction record:
- Type: "buyin"
- Amount: buyin_amount (int64 cents)
- Chips: starting_chips + any applicable bonuses (early signup, punctuality)
- Create rake split transactions (one per category: house, staff, league, season_reserve)
- If PKO tournament: create bounty initialization (bounty_amount → half initial bounty value on player)
- Persist all to transactions table
- Record audit entry with full state
- Broadcast via WebSocket
- Return transaction for receipt generation
- `ProcessRebuy(ctx, tournamentID, playerID string) (*Transaction, error)`:
- Check player is active in tournament
- Check rebuy eligibility:
- Rebuy allowed in buyin config
- Player's rebuy count < rebuy_limit (0 = unlimited)
- Current level/time within rebuy cutoff
- Player's chip count <= rebuy_chip_threshold (if configured)
- Create transaction record (type: "rebuy")
- Increment player's rebuy count
- Record audit entry
- Broadcast
- Return transaction
- `ProcessAddOn(ctx, tournamentID, playerID string) (*Transaction, error)`:
- Check addon allowed and within addon window (level_start to level_end)
- Check player hasn't already added on (addon is typically once per tournament)
- Create transaction, increment addon count
- Record audit entry, broadcast, return
- `ProcessReEntry(ctx, tournamentID, playerID string) (*Transaction, error)`:
- Re-entry is distinct from rebuy (FIN-04): player must be busted first
- Check player status is "busted"
- Check reentry allowed and within reentry limit
- Reactivate player with fresh starting chips
- Increment reentry count (does NOT count as new unique entry for payout brackets)
- Process like a buy-in financially (same cost, rake)
- Re-seat player (auto-seat)
- Record audit entry, broadcast, return
- `ProcessBountyTransfer(ctx, tournamentID, eliminatedPlayerID, hitmanPlayerID string) error`:
- For PKO tournaments (FIN-07):
- Eliminated player's bounty value → half goes to hitman as cash prize, half adds to hitman's own bounty
- Track the chain in transaction metadata
- Create bounty_collected transaction for hitman
- Create bounty_paid transaction for eliminated player
- For fixed bounty (non-progressive):
- Full bounty amount goes to hitman
- Record audit entry with bounty chain details
- `UndoTransaction(ctx, transactionID string) error`:
- Use undo engine (Plan C) to create reversal audit entry
- Reverse the financial effects:
- Subtract chips from player
- Decrement rebuy/addon/reentry count
- If buyin undo: remove player from tournament (status → removed)
- If bounty undo: reverse bounty transfer
- Trigger re-ranking (will be called by player management in Plan G)
- Broadcast updates
- `GetTransactions(ctx, tournamentID string) ([]Transaction, error)` — all transactions for a tournament
- `GetPlayerTransactions(ctx, tournamentID, playerID string) ([]Transaction, error)` — player-specific
**2. Late Registration Logic:**
- `IsLateRegistrationOpen(ctx, tournamentID string) (bool, error)`:
- Check buyin config cutoffs (level AND/OR time — FIN-03: "end of Level 6 or first 90 minutes, whichever comes first")
- Load current clock state
- Both conditions checked: if either cutoff is exceeded, late reg is closed
- Return true if still open
**3. Season Withholding** (FIN-12):
- When processing rake splits, if a "season_reserve" category exists, that portion is earmarked
- Track in a `season_reserves` summary (aggregated from rake_split transactions)
- API endpoint to query season reserves: `GET /api/v1/season-reserves`
**Verification:**
- Buy-in creates correct transaction with rake splits
- Rebuy respects limits and cutoffs
- Add-on respects window
- Re-entry requires busted status
- PKO bounty transfer splits correctly (half cash, half to bounty)
- Late registration cutoff enforced (level AND time conditions)
- Admin override bypasses late reg with audit log
- Transaction undo reverses all effects
</task>
<task id="F2" title="Implement prize pool calculation, payout distribution, and receipts">
**1. Prize Pool Calculator** (`internal/financial/payout.go`):
- `CalculatePrizePool(ctx, tournamentID string) (PrizePoolSummary, error)`:
- Load all non-undone transactions for the tournament
- Sum all buy-in amounts (minus rake)
- Sum all rebuy amounts (minus rake)
- Sum all add-on amounts (minus rake)
- Sum all re-entry amounts (minus rake)
- Prize pool = total contributions (not rake)
- Check guaranteed pot (FIN-09): if prize pool < guarantee, house covers shortfall
- Return summary:
```go
type PrizePoolSummary struct {
TotalEntries int // Unique entries only (not rebuys/addons)
TotalRebuys int
TotalAddOns int
TotalReEntries int
TotalBuyInAmount int64 // cents
TotalRebuyAmount int64
TotalAddOnAmount int64
TotalReEntryAmount int64
TotalRake int64 // All rake categories combined
RakeByCategory map[string]int64 // house, staff, league, season_reserve
PrizePool int64 // After rake, before guarantee
Guarantee int64 // Configured guarantee amount
HouseContribution int64 // House covers if pool < guarantee
FinalPrizePool int64 // Max(PrizePool, Guarantee)
TotalBountyPool int64 // Sum of all bounty amounts (PKO)
TotalChipsInPlay int64 // CHIP-03
AverageStack int64 // CHIP-04: TotalChipsInPlay / remaining players
RemainingPlayers int
}
```
- `CalculatePayouts(ctx, tournamentID string) ([]Payout, error)`:
- Load prize pool and entry count
- Load payout structure, select correct bracket by entry count (unique entries only)
- Load venue rounding denomination
- Calculate each position's payout:
- Raw amount = FinalPrizePool * tier.PercentageBasisPoints / 10000
- Rounded = (raw / roundingDenomination) * roundingDenomination — always round DOWN
- Assign remainder to 1st place (standard poker convention)
- Return list of Payout structs: `{Position int, Amount int64, Percentage int64}`
- **CRITICAL:** Assert `sum(payouts) == FinalPrizePool` — if not, panic (this is a bug)
- `ApplyPayouts(ctx, tournamentID string, payouts []Payout) error`:
- For each payout, create a "payout" transaction for the player in that finishing position
- Record audit entries
- Broadcast updates
**2. Bubble Prize** (from CONTEXT.md):
- `CalculateBubblePrize(ctx, tournamentID string, amount int64) (*BubblePrizeProposal, error)`:
- Load current payouts
- Calculate redistribution: shave from top prizes (proportionally from 1st-3rd primarily, extending to 4th-5th if needed)
- Return proposal showing original and adjusted payouts for each position
- `ConfirmBubblePrize(ctx, tournamentID string, proposalID string) error`:
- Apply the redistributed payout structure
- Add bubble prize position to payouts
- Record audit entry
**3. Receipt Generation** (`internal/financial/receipt.go`):
- `GenerateReceipt(ctx, transaction Transaction) (*Receipt, error)`:
- Build receipt data from transaction:
- Venue name, tournament name, date/time
- Player name
- Transaction type and amount
- Chips received
- Running totals (total entries, rebuys, addons for this player)
- Receipt number (sequential per tournament)
- Return Receipt struct with all fields (rendering is frontend's job for digital, or ESC/POS for print — defer print to later)
- `GetReceipt(ctx, transactionID string) (*Receipt, error)` — retrieve saved receipt
- `ReprintReceipt(ctx, transactionID string) (*Receipt, error)` — FIN-14: reprint capability (same receipt data, new print timestamp)
**4. API Routes** (`internal/server/routes/financials.go`):
- `GET /api/v1/tournaments/{id}/prize-pool` — current prize pool summary
- `GET /api/v1/tournaments/{id}/payouts` — calculated payout table
- `GET /api/v1/tournaments/{id}/payouts/preview` — preview with current entries (not applied yet)
- `POST /api/v1/tournaments/{id}/bubble-prize` — body: `{"amount": 5000}` → proposal
- `POST /api/v1/tournaments/{id}/bubble-prize/{proposalId}/confirm` — confirm bubble prize
- `GET /api/v1/tournaments/{id}/transactions` — all transactions
- `GET /api/v1/tournaments/{id}/transactions/{txId}/receipt` — get receipt
- `POST /api/v1/tournaments/{id}/transactions/{txId}/reprint` — reprint receipt
- `GET /api/v1/season-reserves` — season withholding summary
**5. CI Gate Test** (`internal/financial/payout_test.go`):
- Property-based test: generate random prize pools (1000 to 10,000,000 cents), random payout structures (2-20 positions), random rounding denominations (100, 500, 1000, 5000)
- For each combination: assert `sum(payouts) == prizePool` — ZERO deviation
- Run at least 10,000 random combinations
- This test MUST pass in CI — it's the financial integrity gate
- Also test:
- Guaranteed pot: pool < guarantee house contribution makes up difference
- Bubble prize: redistributed payouts still sum to prize pool + bubble amount
- Rounding never creates money (never rounds up)
- Entry count uses unique entries only (not rebuys)
**Verification:**
- Prize pool calculation matches manual calculation for test scenarios
- Payouts sum to exactly prize pool in all cases (CI gate test)
- Bubble prize redistribution is correct and prominent
- Receipts contain all required information
- Season withholding is tracked separately
- Guaranteed pot fills shortfall correctly
</task>
## Verification Criteria
1. Buy-in, rebuy, add-on, and re-entry flows produce correct transactions
2. Late registration cutoff works (level AND/OR time — whichever comes first)
3. Admin override for late reg is logged in audit trail
4. PKO bounty transfer splits correctly (half cash, half to own bounty)
5. Prize pool calculation includes all contributions minus rake
6. Guaranteed pot: house covers shortfall
7. Payout rounding always rounds DOWN, remainder to 1st place
8. CI gate test: sum(payouts) == prize_pool across 10,000+ random inputs
9. Bubble prize redistribution is correct and traceable
10. Receipts generated for every financial transaction
11. Transaction undo reverses all effects with audit trail
12. Season withholding (reserve rake) tracked separately
## Must-Haves (Goal-Backward)
- [ ] All financial math uses int64 cents — zero float64
- [ ] CI gate test proves sum(payouts) == prize_pool, always, zero deviation
- [ ] Late registration with admin override (soft lock, not hard lock)
- [ ] PKO bounty half-split with chain tracking
- [ ] Prize pool auto-calculation from all inputs
- [ ] Guaranteed pot support (house covers shortfall)
- [ ] Every financial action generates a receipt
- [ ] Transaction editing with audit trail and receipt reprint
- [ ] Bubble prize creation is fast and accessible

View file

@ -0,0 +1,254 @@
# Plan G: Player Management
---
wave: 4
depends_on: [01-PLAN-C, 01-PLAN-E, 01-PLAN-F]
files_modified:
- internal/player/player.go
- internal/player/ranking.go
- internal/player/qrcode.go
- internal/player/import.go
- internal/server/routes/players.go
- internal/player/ranking_test.go
- internal/player/player_test.go
autonomous: true
requirements: [PLYR-02, PLYR-03, PLYR-04, PLYR-05, PLYR-06, PLYR-07]
---
## Goal
The player database supports search with typeahead (FTS5), merge duplicates, and CSV import. The buy-in and bust-out flows are the core transaction paths. Rankings are derived from the ordered bust-out list (never stored independently). Undo on any player action triggers full re-ranking. Per-player tracking captures all stats. QR codes are generated per player for future self-check-in.
## Context
- **Player database is persistent on Leaf** (PLYR-01) — already in schema (Plan B)
- **FTS5 virtual table** for typeahead — already created in Plan B
- **Financial engine** for buy-in/rebuy/addon processing — Plan F
- **Audit trail + undo** — Plan C
- **Auto-seating** — Plan H (seating). Buy-in flow calls seating after financial processing
- **Rankings are derived, not stored** — from 01-RESEARCH.md Pitfall 6: "Rankings should be derived from the ordered bust-out list, not stored as independent values"
- See 01-RESEARCH.md Pitfall 6 (Undo Re-Rank)
## User Decisions (from CONTEXT.md)
- **Buy-in flow:** search/select player → auto-seat suggests optimal seat → TD can override → confirm → receipt
- **Bust-out flow:** tap Bust → pick table → pick seat → verify name → confirm → select hitman (mandatory in PKO, optional otherwise) → done
- **Undo is critical** — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking
- **Bust-out flow must be as few taps as possible** — TD is under time pressure
## Tasks
<task id="G1" title="Implement player CRUD, search, merge, import, and QR codes">
**1. Player Service** (`internal/player/player.go`):
- `PlayerService` struct with db, audit trail
- `CreatePlayer(ctx, player Player) (*Player, error)`:
- Generate UUID
- Insert into players table
- FTS5 auto-syncs via trigger (Plan B)
- Record audit entry
- Return player
- `GetPlayer(ctx, id string) (*Player, error)`
- `UpdatePlayer(ctx, player Player) error`:
- Update fields, record audit entry
- FTS5 auto-syncs via trigger
- `SearchPlayers(ctx, query string, limit int) ([]Player, error)` — PLYR-02:
- Use FTS5: `SELECT p.* FROM players p JOIN players_fts f ON p.rowid = f.rowid WHERE players_fts MATCH ? ORDER BY rank LIMIT ?`
- Support prefix matching for typeahead: append `*` to query terms
- Return results ordered by relevance (FTS5 rank)
- If query is empty, return most recently active players (for quick access)
- `ListPlayers(ctx, limit, offset int) ([]Player, error)` — paginated list
- `MergePlayers(ctx, keepID, mergeID string) error` — PLYR-02:
- Merge player `mergeID` into `keepID`:
- Move all tournament_players records from mergeID to keepID
- Move all transactions from mergeID to keepID
- Merge any non-empty fields from mergeID into keepID (name takes keepID, fill in blanks)
- Delete mergeID player record
- Record audit entry with full merge details
- This is destructive — require admin role
- `ImportFromCSV(ctx, reader io.Reader) (ImportResult, error)` — PLYR-02:
- Parse CSV with headers: name, nickname, email, phone, notes (minimum: name required)
- For each row:
- Check for potential duplicates (FTS5 search on name)
- If exact name match exists, flag as duplicate (don't auto-merge)
- Otherwise create new player
- Return ImportResult: created count, duplicate count, error count, duplicate details
- Require admin role
**2. Tournament Player Operations:**
- `RegisterPlayer(ctx, tournamentID, playerID string) (*TournamentPlayer, error)`:
- Create tournament_players record with status "registered"
- Check tournament isn't full (max_players)
- Return tournament player record
- `GetTournamentPlayers(ctx, tournamentID string) ([]TournamentPlayerDetail, error)`:
- Join tournament_players with players table
- Include computed fields: total investment, net result, action count
- Sort by status (active first), then by name
- `GetTournamentPlayer(ctx, tournamentID, playerID string) (*TournamentPlayerDetail, error)`:
- Full player detail within tournament context (PLYR-07):
- Current chips, seat (table + position), playing time
- Rebuys count, add-ons count, re-entries count
- Bounties collected, bounty value (PKO)
- Prize amount, points awarded
- Net take (prize - total investment)
- Full action history (from audit trail + transactions)
- `BustPlayer(ctx, tournamentID, playerID string, hitmanPlayerID *string) error` — PLYR-05:
- Validate player is active
- Set status to "busted", bust_out_at to now
- Calculate bust_out_order (count of currently busted players + 1, from the end)
- If PKO: hitman is mandatory, process bounty transfer (call financial engine)
- If not PKO: hitman is optional (for tracking who eliminated whom)
- Set hitman_player_id
- Clear seat assignment (remove from table)
- Record audit entry with previous state (for undo)
- Trigger balance check (notify if tables are unbalanced — seating engine, Plan H)
- Broadcast player bust event
- Check if tournament should auto-close (1 player remaining → tournament.end)
- `UndoBust(ctx, tournamentID, playerID string) error` — PLYR-06:
- Restore player to active status
- Clear bust_out_at, bust_out_order, finishing_position, hitman_player_id
- If PKO: reverse bounty transfer
- Re-seat player (needs a seat — auto-assign or put back in original seat from audit trail)
- Trigger full re-ranking
- Record audit entry
- Broadcast
**3. QR Code Generation** (`internal/player/qrcode.go`) — PLYR-03:
- `GenerateQRCode(ctx, playerID string) ([]byte, error)`:
- Generate QR code encoding player UUID
- Return PNG image bytes
- QR code URL format: `felt://player/{uuid}` (for future PWA self-check-in)
- API endpoint: `GET /api/v1/players/{id}/qrcode` — returns PNG image
**Verification:**
- Player CRUD works via API
- FTS5 typeahead search returns results with prefix matching
- CSV import creates players and flags duplicates
- Merge combines two player records correctly
- QR code generates valid PNG image
</task>
<task id="G2" title="Implement ranking engine and player API routes">
**1. Ranking Engine** (`internal/player/ranking.go`):
- Rankings are **derived from the ordered bust-out list**, never stored independently
- `CalculateRankings(ctx, tournamentID string) ([]PlayerRanking, error)`:
- Load all tournament_players ordered by bust_out_at (nulls = still active)
- Active players: no ranking yet (still playing) — all share the same "current position" = remaining player count
- Busted players: ranked in reverse bust order (last busted = highest remaining, first busted = last place)
- Finishing position = total_unique_entries - bust_order + 1
- Handle re-entries: a re-entered player's previous bust is "cancelled" — they only have the final bust (or are still active)
- Handle deals: players who took a deal have status "deal" with manually assigned finishing positions
- `RecalculateAllRankings(ctx, tournamentID string) error`:
- Called after any undo operation
- Recalculates ALL bust_out_order values from the bust_out_at timestamps
- Updates finishing_position for all busted players
- This ensures consistency even after undoing busts in the middle of the sequence
- Broadcast updated rankings
- `GetRankings(ctx, tournamentID string) ([]PlayerRanking, error)`:
- Returns current rankings for display:
```go
type PlayerRanking struct {
Position int // Current ranking position
PlayerID string
PlayerName string
Status string // active, busted, deal
ChipCount int64
BustOutTime *int64
HitmanName *string
BountiesCollected int
Prize int64
Points int64
}
```
- Active players sorted by chip count (if available), then alphabetically
- Busted players sorted by bust order (most recent first)
**2. Player API Routes** (`internal/server/routes/players.go`):
Player database (venue-level):
- `GET /api/v1/players` — list all players (paginated)
- `GET /api/v1/players/search?q=john` — typeahead search (PLYR-02)
- `POST /api/v1/players` — create player
- `GET /api/v1/players/{id}` — get player
- `PUT /api/v1/players/{id}` — update player
- `GET /api/v1/players/{id}/qrcode` — QR code PNG (PLYR-03)
- `POST /api/v1/players/merge` — body: `{"keep_id": "...", "merge_id": "..."}` (admin only)
- `POST /api/v1/players/import` — multipart CSV upload (admin only)
Tournament players:
- `GET /api/v1/tournaments/{id}/players` — all players in tournament with stats
- `GET /api/v1/tournaments/{id}/players/{playerId}` — player detail with full tracking (PLYR-07)
- `POST /api/v1/tournaments/{id}/players/{playerId}/buyin` — buy-in flow (PLYR-04):
- Calls financial engine (Plan F) for transaction
- Calls seating engine (Plan H) for auto-seat
- Returns: transaction, seat assignment, receipt
- `POST /api/v1/tournaments/{id}/players/{playerId}/bust` — bust-out flow (PLYR-05):
- Body: `{"hitman_player_id": "..."}` (required for PKO, optional otherwise)
- Calls financial engine for bounty transfer
- Calls ranking engine for re-ranking
- Calls seating engine for balance check
- Returns: updated rankings
- `POST /api/v1/tournaments/{id}/players/{playerId}/rebuy` — rebuy (delegates to financial engine)
- `POST /api/v1/tournaments/{id}/players/{playerId}/addon` — add-on (delegates to financial engine)
- `POST /api/v1/tournaments/{id}/players/{playerId}/reentry` — re-entry (delegates to financial engine + seating)
- `POST /api/v1/tournaments/{id}/players/{playerId}/undo-bust` — undo bust (PLYR-06)
- `POST /api/v1/tournaments/{id}/transactions/{txId}/undo` — undo any transaction (PLYR-06)
Rankings:
- `GET /api/v1/tournaments/{id}/rankings` — current rankings
All mutation endpoints record audit entries and broadcast via WebSocket.
**3. Tests** (`internal/player/ranking_test.go`):
- Test rankings derived correctly from bust order
- Test undo bust triggers re-ranking of all subsequent positions
- Test undo early bust (not the most recent) re-ranks correctly
- Test re-entry doesn't count as new entry for ranking purposes
- Test deal players get manually assigned positions
- Test auto-close when 1 player remains
- Test concurrent busts (order preserved by timestamp)
**Verification:**
- Buy-in flow creates player entry, transaction, and seat assignment
- Bust-out flow busts player, processes bounty, re-ranks, checks balance
- Undo bust restores player and re-ranks all subsequent busts
- Rankings are always consistent with bust order
- Player detail shows complete tracking data (PLYR-07)
- Search returns results with typeahead behavior
</task>
## Verification Criteria
1. Player search with typeahead returns results via FTS5 prefix matching
2. Duplicate merge combines records correctly (admin only)
3. CSV import creates players and flags duplicates
4. QR code generates valid PNG with player UUID
5. Buy-in flow: search → financial transaction → auto-seat → receipt
6. Bust-out flow: select → hitman → bounty → rank → balance check
7. Undo bust restores player with full re-ranking of all positions
8. Undo buy-in removes player from tournament
9. Per-player tracking shows all stats (PLYR-07)
10. Rankings are always derived from bust-out list, never stored independently
11. Tournament auto-closes when one player remains
## Must-Haves (Goal-Backward)
- [ ] Typeahead search on player names using FTS5
- [ ] Buy-in flow produces transaction + seat assignment + receipt
- [ ] Bust-out flow with hitman selection and bounty transfer (PKO)
- [ ] Undo capability for bust-out, rebuy, add-on, buy-in with full re-ranking
- [ ] Rankings derived from ordered bust-out list (not stored independently)
- [ ] Per-player tracking: chips, time, seat, moves, rebuys, add-ons, bounties, prize, points, net, history
- [ ] QR code generation per player

View file

@ -0,0 +1,342 @@
# Plan H: Table & Seating Engine
---
wave: 3
depends_on: [01-PLAN-C, 01-PLAN-B, 01-PLAN-D]
files_modified:
- internal/seating/table.go
- internal/seating/balance.go
- internal/seating/breaktable.go
- internal/seating/blueprint.go
- internal/server/routes/tables.go
- internal/seating/balance_test.go
- internal/seating/breaktable_test.go
autonomous: true
requirements: [SEAT-01, SEAT-02, SEAT-03, SEAT-04, SEAT-05, SEAT-06, SEAT-07, SEAT-08, SEAT-09]
---
## Goal
Tables with configurable seat counts are managed per tournament. Random initial seating fills tables evenly. The balancing engine suggests moves when tables are unbalanced, with live-adaptive suggestions that recalculate when the situation changes. Break Table dissolves a table and distributes players evenly. Dealer button tracking, hand-for-hand mode, and table blueprints (venue layout) are all supported. All moves use tap-tap flow (no drag-and-drop in Phase 1).
## Context
- **Tables are tournament-scoped** — each tournament has its own set of tables
- **Table blueprints** are venue-level — save/load table configurations
- **Balancing suggestions are pending proposals** — re-validated before execution (from 01-RESEARCH.md Pitfall 7)
- **TDA-compliant balancing** — size difference threshold, move fairness, button awareness
- **No drag-and-drop in Phase 1** — tap-tap flow (CONTEXT.md locked decision)
- See 01-RESEARCH.md Pitfall 7 (Table Balancing Race Condition)
## User Decisions (from CONTEXT.md)
- **Oval table view (default)** — top-down view with numbered seats, switchable to list view
- **Balancing is TD-driven, system-assisted:**
1. System alerts: tables are unbalanced
2. TD requests suggestion: system says "move 1 from Table 1 to Table 4"
3. TD announces to the floor
4. Assistant reports back: "Seat 3 to Seat 5" — TD taps two seat numbers, done
5. Suggestion is live and adaptive — if situation changes, system recalculates or cancels
- **Break Table is fully automatic** — distributes evenly, TD sees result
- **No drag-and-drop in Phase 1** — tap-tap flow for all moves
## Tasks
<task id="H1" title="Implement table management, auto-seating, and blueprints">
**1. Table Service** (`internal/seating/table.go`):
- `TableService` struct with db, audit trail, hub
- `CreateTable(ctx, tournamentID string, name string, seatCount int) (*Table, error)`:
- Validate seatCount is 6-10 (SEAT-01)
- Generate UUID, insert into tables table
- Record audit entry, broadcast
- `CreateTablesFromBlueprint(ctx, tournamentID, blueprintID string) ([]Table, error)`:
- Load blueprint's table configs
- Create all tables from the blueprint
- Return created tables
- `GetTables(ctx, tournamentID string) ([]TableDetail, error)`:
- Return all active tables with seated players:
```go
type TableDetail struct {
ID string
Name string
SeatCount int
DealerButtonPosition *int // SEAT-03
IsActive bool
Seats []SeatDetail // Array of seat_count length
}
type SeatDetail struct {
Position int
PlayerID *string
PlayerName *string
ChipCount *int64
IsEmpty bool
}
```
- `UpdateTable(ctx, tournamentID, tableID string, name string, seatCount int) error`
- `DeactivateTable(ctx, tournamentID, tableID string) error` — soft delete (is_active = false)
- `AssignSeat(ctx, tournamentID, playerID, tableID string, seatPosition int) error`:
- Validate seat is empty
- Update tournament_players record (seat_table_id, seat_position)
- Record audit entry, broadcast
- `AutoAssignSeat(ctx, tournamentID, playerID string) (*SeatAssignment, error)` — SEAT-04:
- Find the table with the fewest players (fills evenly)
- If tie, pick randomly among tied tables
- Pick a random empty seat at that table
- Return the assignment (table, seat) for TD confirmation (or auto-apply)
- **Does NOT assign yet** — returns suggestion for TD to confirm or override
- `ConfirmSeatAssignment(ctx, tournamentID, playerID string, assignment SeatAssignment) error`:
- Apply the seat assignment
- Record audit entry, broadcast
- `MoveSeat(ctx, tournamentID, playerID string, toTableID string, toSeatPosition int) error` — SEAT-06 (tap-tap flow):
- Validate destination seat is empty
- Move player: update tournament_players record
- Record audit entry with from/to details
- Broadcast
- `SwapSeats(ctx, tournamentID, player1ID, player2ID string) error`:
- Swap two players' seats atomically
- Record audit entry, broadcast
**2. Dealer Button** (`internal/seating/table.go`) — SEAT-03:
- `SetDealerButton(ctx, tournamentID, tableID string, position int) error`
- `AdvanceDealerButton(ctx, tournamentID, tableID string) error`:
- Move button to next occupied seat (clockwise)
- Skip empty seats
- Record audit entry
**3. Table Blueprints** (`internal/seating/blueprint.go`) — SEAT-02:
- `Blueprint` struct: ID, Name, TableConfigs (JSON array of {name, seat_count})
- CRUD operations:
- `CreateBlueprint(ctx, name string, configs []BlueprintTableConfig) (*Blueprint, error)`
- `GetBlueprint(ctx, id string) (*Blueprint, error)`
- `ListBlueprints(ctx) ([]Blueprint, error)`
- `UpdateBlueprint(ctx, blueprint Blueprint) error`
- `DeleteBlueprint(ctx, id string) error`
- `SaveBlueprintFromTournament(ctx, tournamentID, name string) (*Blueprint, error)` — snapshot current tables as blueprint
**4. Hand-for-Hand Mode** — SEAT-09:
Hand-for-hand is a synchronization mechanism: all tables play one hand at a time. Clock stays paused the entire time — no time deduction.
State needed:
- Tournament-level: `handForHand bool`, `currentHandNumber int`
- Table-level: `handCompleted bool` (reset each hand round)
- `SetHandForHand(ctx, tournamentID string, enabled bool) error`:
- Set hand_for_hand flag on tournament, initialize currentHandNumber = 1
- Pause clock (call clock engine `SetHandForHand(true)`)
- Reset handCompleted = false on all active tables
- Broadcast mode change to all clients (prominent visual indicator)
- Record audit entry
- `TableHandComplete(ctx, tournamentID, tableID string) error`:
- Set handCompleted = true for this table
- Check if ALL active tables have handCompleted = true
- If yes: increment currentHandNumber, reset all tables to handCompleted = false, broadcast "next hand"
- If no: broadcast updated completion status (e.g., "3/5 tables complete")
- Record audit entry
- `DisableHandForHand(ctx, tournamentID string) error`:
- Called when bubble bursts (player busted during hand-for-hand)
- Clear hand_for_hand flag, resume clock (call clock engine `SetHandForHand(false)`)
- Broadcast mode change
- Record audit entry
API routes:
- `POST /api/v1/tournaments/{id}/hand-for-hand` — body: `{"enabled": true}` — enable/disable
- `POST /api/v1/tournaments/{id}/tables/{tableId}/hand-complete` — TD marks table as hand complete
**Verification:**
- Create tables with 6-10 seat counts
- Auto-assign seats fills tables evenly
- Move seat works with tap-tap (source, destination)
- Dealer button advances to next occupied seat
- Blueprints save and restore table layouts
- Hand-for-hand mode pauses clock and enables per-hand tracking
</task>
<task id="H2" title="Implement table balancing and break table">
**1. Balancing Engine** (`internal/seating/balance.go`) — SEAT-05:
- `BalanceEngine` struct with db, audit trail, hub
- `CheckBalance(ctx, tournamentID string) (*BalanceStatus, error)`:
- Load all active tables with player counts
- Calculate max difference between largest and smallest table
- TDA rule: tables are unbalanced if max difference > 1
- Return:
```go
type BalanceStatus struct {
IsBalanced bool
MaxDifference int
Tables []TableCount // {TableID, TableName, PlayerCount}
NeedsMoves int // How many players need to move
}
```
- `SuggestMoves(ctx, tournamentID string) ([]BalanceSuggestion, error)`:
- Algorithm (TDA-compliant):
1. Identify tables that need to lose players (largest tables)
2. Identify tables that need to gain players (smallest tables)
3. For each needed move:
a. Select player to move from source table:
- Prefer the player in the worst position relative to the button (fairness)
- Avoid moving a player who just moved (if tracked)
- Never move a player who is locked (if lock feature exists)
b. Select seat at destination table:
- Prefer seat that avoids giving the moved player immediate button advantage
- Random among equivalent seats
4. Generate suggestion:
```go
type BalanceSuggestion struct {
ID string
FromTableID string
FromTableName string
ToTableID string
ToTableName string
PlayerID *string // Suggested player (nullable — TD chooses)
PlayerName *string
Status string // pending, accepted, cancelled, expired
CreatedAt int64
}
```
- Suggestions are PROPOSALS — not auto-applied (CONTEXT.md: "operator confirmation required")
- Suggestions include dry-run preview (SEAT-05: "dry-run preview, never auto-apply")
- `AcceptSuggestion(ctx, tournamentID, suggestionID string, fromSeat, toSeat int) error`:
- Re-validate the suggestion is still valid (live and adaptive):
- Is the source table still larger than the destination?
- Is the player still at the source table?
- Is the destination seat still empty?
- If invalid: cancel suggestion, generate new one, return error with "stale suggestion"
- If valid: execute the move (call MoveSeat)
- Mark suggestion as accepted
- Re-check if more moves are needed
- Record audit entry
- `CancelSuggestion(ctx, tournamentID, suggestionID string) error`:
- Mark as cancelled
- Record audit entry
- `InvalidateStaleSuggestions(ctx, tournamentID string) error`:
- Called whenever table state changes (bust, move, break table)
- Check all pending suggestions — if source/dest player counts no longer justify the move, cancel them
- If still needed but details changed, cancel and regenerate
- This implements the "live and adaptive" behavior from CONTEXT.md
**2. Break Table** (`internal/seating/breaktable.go`) — SEAT-07:
- `BreakTable(ctx, tournamentID, tableID string) (*BreakTableResult, error)`:
- Load all players at the table being broken
- Load all remaining active tables with available seats
- Distribute players as evenly as possible:
1. Sort destination tables by current player count (ascending)
2. For each player from broken table, assign to the table with fewest players
3. Assign random empty seat at destination table
4. Consider button position for fairness (TDA rules)
- Deactivate the broken table (is_active = false)
- Return result showing all moves:
```go
type BreakTableResult struct {
BrokenTableID string
BrokenTableName string
Moves []BreakTableMove // {PlayerID, PlayerName, ToTableName, ToSeat}
}
```
- The result is informational — the moves are already applied (Break Table is fully automatic per CONTEXT.md)
- Record audit entry with all moves
- Invalidate any pending balance suggestions
- Broadcast all changes
**3. API Routes** (`internal/server/routes/tables.go`):
Tables:
- `GET /api/v1/tournaments/{id}/tables` — all tables with seated players (SEAT-08)
- `POST /api/v1/tournaments/{id}/tables` — create table
- `POST /api/v1/tournaments/{id}/tables/from-blueprint` — body: `{"blueprint_id": "..."}`
- `PUT /api/v1/tournaments/{id}/tables/{tableId}` — update table
- `DELETE /api/v1/tournaments/{id}/tables/{tableId}` — deactivate table
Seating:
- `POST /api/v1/tournaments/{id}/players/{playerId}/auto-seat` — get auto-seat suggestion (SEAT-04)
- `POST /api/v1/tournaments/{id}/players/{playerId}/seat` — body: `{"table_id": "...", "seat_position": 3}` — assign/move
- `POST /api/v1/tournaments/{id}/seats/swap` — body: `{"player1_id": "...", "player2_id": "..."}`
Balancing:
- `GET /api/v1/tournaments/{id}/balance` — check balance status
- `POST /api/v1/tournaments/{id}/balance/suggest` — get move suggestions
- `POST /api/v1/tournaments/{id}/balance/suggestions/{suggId}/accept` — body: `{"from_seat": 3, "to_seat": 7}` — accept with seat specifics
- `POST /api/v1/tournaments/{id}/balance/suggestions/{suggId}/cancel` — cancel suggestion
Break Table:
- `POST /api/v1/tournaments/{id}/tables/{tableId}/break` — break table and redistribute
Dealer Button:
- `POST /api/v1/tournaments/{id}/tables/{tableId}/button` — body: `{"position": 3}`
- `POST /api/v1/tournaments/{id}/tables/{tableId}/button/advance`
Blueprints (venue-level):
- `GET /api/v1/blueprints` — list all
- `POST /api/v1/blueprints` — create
- `GET /api/v1/blueprints/{id}`
- `PUT /api/v1/blueprints/{id}`
- `DELETE /api/v1/blueprints/{id}`
- `POST /api/v1/tournaments/{id}/tables/save-blueprint` — body: `{"name": "..."}`
Hand-for-Hand:
- `POST /api/v1/tournaments/{id}/hand-for-hand` — body: `{"enabled": true}` — enable/disable mode
- `POST /api/v1/tournaments/{id}/tables/{tableId}/hand-complete` — TD marks table's hand as complete
All mutations record audit entries and broadcast.
**4. Tests** (`internal/seating/balance_test.go`, `internal/seating/breaktable_test.go`):
- Test balance detection: 2 tables with counts [8, 6] → unbalanced (diff > 1)
- Test balance detection: 2 tables with counts [7, 6] → balanced (diff = 1)
- Test suggestion generation picks player from largest table
- Test stale suggestion detection (table state changed since suggestion)
- Test live-adaptive behavior: bust during pending suggestion → suggestion cancelled
- Test break table distributes evenly across remaining tables
- Test break table with odd player count (some tables get one more)
- Test break table deactivates the broken table
- Test auto-seat fills tables evenly
- Test dealer button advances to next occupied seat, skipping empty
**Verification:**
- Tables with 6-10 seats can be created and managed
- Auto-seating fills tables evenly
- Balance engine detects unbalanced tables (diff > 1)
- Suggestions are live and adaptive (re-validated on accept)
- Break Table distributes players evenly and deactivates the table
- Dealer button tracks and advances correctly
- Hand-for-hand mode works with clock integration
</task>
## Verification Criteria
1. Tables with configurable seat counts (6-max to 10-max) work correctly
2. Table blueprints save and restore venue layouts
3. Dealer button tracking and advancement work
4. Random initial seating fills tables evenly
5. Balancing suggestions with operator confirmation (never auto-apply)
6. Suggestions are live and adaptive (recalculate on state change)
7. Tap-tap seat moves work (no drag-and-drop)
8. Break Table dissolves and distributes players evenly
9. Visual table data available (top-down with seats, SEAT-08)
10. Hand-for-hand mode pauses clock with per-table completion tracking (all tables complete → next hand)
## Must-Haves (Goal-Backward)
- [ ] Tables with configurable seat counts (6-10) per tournament
- [ ] Random initial seating that fills tables evenly
- [ ] Balancing suggestions are TD-driven with operator confirmation, never auto-apply
- [ ] Suggestions are live and adaptive (invalidated when state changes)
- [ ] Break Table automatically distributes players evenly
- [ ] Tap-tap flow for seat moves (no drag-and-drop in Phase 1)
- [ ] Dealer button tracking per table
- [ ] Table blueprints for saving venue layouts
- [ ] Hand-for-hand mode with per-table completion tracking and clock pause

View file

@ -0,0 +1,334 @@
# 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

View file

@ -0,0 +1,220 @@
# Plan J: SvelteKit Frontend Scaffold + Theme + Clients
---
wave: 2
depends_on: [01-PLAN-A]
files_modified:
- frontend/package.json
- frontend/svelte.config.js
- frontend/vite.config.ts
- frontend/tsconfig.json
- frontend/src/app.html
- frontend/src/app.css
- frontend/src/routes/+layout.ts
- frontend/src/lib/theme/catppuccin.css
- frontend/src/lib/ws.ts
- frontend/src/lib/api.ts
- frontend/src/lib/stores/tournament.svelte.ts
- frontend/src/lib/stores/auth.svelte.ts
- frontend/src/routes/login/+page.svelte
autonomous: true
requirements: [UI-05, UI-06]
---
## Goal
SvelteKit SPA scaffold with Catppuccin Mocha dark theme, WebSocket client with reconnect, HTTP API client, authentication state, tournament state store, and PIN login page. This is the foundation that the layout shell (Plan M) and all feature views build on.
## Context
- **SvelteKit with adapter-static** — SPA mode, embedded in Go binary via go:embed
- **Svelte 5 runes** — $state, $derived, $effect for all reactivity (not stores)
- **Catppuccin Mocha** dark theme default, Latte light theme alternate
- **Mobile-first** with responsive desktop layout (sidebar instead of bottom tabs)
- **48px minimum touch targets** — poker room environment, TD using phone with one hand
- See 01-RESEARCH.md: Svelte 5 Runes WebSocket State, Catppuccin Mocha Theme Setup, Pattern 3 (SvelteKit SPA)
## User Decisions (from CONTEXT.md)
- **Overview tab priority** — Clock > Time to break > Player count > Table balance > Financial summary > Activity feed
- **Mobile-first bottom tab bar** — Overview, Players, Tables, Financials, More
- **FAB for quick actions** — Bust, Buy In, Rebuy, Add-On, Pause/Resume
- **Persistent header** showing clock, level, blinds, player count
- **Desktop/laptop sidebar** with wider content area
- **Catppuccin Mocha dark theme** (default), Latte light
- **48px minimum touch targets**, press-state animations, loading states
- **Toast notifications** (success, info, warning, error) with auto-dismiss
- **Multi-tournament switching** — tabs at top (phone) or split view (tablet landscape)
## Tasks
<task id="J1" title="Initialize SvelteKit project with theme, WebSocket client, and API client">
**1. SvelteKit Project Setup:**
- Initialize SvelteKit project in `frontend/` directory:
- `npm create svelte@latest frontend -- --template skeleton`
- Install adapter-static: `npm install -D @sveltejs/adapter-static`
- Install Catppuccin: `npm install @catppuccin/palette`
- Configure TypeScript
- `svelte.config.js`:
```javascript
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html', // SPA fallback
precompress: false,
strict: true
}),
paths: { base: '' }
}
};
```
- `frontend/src/routes/+layout.ts`:
```typescript
export const prerender = true;
export const ssr = false; // SPA mode, no SSR
```
**2. Catppuccin Theme** (`frontend/src/lib/theme/catppuccin.css`) — UI-05:
- Define CSS custom properties for Mocha and Latte flavors (all 26 Catppuccin colors)
- Use `[data-theme="mocha"]` and `[data-theme="latte"]` selectors
- Default to Mocha (dark theme)
- Include semantic color mappings:
- `--color-bg`: base
- `--color-surface`: surface0
- `--color-surface-hover`: surface1
- `--color-text`: text
- `--color-text-secondary`: subtext1
- `--color-primary`: blue
- `--color-success`: green
- `--color-warning`: yellow
- `--color-error`: red
- `--color-accent`: mauve
- Poker-specific: `--color-felt`: green, `--color-card`: text, `--color-bounty`: pink, `--color-prize`: yellow
- Typography: system-ui for body, monospace for timers/numbers
- Base styles: body background, text color, font size, line height
- `app.css`:
- Import catppuccin.css
- Reset styles (box-sizing, margin, padding)
- Touch action: `touch-action: manipulation` on interactive elements (prevent double-tap zoom)
- Minimum touch target: `.touch-target { min-height: 48px; min-width: 48px; }` — UI-06
- Active/pressed states: `:active { transform: scale(0.97); opacity: 0.9; }` — UI-06
- Focus visible styles for accessibility
- Scrollbar styling for dark theme
**3. WebSocket Client** (`frontend/src/lib/ws.ts`):
- `WebSocketClient` class:
- Connect to `ws://host/ws` (auto-detect protocol for HTTPS → WSS)
- Auto-reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s)
- Connection state tracking: connecting, connected, disconnected, reconnecting
- `send(message)` — JSON serialize and send
- `subscribe(tournamentID)` — send subscription message
- `onMessage(callback)` — register message handler
- `onStateChange(callback)` — register connection state handler
- Parse incoming messages: `{type: string, data: any}`
- Route messages to the tournament state store
**4. HTTP API Client** (`frontend/src/lib/api.ts`):
- `api` object with methods:
- `get(path)`, `post(path, body)`, `put(path, body)`, `delete(path)`
- Base URL: auto-detect from current host
- Automatically attach JWT from auth store as `Authorization: Bearer` header
- Handle 401 responses: clear auth state, redirect to login
- Handle errors: parse error response, throw typed errors
- Response parsing: auto-parse JSON
- Loading state tracking (for UI loading indicators)
**5. Auth State** (`frontend/src/lib/stores/auth.svelte.ts`):
- Using Svelte 5 runes:
```typescript
class AuthState {
token = $state<string | null>(null);
operator = $state<Operator | null>(null);
get isAuthenticated() { return this.token !== null; }
get isAdmin() { return this.operator?.role === 'admin'; }
get isFloor() { return ['admin', 'floor'].includes(this.operator?.role ?? ''); }
login(token: string, operator: Operator) { ... }
logout() { ... }
}
export const auth = new AuthState();
```
- Persist token to localStorage
- Load token from localStorage on app startup
- Clear on logout or 401 response
**6. Tournament State** (`frontend/src/lib/stores/tournament.svelte.ts`):
- Using Svelte 5 runes per 01-RESEARCH.md pattern:
```typescript
class TournamentState {
clock = $state<ClockSnapshot | null>(null);
players = $state<Player[]>([]);
tables = $state<Table[]>([]);
financials = $state<FinancialSummary | null>(null);
activity = $state<ActivityEntry[]>([]);
rankings = $state<PlayerRanking[]>([]);
balanceStatus = $state<BalanceStatus | null>(null);
get remainingPlayers() { return this.players.filter(p => p.status === 'active').length; }
get isBalanced() { ... }
handleMessage(msg: WSMessage) {
switch (msg.type) {
case 'clock.tick': this.clock = msg.data; break;
case 'player.bust': ... break;
case 'state.snapshot': this.loadFullState(msg.data); break;
// ... all message types
}
}
}
export const tournament = new TournamentState();
```
**7. Login Page** (`frontend/src/routes/login/+page.svelte`):
- PIN input with 4 large digit buttons (48px+ touch targets)
- PIN display with dots (masked)
- Submit button
- Error display for wrong PIN / rate limited
- Auto-redirect to overview on successful login
- Catppuccin Mocha styling
**8. Update Makefile:**
- Update `make frontend` target to actually build the SvelteKit project:
- `cd frontend && npm install && npm run build`
- Update `make all` to build frontend first, then Go binary
**Verification:**
- `cd frontend && npm run build` produces `frontend/build/index.html`
- `make all` builds the full binary with embedded frontend
- Login page renders with dark theme
- WebSocket client connects and logs messages to console
- API client sends requests with JWT header
</task>
## Verification Criteria
1. SvelteKit builds to static SPA with `npm run build`
2. Catppuccin Mocha dark theme applied with CSS custom properties
3. 48px minimum touch targets defined in base styles
4. WebSocket client connects with auto-reconnect and exponential backoff
5. HTTP API client sends requests with JWT Authorization header
6. Auth state persists to localStorage and clears on 401
7. Tournament state store handles all WebSocket message types
8. Login page works with PIN authentication
9. `make all` builds the full binary with embedded frontend
## Must-Haves (Goal-Backward)
- [ ] Catppuccin Mocha dark theme as default with semantic color variables
- [ ] 48px minimum touch targets on all interactive elements (base CSS)
- [ ] WebSocket client with auto-reconnect for real-time updates
- [ ] HTTP API client with JWT auth and error handling
- [ ] Tournament state store reactive via Svelte 5 runes
- [ ] Auth state with localStorage persistence
- [ ] PIN login page

View file

@ -0,0 +1,212 @@
# Plan K: Frontend — Overview + Clock + Financials Views
---
wave: 6
depends_on: [01-PLAN-M, 01-PLAN-D, 01-PLAN-F, 01-PLAN-I]
files_modified:
- frontend/src/routes/overview/+page.svelte
- frontend/src/routes/financials/+page.svelte
- frontend/src/lib/components/ClockDisplay.svelte
- frontend/src/lib/components/BlindInfo.svelte
- frontend/src/lib/components/ActivityFeed.svelte
- frontend/src/lib/components/PrizePoolCard.svelte
- frontend/src/lib/components/TransactionList.svelte
- frontend/src/lib/components/BubblePrize.svelte
- frontend/src/lib/components/DealFlow.svelte
autonomous: true
requirements: [CHIP-03, CHIP-04, MULTI-02]
---
## Goal
The Overview tab shows the TD's primary workspace: a large clock display, time to next break, player counts, table balance status, financial summary, and recent activity feed. The Financials tab shows prize pool breakdown, transaction history, payout preview, bubble prize action, and chop/deal initiation. All views are reactive and update in real time via WebSocket.
## Context
- **Clock engine API** — Plan D (clock state, pause/resume, warnings)
- **Financial engine API** — Plan F (prize pool, transactions, payouts, bubble prize)
- **Tournament lifecycle API** — Plan I (tournament state, activity feed, chop/deal)
- **UI shell** — Plan J (layout, header, FAB, toast, data table, WebSocket client)
- **Overview tab is the TD's primary view** — most time spent here during a tournament
## User Decisions (from CONTEXT.md)
- **Overview tab priority** (top to bottom):
1. Clock & current level (biggest element)
2. Time to next break
3. Player count (registered / remaining / busted)
4. Table balance status
5. Financial summary (prize pool, entries, rebuys)
6. Recent activity feed (last few actions)
- **Bubble prize** must be fast and prominent, "Add bubble prize" easily accessible, not buried in menus
- **Flexible chop/deal** — ICM, custom split, partial chop, any number of players
## Tasks
<task id="K1" title="Implement Overview tab with clock display and activity feed">
**1. Clock Display Component** (`frontend/src/lib/components/ClockDisplay.svelte`):
- Large countdown timer: MM:SS format
- Font size: as large as possible while fitting the container
- Color: white text normally, transitions to `--ctp-red` in final 10 seconds
- Pulsing animation in final 10 seconds
- Current level label: "Level 5" with game type if not default (e.g., "Level 5 — PLO")
- Blinds display: "SB: 100 / BB: 200" (large, readable)
- Ante display: "Ante: 25" (if > 0), "BB Ante: 200" (if BB ante)
- Next level preview: smaller text showing "Next: 150/300 (20:00)" or "Next: BREAK (10:00)"
- Chip-up indicator: if next level has chip-up, show "Chip-up: Remove 25s"
- Break styling: when current level is a break, display "BREAK" prominently with a different background color (teal from Catppuccin)
- Pause overlay: when paused, overlay "PAUSED" text with pulsing animation
- Hand-for-hand indicator: when active, show "HAND FOR HAND" badge
- No tap-to-pause on clock display (too easy to accidentally pause) — pause/resume lives exclusively in the FAB
**2. Time to Break Display:**
- Calculate time until next break level
- Show: "Break in: 45:00" (countdown to next break)
- If currently on break: show "Break ends in: 05:30"
- If no upcoming break: hide
**3. Player Count Card:**
- Show: "12 / 20 remaining" (active / total entries)
- Busted count: "8 busted"
- Average stack: "Avg: 25,000" (CHIP-04)
- Total chips in play: small text (CHIP-03)
**4. Table Balance Status:**
- If balanced: green indicator "Tables balanced"
- If unbalanced: yellow/red warning "Tables unbalanced — tap to view"
- Tap opens table balance view (navigates to Tables tab or modal)
**5. Financial Summary Card:**
- Prize pool: large number "€5,000"
- Breakdown: entries (15), rebuys (3), add-ons (2)
- Guarantee status: if guarantee exists and not met, show "Guarantee: €3,000 (house covers €500)"
- Tap opens Financials tab
**6. Activity Feed** (`frontend/src/lib/components/ActivityFeed.svelte`):
- Last 10-20 actions in reverse chronological order
- Each entry: icon + text + relative timestamp ("2m ago")
- Entry types with icons and colors:
- Buy-in: green, player icon
- Bust: red, skull/X icon
- Rebuy: blue, refresh icon
- Level change: clock icon
- Break start/end: coffee/play icon
- Seat move: arrow icon
- Table break: table icon
- Auto-updates from WebSocket (new entries slide in at top)
- "View all" link → full audit log
**7. Overview Page** (`frontend/src/routes/overview/+page.svelte`):
- Assembles all components in the priority order from CONTEXT.md:
1. ClockDisplay (takes ~40% of viewport on mobile)
2. Time to break
3. Player count card
4. Table balance status
5. Financial summary card
6. Activity feed (scrollable, takes remaining space)
- All data reactive from `tournament` state store
- Skeleton loading state when tournament data is loading
**Verification:**
- Overview tab shows all priority items in correct order
- Clock counts down in real time from WebSocket updates
- Player count updates when players buy in or bust
- Activity feed shows recent actions with real-time updates
- Break styling shows distinctly when on break
</task>
<task id="K2" title="Implement Financials tab with prize pool, bubble prize, and chop/deal">
**1. Prize Pool Card** (`frontend/src/lib/components/PrizePoolCard.svelte`):
- Large prize pool display: "€5,000"
- Breakdown table:
- Entries: count x amount = subtotal
- Rebuys: count x amount = subtotal
- Add-ons: count x amount = subtotal
- Re-entries: count x amount = subtotal
- Total contributions: sum
- Rake: -amount (with category breakdown on expand)
- Prize pool: final amount
- Guarantee indicator if active
- Season reserve amount if configured (FIN-12)
**2. Payout Preview:**
- Table showing payout structure for current entry count:
- Position | Percentage | Amount
- 1st | 50.0% | €2,500
- 2nd | 30.0% | €1,500
- 3rd | 20.0% | €1,000
- Auto-selects correct bracket based on entry count
- Updates in real time as entries change
- Rounding denomination shown (e.g., "Rounded to nearest €5")
**3. Bubble Prize** (`frontend/src/lib/components/BubblePrize.svelte`):
- **Prominent placement** — not buried in menus (CONTEXT.md: "Add bubble prize" easily accessible)
- Button: "Add Bubble Prize" with icon, placed prominently on Financials tab
- Flow:
1. TD taps "Add Bubble Prize"
2. Amount input (pre-filled with buy-in amount as suggestion)
3. Preview: shows redistribution (original → adjusted for each position)
4. Confirm button
- Visual: before/after comparison for top 3-5 prizes
**4. Transaction List** (`frontend/src/lib/components/TransactionList.svelte`):
- Uses DataTable component (Plan J)
- Columns: Time, Player, Type, Amount, Chips, Actions
- Filter by type (buy-in, rebuy, add-on, etc.)
- Search by player name
- Swipe action: Undo (with confirmation dialog)
- Receipt view: tap a row to see receipt details
- Reprint button on receipt view (FIN-14)
**5. Chop/Deal Flow** (`frontend/src/lib/components/DealFlow.svelte`):
- Button: "Propose Deal" (visible when 2+ players remain)
- Step 1: Select deal type (ICM, Chip Chop, Even Chop, Custom, Partial Chop)
- Step 2 (type-specific):
- ICM: input chip stacks for each remaining player → calculate
- Chip Chop: input chip stacks → proportional calculation
- Even Chop: automatic (equal split)
- Custom: input amount per player
- Partial Chop: input amount to split + amount to leave in play
- Step 3: Review proposal showing each player's payout
- Step 4: Confirm deal → apply payouts
- Note: "Prize money and league positions are independent" — show info message
**6. Financials Page** (`frontend/src/routes/financials/+page.svelte`):
- Assembles:
1. Prize Pool Card (collapsible for detail)
2. Payout Preview table
3. Bubble Prize button (prominent)
4. Deal/Chop button (when applicable)
5. Transaction list (scrollable, filterable)
- All reactive from tournament state
**Verification:**
- Prize pool displays correctly with breakdown
- Payout preview matches selected bracket
- Bubble prize flow: propose → preview redistribution → confirm
- Transaction list with filter, search, and undo action
- Chop/deal flow works for all types (ICM, chip chop, even, custom, partial)
- Season reserve amount shown when configured
</task>
## Verification Criteria
1. Overview tab shows all items in CONTEXT.md priority order
2. Clock display is large, readable, and updates in real time
3. Break and pause states display distinctly
4. Activity feed updates in real time
5. Prize pool breakdown is accurate and updates live
6. Payout preview auto-selects correct bracket by entry count
7. Bubble prize flow is fast and prominent (not buried in menus)
8. Transaction list supports filter, search, undo, receipt view
9. Chop/deal flow supports all types
10. All views are reactive from WebSocket state
## Must-Haves (Goal-Backward)
- [ ] Overview tab is the TD's primary workspace with clock as the biggest element
- [ ] Activity feed shows recent actions in real time
- [ ] Prize pool and payout preview update live as entries change
- [ ] Bubble prize creation is fast and prominent
- [ ] Transaction undo is accessible via swipe action
- [ ] Chop/deal supports ICM, chip-chop, even-chop, custom, and partial

View file

@ -0,0 +1,138 @@
# Plan L: Frontend — Players Tab + Buy-In/Bust-Out Flows
---
wave: 6
depends_on: [01-PLAN-M, 01-PLAN-G, 01-PLAN-I]
files_modified:
- frontend/src/routes/players/+page.svelte
- frontend/src/lib/components/PlayerSearch.svelte
- frontend/src/lib/components/BuyInFlow.svelte
- frontend/src/lib/components/BustOutFlow.svelte
- frontend/src/lib/components/PlayerDetail.svelte
autonomous: true
requirements: [PLYR-04, PLYR-05, PLYR-07]
---
## Goal
The Players tab shows all tournament players with search, buy-in flow, bust-out flow, and player detail. All flows are optimized for minimal taps — the TD is under time pressure during a running tournament.
## Context
- **Player management API** — Plan G (search, buy-in, bust-out, undo, rankings)
- **Seating engine API** — Plan H (tables, auto-seat, balancing, break table)
- **Template API** — Plan E (building blocks, templates, wizard)
- **UI shell** — Plan J (layout, data table, FAB, toast)
- **All flows use tap-tap pattern** — no drag-and-drop in Phase 1
## User Decisions (from CONTEXT.md)
- **Buy-in flow:** search/select player → auto-seat suggests optimal seat → TD can override → confirm → receipt
- **Bust-out flow:** tap Bust → pick table → pick seat → verify name → confirm → select hitman → done
- **Bust-out flow must be as few taps as possible**
- **Oval table view (default)** — top-down with numbered seats, switchable to list view
- **Balancing is TD-driven, system-assisted** — 2-tap recording (source seat, destination seat)
- **Break Table is fully automatic**
- **Template building blocks feel like LEGO**
- **No drag-and-drop in Phase 1** — tap-tap for all moves
- **Structure wizard** lives in template management
## Tasks
<task id="L1" title="Implement Players tab with buy-in and bust-out flows">
**1. Player Search** (`frontend/src/lib/components/PlayerSearch.svelte`):
- Typeahead input: as TD types, results appear below (debounced 200ms)
- Results show: name, nickname (if set), last tournament date
- Tap a result to select
- "Add New Player" option at bottom if no match
- 48px result rows for touch targets
- Quick search: when empty, show recently active players
**2. Buy-In Flow** (`frontend/src/lib/components/BuyInFlow.svelte`) — PLYR-04:
- Step-by-step flow (each step is a single screen):
1. **Search/Select Player**: PlayerSearch component → select player
2. **Auto-Seat Preview**: system suggests table + seat → TD can accept or tap a different seat
- Show mini table diagram highlighting suggested seat
- "Override" button → shows all available seats
3. **Confirm**: summary card showing player name, table, seat, buy-in amount, chips
- Big "Confirm Buy-In" button (48px, green)
4. **Receipt**: brief confirmation toast + receipt data available
- Late registration indicator: if cutoff approaching, show warning
- Late registration override: if past cutoff, show admin override option (logged in audit)
- Bonuses: show if early signup or punctuality bonus applies
- Triggered from: FAB "Buy In" action, or Players tab "+" button
**3. Bust-Out Flow** (`frontend/src/lib/components/BustOutFlow.svelte`) — PLYR-05:
- Optimized for minimum taps:
1. **Pick Table**: grid of active tables (large tap targets), each showing table name + player count
2. **Pick Seat**: oval table view of selected table, tap the seat of the busted player
3. **Verify**: confirmation card: "Bust [Player Name]?" with player photo/avatar
- Big "Confirm Bust" button (48px, red)
4. **Select Hitman** (PKO mandatory, otherwise optional):
- List of active players at the same table (most likely hitman)
- "Other" option → search all active players
- "Skip" option (non-PKO only)
5. **Done**: toast notification "Player Name busted — 12th place"
- Auto-ranking happens in background
- Balance check: if tables become unbalanced, show notification
- Triggered from: FAB "Bust" action
**4. Player Detail** (`frontend/src/lib/components/PlayerDetail.svelte`) — PLYR-07:
- Full player tracking within tournament:
- Current status (active/busted)
- Current seat (Table X, Seat Y)
- Chip count
- Playing time
- Buy-in time
- Rebuys: count and total amount
- Add-ons: count and total amount
- Bounties collected (with hitman chain details for PKO)
- Prize amount
- Points awarded
- Net take (prize - total investment)
- Action history (from audit trail): chronological list of all actions
- Undo buttons on applicable actions (bust, rebuy, addon, buyin)
**5. Players Page** (`frontend/src/routes/players/+page.svelte`):
- Tab layout: "Active" | "Busted" | "All"
- DataTable (from Plan J) showing:
- Active: Name, Table/Seat, Chips, Rebuys, Status
- Busted: Name, Position, Bust Time, Hitman, Prize
- All: Name, Status, Chips/Position, Net
- Search bar at top (filter current list)
- Tap row → Player Detail
- "Buy In" button at top (alternative to FAB)
- Swipe actions: Bust (active players), Undo Bust (busted players)
**6. Rebuy/Add-On Flows:**
- Rebuy: triggered from FAB or player detail
- Select player (show only eligible — active, under chip threshold, within cutoff)
- Confirm rebuy amount and chips
- Quick flow: 2 taps (select + confirm)
- Add-On: similar quick flow
- Show only during addon window
- "Add-On All" option for break-time mass add-ons (one-by-one confirmation)
**Verification:**
- Buy-in flow: search → auto-seat → confirm → receipt in minimal taps
- Bust-out flow: table → seat → verify → hitman → done in minimal taps
- Player detail shows all tracking stats
- Player list with tabs (Active/Busted/All) and search
- Undo works from player detail
</task>
## Verification Criteria
1. Buy-in flow completes in minimal taps with auto-seat
2. Bust-out flow completes in minimal taps with hitman selection
3. Player detail shows full per-player tracking stats (PLYR-07)
4. Player list with Active/Busted/All tabs and search
5. Undo works from player detail view
6. Rebuy and add-on quick flows work from FAB and player detail
## Must-Haves (Goal-Backward)
- [ ] Buy-in flow: search → auto-seat → confirm → receipt (minimal taps)
- [ ] Bust-out flow: table → seat → verify → hitman → done (minimal taps, TD under time pressure)
- [ ] Player detail with complete tracking data
- [ ] All flows optimized for touch (48px targets, no drag-and-drop)

View file

@ -0,0 +1,180 @@
# Plan M: Layout Shell — Header, Tabs, FAB, Toast, Data Table
---
wave: 3
depends_on: [01-PLAN-J]
files_modified:
- frontend/src/routes/+layout.svelte
- frontend/src/lib/components/Header.svelte
- frontend/src/lib/components/BottomTabs.svelte
- frontend/src/lib/components/FAB.svelte
- frontend/src/lib/components/Toast.svelte
- frontend/src/lib/components/DataTable.svelte
autonomous: true
requirements: [UI-01, UI-02, UI-03, UI-04, UI-07, UI-08]
---
## Goal
The layout shell wraps all page content: a persistent clock header, mobile bottom tab bar (or desktop sidebar), floating action button (FAB) for quick actions, toast notification system, and a reusable data table component. Multi-tournament tab switching when 2+ tournaments are active.
## Context
- **SvelteKit scaffold** — Plan J (theme, WS client, API client, auth/tournament state stores)
- **Svelte 5 runes** for all reactivity
- **Mobile-first** with responsive desktop layout (sidebar instead of bottom tabs)
- **48px minimum touch targets** — poker room environment
- See 01-RESEARCH.md: Pattern 3 (SvelteKit SPA)
## User Decisions (from CONTEXT.md)
- **Mobile-first bottom tab bar** — Overview, Players, Tables, Financials, More
- **FAB for quick actions** — Bust, Buy In, Rebuy, Add-On, Pause/Resume
- **Persistent header** showing clock, level, blinds, player count
- **Desktop/laptop sidebar** with wider content area
- **Toast notifications** (success, info, warning, error) with auto-dismiss
- **Multi-tournament switching** — tabs at top (phone)
## Tasks
<task id="M1" title="Implement layout shell: header, bottom tabs, FAB, toast, data table">
**1. Root Layout** (`frontend/src/routes/+layout.svelte`):
- Auth guard: if not authenticated, redirect to /login
- Structure:
```
┌─────────────────────────┐
│ PersistentHeader │ ← Fixed top, always visible
├─────────────────────────┤
│ [Tournament Tabs] │ ← Multi-tournament selector (when 2+ active)
├─────────────────────────┤
│ │
<slot /> │ ← Page content (scrollable)
│ │
├─────────────────────────┤
│ BottomTabBar │ ← Fixed bottom (mobile), Sidebar (desktop)
└─────────────────────────┘
│ FAB (floating) │ ← Bottom-right, above tab bar
│ Toast (floating) │ ← Top-right or bottom-center
```
- Responsive: detect screen width
- Mobile (< 768px): bottom tab bar, content full width
- Desktop (>= 768px): sidebar left, content fills remaining width — UI-04
**2. Persistent Header** (`frontend/src/lib/components/Header.svelte`) — UI-03:
- Fixed at top, always visible
- Content (reactive, from tournament state):
- Clock: large countdown timer (MM:SS format, red text in final 10s)
- Current level number and name (e.g., "Level 5 — NL Hold'em")
- Blinds: SB/BB display (e.g., "100/200")
- Ante: if > 0, show ante (e.g., "Ante 25")
- Player count: "12/20 remaining" (active/total)
- Pause indicator: pulsing "PAUSED" when clock is paused
- Break indicator: "BREAK" with different styling when on break level
- Compact on mobile (smaller font, abbreviated), expanded on desktop
- Connected to tournament state store (auto-updates from WebSocket)
**3. Bottom Tab Bar** (`frontend/src/lib/components/BottomTabs.svelte`) — UI-01:
- 5 tabs: Overview, Players, Tables, Financials, More
- Each tab: icon + label
- Active tab highlighted with accent color
- 48px touch targets — UI-06
- Renders only on mobile (hidden on desktop where sidebar shows)
- Navigation: SvelteKit goto() or <a> elements
**4. Desktop Sidebar** — UI-04:
- Same 5 navigation items as bottom tabs but in vertical sidebar
- Wider labels, no icons-only mode
- Active item highlighted
- Renders only on desktop (>= 768px)
**5. Floating Action Button** (`frontend/src/lib/components/FAB.svelte`) — UI-02:
- Positioned bottom-right, above the tab bar
- Default state: single button with "+" icon
- Expanded state: fan out action buttons:
- Bust (red) — opens bust-out flow
- Buy In (green) — opens buy-in flow
- Rebuy (blue) — opens rebuy flow
- Add-On (yellow) — opens add-on flow
- Pause/Resume (orange) — toggles clock
- Each action button: 48px, with label
- Press-state animation (scale down on press) — UI-06
- Context-aware: only show relevant actions (e.g., hide "Add-On" if not in addon window)
- Close on backdrop tap or ESC
**6. Toast Notifications** (`frontend/src/lib/components/Toast.svelte`) — UI-07:
- Toast state using Svelte 5 runes:
```typescript
class ToastState {
toasts = $state<Toast[]>([]);
success(message: string, duration?: number) { ... }
info(message: string, duration?: number) { ... }
warning(message: string, duration?: number) { ... }
error(message: string, duration?: number) { ... }
dismiss(id: string) { ... }
}
export const toast = new ToastState();
```
- Auto-dismiss: success (3s), info (4s), warning (5s), error (manual dismiss or 8s)
- Stacking: multiple toasts stack vertically
- Animation: slide in from right, fade out
- Color coding: green (success), blue (info), yellow (warning), red (error) — using Catppuccin colors
**7. Data Table** (`frontend/src/lib/components/DataTable.svelte`) — UI-08:
- Props: columns config, data array, sortable flag, searchable flag
- Features:
- Sort by clicking column header (asc/desc toggle)
- Sticky header on scroll
- Search/filter input (filters across all visible columns)
- Row click handler (for detail navigation)
- Mobile: swipe actions (swipe left reveals action buttons like "Bust", "Rebuy")
- Loading state: skeleton rows
- Empty state: "No data" message
- Responsive: hide less important columns on mobile (configurable per column)
- 48px row height for touch targets — UI-06
**8. Multi-Tournament Tabs:**
- Show tabs at top of content area when 2+ tournaments are active
- Each tab: tournament name + status indicator
- Tapping a tab switches the active tournament (changes which state the views render)
- Keep both tournament states in memory (keyed by tournament ID) for fast switching — don't clear/re-fetch on tab change
- WebSocket subscribes to all active tournaments simultaneously; messages route to the correct state by tournament ID
- On phone: scrollable horizontal tabs
**9. Loading States** — UI-06:
- Skeleton loading component: animated placeholder matching content shape
- Used in all data-fetching views
- Full-page loading spinner for initial app load
- Inline loading states for buttons (spinner replaces label during action)
**Verification:**
- App renders with Catppuccin Mocha dark theme
- Header shows clock countdown (updates from WebSocket)
- Bottom tabs navigate between Overview/Players/Tables/Financials/More on mobile
- Sidebar navigation works on desktop
- FAB expands to show action buttons
- Toast notifications appear and auto-dismiss
- Data table sorts, filters, and handles mobile swipe actions
- Multi-tournament tabs appear when 2+ tournaments exist
- All interactive elements meet 48px minimum touch target
</task>
## Verification Criteria
1. Mobile-first bottom tab bar with Overview, Players, Tables, Financials, More
2. FAB expands to show quick actions (Bust, Buy In, Rebuy, Add-On, Pause/Resume)
3. Persistent header shows clock, level, blinds, player count — updates in real time
4. Desktop sidebar navigation for wider screens
5. Toast notifications work (success, info, warning, error) with auto-dismiss
6. Data tables with sort, sticky header, search/filter, swipe actions (mobile)
7. Multi-tournament tabs appear when 2+ tournaments active
8. All interactive elements meet 48px minimum touch targets
9. Loading states (skeleton, spinner) for all data-fetching views
## Must-Haves (Goal-Backward)
- [ ] Mobile-first bottom tab bar with 5 navigation tabs
- [ ] FAB with context-aware quick actions
- [ ] Persistent header with live clock, level, blinds, player count
- [ ] Responsive layout (bottom tabs on mobile, sidebar on desktop)
- [ ] Data table component reusable across all views
- [ ] Toast notification system

View file

@ -0,0 +1,186 @@
# Plan N: Frontend — Tables Tab + More Tab (Templates, Settings)
---
wave: 6
depends_on: [01-PLAN-M, 01-PLAN-H, 01-PLAN-E, 01-PLAN-I]
files_modified:
- frontend/src/routes/tables/+page.svelte
- frontend/src/routes/more/+page.svelte
- frontend/src/routes/more/templates/+page.svelte
- frontend/src/routes/more/settings/+page.svelte
- frontend/src/lib/components/OvalTable.svelte
- frontend/src/lib/components/TableListView.svelte
- frontend/src/lib/components/BalancingPanel.svelte
- frontend/src/lib/components/TemplateManager.svelte
- frontend/src/lib/components/BlindStructureEditor.svelte
- frontend/src/lib/components/StructureWizard.svelte
autonomous: true
requirements: [SEAT-05, SEAT-07, SEAT-08, BLIND-06]
---
## Goal
The Tables tab shows oval table views (default) with seated players, list view alternative, balancing panel, and break table action. The More tab provides template management (LEGO building blocks), blind structure editor, structure wizard, venue settings, and audit log access. All interactions use tap-tap flow (no drag-and-drop in Phase 1).
## Context
- **Seating engine API** — Plan H (tables, auto-seat, balancing, break table)
- **Template/building blocks API** — Plan E (CRUD, templates, wizard)
- **Tournament lifecycle API** — Plan I (tournament state)
- **UI shell** — Plan M (layout, data table, FAB, toast)
- **All flows use tap-tap pattern** — no drag-and-drop in Phase 1
## User Decisions (from CONTEXT.md)
- **Oval table view (default)** — top-down with numbered seats, switchable to list view
- **Balancing is TD-driven, system-assisted** — 2-tap recording (source seat, destination seat)
- **Break Table is fully automatic**
- **Template building blocks feel like LEGO**
- **No drag-and-drop in Phase 1** — tap-tap for all moves
- **Structure wizard** lives in template management
## Tasks
<task id="N1" title="Implement Tables tab with oval view and balancing panel">
**1. Oval Table View** (`frontend/src/lib/components/OvalTable.svelte`) — SEAT-08:
- SVG-based top-down oval table:
- Oval shape with seat positions around the edge
- Each seat: numbered circle (48px touch target)
- Occupied seats: show player name (truncated if needed)
- Empty seats: show seat number only (lighter color)
- Dealer button indicator on the button seat
- Active/selected seat highlighted
- Responsive: scales to container width
- Tap seat → select for move/bust/details
- Support 6-max through 10-max configurations
**2. Table List View** (`frontend/src/lib/components/TableListView.svelte`) — SEAT-08:
- Alternative to oval view for density (10+ table tournaments)
- DataTable format: Table Name | Players | Seats | Balance
- Expand row → show seated players list
- Toggle between Oval and List view
**3. Balancing Panel** (`frontend/src/lib/components/BalancingPanel.svelte`) — SEAT-05:
- Shown when tables are unbalanced (yellow/red indicator)
- "Suggest Moves" button → shows system-generated suggestions
- Each suggestion: "Move 1 player from Table 1 (8 players) to Table 4 (6 players)"
- Accept flow (2 taps):
1. Tap source seat on source table (who is moving)
2. Tap destination seat on destination table (where they're going)
3. System confirms and executes
- Cancel suggestion button
- Status: "Live" indicator showing suggestion is current (adaptive)
- If suggestion becomes stale (state changed), auto-cancel and show updated suggestion
**4. Tables Page** (`frontend/src/routes/tables/+page.svelte`):
- Grid of oval tables (2 per row on phone, more on tablet/desktop)
- View toggle: Oval / List
- Balance status banner at top (if unbalanced)
- Balancing panel (expandable)
- Actions per table:
- "Break Table" button (with confirmation: "Distribute N players to remaining tables?")
- "Add Table" button
- Seat move: tap source seat, tap destination seat (different table) → confirm move
- Hand-for-hand toggle (when near bubble)
**Verification:**
- Oval table view renders with correct seat positions and player names
- Tap-tap seat move works across tables
- Balancing panel shows suggestions and executes moves
- Break Table dissolves table and shows redistribution result
</task>
<task id="N2" title="Implement More tab with template management, structure wizard, settings">
**1. More Page** (`frontend/src/routes/more/+page.svelte`):
- Navigation list to sub-pages:
- Tournament Templates
- Blind Structures
- Chip Sets
- Payout Structures
- Buy-in Configs
- Venue Settings
- Operators (admin only)
- Audit Log
- About / Version
**2. Template Manager** (`frontend/src/routes/more/templates/+page.svelte` and components):
**Template List:**
- DataTable: Name, Type (Turbo/Standard/etc.), Built-in badge
- Actions: Edit, Duplicate, Delete (not for built-in)
- "Create Template" button
**Template Editor** (`frontend/src/lib/components/TemplateManager.svelte`):
- LEGO-style composition:
1. Name and description
2. Chip Set: dropdown selector (preview denominations below)
3. Blind Structure: dropdown selector (preview level summary below)
4. Payout Structure: dropdown selector (preview brackets below)
5. Buy-in Config: dropdown selector (preview amounts below)
6. Points Formula: dropdown selector (optional)
7. Tournament options: min/max players, PKO, bonuses
- Each dropdown: show current selection with summary, tap to change
- "Create New" option in each dropdown → navigate to that building block's editor
**3. Blind Structure Editor** (`frontend/src/lib/components/BlindStructureEditor.svelte`):
- List of levels with all fields per row (BLIND-01):
- Position, Type (round/break), Game Type, SB, BB, Ante, BB Ante, Duration, Chip-up, Notes
- Add level button (appends)
- Delete level button (per row)
- Reorder (move up/down buttons — no drag, Phase 1)
- Auto-numbering (positions auto-increment)
- Mixed game support: game type dropdown per level (BLIND-03)
**4. Structure Wizard** (`frontend/src/lib/components/StructureWizard.svelte`) — BLIND-06:
- Inputs:
- Player count (slider or number input, 8-200)
- Starting chips (number input, common presets: 10K, 15K, 25K, 50K)
- Target duration (slider: 1-8 hours)
- Chip set (dropdown — for denomination alignment)
- "Generate" button → calls wizard API
- Preview: generated level list (editable before saving)
- "Save as Structure" button → creates new blind structure
- "Use in Template" button → pre-selects in template editor
**5. Settings Page** (`frontend/src/routes/more/settings/+page.svelte`):
- Venue name
- Currency (code + symbol)
- Rounding denomination
- Receipt mode (off / digital / print / both)
- Theme toggle (Mocha / Latte)
- Operator management (admin only): list operators, change PINs, change roles
**6. Audit Log Page:**
- DataTable with audit entries
- Filters: by action type, by tournament, by operator, date range
- Entry detail: full previous/new state JSON
- Linked to undo: "Undo this action" button where applicable
**Verification:**
- Template editor composes building blocks LEGO-style
- Blind structure editor supports all level fields
- Structure wizard generates and previews a blind structure
- Settings page saves venue configuration
- Audit log shows filterable action history
</task>
## Verification Criteria
1. Oval table view renders correctly for 6-10 max configurations
2. Tap-tap seat moves work between tables
3. Balancing panel shows adaptive suggestions and executes with 2 taps
4. Break Table distributes players and shows result
5. Template editor composes building blocks like LEGO
6. Blind structure editor supports all fields including BB Ante and mixed game
7. Structure wizard generates playable structures
8. Settings page saves venue configuration
9. Audit log shows filterable action history with undo capability
## Must-Haves (Goal-Backward)
- [ ] Oval table view with numbered seats and player names
- [ ] 2-tap seat recording for balancing (source seat, destination seat)
- [ ] Break Table shows redistribution result
- [ ] Template management with LEGO-style building block composition
- [ ] Structure wizard lives in template management
- [ ] All interactions use tap-tap flow (no drag-and-drop)