diff --git a/internal/seating/blueprint.go b/internal/seating/blueprint.go index 63b7c6c..f015ff1 100644 --- a/internal/seating/blueprint.go +++ b/internal/seating/blueprint.go @@ -1 +1,227 @@ package seating + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +// Blueprint is a venue-level saved table layout configuration. +type Blueprint struct { + ID int `json:"id"` + Name string `json:"name"` + TableConfigs []BlueprintTableConfig `json:"table_configs"` +} + +// BlueprintTableConfig is a single table spec within a blueprint. +type BlueprintTableConfig struct { + Name string `json:"name"` + SeatCount int `json:"seat_count"` +} + +// BlueprintService provides CRUD operations for table blueprints. +type BlueprintService struct { + db *sql.DB +} + +// NewBlueprintService creates a new BlueprintService. +func NewBlueprintService(db *sql.DB) *BlueprintService { + return &BlueprintService{db: db} +} + +// CreateBlueprint creates a new blueprint. +func (s *BlueprintService) CreateBlueprint(ctx context.Context, name string, configs []BlueprintTableConfig) (*Blueprint, error) { + if name == "" { + return nil, fmt.Errorf("blueprint name is required") + } + if len(configs) == 0 { + return nil, fmt.Errorf("at least one table config is required") + } + for i, c := range configs { + if c.SeatCount < 6 || c.SeatCount > 10 { + return nil, fmt.Errorf("table config %d: seat count must be between 6 and 10", i+1) + } + if c.Name == "" { + return nil, fmt.Errorf("table config %d: name is required", i+1) + } + } + + configsJSON, err := json.Marshal(configs) + if err != nil { + return nil, fmt.Errorf("marshal configs: %w", err) + } + + result, err := s.db.ExecContext(ctx, + `INSERT INTO table_blueprints (name, table_configs) VALUES (?, ?)`, + name, string(configsJSON), + ) + if err != nil { + return nil, fmt.Errorf("create blueprint: %w", err) + } + + id, _ := result.LastInsertId() + return &Blueprint{ + ID: int(id), + Name: name, + TableConfigs: configs, + }, nil +} + +// GetBlueprint returns a blueprint by ID. +func (s *BlueprintService) GetBlueprint(ctx context.Context, id int) (*Blueprint, error) { + var bp Blueprint + var configsJSON string + err := s.db.QueryRowContext(ctx, + `SELECT id, name, table_configs FROM table_blueprints WHERE id = ?`, id, + ).Scan(&bp.ID, &bp.Name, &configsJSON) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("blueprint not found") + } + if err != nil { + return nil, fmt.Errorf("get blueprint: %w", err) + } + if err := json.Unmarshal([]byte(configsJSON), &bp.TableConfigs); err != nil { + return nil, fmt.Errorf("unmarshal configs: %w", err) + } + return &bp, nil +} + +// ListBlueprints returns all blueprints. +func (s *BlueprintService) ListBlueprints(ctx context.Context) ([]Blueprint, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, table_configs FROM table_blueprints ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("list blueprints: %w", err) + } + defer rows.Close() + + var blueprints []Blueprint + for rows.Next() { + var bp Blueprint + var configsJSON string + if err := rows.Scan(&bp.ID, &bp.Name, &configsJSON); err != nil { + return nil, fmt.Errorf("scan blueprint: %w", err) + } + if err := json.Unmarshal([]byte(configsJSON), &bp.TableConfigs); err != nil { + return nil, fmt.Errorf("unmarshal configs: %w", err) + } + blueprints = append(blueprints, bp) + } + return blueprints, rows.Err() +} + +// UpdateBlueprint updates a blueprint. +func (s *BlueprintService) UpdateBlueprint(ctx context.Context, bp Blueprint) error { + if bp.Name == "" { + return fmt.Errorf("blueprint name is required") + } + for i, c := range bp.TableConfigs { + if c.SeatCount < 6 || c.SeatCount > 10 { + return fmt.Errorf("table config %d: seat count must be between 6 and 10", i+1) + } + if c.Name == "" { + return fmt.Errorf("table config %d: name is required", i+1) + } + } + + configsJSON, err := json.Marshal(bp.TableConfigs) + if err != nil { + return fmt.Errorf("marshal configs: %w", err) + } + + result, err := s.db.ExecContext(ctx, + `UPDATE table_blueprints SET name = ?, table_configs = ?, updated_at = unixepoch() + WHERE id = ?`, + bp.Name, string(configsJSON), bp.ID, + ) + if err != nil { + return fmt.Errorf("update blueprint: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("blueprint not found") + } + return nil +} + +// DeleteBlueprint deletes a blueprint. +func (s *BlueprintService) DeleteBlueprint(ctx context.Context, id int) error { + result, err := s.db.ExecContext(ctx, + `DELETE FROM table_blueprints WHERE id = ?`, id, + ) + if err != nil { + return fmt.Errorf("delete blueprint: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("blueprint not found") + } + return nil +} + +// SaveBlueprintFromTournament creates a blueprint from the current tables in a tournament. +func (s *BlueprintService) SaveBlueprintFromTournament(ctx context.Context, db *sql.DB, tournamentID, name string) (*Blueprint, error) { + if name == "" { + return nil, fmt.Errorf("blueprint name is required") + } + + rows, err := db.QueryContext(ctx, + `SELECT name, seat_count FROM tables + WHERE tournament_id = ? AND is_active = 1 + ORDER BY name`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("get tournament tables: %w", err) + } + defer rows.Close() + + var configs []BlueprintTableConfig + for rows.Next() { + var c BlueprintTableConfig + if err := rows.Scan(&c.Name, &c.SeatCount); err != nil { + return nil, fmt.Errorf("scan table: %w", err) + } + configs = append(configs, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if len(configs) == 0 { + return nil, fmt.Errorf("no active tables in tournament") + } + + return s.CreateBlueprint(ctx, name, configs) +} + +// CreateTablesFromBlueprint creates tables in a tournament from a blueprint. +func (s *BlueprintService) CreateTablesFromBlueprint(ctx context.Context, db *sql.DB, tournamentID string, blueprintID int) ([]Table, error) { + bp, err := s.GetBlueprint(ctx, blueprintID) + if err != nil { + return nil, err + } + + var tables []Table + for _, cfg := range bp.TableConfigs { + result, err := db.ExecContext(ctx, + `INSERT INTO tables (tournament_id, name, seat_count, is_active) + VALUES (?, ?, ?, 1)`, + tournamentID, cfg.Name, cfg.SeatCount, + ) + if err != nil { + return nil, fmt.Errorf("create table from blueprint: %w", err) + } + id, _ := result.LastInsertId() + tables = append(tables, Table{ + ID: int(id), + TournamentID: tournamentID, + Name: cfg.Name, + SeatCount: cfg.SeatCount, + IsActive: true, + }) + } + return tables, nil +} diff --git a/internal/seating/table.go b/internal/seating/table.go index 63b7c6c..7d5778b 100644 --- a/internal/seating/table.go +++ b/internal/seating/table.go @@ -1 +1,927 @@ +// 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 +} diff --git a/internal/store/migrations/006_seating_hfh.sql b/internal/store/migrations/006_seating_hfh.sql new file mode 100644 index 0000000..f82657e --- /dev/null +++ b/internal/store/migrations/006_seating_hfh.sql @@ -0,0 +1,8 @@ +-- 006_seating_hfh.sql +-- Add hand-for-hand columns for table-level completion tracking + +-- Tournament-level hand-for-hand counter +ALTER TABLE tournaments ADD COLUMN hand_for_hand_hand_number INTEGER NOT NULL DEFAULT 0; + +-- Table-level hand completion tracking +ALTER TABLE tables ADD COLUMN hand_completed INTEGER NOT NULL DEFAULT 0;