felt/internal/seating/breaktable.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

288 lines
7.5 KiB
Go

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