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

932 lines
28 KiB
Go

// Package seating provides table management, auto-seating, balancing, break
// table, dealer button tracking, and hand-for-hand mode for the Felt
// tournament engine. All mutations record audit entries and broadcast via
// WebSocket.
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/clock"
"github.com/felt-app/felt/internal/server/ws"
)
// ---------- Types ----------
// Table represents a tournament table with seats.
type Table struct {
ID int `json:"id"`
TournamentID string `json:"tournament_id"`
Name string `json:"name"`
SeatCount int `json:"seat_count"`
DealerButtonPosition *int `json:"dealer_button_position,omitempty"`
IsActive bool `json:"is_active"`
HandCompleted bool `json:"hand_completed"`
}
// TableDetail is a table with its seated players.
type TableDetail struct {
ID int `json:"id"`
Name string `json:"name"`
SeatCount int `json:"seat_count"`
DealerButtonPosition *int `json:"dealer_button_position,omitempty"`
IsActive bool `json:"is_active"`
HandCompleted bool `json:"hand_completed"`
Seats []SeatDetail `json:"seats"`
}
// SeatDetail describes one seat at a table.
type SeatDetail struct {
Position int `json:"position"`
PlayerID *string `json:"player_id,omitempty"`
PlayerName *string `json:"player_name,omitempty"`
ChipCount *int64 `json:"chip_count,omitempty"`
IsEmpty bool `json:"is_empty"`
}
// SeatAssignment is the result of an auto-seat suggestion.
type SeatAssignment struct {
TableID int `json:"table_id"`
TableName string `json:"table_name"`
SeatPosition int `json:"seat_position"`
}
// HandForHandStatus tracks hand-for-hand completion.
type HandForHandStatus struct {
Enabled bool `json:"enabled"`
CurrentHandNumber int `json:"current_hand_number"`
Tables []TableHandState `json:"tables"`
CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"`
}
// TableHandState tracks one table's hand completion.
type TableHandState struct {
TableID int `json:"table_id"`
TableName string `json:"table_name"`
HandCompleted bool `json:"hand_completed"`
}
// ---------- Service ----------
// TableService manages tables, seating, dealer button, and hand-for-hand.
type TableService struct {
db *sql.DB
audit *audit.Trail
hub *ws.Hub
clockRegistry *clock.Registry
}
// NewTableService creates a new TableService.
func NewTableService(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub, clockRegistry *clock.Registry) *TableService {
return &TableService{
db: db,
audit: auditTrail,
hub: hub,
clockRegistry: clockRegistry,
}
}
// 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.
func (s *TableService) CreateTable(ctx context.Context, tournamentID, name string, seatCount int) (*Table, error) {
if seatCount < 6 || seatCount > 10 {
return nil, fmt.Errorf("seat count must be between 6 and 10, got %d", seatCount)
}
if name == "" {
return nil, fmt.Errorf("table name is required")
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO tables (tournament_id, name, seat_count, is_active)
VALUES (?, ?, ?, 1)`,
tournamentID, name, seatCount,
)
if err != nil {
return nil, fmt.Errorf("create table: %w", err)
}
id, _ := result.LastInsertId()
table := &Table{
ID: int(id),
TournamentID: tournamentID,
Name: name,
SeatCount: seatCount,
IsActive: true,
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "table", fmt.Sprintf("%d", table.ID), nil, table)
s.broadcast(tournamentID, "table.created", table)
return table, nil
}
// GetTables returns all active tables with seated players for a tournament.
func (s *TableService) GetTables(ctx context.Context, tournamentID string) ([]TableDetail, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, seat_count, dealer_button_position, is_active, hand_completed
FROM tables
WHERE tournament_id = ? AND is_active = 1
ORDER BY name`,
tournamentID,
)
if err != nil {
return nil, fmt.Errorf("get tables: %w", err)
}
defer rows.Close()
var tables []TableDetail
for rows.Next() {
var t TableDetail
var btnPos sql.NullInt64
var handCompleted int
if err := rows.Scan(&t.ID, &t.Name, &t.SeatCount, &btnPos, &t.IsActive, &handCompleted); err != nil {
return nil, fmt.Errorf("scan table: %w", err)
}
if btnPos.Valid {
pos := int(btnPos.Int64)
t.DealerButtonPosition = &pos
}
t.HandCompleted = handCompleted == 1
// Build seat array
t.Seats = s.buildSeats(ctx, t.ID, t.SeatCount)
tables = append(tables, t)
}
return tables, rows.Err()
}
// GetTable returns a single table with seated players.
func (s *TableService) GetTable(ctx context.Context, tournamentID string, tableID int) (*TableDetail, error) {
var t TableDetail
var btnPos sql.NullInt64
var handCompleted int
err := s.db.QueryRowContext(ctx,
`SELECT id, name, seat_count, dealer_button_position, is_active, hand_completed
FROM tables
WHERE id = ? AND tournament_id = ? AND is_active = 1`,
tableID, tournamentID,
).Scan(&t.ID, &t.Name, &t.SeatCount, &btnPos, &t.IsActive, &handCompleted)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("table not found")
}
if err != nil {
return nil, fmt.Errorf("get table: %w", err)
}
if btnPos.Valid {
pos := int(btnPos.Int64)
t.DealerButtonPosition = &pos
}
t.HandCompleted = handCompleted == 1
t.Seats = s.buildSeats(ctx, t.ID, t.SeatCount)
return &t, nil
}
// UpdateTable updates a table's name and seat count.
func (s *TableService) UpdateTable(ctx context.Context, tournamentID string, tableID int, name string, seatCount int) error {
if seatCount < 6 || seatCount > 10 {
return fmt.Errorf("seat count must be between 6 and 10, got %d", seatCount)
}
if name == "" {
return fmt.Errorf("table name is required")
}
result, err := s.db.ExecContext(ctx,
`UPDATE tables SET name = ?, seat_count = ?, updated_at = unixepoch()
WHERE id = ? AND tournament_id = ? AND is_active = 1`,
name, seatCount, tableID, tournamentID,
)
if err != nil {
return fmt.Errorf("update table: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("table not found")
}
s.broadcast(tournamentID, "table.updated", map[string]interface{}{"table_id": tableID, "name": name, "seat_count": seatCount})
return nil
}
// DeactivateTable soft-deletes a table.
func (s *TableService) DeactivateTable(ctx context.Context, tournamentID string, tableID int) error {
result, err := s.db.ExecContext(ctx,
`UPDATE tables SET is_active = 0, updated_at = unixepoch()
WHERE id = ? AND tournament_id = ? AND is_active = 1`,
tableID, tournamentID,
)
if err != nil {
return fmt.Errorf("deactivate table: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("table not found")
}
// Unseat all players at this table
_, _ = s.db.ExecContext(ctx,
`UPDATE tournament_players SET seat_table_id = NULL, seat_position = NULL, updated_at = unixepoch()
WHERE tournament_id = ? AND seat_table_id = ?`,
tournamentID, tableID,
)
s.recordAudit(ctx, tournamentID, audit.ActionSeatBreakTable, "table", fmt.Sprintf("%d", tableID), nil, map[string]interface{}{"deactivated": true})
s.broadcast(tournamentID, "table.deactivated", map[string]interface{}{"table_id": tableID})
return nil
}
// ---------- Seating ----------
// AssignSeat assigns a player to a specific seat.
func (s *TableService) AssignSeat(ctx context.Context, tournamentID, playerID string, tableID, seatPosition int) error {
// Validate seat is within bounds
var seatCount int
err := s.db.QueryRowContext(ctx,
`SELECT seat_count FROM tables WHERE id = ? AND tournament_id = ? AND is_active = 1`,
tableID, tournamentID,
).Scan(&seatCount)
if err != nil {
return fmt.Errorf("table not found: %w", err)
}
if seatPosition < 1 || seatPosition > seatCount {
return fmt.Errorf("seat position must be between 1 and %d", seatCount)
}
// Check seat is empty
var occupiedCount int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players
WHERE tournament_id = ? AND seat_table_id = ? AND seat_position = ?
AND status IN ('registered', 'active')`,
tournamentID, tableID, seatPosition,
).Scan(&occupiedCount)
if err != nil {
return fmt.Errorf("check seat: %w", err)
}
if occupiedCount > 0 {
return fmt.Errorf("seat %d at table %d is already occupied", seatPosition, tableID)
}
// Assign the seat
result, 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')`,
tableID, seatPosition, tournamentID, playerID,
)
if err != nil {
return fmt.Errorf("assign seat: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("player not found in tournament")
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "player", playerID, nil,
map[string]interface{}{"table_id": tableID, "seat_position": seatPosition})
s.broadcast(tournamentID, "seat.assigned", map[string]interface{}{
"player_id": playerID, "table_id": tableID, "seat_position": seatPosition,
})
return nil
}
// AutoAssignSeat suggests a seat assignment (fills tables evenly). Does NOT apply.
func (s *TableService) AutoAssignSeat(ctx context.Context, tournamentID, playerID string) (*SeatAssignment, error) {
// Get all active tables with player counts
type tableInfo struct {
ID int
Name string
SeatCount int
Players int
}
rows, 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
ORDER BY player_count ASC, t.id ASC`,
tournamentID,
)
if err != nil {
return nil, fmt.Errorf("get tables for auto-seat: %w", err)
}
defer rows.Close()
var tables []tableInfo
for rows.Next() {
var ti tableInfo
if err := rows.Scan(&ti.ID, &ti.Name, &ti.SeatCount, &ti.Players); err != nil {
return nil, fmt.Errorf("scan table info: %w", err)
}
tables = append(tables, ti)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(tables) == 0 {
return nil, fmt.Errorf("no active tables in tournament")
}
// Filter out full tables
var candidates []tableInfo
for _, t := range tables {
if t.Players < t.SeatCount {
candidates = append(candidates, t)
}
}
if len(candidates) == 0 {
return nil, fmt.Errorf("all tables are full")
}
// Find tables with minimum player count (fill evenly)
minPlayers := candidates[0].Players
var tied []tableInfo
for _, t := range candidates {
if t.Players == minPlayers {
tied = append(tied, t)
}
}
// Pick randomly among tied tables
chosen := tied[0]
if len(tied) > 1 {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(tied))))
chosen = tied[int(idx.Int64())]
}
// Find a random empty seat
occupiedSeats := 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, chosen.ID,
)
if err != nil {
return nil, fmt.Errorf("get occupied seats: %w", err)
}
defer seatRows.Close()
for seatRows.Next() {
var pos int
seatRows.Scan(&pos)
occupiedSeats[pos] = true
}
var emptySeats []int
for i := 1; i <= chosen.SeatCount; i++ {
if !occupiedSeats[i] {
emptySeats = append(emptySeats, i)
}
}
if len(emptySeats) == 0 {
return nil, fmt.Errorf("no empty seats at selected table")
}
seatIdx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(emptySeats))))
return &SeatAssignment{
TableID: chosen.ID,
TableName: chosen.Name,
SeatPosition: emptySeats[int(seatIdx.Int64())],
}, nil
}
// ConfirmSeatAssignment applies a previously suggested seat assignment.
func (s *TableService) ConfirmSeatAssignment(ctx context.Context, tournamentID, playerID string, assignment SeatAssignment) error {
return s.AssignSeat(ctx, tournamentID, playerID, assignment.TableID, assignment.SeatPosition)
}
// MoveSeat moves a player to a different seat (tap-tap flow).
func (s *TableService) MoveSeat(ctx context.Context, tournamentID, playerID string, toTableID, toSeatPosition int) error {
// Get current seat info for audit
var fromTableID sql.NullInt64
var fromSeatPos sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT seat_table_id, seat_position FROM tournament_players
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
tournamentID, playerID,
).Scan(&fromTableID, &fromSeatPos)
if err != nil {
return fmt.Errorf("player not found: %w", err)
}
// Validate destination seat
var seatCount int
err = s.db.QueryRowContext(ctx,
`SELECT seat_count FROM tables WHERE id = ? AND tournament_id = ? AND is_active = 1`,
toTableID, tournamentID,
).Scan(&seatCount)
if err != nil {
return fmt.Errorf("destination table not found: %w", err)
}
if toSeatPosition < 1 || toSeatPosition > seatCount {
return fmt.Errorf("seat position must be between 1 and %d", seatCount)
}
// Check destination seat is empty
var occupiedCount int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players
WHERE tournament_id = ? AND seat_table_id = ? AND seat_position = ?
AND status IN ('registered', 'active')`,
tournamentID, toTableID, toSeatPosition,
).Scan(&occupiedCount)
if err != nil {
return fmt.Errorf("check seat: %w", err)
}
if occupiedCount > 0 {
return fmt.Errorf("destination seat %d is already occupied", toSeatPosition)
}
// Move the player
_, 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')`,
toTableID, toSeatPosition, tournamentID, playerID,
)
if err != nil {
return fmt.Errorf("move seat: %w", err)
}
prevState := map[string]interface{}{}
if fromTableID.Valid {
prevState["table_id"] = fromTableID.Int64
}
if fromSeatPos.Valid {
prevState["seat_position"] = fromSeatPos.Int64
}
newState := map[string]interface{}{"table_id": toTableID, "seat_position": toSeatPosition}
s.recordAudit(ctx, tournamentID, audit.ActionSeatMove, "player", playerID, prevState, newState)
s.broadcast(tournamentID, "seat.moved", map[string]interface{}{
"player_id": playerID, "from": prevState, "to": newState,
})
return nil
}
// SwapSeats swaps two players' seats atomically.
func (s *TableService) SwapSeats(ctx context.Context, tournamentID, player1ID, player2ID string) error {
// Get current seats for both players
var table1ID, seat1Pos, table2ID, seat2Pos sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT seat_table_id, seat_position FROM tournament_players
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
tournamentID, player1ID,
).Scan(&table1ID, &seat1Pos)
if err != nil {
return fmt.Errorf("player 1 not found: %w", err)
}
err = s.db.QueryRowContext(ctx,
`SELECT seat_table_id, seat_position FROM tournament_players
WHERE tournament_id = ? AND player_id = ? AND status IN ('registered', 'active')`,
tournamentID, player2ID,
).Scan(&table2ID, &seat2Pos)
if err != nil {
return fmt.Errorf("player 2 not found: %w", err)
}
if !table1ID.Valid || !seat1Pos.Valid {
return fmt.Errorf("player 1 is not seated")
}
if !table2ID.Valid || !seat2Pos.Valid {
return fmt.Errorf("player 2 is not seated")
}
// Swap: move player1 to a temp position, move player2 to player1's spot, then player1 to player2's spot
// Use seat_position = -1 as temporary to avoid unique constraint issues
_, err = s.db.ExecContext(ctx,
`UPDATE tournament_players SET seat_table_id = NULL, seat_position = NULL, updated_at = unixepoch()
WHERE tournament_id = ? AND player_id = ?`,
tournamentID, player1ID,
)
if err != nil {
return fmt.Errorf("swap step 1: %w", err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE tournament_players SET seat_table_id = ?, seat_position = ?, updated_at = unixepoch()
WHERE tournament_id = ? AND player_id = ?`,
table1ID.Int64, seat1Pos.Int64, tournamentID, player2ID,
)
if err != nil {
return fmt.Errorf("swap step 2: %w", err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE tournament_players SET seat_table_id = ?, seat_position = ?, updated_at = unixepoch()
WHERE tournament_id = ? AND player_id = ?`,
table2ID.Int64, seat2Pos.Int64, tournamentID, player1ID,
)
if err != nil {
return fmt.Errorf("swap step 3: %w", err)
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatMove, "swap", player1ID+":"+player2ID,
map[string]interface{}{
"player1": map[string]interface{}{"table_id": table1ID.Int64, "seat": seat1Pos.Int64},
"player2": map[string]interface{}{"table_id": table2ID.Int64, "seat": seat2Pos.Int64},
},
map[string]interface{}{
"player1": map[string]interface{}{"table_id": table2ID.Int64, "seat": seat2Pos.Int64},
"player2": map[string]interface{}{"table_id": table1ID.Int64, "seat": seat1Pos.Int64},
},
)
s.broadcast(tournamentID, "seat.swapped", map[string]interface{}{
"player1_id": player1ID, "player2_id": player2ID,
})
return nil
}
// ---------- Dealer Button ----------
// SetDealerButton sets the dealer button position on a table.
func (s *TableService) SetDealerButton(ctx context.Context, tournamentID string, tableID, position int) error {
var seatCount int
err := s.db.QueryRowContext(ctx,
`SELECT seat_count FROM tables WHERE id = ? AND tournament_id = ? AND is_active = 1`,
tableID, tournamentID,
).Scan(&seatCount)
if err != nil {
return fmt.Errorf("table not found: %w", err)
}
if position < 1 || position > seatCount {
return fmt.Errorf("button position must be between 1 and %d", seatCount)
}
_, err = s.db.ExecContext(ctx,
`UPDATE tables SET dealer_button_position = ?, updated_at = unixepoch()
WHERE id = ? AND tournament_id = ?`,
position, tableID, tournamentID,
)
if err != nil {
return fmt.Errorf("set dealer button: %w", err)
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "button", fmt.Sprintf("%d", tableID), nil,
map[string]interface{}{"position": position})
s.broadcast(tournamentID, "button.set", map[string]interface{}{"table_id": tableID, "position": position})
return nil
}
// AdvanceDealerButton moves the button to the next occupied seat clockwise.
func (s *TableService) AdvanceDealerButton(ctx context.Context, tournamentID string, tableID int) error {
var seatCount int
var btnPos sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT seat_count, dealer_button_position FROM tables
WHERE id = ? AND tournament_id = ? AND is_active = 1`,
tableID, tournamentID,
).Scan(&seatCount, &btnPos)
if err != nil {
return fmt.Errorf("table not found: %w", err)
}
// Get occupied seats
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')
ORDER BY seat_position`,
tournamentID, tableID,
)
if err != nil {
return fmt.Errorf("get occupied seats: %w", err)
}
defer seatRows.Close()
var occupied []int
for seatRows.Next() {
var pos int
seatRows.Scan(&pos)
occupied = append(occupied, pos)
}
if len(occupied) == 0 {
return fmt.Errorf("no players seated at table")
}
// Find next occupied seat clockwise from current position
currentPos := 0
if btnPos.Valid {
currentPos = int(btnPos.Int64)
}
// Sort occupied seats for searching
sort.Ints(occupied)
// Find the next occupied seat after currentPos (wrapping around)
nextPos := occupied[0] // default: wrap to first occupied
for _, pos := range occupied {
if pos > currentPos {
nextPos = pos
break
}
}
_, err = s.db.ExecContext(ctx,
`UPDATE tables SET dealer_button_position = ?, updated_at = unixepoch()
WHERE id = ? AND tournament_id = ?`,
nextPos, tableID, tournamentID,
)
if err != nil {
return fmt.Errorf("advance button: %w", err)
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "button", fmt.Sprintf("%d", tableID),
map[string]interface{}{"position": currentPos},
map[string]interface{}{"position": nextPos})
s.broadcast(tournamentID, "button.advanced", map[string]interface{}{"table_id": tableID, "position": nextPos})
return nil
}
// ---------- Hand-for-Hand Mode ----------
// SetHandForHand enables or disables hand-for-hand mode for a tournament.
func (s *TableService) SetHandForHand(ctx context.Context, tournamentID string, enabled bool) error {
if enabled {
// Enable: set flag, initialize hand number, reset all table completion, pause clock
_, err := s.db.ExecContext(ctx,
`UPDATE tournaments SET hand_for_hand = 1, hand_for_hand_hand_number = 1, updated_at = unixepoch()
WHERE id = ?`,
tournamentID,
)
if err != nil {
return fmt.Errorf("enable hand-for-hand: %w", err)
}
// Reset hand_completed on all active tables
_, err = s.db.ExecContext(ctx,
`UPDATE tables SET hand_completed = 0, updated_at = unixepoch()
WHERE tournament_id = ? AND is_active = 1`,
tournamentID,
)
if err != nil {
return fmt.Errorf("reset table hand completion: %w", err)
}
// Pause clock via clock engine
if s.clockRegistry != nil {
engine := s.clockRegistry.Get(tournamentID)
if engine != nil {
_ = engine.SetHandForHand(true, "system")
}
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "hand_for_hand", tournamentID, nil,
map[string]interface{}{"enabled": true, "hand_number": 1})
s.broadcast(tournamentID, "hand_for_hand.enabled", map[string]interface{}{
"enabled": true, "hand_number": 1,
})
} else {
// Disable: clear flag, resume clock
_, err := s.db.ExecContext(ctx,
`UPDATE tournaments SET hand_for_hand = 0, hand_for_hand_hand_number = 0, updated_at = unixepoch()
WHERE id = ?`,
tournamentID,
)
if err != nil {
return fmt.Errorf("disable hand-for-hand: %w", err)
}
// Resume clock via clock engine
if s.clockRegistry != nil {
engine := s.clockRegistry.Get(tournamentID)
if engine != nil {
_ = engine.SetHandForHand(false, "system")
}
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "hand_for_hand", tournamentID, nil,
map[string]interface{}{"enabled": false})
s.broadcast(tournamentID, "hand_for_hand.disabled", map[string]interface{}{
"enabled": false,
})
}
return nil
}
// TableHandComplete marks a table's hand as complete during hand-for-hand.
func (s *TableService) TableHandComplete(ctx context.Context, tournamentID string, tableID int) error {
// Verify hand-for-hand is active
var hfh int
var handNumber int
err := s.db.QueryRowContext(ctx,
`SELECT hand_for_hand, hand_for_hand_hand_number FROM tournaments WHERE id = ?`,
tournamentID,
).Scan(&hfh, &handNumber)
if err != nil {
return fmt.Errorf("tournament not found: %w", err)
}
if hfh == 0 {
return fmt.Errorf("hand-for-hand mode is not active")
}
// Mark this table as hand complete
result, err := s.db.ExecContext(ctx,
`UPDATE tables SET hand_completed = 1, updated_at = unixepoch()
WHERE id = ? AND tournament_id = ? AND is_active = 1`,
tableID, tournamentID,
)
if err != nil {
return fmt.Errorf("mark hand complete: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("table not found")
}
// Check if all active tables have completed their hand
var totalActive, totalComplete int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*), SUM(hand_completed) FROM tables
WHERE tournament_id = ? AND is_active = 1`,
tournamentID,
).Scan(&totalActive, &totalComplete)
if err != nil {
return fmt.Errorf("check completion: %w", err)
}
s.recordAudit(ctx, tournamentID, audit.ActionSeatAssign, "hand_complete", fmt.Sprintf("%d", tableID), nil,
map[string]interface{}{"table_id": tableID, "completed": totalComplete, "total": totalActive})
if totalComplete >= totalActive {
// All tables done: advance to next hand
newHandNumber := handNumber + 1
_, err = s.db.ExecContext(ctx,
`UPDATE tournaments SET hand_for_hand_hand_number = ?, updated_at = unixepoch()
WHERE id = ?`,
newHandNumber, tournamentID,
)
if err != nil {
return fmt.Errorf("advance hand number: %w", err)
}
// Reset all tables
_, err = s.db.ExecContext(ctx,
`UPDATE tables SET hand_completed = 0, updated_at = unixepoch()
WHERE tournament_id = ? AND is_active = 1`,
tournamentID,
)
if err != nil {
return fmt.Errorf("reset hand completion: %w", err)
}
s.broadcast(tournamentID, "hand_for_hand.next_hand", map[string]interface{}{
"hand_number": newHandNumber,
})
} else {
s.broadcast(tournamentID, "hand_for_hand.progress", map[string]interface{}{
"completed": totalComplete,
"total": totalActive,
})
}
return nil
}
// GetHandForHandStatus returns the current hand-for-hand status.
func (s *TableService) GetHandForHandStatus(ctx context.Context, tournamentID string) (*HandForHandStatus, error) {
var hfh, handNumber int
err := s.db.QueryRowContext(ctx,
`SELECT hand_for_hand, hand_for_hand_hand_number FROM tournaments WHERE id = ?`,
tournamentID,
).Scan(&hfh, &handNumber)
if err != nil {
return nil, fmt.Errorf("tournament not found: %w", err)
}
status := &HandForHandStatus{
Enabled: hfh == 1,
CurrentHandNumber: handNumber,
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, hand_completed FROM tables
WHERE tournament_id = ? AND is_active = 1
ORDER BY name`,
tournamentID,
)
if err != nil {
return nil, fmt.Errorf("get table states: %w", err)
}
defer rows.Close()
for rows.Next() {
var ts TableHandState
var hc int
if err := rows.Scan(&ts.TableID, &ts.TableName, &hc); err != nil {
return nil, fmt.Errorf("scan table state: %w", err)
}
ts.HandCompleted = hc == 1
status.Tables = append(status.Tables, ts)
status.TotalCount++
if ts.HandCompleted {
status.CompletedCount++
}
}
return status, rows.Err()
}
// ---------- Helpers ----------
// buildSeats returns the seat array for a table, filled with players where occupied.
func (s *TableService) buildSeats(ctx context.Context, tableID, seatCount int) []SeatDetail {
seats := make([]SeatDetail, seatCount)
for i := range seats {
seats[i] = SeatDetail{Position: i + 1, IsEmpty: true}
}
rows, err := s.db.QueryContext(ctx,
`SELECT tp.seat_position, tp.player_id, p.name, tp.current_chips
FROM tournament_players tp
JOIN players p ON p.id = tp.player_id
WHERE tp.seat_table_id = ? AND tp.seat_position IS NOT NULL
AND tp.status IN ('registered', 'active')
ORDER BY tp.seat_position`,
tableID,
)
if err != nil {
return seats
}
defer rows.Close()
for rows.Next() {
var pos int
var pid, pname string
var chips int64
if err := rows.Scan(&pos, &pid, &pname, &chips); err != nil {
continue
}
if pos >= 1 && pos <= seatCount {
seats[pos-1] = SeatDetail{
Position: pos,
PlayerID: &pid,
PlayerName: &pname,
ChipCount: &chips,
IsEmpty: false,
}
}
}
return seats
}
func (s *TableService) 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 *TableService) 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)
}
// TableCount returns the number of active players at a table.
func (s *TableService) TableCount(ctx context.Context, tournamentID string, tableID int) (int, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tournament_players
WHERE tournament_id = ? AND seat_table_id = ? AND status IN ('registered', 'active')`,
tournamentID, tableID,
).Scan(&count)
return count, err
}