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>
16 KiB
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:
- System alerts: tables are unbalanced
- TD requests suggestion: system says "move 1 from Table 1 to Table 4"
- TD announces to the floor
- Assistant reports back: "Seat 3 to Seat 5" — TD taps two seat numbers, done
- 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
**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:
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 }
- Return all active tables with seated players:
-
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) errorAdvanceDealerButton(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:
Blueprintstruct: 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) errorDeleteBlueprint(ctx, id string) errorSaveBlueprintFromTournament(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/disablePOST /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
-
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:
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):
- Identify tables that need to lose players (largest tables)
- Identify tables that need to gain players (smallest tables)
- 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
- Generate suggestion:
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")
- Algorithm (TDA-compliant):
-
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
- Re-validate the suggestion is still valid (live and adaptive):
-
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:
- Sort destination tables by current player count (ascending)
- For each player from broken table, assign to the table with fewest players
- Assign random empty seat at destination table
- Consider button position for fairness (TDA rules)
- Deactivate the broken table (is_active = false)
- Return result showing all moves:
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 tablePOST /api/v1/tournaments/{id}/tables/from-blueprint— body:{"blueprint_id": "..."}PUT /api/v1/tournaments/{id}/tables/{tableId}— update tableDELETE /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/movePOST /api/v1/tournaments/{id}/seats/swap— body:{"player1_id": "...", "player2_id": "..."}
Balancing:
GET /api/v1/tournaments/{id}/balance— check balance statusPOST /api/v1/tournaments/{id}/balance/suggest— get move suggestionsPOST /api/v1/tournaments/{id}/balance/suggestions/{suggId}/accept— body:{"from_seat": 3, "to_seat": 7}— accept with seat specificsPOST /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 allPOST /api/v1/blueprints— createGET /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 modePOST /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
Verification Criteria
- Tables with configurable seat counts (6-max to 10-max) work correctly
- Table blueprints save and restore venue layouts
- Dealer button tracking and advancement work
- Random initial seating fills tables evenly
- Balancing suggestions with operator confirmation (never auto-apply)
- Suggestions are live and adaptive (recalculate on state change)
- Tap-tap seat moves work (no drag-and-drop)
- Break Table dissolves and distributes players evenly
- Visual table data available (top-down with seats, SEAT-08)
- 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