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) }