- TableService with CRUD, AssignSeat, AutoAssignSeat (fills evenly), MoveSeat, SwapSeats - Dealer button tracking with SetDealerButton and AdvanceDealerButton (skips empty seats) - Hand-for-hand mode with per-table completion tracking and clock integration - BlueprintService with CRUD, SaveBlueprintFromTournament, CreateTablesFromBlueprint - Migration 006 adds hand_for_hand_hand_number and hand_completed columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
927 lines
28 KiB
Go
927 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,
|
|
}
|
|
}
|
|
|
|
// ---------- 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
|
|
}
|