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/server/ws" ) // ---------- Types ---------- // BreakTableResult shows all moves from breaking a table. type BreakTableResult struct { BrokenTableID int `json:"broken_table_id"` BrokenTableName string `json:"broken_table_name"` Moves []BreakTableMove `json:"moves"` } // BreakTableMove describes one player's reassignment when a table is broken. type BreakTableMove struct { PlayerID string `json:"player_id"` PlayerName string `json:"player_name"` ToTableID int `json:"to_table_id"` ToTableName string `json:"to_table_name"` ToSeat int `json:"to_seat"` } // ---------- Service ---------- // BreakTableService handles breaking a table and redistributing players. type BreakTableService struct { db *sql.DB audit *audit.Trail hub *ws.Hub } // NewBreakTableService creates a new BreakTableService. func NewBreakTableService(db *sql.DB, auditTrail *audit.Trail, hub *ws.Hub) *BreakTableService { return &BreakTableService{ db: db, audit: auditTrail, hub: hub, } } // ---------- Break Table ---------- // BreakTable dissolves a table and distributes its players evenly across // remaining active tables. This is fully automatic per CONTEXT.md -- the // moves are applied immediately and the result is informational. func (s *BreakTableService) BreakTable(ctx context.Context, tournamentID string, tableID int) (*BreakTableResult, error) { // Load the table being broken var tableName string var isActive int err := s.db.QueryRowContext(ctx, `SELECT name, is_active FROM tables WHERE id = ? AND tournament_id = ?`, tableID, tournamentID, ).Scan(&tableName, &isActive) if err == sql.ErrNoRows { return nil, fmt.Errorf("table not found") } if err != nil { return nil, fmt.Errorf("load table: %w", err) } if isActive == 0 { return nil, fmt.Errorf("table is already inactive") } // Load all players at the table being broken type playerInfo struct { id string name string } playerRows, err := s.db.QueryContext(ctx, `SELECT tp.player_id, p.name FROM tournament_players tp JOIN players p ON p.id = tp.player_id WHERE tp.tournament_id = ? AND tp.seat_table_id = ? AND tp.status IN ('registered', 'active') ORDER BY tp.seat_position`, tournamentID, tableID, ) if err != nil { return nil, fmt.Errorf("load players: %w", err) } defer playerRows.Close() var players []playerInfo for playerRows.Next() { var pi playerInfo if err := playerRows.Scan(&pi.id, &pi.name); err != nil { return nil, fmt.Errorf("scan player: %w", err) } players = append(players, pi) } if err := playerRows.Err(); err != nil { return nil, err } // Load remaining active tables (exclude the one being broken) type destTable struct { id int name string seatCount int players int } destRows, 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 AND t.id != ? ORDER BY player_count ASC, t.name ASC`, tournamentID, tableID, ) if err != nil { return nil, fmt.Errorf("load destination tables: %w", err) } defer destRows.Close() var dests []destTable for destRows.Next() { var dt destTable if err := destRows.Scan(&dt.id, &dt.name, &dt.seatCount, &dt.players); err != nil { return nil, fmt.Errorf("scan dest table: %w", err) } dests = append(dests, dt) } if err := destRows.Err(); err != nil { return nil, err } if len(dests) == 0 { return nil, fmt.Errorf("no remaining active tables to distribute players to") } // Build occupied seat sets for destination tables occupiedSeats := make(map[int]map[int]bool) for _, dt := range dests { occupiedSeats[dt.id] = 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, dt.id, ) if err != nil { return nil, fmt.Errorf("get occupied seats for table %d: %w", dt.id, err) } for seatRows.Next() { var pos int seatRows.Scan(&pos) occupiedSeats[dt.id][pos] = true } seatRows.Close() } // Distribute players evenly: for each player, assign to the table with fewest players var moves []BreakTableMove for _, pi := range players { // Sort destinations by current player count (ascending) sort.Slice(dests, func(i, j int) bool { if dests[i].players == dests[j].players { return dests[i].id < dests[j].id } return dests[i].players < dests[j].players }) // Find a table with available seats assigned := false for idx := range dests { dt := &dests[idx] if dt.players >= dt.seatCount { continue // table is full } // Pick a random empty seat var emptySeats []int for pos := 1; pos <= dt.seatCount; pos++ { if !occupiedSeats[dt.id][pos] { emptySeats = append(emptySeats, pos) } } if len(emptySeats) == 0 { continue } seatIdx := 0 if len(emptySeats) > 1 { n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(emptySeats)))) seatIdx = int(n.Int64()) } seat := emptySeats[seatIdx] // Execute the move _, 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')`, dt.id, seat, tournamentID, pi.id, ) if err != nil { return nil, fmt.Errorf("move player %s: %w", pi.id, err) } occupiedSeats[dt.id][seat] = true dt.players++ moves = append(moves, BreakTableMove{ PlayerID: pi.id, PlayerName: pi.name, ToTableID: dt.id, ToTableName: dt.name, ToSeat: seat, }) assigned = true break } if !assigned { return nil, fmt.Errorf("no available seat for player %s (%s)", pi.id, pi.name) } } // Deactivate the broken table _, err = s.db.ExecContext(ctx, `UPDATE tables SET is_active = 0, updated_at = unixepoch() WHERE id = ? AND tournament_id = ?`, tableID, tournamentID, ) if err != nil { return nil, fmt.Errorf("deactivate table: %w", err) } result := &BreakTableResult{ BrokenTableID: tableID, BrokenTableName: tableName, Moves: moves, } s.recordAudit(ctx, tournamentID, audit.ActionSeatBreakTable, "table", fmt.Sprintf("%d", tableID), nil, result) s.broadcast(tournamentID, "table.broken", result) return result, nil } // ---------- Helpers ---------- func (s *BreakTableService) 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 *BreakTableService) 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) }