From 2d3cb0ac9e9998477c15da2c2e4213af9595fc52 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 04:24:52 +0100 Subject: [PATCH] feat(01-08): implement balance engine, break table, and seating API routes - TDA-compliant balance engine with live-adaptive suggestions - Break table distributes players evenly across remaining tables - Stale suggestion detection and invalidation on state changes - Full REST API for tables, seating, balancing, blueprints, hand-for-hand - 15 tests covering balance, break table, auto-seat, and dealer button Co-Authored-By: Claude Opus 4.6 --- internal/seating/balance.go | 674 ++++++++++++++++++++++++++++ internal/seating/balance_test.go | 576 ++++++++++++++++++++++++ internal/seating/breaktable.go | 287 ++++++++++++ internal/seating/breaktable_test.go | 208 +++++++++ internal/seating/table.go | 5 + internal/server/routes/tables.go | 536 ++++++++++++++++++++++ 6 files changed, 2286 insertions(+) create mode 100644 internal/seating/balance_test.go create mode 100644 internal/seating/breaktable_test.go create mode 100644 internal/server/routes/tables.go diff --git a/internal/seating/balance.go b/internal/seating/balance.go index 63b7c6c..bda7aca 100644 --- a/internal/seating/balance.go +++ b/internal/seating/balance.go @@ -1 +1,675 @@ package seating + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "sort" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/server/ws" +) + +// ---------- Types ---------- + +// BalanceStatus describes whether tables are balanced and what moves are needed. +type BalanceStatus struct { + IsBalanced bool `json:"is_balanced"` + MaxDifference int `json:"max_difference"` + Tables []TableCount `json:"tables"` + NeedsMoves int `json:"needs_moves"` +} + +// TableCount is a summary of one table's player count. +type TableCount struct { + TableID int `json:"table_id"` + TableName string `json:"table_name"` + PlayerCount int `json:"player_count"` +} + +// BalanceSuggestion is a proposed move to balance tables. +type BalanceSuggestion struct { + ID int `json:"id"` + FromTableID int `json:"from_table_id"` + FromTableName string `json:"from_table_name"` + ToTableID int `json:"to_table_id"` + ToTableName string `json:"to_table_name"` + PlayerID *string `json:"player_id,omitempty"` + PlayerName *string `json:"player_name,omitempty"` + Status string `json:"status"` // pending, accepted, cancelled, expired + CreatedAt int64 `json:"created_at"` +} + +// ---------- Service ---------- + +// BalanceEngine manages table balancing suggestions per TDA rules. +type BalanceEngine struct { + db *sql.DB + audit *audit.Trail + hub *ws.Hub +} + +// NewBalanceEngine creates a new BalanceEngine. +func NewBalanceEngine(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub) *BalanceEngine { + return &BalanceEngine{ + db: db, + audit: auditTrail, + hub: hub, + } +} + +// ---------- Balance Check ---------- + +// CheckBalance checks whether tables in a tournament are balanced. +// TDA rule: tables are unbalanced if the difference between the largest +// and smallest table exceeds 1. +func (e *BalanceEngine) CheckBalance(ctx context.Context, tournamentID string) (*BalanceStatus, error) { + counts, err := e.getTableCounts(ctx, tournamentID) + if err != nil { + return nil, err + } + + if len(counts) < 2 { + return &BalanceStatus{ + IsBalanced: true, + Tables: counts, + }, nil + } + + // Find min and max player counts + minCount := counts[0].PlayerCount + maxCount := counts[0].PlayerCount + for _, tc := range counts[1:] { + if tc.PlayerCount < minCount { + minCount = tc.PlayerCount + } + if tc.PlayerCount > maxCount { + maxCount = tc.PlayerCount + } + } + + diff := maxCount - minCount + balanced := diff <= 1 + + // Calculate how many moves are needed to balance + needsMoves := 0 + if !balanced { + // Target: all tables should have either floor(total/n) or ceil(total/n) players + total := 0 + for _, tc := range counts { + total += tc.PlayerCount + } + n := len(counts) + base := total / n + extra := total % n + + // Sort by player count descending to count surplus + sorted := make([]TableCount, len(counts)) + copy(sorted, counts) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].PlayerCount > sorted[j].PlayerCount + }) + + for i, tc := range sorted { + target := base + if i < extra { + target = base + 1 + } + if tc.PlayerCount > target { + needsMoves += tc.PlayerCount - target + } + } + } + + return &BalanceStatus{ + IsBalanced: balanced, + MaxDifference: diff, + Tables: counts, + NeedsMoves: needsMoves, + }, nil +} + +// ---------- Suggestions ---------- + +// SuggestMoves generates balance move suggestions. Suggestions are proposals +// that must be accepted by the TD -- never auto-applied. +func (e *BalanceEngine) SuggestMoves(ctx context.Context, tournamentID string) ([]BalanceSuggestion, error) { + status, err := e.CheckBalance(ctx, tournamentID) + if err != nil { + return nil, err + } + + if status.IsBalanced { + return nil, nil + } + + // Build table name map for later use + tableNames := make(map[int]string) + for _, tc := range status.Tables { + tableNames[tc.TableID] = tc.TableName + } + + // Calculate targets: each table should have floor(total/n) or ceil(total/n) + total := 0 + for _, tc := range status.Tables { + total += tc.PlayerCount + } + n := len(status.Tables) + base := total / n + extra := total % n + + // Sort tables by player count descending + sorted := make([]TableCount, len(status.Tables)) + copy(sorted, status.Tables) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].PlayerCount > sorted[j].PlayerCount + }) + + // Assign targets: top 'extra' tables get base+1, rest get base + targets := make(map[int]int) + for i, tc := range sorted { + if i < extra { + targets[tc.TableID] = base + 1 + } else { + targets[tc.TableID] = base + } + } + + // Generate moves: from tables with surplus to tables with deficit + type surplus struct { + tableID int + excess int + } + type deficit struct { + tableID int + need int + } + + var surplusTables []surplus + var deficitTables []deficit + + for _, tc := range sorted { + target := targets[tc.TableID] + if tc.PlayerCount > target { + surplusTables = append(surplusTables, surplus{tc.TableID, tc.PlayerCount - target}) + } else if tc.PlayerCount < target { + deficitTables = append(deficitTables, deficit{tc.TableID, target - tc.PlayerCount}) + } + } + + var suggestions []BalanceSuggestion + + si, di := 0, 0 + for si < len(surplusTables) && di < len(deficitTables) { + s := &surplusTables[si] + d := &deficitTables[di] + + // Pick a player from the surplus table (prefer player farthest from button for fairness) + playerID, playerName := e.pickPlayerToMove(ctx, tournamentID, s.tableID) + + // Insert suggestion into DB + result, err := e.db.ExecContext(ctx, + `INSERT INTO balance_suggestions + (tournament_id, from_table_id, to_table_id, player_id, status, reason) + VALUES (?, ?, ?, ?, 'pending', 'balance')`, + tournamentID, s.tableID, d.tableID, playerID, + ) + if err != nil { + return nil, fmt.Errorf("insert suggestion: %w", err) + } + + id, _ := result.LastInsertId() + var createdAt int64 + _ = e.db.QueryRowContext(ctx, + `SELECT created_at FROM balance_suggestions WHERE id = ?`, id, + ).Scan(&createdAt) + + sugg := BalanceSuggestion{ + ID: int(id), + FromTableID: s.tableID, + FromTableName: tableNames[s.tableID], + ToTableID: d.tableID, + ToTableName: tableNames[d.tableID], + PlayerID: playerID, + PlayerName: playerName, + Status: "pending", + CreatedAt: createdAt, + } + suggestions = append(suggestions, sugg) + + s.excess-- + d.need-- + if s.excess == 0 { + si++ + } + if d.need == 0 { + di++ + } + } + + e.recordAudit(ctx, tournamentID, audit.ActionSeatBalance, "balance", tournamentID, nil, + map[string]interface{}{"suggestions": len(suggestions)}) + e.broadcast(tournamentID, "balance.suggestions", suggestions) + + return suggestions, nil +} + +// AcceptSuggestion re-validates and executes a balance suggestion. +// If the suggestion is stale (state changed), it is cancelled and an error is returned. +func (e *BalanceEngine) AcceptSuggestion(ctx context.Context, tournamentID string, suggestionID, fromSeat, toSeat int) error { + // Load suggestion + var sugg BalanceSuggestion + var playerID sql.NullString + err := e.db.QueryRowContext(ctx, + `SELECT id, from_table_id, to_table_id, player_id, status + FROM balance_suggestions + WHERE id = ? AND tournament_id = ?`, + suggestionID, tournamentID, + ).Scan(&sugg.ID, &sugg.FromTableID, &sugg.ToTableID, &playerID, &sugg.Status) + if err == sql.ErrNoRows { + return fmt.Errorf("suggestion not found") + } + if err != nil { + return fmt.Errorf("load suggestion: %w", err) + } + + if sugg.Status != "pending" { + return fmt.Errorf("suggestion is %s, not pending", sugg.Status) + } + + // Re-validate: is the source table still larger than destination? + var fromCount, toCount int + err = e.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND seat_table_id = ? AND status IN ('registered', 'active')`, + tournamentID, sugg.FromTableID, + ).Scan(&fromCount) + if err != nil { + return fmt.Errorf("count from table: %w", err) + } + + err = e.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND seat_table_id = ? AND status IN ('registered', 'active')`, + tournamentID, sugg.ToTableID, + ).Scan(&toCount) + if err != nil { + return fmt.Errorf("count to table: %w", err) + } + + // Stale check: if source is not larger than destination by at least 2, suggestion is stale + if fromCount-toCount < 2 { + // Cancel the stale suggestion + _, _ = e.db.ExecContext(ctx, + `UPDATE balance_suggestions SET status = 'expired', resolved_at = unixepoch() + WHERE id = ?`, suggestionID) + e.broadcast(tournamentID, "balance.suggestion_expired", map[string]interface{}{ + "suggestion_id": suggestionID, "reason": "stale", + }) + return fmt.Errorf("suggestion is stale: table counts no longer justify this move (from=%d, to=%d)", fromCount, toCount) + } + + // Determine which player to move + pid := "" + if playerID.Valid { + pid = playerID.String + } + if pid == "" { + return fmt.Errorf("no player specified in suggestion") + } + + // Verify player is still at the source table + var playerTable sql.NullInt64 + err = e.db.QueryRowContext(ctx, + `SELECT seat_table_id FROM tournament_players + WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`, + tournamentID, pid, + ).Scan(&playerTable) + if err != nil { + return fmt.Errorf("check player location: %w", err) + } + if !playerTable.Valid || int(playerTable.Int64) != sugg.FromTableID { + _, _ = e.db.ExecContext(ctx, + `UPDATE balance_suggestions SET status = 'expired', resolved_at = unixepoch() + WHERE id = ?`, suggestionID) + return fmt.Errorf("suggestion is stale: player no longer at source table") + } + + // Verify destination seat is empty + var occCount int + err = e.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND seat_table_id = ? AND seat_position = ? + AND status IN ('registered', 'active')`, + tournamentID, sugg.ToTableID, toSeat, + ).Scan(&occCount) + if err != nil { + return fmt.Errorf("check destination seat: %w", err) + } + if occCount > 0 { + return fmt.Errorf("destination seat %d is already occupied", toSeat) + } + + // Execute the move + _, err = e.db.ExecContext(ctx, + `UPDATE tournament_players SET seat_table_id = ?, seat_position = ?, updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`, + sugg.ToTableID, toSeat, tournamentID, pid, + ) + if err != nil { + return fmt.Errorf("move player: %w", err) + } + + // Mark suggestion as accepted + _, err = e.db.ExecContext(ctx, + `UPDATE balance_suggestions SET status = 'accepted', from_seat = ?, to_seat = ?, resolved_at = unixepoch() + WHERE id = ?`, + fromSeat, toSeat, suggestionID, + ) + if err != nil { + return fmt.Errorf("mark accepted: %w", err) + } + + e.recordAudit(ctx, tournamentID, audit.ActionSeatBalance, "player", pid, + map[string]interface{}{"table_id": sugg.FromTableID, "seat": fromSeat}, + map[string]interface{}{"table_id": sugg.ToTableID, "seat": toSeat}) + e.broadcast(tournamentID, "balance.accepted", map[string]interface{}{ + "suggestion_id": suggestionID, "player_id": pid, + "from_table_id": sugg.FromTableID, "to_table_id": sugg.ToTableID, + "from_seat": fromSeat, "to_seat": toSeat, + }) + + return nil +} + +// CancelSuggestion cancels a pending balance suggestion. +func (e *BalanceEngine) CancelSuggestion(ctx context.Context, tournamentID string, suggestionID int) error { + result, err := e.db.ExecContext(ctx, + `UPDATE balance_suggestions SET status = 'cancelled', resolved_at = unixepoch() + WHERE id = ? AND tournament_id = ? AND status = 'pending'`, + suggestionID, tournamentID, + ) + if err != nil { + return fmt.Errorf("cancel suggestion: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("suggestion not found or not pending") + } + + e.recordAudit(ctx, tournamentID, audit.ActionSeatBalance, "suggestion", fmt.Sprintf("%d", suggestionID), nil, + map[string]interface{}{"status": "cancelled"}) + e.broadcast(tournamentID, "balance.cancelled", map[string]interface{}{ + "suggestion_id": suggestionID, + }) + return nil +} + +// InvalidateStaleSuggestions checks all pending suggestions and cancels those +// that are no longer valid due to state changes. This implements the "live and +// adaptive" behavior from CONTEXT.md. +func (e *BalanceEngine) InvalidateStaleSuggestions(ctx context.Context, tournamentID string) error { + rows, err := e.db.QueryContext(ctx, + `SELECT id, from_table_id, to_table_id, player_id + FROM balance_suggestions + WHERE tournament_id = ? AND status = 'pending'`, + tournamentID, + ) + if err != nil { + return fmt.Errorf("query pending suggestions: %w", err) + } + defer rows.Close() + + type pending struct { + id int + fromTableID int + toTableID int + playerID sql.NullString + } + + var pendings []pending + for rows.Next() { + var p pending + if err := rows.Scan(&p.id, &p.fromTableID, &p.toTableID, &p.playerID); err != nil { + return fmt.Errorf("scan suggestion: %w", err) + } + pendings = append(pendings, p) + } + if err := rows.Err(); err != nil { + return err + } + + // Get current table counts + counts, err := e.getTableCounts(ctx, tournamentID) + if err != nil { + return err + } + countMap := make(map[int]int) + for _, tc := range counts { + countMap[tc.TableID] = tc.PlayerCount + } + + for _, p := range pendings { + fromCount := countMap[p.fromTableID] + toCount := countMap[p.toTableID] + + stale := false + + // Check: does this move still make sense? + if fromCount-toCount < 2 { + stale = true + } + + // Check: is the suggested player still at the source table? + if !stale && p.playerID.Valid { + var playerTableID sql.NullInt64 + err := e.db.QueryRowContext(ctx, + `SELECT seat_table_id FROM tournament_players + WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`, + tournamentID, p.playerID.String, + ).Scan(&playerTableID) + if err != nil || !playerTableID.Valid || int(playerTableID.Int64) != p.fromTableID { + stale = true + } + } + + if stale { + _, _ = e.db.ExecContext(ctx, + `UPDATE balance_suggestions SET status = 'expired', resolved_at = unixepoch() + WHERE id = ?`, p.id) + e.broadcast(tournamentID, "balance.suggestion_expired", map[string]interface{}{ + "suggestion_id": p.id, + }) + } + } + + return nil +} + +// GetPendingSuggestions returns all pending balance suggestions for a tournament. +func (e *BalanceEngine) GetPendingSuggestions(ctx context.Context, tournamentID string) ([]BalanceSuggestion, error) { + rows, err := e.db.QueryContext(ctx, + `SELECT bs.id, bs.from_table_id, ft.name, bs.to_table_id, tt.name, + bs.player_id, p.name, bs.status, bs.created_at + FROM balance_suggestions bs + JOIN tables ft ON ft.id = bs.from_table_id + JOIN tables tt ON tt.id = bs.to_table_id + LEFT JOIN players p ON p.id = bs.player_id + WHERE bs.tournament_id = ? AND bs.status = 'pending' + ORDER BY bs.created_at`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("query suggestions: %w", err) + } + defer rows.Close() + + var suggestions []BalanceSuggestion + for rows.Next() { + var s BalanceSuggestion + var pid, pname sql.NullString + if err := rows.Scan(&s.ID, &s.FromTableID, &s.FromTableName, + &s.ToTableID, &s.ToTableName, &pid, &pname, &s.Status, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("scan suggestion: %w", err) + } + if pid.Valid { + s.PlayerID = &pid.String + } + if pname.Valid { + s.PlayerName = &pname.String + } + suggestions = append(suggestions, s) + } + return suggestions, rows.Err() +} + +// ---------- Helpers ---------- + +// getTableCounts returns player counts per active table. +func (e *BalanceEngine) getTableCounts(ctx context.Context, tournamentID string) ([]TableCount, error) { + rows, err := e.db.QueryContext(ctx, + `SELECT t.id, t.name, + (SELECT COUNT(*) FROM tournament_players tp + WHERE tp.seat_table_id = t.id AND tp.tournament_id = t.tournament_id + AND tp.status IN ('registered', 'active')) AS player_count + FROM tables t + WHERE t.tournament_id = ? AND t.is_active = 1 + ORDER BY t.name`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("get table counts: %w", err) + } + defer rows.Close() + + var counts []TableCount + for rows.Next() { + var tc TableCount + if err := rows.Scan(&tc.TableID, &tc.TableName, &tc.PlayerCount); err != nil { + return nil, fmt.Errorf("scan table count: %w", err) + } + counts = append(counts, tc) + } + return counts, rows.Err() +} + +// pickPlayerToMove selects a player to move from a table for balancing. +// Prefers the player in the worst position relative to the dealer button +// (fairness per TDA rules). +func (e *BalanceEngine) pickPlayerToMove(ctx context.Context, tournamentID string, tableID int) (*string, *string) { + // Get dealer button position + var btnPos sql.NullInt64 + var seatCount int + _ = e.db.QueryRowContext(ctx, + `SELECT dealer_button_position, seat_count FROM tables + WHERE id = ? AND tournament_id = ?`, + tableID, tournamentID, + ).Scan(&btnPos, &seatCount) + + // Get all players at this table + rows, err := e.db.QueryContext(ctx, + `SELECT tp.player_id, p.name, tp.seat_position + FROM tournament_players tp + JOIN players p ON p.id = tp.player_id + WHERE tp.tournament_id = ? AND tp.seat_table_id = ? + AND tp.status IN ('registered', 'active') AND tp.seat_position IS NOT NULL + ORDER BY tp.seat_position`, + tournamentID, tableID, + ) + if err != nil { + return nil, nil + } + defer rows.Close() + + type playerSeat struct { + id string + name string + position int + } + var players []playerSeat + for rows.Next() { + var ps playerSeat + if err := rows.Scan(&ps.id, &ps.name, &ps.position); err != nil { + continue + } + players = append(players, ps) + } + + if len(players) == 0 { + return nil, nil + } + + // If no button set, pick the last player (arbitrary but deterministic) + if !btnPos.Valid { + p := players[len(players)-1] + return &p.id, &p.name + } + + btn := int(btnPos.Int64) + + // Pick the player farthest from the button in clockwise direction + // (i.e. the player who will wait longest for the button) + // The player immediately after the button (SB) waits the longest for + // their next button. + // We want to move the player who is "worst off" = closest clockwise AFTER + // the button (big blind or later positions are less penalized by a move). + // + // Simplification: pick the player in the first position clockwise after + // the button (the small blind position) — that player benefits least from + // staying. + best := players[0] + bestDist := clockwiseDist(btn, players[0].position, seatCount) + for _, ps := range players[1:] { + d := clockwiseDist(btn, ps.position, seatCount) + if d < bestDist { + bestDist = d + best = ps + } + } + + return &best.id, &best.name +} + +// clockwiseDist returns the clockwise distance from 'from' to 'to' on a +// table with seatCount seats. +func clockwiseDist(from, to, seatCount int) int { + d := to - from + if d <= 0 { + d += seatCount + } + return d +} + +func (e *BalanceEngine) recordAudit(ctx context.Context, tournamentID, action, targetType, targetID string, prevState, newState interface{}) { + if e.audit == nil { + return + } + var prev, next json.RawMessage + if prevState != nil { + prev, _ = json.Marshal(prevState) + } + if newState != nil { + next, _ = json.Marshal(newState) + } + tid := tournamentID + e.audit.Record(ctx, audit.AuditEntry{ + TournamentID: &tid, + Action: action, + TargetType: targetType, + TargetID: targetID, + PreviousState: prev, + NewState: next, + }) +} + +func (e *BalanceEngine) broadcast(tournamentID, msgType string, data interface{}) { + if e.hub == nil { + return + } + payload, err := json.Marshal(data) + if err != nil { + return + } + e.hub.Broadcast(tournamentID, msgType, payload) +} diff --git a/internal/seating/balance_test.go b/internal/seating/balance_test.go new file mode 100644 index 0000000..2e4a582 --- /dev/null +++ b/internal/seating/balance_test.go @@ -0,0 +1,576 @@ +package seating + +import ( + "context" + "database/sql" + "fmt" + "testing" + + _ "github.com/tursodatabase/go-libsql" +) + +// testDB creates an in-memory SQLite database with the required schema. +func testDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("libsql", "file::memory:?cache=shared") + if err != nil { + t.Fatalf("open test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + stmts := []string{ + `CREATE TABLE IF NOT EXISTS tournaments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'created', + buyin_config_id INTEGER NOT NULL DEFAULT 1, + chip_set_id INTEGER NOT NULL DEFAULT 1, + blind_structure_id INTEGER NOT NULL DEFAULT 1, + payout_structure_id INTEGER NOT NULL DEFAULT 1, + min_players INTEGER NOT NULL DEFAULT 2, + current_level INTEGER NOT NULL DEFAULT 0, + clock_state TEXT NOT NULL DEFAULT 'stopped', + clock_remaining_ns INTEGER NOT NULL DEFAULT 0, + total_elapsed_ns INTEGER NOT NULL DEFAULT 0, + hand_for_hand INTEGER NOT NULL DEFAULT 0, + hand_for_hand_hand_number INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tables ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + name TEXT NOT NULL, + seat_count INTEGER NOT NULL DEFAULT 9, + dealer_button_position INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + hand_completed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournament_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + seat_table_id INTEGER, + seat_position INTEGER, + current_chips INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(tournament_id, player_id) + )`, + `CREATE TABLE IF NOT EXISTS balance_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + from_table_id INTEGER NOT NULL, + to_table_id INTEGER NOT NULL, + player_id TEXT, + from_seat INTEGER, + to_seat INTEGER, + reason TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + resolved_at INTEGER + )`, + `CREATE TABLE IF NOT EXISTS table_blueprints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + table_configs TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS audit_entries ( + id TEXT PRIMARY KEY, + tournament_id TEXT, + timestamp INTEGER NOT NULL DEFAULT 0, + operator_id TEXT NOT NULL DEFAULT 'system', + action TEXT NOT NULL DEFAULT '', + target_type TEXT NOT NULL DEFAULT '', + target_id TEXT NOT NULL DEFAULT '', + previous_state TEXT, + new_state TEXT, + metadata TEXT, + undone_by TEXT + )`, + } + + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("exec schema: %v: %s", err, stmt[:60]) + } + } + + return db +} + +// seedTournament creates a test tournament. +func seedTournament(t *testing.T, db *sql.DB, id string) { + t.Helper() + _, err := db.Exec(`INSERT INTO tournaments (id, name) VALUES (?, ?)`, id, "Test Tournament") + if err != nil { + t.Fatalf("seed tournament: %v", err) + } +} + +// seedTable creates a test table and returns its ID. +func seedTable(t *testing.T, db *sql.DB, tournamentID, name string, seatCount int) int { + t.Helper() + result, err := db.Exec( + `INSERT INTO tables (tournament_id, name, seat_count, is_active) VALUES (?, ?, ?, 1)`, + tournamentID, name, seatCount, + ) + if err != nil { + t.Fatalf("seed table: %v", err) + } + id, _ := result.LastInsertId() + return int(id) +} + +// seedPlayer creates a test player and returns their ID. +func seedPlayer(t *testing.T, db *sql.DB, id, name string) { + t.Helper() + _, err := db.Exec(`INSERT INTO players (id, name) VALUES (?, ?)`, id, name) + if err != nil { + t.Fatalf("seed player: %v", err) + } +} + +// seatPlayer creates a tournament_players entry with a specific seat. +func seatPlayer(t *testing.T, db *sql.DB, tournamentID, playerID string, tableID, seatPos int) { + t.Helper() + _, err := db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, seat_table_id, seat_position, current_chips) + VALUES (?, ?, 'active', ?, ?, 10000)`, + tournamentID, playerID, tableID, seatPos, + ) + if err != nil { + t.Fatalf("seat player: %v", err) + } +} + +// ---------- Balance Tests ---------- + +func TestCheckBalance_TwoTables_Balanced(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + // 7 and 6 players: diff = 1, balanced + for i := 1; i <= 7; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i+7)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + + engine := NewBalanceEngine(db, nil, nil) + status, err := engine.CheckBalance(ctx, tournamentID) + if err != nil { + t.Fatalf("check balance: %v", err) + } + + if !status.IsBalanced { + t.Errorf("expected balanced, got unbalanced with diff=%d", status.MaxDifference) + } + if status.MaxDifference != 1 { + t.Errorf("expected max difference 1, got %d", status.MaxDifference) + } +} + +func TestCheckBalance_TwoTables_Unbalanced(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + // 8 and 6 players: diff = 2, unbalanced + for i := 1; i <= 8; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player %d", i+8)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + + engine := NewBalanceEngine(db, nil, nil) + status, err := engine.CheckBalance(ctx, tournamentID) + if err != nil { + t.Fatalf("check balance: %v", err) + } + + if status.IsBalanced { + t.Error("expected unbalanced, got balanced") + } + if status.MaxDifference != 2 { + t.Errorf("expected max difference 2, got %d", status.MaxDifference) + } + if status.NeedsMoves != 1 { + t.Errorf("expected 1 move needed, got %d", status.NeedsMoves) + } +} + +func TestCheckBalance_SingleTable(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl := seedTable(t, db, tournamentID, "Table 1", 9) + seedPlayer(t, db, "p1", "Player 1") + seatPlayer(t, db, tournamentID, "p1", tbl, 1) + + engine := NewBalanceEngine(db, nil, nil) + status, err := engine.CheckBalance(ctx, tournamentID) + if err != nil { + t.Fatalf("check balance: %v", err) + } + + if !status.IsBalanced { + t.Error("single table should always be balanced") + } +} + +func TestSuggestMoves_PicksFromLargestTable(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + // 8 vs 6: need 1 move from tbl1 to tbl2 + for i := 1; i <= 8; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + + engine := NewBalanceEngine(db, nil, nil) + suggestions, err := engine.SuggestMoves(ctx, tournamentID) + if err != nil { + t.Fatalf("suggest moves: %v", err) + } + + if len(suggestions) != 1 { + t.Fatalf("expected 1 suggestion, got %d", len(suggestions)) + } + + sugg := suggestions[0] + if sugg.FromTableID != tbl1 { + t.Errorf("expected move from table %d, got %d", tbl1, sugg.FromTableID) + } + if sugg.ToTableID != tbl2 { + t.Errorf("expected move to table %d, got %d", tbl2, sugg.ToTableID) + } + if sugg.Status != "pending" { + t.Errorf("expected status pending, got %s", sugg.Status) + } + if sugg.PlayerID == nil { + t.Error("expected a player to be suggested") + } +} + +func TestAcceptSuggestion_StaleDetection(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + // Setup: 8 vs 6, generate suggestion + for i := 1; i <= 8; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + + engine := NewBalanceEngine(db, nil, nil) + suggestions, err := engine.SuggestMoves(ctx, tournamentID) + if err != nil { + t.Fatalf("suggest moves: %v", err) + } + if len(suggestions) == 0 { + t.Fatal("expected at least 1 suggestion") + } + + suggID := suggestions[0].ID + + // Simulate state change: move a player manually so tables are 7/7 + _, _ = db.Exec( + `UPDATE tournament_players SET seat_table_id = ?, seat_position = 7 + WHERE tournament_id = ? AND player_id = 'p1_8'`, + tbl2, tournamentID, + ) + + // Accept should fail as stale + err = engine.AcceptSuggestion(ctx, tournamentID, suggID, 8, 7) + if err == nil { + t.Fatal("expected error for stale suggestion") + } + + // Verify suggestion was expired + var status string + db.QueryRow(`SELECT status FROM balance_suggestions WHERE id = ?`, suggID).Scan(&status) + if status != "expired" { + t.Errorf("expected suggestion status 'expired', got '%s'", status) + } +} + +func TestInvalidateStaleSuggestions_BustDuringPending(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + // Setup: 8 vs 6 + for i := 1; i <= 8; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + + engine := NewBalanceEngine(db, nil, nil) + suggestions, _ := engine.SuggestMoves(ctx, tournamentID) + if len(suggestions) == 0 { + t.Fatal("expected suggestions") + } + + // Simulate bust: player busts from tbl1, making it 7 vs 6 (balanced) + _, _ = db.Exec( + `UPDATE tournament_players SET status = 'busted', seat_table_id = NULL, seat_position = NULL + WHERE tournament_id = ? AND player_id = 'p1_8'`, + tournamentID, + ) + + // Invalidate stale suggestions + err := engine.InvalidateStaleSuggestions(ctx, tournamentID) + if err != nil { + t.Fatalf("invalidate: %v", err) + } + + // All pending suggestions should be expired + var pendingCount int + db.QueryRow(`SELECT COUNT(*) FROM balance_suggestions WHERE tournament_id = ? AND status = 'pending'`, + tournamentID).Scan(&pendingCount) + if pendingCount != 0 { + t.Errorf("expected 0 pending suggestions after invalidation, got %d", pendingCount) + } +} + +func TestCheckBalance_ThreeTables_NeedsTwoMoves(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + tbl3 := seedTable(t, db, tournamentID, "Table 3", 9) + + // 9, 5, 4 players across 3 tables -> need to redistribute to ~6,6,6 + for i := 1; i <= 9; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 5; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + for i := 1; i <= 4; i++ { + pid := fmt.Sprintf("p3_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl3, i) + } + + engine := NewBalanceEngine(db, nil, nil) + status, err := engine.CheckBalance(ctx, tournamentID) + if err != nil { + t.Fatalf("check balance: %v", err) + } + + if status.IsBalanced { + t.Error("expected unbalanced") + } + if status.MaxDifference != 5 { + t.Errorf("expected max difference 5, got %d", status.MaxDifference) + } + // 18 total / 3 tables = 6 each. Table 1 has 3 surplus. + if status.NeedsMoves != 3 { + t.Errorf("expected 3 moves needed, got %d", status.NeedsMoves) + } +} + +func TestCancelSuggestion(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + for i := 1; i <= 8; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + + engine := NewBalanceEngine(db, nil, nil) + suggestions, _ := engine.SuggestMoves(ctx, tournamentID) + if len(suggestions) == 0 { + t.Fatal("expected suggestions") + } + + err := engine.CancelSuggestion(ctx, tournamentID, suggestions[0].ID) + if err != nil { + t.Fatalf("cancel suggestion: %v", err) + } + + var status string + db.QueryRow(`SELECT status FROM balance_suggestions WHERE id = ?`, suggestions[0].ID).Scan(&status) + if status != "cancelled" { + t.Errorf("expected status 'cancelled', got '%s'", status) + } +} + +// ---------- Auto-seat Tests ---------- + +func TestAutoSeat_FillsEvenly(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 6) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 6) + + // Seat 3 at table 1, 1 at table 2 + for i := 1; i <= 3; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + seedPlayer(t, db, "p2_1", "Player 2-1") + seatPlayer(t, db, tournamentID, "p2_1", tbl2, 1) + + // Auto-seat should pick table 2 (fewest players) + ts := NewTableService(db, nil, nil, nil) + seedPlayer(t, db, "new_player", "New Player") + _, _ = db.Exec(`INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, ?, 'registered', 0)`, + tournamentID, "new_player") + + assignment, err := ts.AutoAssignSeat(ctx, tournamentID, "new_player") + if err != nil { + t.Fatalf("auto assign: %v", err) + } + + if assignment.TableID != tbl2 { + t.Errorf("expected assignment to table %d (fewer players), got %d", tbl2, assignment.TableID) + } +} + +// ---------- Dealer Button Tests ---------- + +func TestDealerButton_AdvancesSkippingEmpty(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl := seedTable(t, db, tournamentID, "Table 1", 9) + + // Seat players at positions 1, 3, 7 (skip 2, 4-6, 8-9) + seedPlayer(t, db, "p1", "Player 1") + seatPlayer(t, db, tournamentID, "p1", tbl, 1) + seedPlayer(t, db, "p3", "Player 3") + seatPlayer(t, db, tournamentID, "p3", tbl, 3) + seedPlayer(t, db, "p7", "Player 7") + seatPlayer(t, db, tournamentID, "p7", tbl, 7) + + ts := NewTableService(db, nil, nil, nil) + + // Set button at position 1 + if err := ts.SetDealerButton(ctx, tournamentID, tbl, 1); err != nil { + t.Fatalf("set button: %v", err) + } + + // Advance should skip to position 3 (next occupied) + if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil { + t.Fatalf("advance button: %v", err) + } + + var btnPos sql.NullInt64 + db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos) + if !btnPos.Valid || int(btnPos.Int64) != 3 { + t.Errorf("expected button at position 3, got %v", btnPos) + } + + // Advance again: should skip to position 7 + if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil { + t.Fatalf("advance button: %v", err) + } + + db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos) + if !btnPos.Valid || int(btnPos.Int64) != 7 { + t.Errorf("expected button at position 7, got %v", btnPos) + } + + // Advance again: should wrap to position 1 + if err := ts.AdvanceDealerButton(ctx, tournamentID, tbl); err != nil { + t.Fatalf("advance button: %v", err) + } + + db.QueryRow(`SELECT dealer_button_position FROM tables WHERE id = ?`, tbl).Scan(&btnPos) + if !btnPos.Valid || int(btnPos.Int64) != 1 { + t.Errorf("expected button at position 1 (wrap), got %v", btnPos) + } +} diff --git a/internal/seating/breaktable.go b/internal/seating/breaktable.go index 63b7c6c..afadc64 100644 --- a/internal/seating/breaktable.go +++ b/internal/seating/breaktable.go @@ -1 +1,288 @@ package seating + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "math/big" + "sort" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/server/ws" +) + +// ---------- Types ---------- + +// BreakTableResult shows all moves from breaking a table. +type BreakTableResult struct { + BrokenTableID int `json:"broken_table_id"` + BrokenTableName string `json:"broken_table_name"` + Moves []BreakTableMove `json:"moves"` +} + +// BreakTableMove describes one player's reassignment when a table is broken. +type BreakTableMove struct { + PlayerID string `json:"player_id"` + PlayerName string `json:"player_name"` + ToTableID int `json:"to_table_id"` + ToTableName string `json:"to_table_name"` + ToSeat int `json:"to_seat"` +} + +// ---------- Service ---------- + +// BreakTableService handles breaking a table and redistributing players. +type BreakTableService struct { + db *sql.DB + audit *audit.Trail + hub *ws.Hub +} + +// NewBreakTableService creates a new BreakTableService. +func NewBreakTableService(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub) *BreakTableService { + return &BreakTableService{ + db: db, + audit: auditTrail, + hub: hub, + } +} + +// ---------- Break Table ---------- + +// BreakTable dissolves a table and distributes its players evenly across +// remaining active tables. This is fully automatic per CONTEXT.md -- the +// moves are applied immediately and the result is informational. +func (s *BreakTableService) BreakTable(ctx context.Context, tournamentID string, tableID int) (*BreakTableResult, error) { + // Load the table being broken + var tableName string + var isActive int + err := s.db.QueryRowContext(ctx, + `SELECT name, is_active FROM tables WHERE id = ? AND tournament_id = ?`, + tableID, tournamentID, + ).Scan(&tableName, &isActive) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("table not found") + } + if err != nil { + return nil, fmt.Errorf("load table: %w", err) + } + if isActive == 0 { + return nil, fmt.Errorf("table is already inactive") + } + + // Load all players at the table being broken + type playerInfo struct { + id string + name string + } + playerRows, err := s.db.QueryContext(ctx, + `SELECT tp.player_id, p.name + FROM tournament_players tp + JOIN players p ON p.id = tp.player_id + WHERE tp.tournament_id = ? AND tp.seat_table_id = ? + AND tp.status IN ('registered', 'active') + ORDER BY tp.seat_position`, + tournamentID, tableID, + ) + if err != nil { + return nil, fmt.Errorf("load players: %w", err) + } + defer playerRows.Close() + + var players []playerInfo + for playerRows.Next() { + var pi playerInfo + if err := playerRows.Scan(&pi.id, &pi.name); err != nil { + return nil, fmt.Errorf("scan player: %w", err) + } + players = append(players, pi) + } + if err := playerRows.Err(); err != nil { + return nil, err + } + + // Load remaining active tables (exclude the one being broken) + type destTable struct { + id int + name string + seatCount int + players int + } + destRows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.name, t.seat_count, + (SELECT COUNT(*) FROM tournament_players tp + WHERE tp.seat_table_id = t.id AND tp.tournament_id = t.tournament_id + AND tp.status IN ('registered', 'active')) AS player_count + FROM tables t + WHERE t.tournament_id = ? AND t.is_active = 1 AND t.id != ? + ORDER BY player_count ASC, t.name ASC`, + tournamentID, tableID, + ) + if err != nil { + return nil, fmt.Errorf("load destination tables: %w", err) + } + defer destRows.Close() + + var dests []destTable + for destRows.Next() { + var dt destTable + if err := destRows.Scan(&dt.id, &dt.name, &dt.seatCount, &dt.players); err != nil { + return nil, fmt.Errorf("scan dest table: %w", err) + } + dests = append(dests, dt) + } + if err := destRows.Err(); err != nil { + return nil, err + } + + if len(dests) == 0 { + return nil, fmt.Errorf("no remaining active tables to distribute players to") + } + + // Build occupied seat sets for destination tables + occupiedSeats := make(map[int]map[int]bool) + for _, dt := range dests { + occupiedSeats[dt.id] = make(map[int]bool) + seatRows, err := s.db.QueryContext(ctx, + `SELECT seat_position FROM tournament_players + WHERE tournament_id = ? AND seat_table_id = ? AND seat_position IS NOT NULL + AND status IN ('registered', 'active')`, + tournamentID, dt.id, + ) + if err != nil { + return nil, fmt.Errorf("get occupied seats for table %d: %w", dt.id, err) + } + for seatRows.Next() { + var pos int + seatRows.Scan(&pos) + occupiedSeats[dt.id][pos] = true + } + seatRows.Close() + } + + // Distribute players evenly: for each player, assign to the table with fewest players + var moves []BreakTableMove + + for _, pi := range players { + // Sort destinations by current player count (ascending) + sort.Slice(dests, func(i, j int) bool { + if dests[i].players == dests[j].players { + return dests[i].id < dests[j].id + } + return dests[i].players < dests[j].players + }) + + // Find a table with available seats + assigned := false + for idx := range dests { + dt := &dests[idx] + if dt.players >= dt.seatCount { + continue // table is full + } + + // Pick a random empty seat + var emptySeats []int + for pos := 1; pos <= dt.seatCount; pos++ { + if !occupiedSeats[dt.id][pos] { + emptySeats = append(emptySeats, pos) + } + } + if len(emptySeats) == 0 { + continue + } + + seatIdx := 0 + if len(emptySeats) > 1 { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(emptySeats)))) + seatIdx = int(n.Int64()) + } + seat := emptySeats[seatIdx] + + // Execute the move + _, err := s.db.ExecContext(ctx, + `UPDATE tournament_players SET seat_table_id = ?, seat_position = ?, updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`, + dt.id, seat, tournamentID, pi.id, + ) + if err != nil { + return nil, fmt.Errorf("move player %s: %w", pi.id, err) + } + + occupiedSeats[dt.id][seat] = true + dt.players++ + + moves = append(moves, BreakTableMove{ + PlayerID: pi.id, + PlayerName: pi.name, + ToTableID: dt.id, + ToTableName: dt.name, + ToSeat: seat, + }) + + assigned = true + break + } + + if !assigned { + return nil, fmt.Errorf("no available seat for player %s (%s)", pi.id, pi.name) + } + } + + // Deactivate the broken table + _, err = s.db.ExecContext(ctx, + `UPDATE tables SET is_active = 0, updated_at = unixepoch() + WHERE id = ? AND tournament_id = ?`, + tableID, tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("deactivate table: %w", err) + } + + result := &BreakTableResult{ + BrokenTableID: tableID, + BrokenTableName: tableName, + Moves: moves, + } + + s.recordAudit(ctx, tournamentID, audit.ActionSeatBreakTable, "table", fmt.Sprintf("%d", tableID), nil, result) + s.broadcast(tournamentID, "table.broken", result) + + return result, nil +} + +// ---------- Helpers ---------- + +func (s *BreakTableService) recordAudit(ctx context.Context, tournamentID, action, targetType, targetID string, prevState, newState interface{}) { + if s.audit == nil { + return + } + var prev, next json.RawMessage + if prevState != nil { + prev, _ = json.Marshal(prevState) + } + if newState != nil { + next, _ = json.Marshal(newState) + } + tid := tournamentID + s.audit.Record(ctx, audit.AuditEntry{ + TournamentID: &tid, + Action: action, + TargetType: targetType, + TargetID: targetID, + PreviousState: prev, + NewState: next, + }) +} + +func (s *BreakTableService) broadcast(tournamentID, msgType string, data interface{}) { + if s.hub == nil { + return + } + payload, err := json.Marshal(data) + if err != nil { + return + } + s.hub.Broadcast(tournamentID, msgType, payload) +} diff --git a/internal/seating/breaktable_test.go b/internal/seating/breaktable_test.go new file mode 100644 index 0000000..d1868b0 --- /dev/null +++ b/internal/seating/breaktable_test.go @@ -0,0 +1,208 @@ +package seating + +import ( + "context" + "fmt" + "testing" +) + +func TestBreakTable_DistributesEvenly(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + tbl3 := seedTable(t, db, tournamentID, "Table 3", 9) + + // Table 1: 6 players (will be broken) + // Table 2: 5 players + // Table 3: 5 players + for i := 1; i <= 6; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 5; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + for i := 1; i <= 5; i++ { + pid := fmt.Sprintf("p3_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl3, i) + } + + svc := NewBreakTableService(db, nil, nil) + result, err := svc.BreakTable(ctx, tournamentID, tbl1) + if err != nil { + t.Fatalf("break table: %v", err) + } + + if result.BrokenTableID != tbl1 { + t.Errorf("expected broken table id %d, got %d", tbl1, result.BrokenTableID) + } + if len(result.Moves) != 6 { + t.Fatalf("expected 6 moves, got %d", len(result.Moves)) + } + + // Count how many went to each table: should be 3 each (5+3=8, 5+3=8) + movesToTable := make(map[int]int) + for _, m := range result.Moves { + movesToTable[m.ToTableID]++ + } + + for tid, count := range movesToTable { + if count < 2 || count > 4 { + t.Errorf("table %d got %d players, expected roughly even distribution", tid, count) + } + } + + // Verify table 1 is deactivated + var isActive int + db.QueryRow(`SELECT is_active FROM tables WHERE id = ?`, tbl1).Scan(&isActive) + if isActive != 0 { + t.Error("broken table should be deactivated") + } + + // Verify all players are seated at other tables + var unseatedCount int + db.QueryRow( + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND status = 'active' AND seat_table_id IS NULL`, + tournamentID, + ).Scan(&unseatedCount) + if unseatedCount != 0 { + t.Errorf("expected 0 unseated players, got %d", unseatedCount) + } +} + +func TestBreakTable_OddPlayerCount(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + tbl3 := seedTable(t, db, tournamentID, "Table 3", 9) + + // Table 1: 5 players (will be broken) + // Table 2: 4 players + // Table 3: 3 players + for i := 1; i <= 5; i++ { + pid := fmt.Sprintf("p1_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 1-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl1, i) + } + for i := 1; i <= 4; i++ { + pid := fmt.Sprintf("p2_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 2-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl2, i) + } + for i := 1; i <= 3; i++ { + pid := fmt.Sprintf("p3_%d", i) + seedPlayer(t, db, pid, fmt.Sprintf("Player 3-%d", i)) + seatPlayer(t, db, tournamentID, pid, tbl3, i) + } + + svc := NewBreakTableService(db, nil, nil) + result, err := svc.BreakTable(ctx, tournamentID, tbl1) + if err != nil { + t.Fatalf("break table: %v", err) + } + + if len(result.Moves) != 5 { + t.Fatalf("expected 5 moves, got %d", len(result.Moves)) + } + + // Count final player counts at tbl2 and tbl3 + var count2, count3 int + db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND seat_table_id = ? AND status = 'active'`, + tournamentID, tbl2).Scan(&count2) + db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND seat_table_id = ? AND status = 'active'`, + tournamentID, tbl3).Scan(&count3) + + // 12 total players across 2 tables -> 6 each + if count2+count3 != 12 { + t.Errorf("expected 12 total, got %d", count2+count3) + } + // Difference should be at most 1 + diff := count2 - count3 + if diff < 0 { + diff = -diff + } + if diff > 1 { + t.Errorf("expected tables balanced to within 1, got diff=%d (table2=%d, table3=%d)", diff, count2, count3) + } +} + +func TestBreakTable_DeactivatesTable(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl1 := seedTable(t, db, tournamentID, "Table 1", 9) + tbl2 := seedTable(t, db, tournamentID, "Table 2", 9) + + // 2 players at each table + seedPlayer(t, db, "p1", "P1") + seatPlayer(t, db, tournamentID, "p1", tbl1, 1) + seedPlayer(t, db, "p2", "P2") + seatPlayer(t, db, tournamentID, "p2", tbl1, 2) + seedPlayer(t, db, "p3", "P3") + seatPlayer(t, db, tournamentID, "p3", tbl2, 1) + seedPlayer(t, db, "p4", "P4") + seatPlayer(t, db, tournamentID, "p4", tbl2, 2) + + svc := NewBreakTableService(db, nil, nil) + _, err := svc.BreakTable(ctx, tournamentID, tbl1) + if err != nil { + t.Fatalf("break table: %v", err) + } + + var isActive int + db.QueryRow(`SELECT is_active FROM tables WHERE id = ?`, tbl1).Scan(&isActive) + if isActive != 0 { + t.Error("broken table should be deactivated (is_active = 0)") + } +} + +func TestBreakTable_InactiveTableReturnsError(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl := seedTable(t, db, tournamentID, "Table 1", 9) + _ = seedTable(t, db, tournamentID, "Table 2", 9) + + // Deactivate the table first + db.Exec(`UPDATE tables SET is_active = 0 WHERE id = ?`, tbl) + + svc := NewBreakTableService(db, nil, nil) + _, err := svc.BreakTable(ctx, tournamentID, tbl) + if err == nil { + t.Fatal("expected error for inactive table") + } +} + +func TestBreakTable_NoDestinationTablesReturnsError(t *testing.T) { + db := testDB(t) + ctx := context.Background() + tournamentID := "t1" + seedTournament(t, db, tournamentID) + + tbl := seedTable(t, db, tournamentID, "Table 1", 9) + seedPlayer(t, db, "p1", "P1") + seatPlayer(t, db, tournamentID, "p1", tbl, 1) + + svc := NewBreakTableService(db, nil, nil) + _, err := svc.BreakTable(ctx, tournamentID, tbl) + if err == nil { + t.Fatal("expected error when no destination tables") + } +} diff --git a/internal/seating/table.go b/internal/seating/table.go index 7d5778b..02a5cfa 100644 --- a/internal/seating/table.go +++ b/internal/seating/table.go @@ -94,6 +94,11 @@ func NewTableService(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub, clockRegi } } +// DB returns the underlying database connection for cross-service use. +func (s *TableService) DB() *sql.DB { + return s.db +} + // ---------- Table CRUD ---------- // CreateTable creates a new table for a tournament. diff --git a/internal/server/routes/tables.go b/internal/server/routes/tables.go new file mode 100644 index 0000000..5078f1c --- /dev/null +++ b/internal/server/routes/tables.go @@ -0,0 +1,536 @@ +package routes + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/felt-app/felt/internal/seating" + "github.com/felt-app/felt/internal/server/middleware" +) + +// TableHandler handles table, seating, balancing, and break table API routes. +type TableHandler struct { + tables *seating.TableService + balance *seating.BalanceEngine + breakTable *seating.BreakTableService + blueprints *seating.BlueprintService +} + +// NewTableHandler creates a new table route handler. +func NewTableHandler( + tables *seating.TableService, + balance *seating.BalanceEngine, + breakTable *seating.BreakTableService, + blueprints *seating.BlueprintService, +) *TableHandler { + return &TableHandler{ + tables: tables, + balance: balance, + breakTable: breakTable, + blueprints: blueprints, + } +} + +// RegisterRoutes registers table and seating routes on the given router. +func (h *TableHandler) RegisterRoutes(r chi.Router) { + // Tournament-scoped routes + r.Route("/tournaments/{id}", func(r chi.Router) { + // Read-only (any authenticated user) + r.Get("/tables", h.handleGetTables) + r.Get("/balance", h.handleCheckBalance) + + // Mutation routes (admin or floor) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireRole(middleware.RoleFloor)) + + // Table management + r.Post("/tables", h.handleCreateTable) + r.Post("/tables/from-blueprint", h.handleCreateFromBlueprint) + r.Post("/tables/save-blueprint", h.handleSaveBlueprint) + r.Put("/tables/{tableId}", h.handleUpdateTable) + r.Delete("/tables/{tableId}", h.handleDeactivateTable) + + // Seating + r.Post("/players/{playerId}/auto-seat", h.handleAutoSeat) + r.Post("/players/{playerId}/seat", h.handleAssignSeat) + r.Post("/seats/swap", h.handleSwapSeats) + + // Balancing + r.Post("/balance/suggest", h.handleSuggestMoves) + r.Post("/balance/suggestions/{suggId}/accept", h.handleAcceptSuggestion) + r.Post("/balance/suggestions/{suggId}/cancel", h.handleCancelSuggestion) + + // Break Table + r.Post("/tables/{tableId}/break", h.handleBreakTable) + + // Dealer Button + r.Post("/tables/{tableId}/button", h.handleSetButton) + r.Post("/tables/{tableId}/button/advance", h.handleAdvanceButton) + + // Hand-for-Hand + r.Post("/hand-for-hand", h.handleSetHandForHand) + r.Post("/tables/{tableId}/hand-complete", h.handleTableHandComplete) + }) + }) + + // Venue-level blueprint routes + r.Route("/blueprints", func(r chi.Router) { + r.Get("/", h.handleListBlueprints) + r.Get("/{id}", h.handleGetBlueprint) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireRole(middleware.RoleAdmin)) + r.Post("/", h.handleCreateBlueprint) + r.Put("/{id}", h.handleUpdateBlueprint) + r.Delete("/{id}", h.handleDeleteBlueprint) + }) + }) +} + +// ---------- Table Handlers ---------- + +func (h *TableHandler) handleGetTables(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tables, err := h.tables.GetTables(r.Context(), tournamentID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, tables) +} + +type createTableRequest struct { + Name string `json:"name"` + SeatCount int `json:"seat_count"` +} + +func (h *TableHandler) handleCreateTable(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + + var req createTableRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + table, err := h.tables.CreateTable(r.Context(), tournamentID, req.Name, req.SeatCount) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, table) +} + +type fromBlueprintRequest struct { + BlueprintID int `json:"blueprint_id"` +} + +func (h *TableHandler) handleCreateFromBlueprint(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + + var req fromBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + tables, err := h.blueprints.CreateTablesFromBlueprint(r.Context(), h.tables.DB(), tournamentID, req.BlueprintID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, tables) +} + +func (h *TableHandler) handleUpdateTable(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tableID, err := strconv.Atoi(chi.URLParam(r, "tableId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid table id") + return + } + + var req createTableRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if err := h.tables.UpdateTable(r.Context(), tournamentID, tableID, req.Name, req.SeatCount); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +func (h *TableHandler) handleDeactivateTable(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tableID, err := strconv.Atoi(chi.URLParam(r, "tableId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid table id") + return + } + + if err := h.tables.DeactivateTable(r.Context(), tournamentID, tableID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deactivated"}) +} + +// ---------- Seating Handlers ---------- + +func (h *TableHandler) handleAutoSeat(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + assignment, err := h.tables.AutoAssignSeat(r.Context(), tournamentID, playerID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, assignment) +} + +type assignSeatRequest struct { + TableID int `json:"table_id"` + SeatPosition int `json:"seat_position"` +} + +func (h *TableHandler) handleAssignSeat(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + var req assignSeatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if err := h.tables.AssignSeat(r.Context(), tournamentID, playerID, req.TableID, req.SeatPosition); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "seated"}) +} + +type swapSeatsRequest struct { + Player1ID string `json:"player1_id"` + Player2ID string `json:"player2_id"` +} + +func (h *TableHandler) handleSwapSeats(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + + var req swapSeatsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if err := h.tables.SwapSeats(r.Context(), tournamentID, req.Player1ID, req.Player2ID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "swapped"}) +} + +// ---------- Balance Handlers ---------- + +func (h *TableHandler) handleCheckBalance(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + status, err := h.balance.CheckBalance(r.Context(), tournamentID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *TableHandler) handleSuggestMoves(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + suggestions, err := h.balance.SuggestMoves(r.Context(), tournamentID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "suggestions": suggestions, + }) +} + +type acceptSuggestionRequest struct { + FromSeat int `json:"from_seat"` + ToSeat int `json:"to_seat"` +} + +func (h *TableHandler) handleAcceptSuggestion(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + suggID, err := strconv.Atoi(chi.URLParam(r, "suggId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid suggestion id") + return + } + + var req acceptSuggestionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if err := h.balance.AcceptSuggestion(r.Context(), tournamentID, suggID, req.FromSeat, req.ToSeat); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"}) +} + +func (h *TableHandler) handleCancelSuggestion(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + suggID, err := strconv.Atoi(chi.URLParam(r, "suggId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid suggestion id") + return + } + + if err := h.balance.CancelSuggestion(r.Context(), tournamentID, suggID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) +} + +// ---------- Break Table Handler ---------- + +func (h *TableHandler) handleBreakTable(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tableID, err := strconv.Atoi(chi.URLParam(r, "tableId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid table id") + return + } + + result, err := h.breakTable.BreakTable(r.Context(), tournamentID, tableID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +// ---------- Dealer Button Handlers ---------- + +type setButtonRequest struct { + Position int `json:"position"` +} + +func (h *TableHandler) handleSetButton(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tableID, err := strconv.Atoi(chi.URLParam(r, "tableId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid table id") + return + } + + var req setButtonRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if err := h.tables.SetDealerButton(r.Context(), tournamentID, tableID, req.Position); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "button_set"}) +} + +func (h *TableHandler) handleAdvanceButton(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tableID, err := strconv.Atoi(chi.URLParam(r, "tableId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid table id") + return + } + + if err := h.tables.AdvanceDealerButton(r.Context(), tournamentID, tableID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "button_advanced"}) +} + +// ---------- Hand-for-Hand Handlers ---------- + +type handForHandRequest struct { + Enabled bool `json:"enabled"` +} + +func (h *TableHandler) handleSetHandForHand(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + + var req handForHandRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if err := h.tables.SetHandForHand(r.Context(), tournamentID, req.Enabled); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": fmt.Sprintf("hand_for_hand_%s", boolStr(req.Enabled)), + }) +} + +func (h *TableHandler) handleTableHandComplete(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + tableID, err := strconv.Atoi(chi.URLParam(r, "tableId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid table id") + return + } + + if err := h.tables.TableHandComplete(r.Context(), tournamentID, tableID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "hand_complete"}) +} + +// ---------- Blueprint Handlers ---------- + +func (h *TableHandler) handleListBlueprints(w http.ResponseWriter, r *http.Request) { + blueprints, err := h.blueprints.ListBlueprints(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, blueprints) +} + +func (h *TableHandler) handleGetBlueprint(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint id") + return + } + + bp, err := h.blueprints.GetBlueprint(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + writeJSON(w, http.StatusOK, bp) +} + +type createBlueprintRequest struct { + Name string `json:"name"` + TableConfigs []seating.BlueprintTableConfig `json:"table_configs"` +} + +func (h *TableHandler) handleCreateBlueprint(w http.ResponseWriter, r *http.Request) { + var req createBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + bp, err := h.blueprints.CreateBlueprint(r.Context(), req.Name, req.TableConfigs) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, bp) +} + +func (h *TableHandler) handleUpdateBlueprint(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint id") + return + } + + var req createBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + bp := seating.Blueprint{ + ID: id, + Name: req.Name, + TableConfigs: req.TableConfigs, + } + if err := h.blueprints.UpdateBlueprint(r.Context(), bp); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +func (h *TableHandler) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint id") + return + } + + if err := h.blueprints.DeleteBlueprint(r.Context(), id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +type saveBlueprintRequest struct { + Name string `json:"name"` +} + +func (h *TableHandler) handleSaveBlueprint(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + + var req saveBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + bp, err := h.blueprints.SaveBlueprintFromTournament(r.Context(), h.tables.DB(), tournamentID, req.Name) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, bp) +} + +// boolStr returns "enabled" or "disabled" for a bool value. +func boolStr(b bool) string { + if b { + return "enabled" + } + return "disabled" +}