felt/internal/seating/balance.go
Mikkel Georgsen 2d3cb0ac9e 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 <noreply@anthropic.com>
2026-03-01 04:24:52 +01:00

675 lines
19 KiB
Go

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