// 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, } } // ---------- 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 }