- 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>
288 lines
7.5 KiB
Go
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)
|
|
}
|