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

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

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

342 lines
16 KiB
Markdown

# 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