feat(01-08): implement table management, auto-seating, blueprints, and hand-for-hand
- 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>
This commit is contained in:
parent
d4956f0c82
commit
e947ab1c47
3 changed files with 1160 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
8
internal/store/migrations/006_seating_hfh.sql
Normal file
8
internal/store/migrations/006_seating_hfh.sql
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue