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>
342 lines
16 KiB
Markdown
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
|