// Package tournament provides the tournament lifecycle management, multi-tournament // coordination, and state aggregation for the Felt tournament engine. It wires // together the clock engine, financial engine, player management, and seating // engine to provide a complete tournament lifecycle: create, configure, start, // run, pause, resume, and end. package tournament import ( "context" "crypto/rand" "database/sql" "encoding/json" "fmt" "log" "time" "github.com/felt-app/felt/internal/audit" "github.com/felt-app/felt/internal/clock" "github.com/felt-app/felt/internal/financial" "github.com/felt-app/felt/internal/player" "github.com/felt-app/felt/internal/seating" "github.com/felt-app/felt/internal/server/middleware" "github.com/felt-app/felt/internal/server/ws" "github.com/felt-app/felt/internal/template" ) // Tournament statuses. const ( StatusCreated = "created" StatusRegistering = "registering" StatusRunning = "running" StatusPaused = "paused" StatusFinalTable = "final_table" StatusCompleted = "completed" StatusCancelled = "cancelled" ) // Errors returned by the tournament service. var ( ErrTournamentNotFound = fmt.Errorf("tournament: not found") ErrInvalidStatus = fmt.Errorf("tournament: invalid status transition") ErrMinPlayersNotMet = fmt.Errorf("tournament: minimum players not met") ErrNoTablesConfigured = fmt.Errorf("tournament: no tables configured") ErrTournamentAlreadyEnded = fmt.Errorf("tournament: already ended or cancelled") ErrTemplateNotFound = fmt.Errorf("tournament: template not found") ErrTournamentNotRunning = fmt.Errorf("tournament: not running") ErrTournamentNotPaused = fmt.Errorf("tournament: not paused") ) // Tournament represents a tournament record as stored in the DB. type Tournament struct { ID string `json:"id"` Name string `json:"name"` TemplateID *int64 `json:"template_id,omitempty"` ChipSetID int64 `json:"chip_set_id"` BlindStructureID int64 `json:"blind_structure_id"` PayoutStructureID int64 `json:"payout_structure_id"` BuyinConfigID int64 `json:"buyin_config_id"` PointsFormulaID *int64 `json:"points_formula_id,omitempty"` Status string `json:"status"` MinPlayers int `json:"min_players"` MaxPlayers *int `json:"max_players,omitempty"` EarlySignupBonusChips int64 `json:"early_signup_bonus_chips"` EarlySignupCutoff *string `json:"early_signup_cutoff,omitempty"` PunctualityBonusChips int64 `json:"punctuality_bonus_chips"` IsPKO bool `json:"is_pko"` CurrentLevel int `json:"current_level"` ClockState string `json:"clock_state"` StartedAt *int64 `json:"started_at,omitempty"` EndedAt *int64 `json:"ended_at,omitempty"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } // TournamentOverrides allows overriding template values when creating from template. type TournamentOverrides struct { Name string `json:"name,omitempty"` MinPlayers *int `json:"min_players,omitempty"` MaxPlayers *int `json:"max_players,omitempty"` IsPKO *bool `json:"is_pko,omitempty"` EarlySignupBonusChips *int64 `json:"early_signup_bonus_chips,omitempty"` PunctualityBonusChips *int64 `json:"punctuality_bonus_chips,omitempty"` } // TournamentConfig for creating a tournament manually (without template). type TournamentConfig struct { Name string `json:"name"` ChipSetID int64 `json:"chip_set_id"` BlindStructureID int64 `json:"blind_structure_id"` PayoutStructureID int64 `json:"payout_structure_id"` BuyinConfigID int64 `json:"buyin_config_id"` PointsFormulaID *int64 `json:"points_formula_id,omitempty"` MinPlayers int `json:"min_players"` MaxPlayers *int `json:"max_players,omitempty"` EarlySignupBonusChips int64 `json:"early_signup_bonus_chips"` PunctualityBonusChips int64 `json:"punctuality_bonus_chips"` IsPKO bool `json:"is_pko"` } // TournamentDetail is the full tournament state for display. type TournamentDetail struct { Tournament Tournament `json:"tournament"` ClockSnapshot *clock.ClockSnapshot `json:"clock_snapshot,omitempty"` Tables []seating.TableDetail `json:"tables"` Players PlayerSummary `json:"players"` PrizePool *financial.PrizePoolSummary `json:"prize_pool,omitempty"` Rankings []player.PlayerRanking `json:"rankings"` RecentActivity []audit.AuditEntry `json:"recent_activity"` BalanceStatus *seating.BalanceStatus `json:"balance_status,omitempty"` } // PlayerSummary summarizes player counts. type PlayerSummary struct { Registered int `json:"registered"` Active int `json:"active"` Busted int `json:"busted"` Deal int `json:"deal"` Total int `json:"total"` } // Service provides tournament lifecycle management. type Service struct { db *sql.DB registry *clock.Registry financial *financial.Engine players *player.Service ranking *player.RankingEngine tables *seating.TableService balance *seating.BalanceEngine templates *template.TournamentTemplateService trail *audit.Trail hub *ws.Hub } // NewService creates a new tournament service. func NewService( db *sql.DB, registry *clock.Registry, fin *financial.Engine, players *player.Service, ranking *player.RankingEngine, tables *seating.TableService, balance *seating.BalanceEngine, templates *template.TournamentTemplateService, trail *audit.Trail, hub *ws.Hub, ) *Service { return &Service{ db: db, registry: registry, financial: fin, players: players, ranking: ranking, tables: tables, balance: balance, templates: templates, trail: trail, hub: hub, } } // CreateFromTemplate creates a tournament from a template with optional overrides. // This is the template-first flow: pick template, everything pre-fills, tweak, start. func (s *Service) CreateFromTemplate(ctx context.Context, templateID int64, overrides TournamentOverrides) (*Tournament, error) { // Load the expanded template expanded, err := s.templates.GetTemplateExpanded(ctx, templateID) if err != nil { return nil, ErrTemplateNotFound } tmpl := expanded.TournamentTemplate tournamentID := generateUUID() now := time.Now().Unix() name := tmpl.Name if overrides.Name != "" { name = overrides.Name } minPlayers := tmpl.MinPlayers if overrides.MinPlayers != nil { minPlayers = *overrides.MinPlayers } maxPlayers := tmpl.MaxPlayers if overrides.MaxPlayers != nil { maxPlayers = overrides.MaxPlayers } isPKO := tmpl.IsPKO if overrides.IsPKO != nil { isPKO = *overrides.IsPKO } earlyBonus := tmpl.EarlySignupBonusChips if overrides.EarlySignupBonusChips != nil { earlyBonus = *overrides.EarlySignupBonusChips } punctBonus := tmpl.PunctualityBonusChips if overrides.PunctualityBonusChips != nil { punctBonus = *overrides.PunctualityBonusChips } isPKOInt := 0 if isPKO { isPKOInt = 1 } var maxPlayersDB sql.NullInt64 if maxPlayers != nil { maxPlayersDB = sql.NullInt64{Int64: int64(*maxPlayers), Valid: true} } var pointsFormulaDB sql.NullInt64 if tmpl.PointsFormulaID != nil { pointsFormulaDB = sql.NullInt64{Int64: *tmpl.PointsFormulaID, Valid: true} } _, err = s.db.ExecContext(ctx, `INSERT INTO tournaments ( id, name, template_id, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, status, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, punctuality_bonus_chips, is_pko, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?)`, tournamentID, name, templateID, tmpl.ChipSetID, tmpl.BlindStructureID, tmpl.PayoutStructureID, tmpl.BuyinConfigID, pointsFormulaDB, minPlayers, maxPlayersDB, earlyBonus, tmpl.EarlySignupCutoff, punctBonus, isPKOInt, now, now, ) if err != nil { return nil, fmt.Errorf("tournament: create from template: %w", err) } tournament := &Tournament{ ID: tournamentID, Name: name, TemplateID: &templateID, ChipSetID: tmpl.ChipSetID, BlindStructureID: tmpl.BlindStructureID, PayoutStructureID: tmpl.PayoutStructureID, BuyinConfigID: tmpl.BuyinConfigID, PointsFormulaID: tmpl.PointsFormulaID, Status: StatusCreated, MinPlayers: minPlayers, MaxPlayers: maxPlayers, EarlySignupBonusChips: earlyBonus, EarlySignupCutoff: tmpl.EarlySignupCutoff, PunctualityBonusChips: punctBonus, IsPKO: isPKO, ClockState: "stopped", CreatedAt: now, UpdatedAt: now, } // Audit entry s.recordAudit(ctx, tournamentID, audit.ActionTournamentCreate, "tournament", tournamentID, tournament) s.broadcast(tournamentID, "tournament.created", tournament) return tournament, nil } // CreateManual creates a tournament without a template. func (s *Service) CreateManual(ctx context.Context, config TournamentConfig) (*Tournament, error) { tournamentID := generateUUID() now := time.Now().Unix() if config.Name == "" { return nil, fmt.Errorf("tournament: name is required") } if config.MinPlayers < 2 { config.MinPlayers = 2 } isPKOInt := 0 if config.IsPKO { isPKOInt = 1 } var maxPlayersDB sql.NullInt64 if config.MaxPlayers != nil { maxPlayersDB = sql.NullInt64{Int64: int64(*config.MaxPlayers), Valid: true} } var pointsFormulaDB sql.NullInt64 if config.PointsFormulaID != nil { pointsFormulaDB = sql.NullInt64{Int64: *config.PointsFormulaID, Valid: true} } _, err := s.db.ExecContext(ctx, `INSERT INTO tournaments ( id, name, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, status, min_players, max_players, early_signup_bonus_chips, punctuality_bonus_chips, is_pko, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?)`, tournamentID, config.Name, config.ChipSetID, config.BlindStructureID, config.PayoutStructureID, config.BuyinConfigID, pointsFormulaDB, config.MinPlayers, maxPlayersDB, config.EarlySignupBonusChips, config.PunctualityBonusChips, isPKOInt, now, now, ) if err != nil { return nil, fmt.Errorf("tournament: create manual: %w", err) } tournament := &Tournament{ ID: tournamentID, Name: config.Name, ChipSetID: config.ChipSetID, BlindStructureID: config.BlindStructureID, PayoutStructureID: config.PayoutStructureID, BuyinConfigID: config.BuyinConfigID, PointsFormulaID: config.PointsFormulaID, Status: StatusCreated, MinPlayers: config.MinPlayers, MaxPlayers: config.MaxPlayers, EarlySignupBonusChips: config.EarlySignupBonusChips, PunctualityBonusChips: config.PunctualityBonusChips, IsPKO: config.IsPKO, ClockState: "stopped", CreatedAt: now, UpdatedAt: now, } s.recordAudit(ctx, tournamentID, audit.ActionTournamentCreate, "tournament", tournamentID, tournament) s.broadcast(tournamentID, "tournament.created", tournament) return tournament, nil } // GetTournament returns the full tournament detail including state from all subsystems. func (s *Service) GetTournament(ctx context.Context, id string) (*TournamentDetail, error) { t, err := s.loadTournament(ctx, id) if err != nil { return nil, err } detail := &TournamentDetail{ Tournament: *t, } // Clock snapshot if engine := s.registry.Get(id); engine != nil { snap := engine.Snapshot() detail.ClockSnapshot = &snap } // Tables if s.tables != nil { tables, err := s.tables.GetTables(ctx, id) if err == nil { detail.Tables = tables } } // Player summary detail.Players = s.getPlayerSummary(ctx, id) // Prize pool if s.financial != nil { pool, err := s.financial.CalculatePrizePool(ctx, id) if err == nil { detail.PrizePool = pool } } // Rankings if s.ranking != nil { rankings, err := s.ranking.CalculateRankings(ctx, id) if err == nil { detail.Rankings = rankings } } // Recent activity (last 20 audit entries) detail.RecentActivity = s.getRecentActivity(ctx, id, 20) // Balance status if s.balance != nil { status, err := s.balance.CheckBalance(ctx, id) if err == nil { detail.BalanceStatus = status } } return detail, nil } // StartTournament transitions a tournament from created/registering to running. func (s *Service) StartTournament(ctx context.Context, id string) error { t, err := s.loadTournament(ctx, id) if err != nil { return err } if t.Status != StatusCreated && t.Status != StatusRegistering { return ErrInvalidStatus } // Validate minimum players met playerCount := s.countActivePlayers(ctx, id) if playerCount < t.MinPlayers { return ErrMinPlayersNotMet } // Validate at least one table exists tableCount := s.countTables(ctx, id) if tableCount == 0 { return ErrNoTablesConfigured } now := time.Now().Unix() // Start the clock engine if s.registry != nil { engine := s.registry.GetOrCreate(id) levels, loadErr := s.loadLevelsFromDB(id) if loadErr == nil && len(levels) > 0 { engine.LoadLevels(levels) engine.SetOnStateChange(func(tid string, snap clock.ClockSnapshot) { s.persistClockState(tid, snap) }) operatorID := middleware.OperatorIDFromCtx(ctx) if startErr := engine.Start(operatorID); startErr != nil { log.Printf("tournament: clock start error: %v", startErr) } _ = s.registry.StartTicker(ctx, id) } } // Update tournament status _, err = s.db.ExecContext(ctx, `UPDATE tournaments SET status = 'running', started_at = ?, updated_at = ? WHERE id = ?`, now, now, id, ) if err != nil { return fmt.Errorf("tournament: start: %w", err) } s.recordAudit(ctx, id, audit.ActionTournamentStart, "tournament", id, map[string]interface{}{ "player_count": playerCount, "table_count": tableCount, }) s.broadcast(id, "tournament.started", map[string]interface{}{ "tournament_id": id, "started_at": now, }) return nil } // PauseTournament pauses a running tournament (pauses the clock). func (s *Service) PauseTournament(ctx context.Context, id string) error { t, err := s.loadTournament(ctx, id) if err != nil { return err } if t.Status != StatusRunning && t.Status != StatusFinalTable { return ErrTournamentNotRunning } // Pause the clock if engine := s.registry.Get(id); engine != nil { operatorID := middleware.OperatorIDFromCtx(ctx) if pauseErr := engine.Pause(operatorID); pauseErr != nil { log.Printf("tournament: clock pause error: %v", pauseErr) } } now := time.Now().Unix() _, err = s.db.ExecContext(ctx, `UPDATE tournaments SET status = 'paused', updated_at = ? WHERE id = ?`, now, id, ) if err != nil { return fmt.Errorf("tournament: pause: %w", err) } s.recordAudit(ctx, id, "tournament.pause", "tournament", id, nil) s.broadcast(id, "tournament.paused", map[string]string{"tournament_id": id}) return nil } // ResumeTournament resumes a paused tournament (resumes the clock). func (s *Service) ResumeTournament(ctx context.Context, id string) error { t, err := s.loadTournament(ctx, id) if err != nil { return err } if t.Status != StatusPaused { return ErrTournamentNotPaused } // Resume the clock if engine := s.registry.Get(id); engine != nil { operatorID := middleware.OperatorIDFromCtx(ctx) if resumeErr := engine.Resume(operatorID); resumeErr != nil { log.Printf("tournament: clock resume error: %v", resumeErr) } } now := time.Now().Unix() _, err = s.db.ExecContext(ctx, `UPDATE tournaments SET status = 'running', updated_at = ? WHERE id = ?`, now, id, ) if err != nil { return fmt.Errorf("tournament: resume: %w", err) } s.recordAudit(ctx, id, "tournament.resume", "tournament", id, nil) s.broadcast(id, "tournament.resumed", map[string]string{"tournament_id": id}) return nil } // EndTournament ends a tournament. Called when 1 player remains or manually for deals. func (s *Service) EndTournament(ctx context.Context, id string) error { t, err := s.loadTournament(ctx, id) if err != nil { return err } if t.Status == StatusCompleted || t.Status == StatusCancelled { return ErrTournamentAlreadyEnded } // Stop the clock if engine := s.registry.Get(id); engine != nil { operatorID := middleware.OperatorIDFromCtx(ctx) _ = engine.Pause(operatorID) // Pause stops the ticker } // Assign finishing positions to remaining active players s.assignFinalPositions(ctx, id) // Calculate and apply payouts if s.financial != nil { payouts, payoutErr := s.financial.CalculatePayouts(ctx, id) if payoutErr == nil && len(payouts) > 0 { if applyErr := s.financial.ApplyPayouts(ctx, id, payouts); applyErr != nil { log.Printf("tournament: apply payouts error: %v", applyErr) } } } now := time.Now().Unix() _, err = s.db.ExecContext(ctx, `UPDATE tournaments SET status = 'completed', ended_at = ?, updated_at = ? WHERE id = ?`, now, now, id, ) if err != nil { return fmt.Errorf("tournament: end: %w", err) } s.recordAudit(ctx, id, audit.ActionTournamentEnd, "tournament", id, map[string]interface{}{ "ended_at": now, }) s.broadcast(id, "tournament.ended", map[string]interface{}{ "tournament_id": id, "ended_at": now, }) return nil } // CancelTournament cancels a tournament, voiding all pending transactions. func (s *Service) CancelTournament(ctx context.Context, id string) error { t, err := s.loadTournament(ctx, id) if err != nil { return err } if t.Status == StatusCompleted || t.Status == StatusCancelled { return ErrTournamentAlreadyEnded } // Stop clock if running if engine := s.registry.Get(id); engine != nil { operatorID := middleware.OperatorIDFromCtx(ctx) _ = engine.Pause(operatorID) } // Mark all non-undone transactions as cancelled (via metadata flag, not deletion) _, _ = s.db.ExecContext(ctx, `UPDATE transactions SET metadata = json_set(COALESCE(metadata, '{}'), '$.cancelled', 1) WHERE tournament_id = ? AND undone = 0`, id, ) now := time.Now().Unix() _, err = s.db.ExecContext(ctx, `UPDATE tournaments SET status = 'cancelled', ended_at = ?, updated_at = ? WHERE id = ?`, now, now, id, ) if err != nil { return fmt.Errorf("tournament: cancel: %w", err) } s.recordAudit(ctx, id, audit.ActionTournamentCancel, "tournament", id, nil) s.broadcast(id, "tournament.cancelled", map[string]string{"tournament_id": id}) return nil } // CheckAutoClose checks if the tournament should auto-close (1 player remains). // Called after every bust-out. func (s *Service) CheckAutoClose(ctx context.Context, id string) error { activeCount := s.countActivePlayers(ctx, id) if activeCount <= 0 { // Edge case: 0 players remaining, cancel return s.CancelTournament(ctx, id) } if activeCount == 1 { // Auto-close: 1 player remaining = winner return s.EndTournament(ctx, id) } return nil } // ListTournaments returns all tournaments, optionally filtered by status. func (s *Service) ListTournaments(ctx context.Context, statusFilter string) ([]Tournament, error) { var query string var args []interface{} if statusFilter != "" { query = `SELECT id, name, template_id, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, status, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, punctuality_bonus_chips, is_pko, current_level, clock_state, started_at, ended_at, created_at, updated_at FROM tournaments WHERE status = ? ORDER BY created_at DESC` args = []interface{}{statusFilter} } else { query = `SELECT id, name, template_id, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, status, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, punctuality_bonus_chips, is_pko, current_level, clock_state, started_at, ended_at, created_at, updated_at FROM tournaments ORDER BY created_at DESC` } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("tournament: list: %w", err) } defer rows.Close() return s.scanTournaments(rows) } // --- Internal helpers --- func (s *Service) loadTournament(ctx context.Context, id string) (*Tournament, error) { t := &Tournament{} var templateID, pointsFormulaID sql.NullInt64 var maxPlayers sql.NullInt64 var earlySignupCutoff sql.NullString var isPKO int var startedAt, endedAt sql.NullInt64 err := s.db.QueryRowContext(ctx, `SELECT id, name, template_id, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, status, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, punctuality_bonus_chips, is_pko, current_level, clock_state, started_at, ended_at, created_at, updated_at FROM tournaments WHERE id = ?`, id, ).Scan( &t.ID, &t.Name, &templateID, &t.ChipSetID, &t.BlindStructureID, &t.PayoutStructureID, &t.BuyinConfigID, &pointsFormulaID, &t.Status, &t.MinPlayers, &maxPlayers, &t.EarlySignupBonusChips, &earlySignupCutoff, &t.PunctualityBonusChips, &isPKO, &t.CurrentLevel, &t.ClockState, &startedAt, &endedAt, &t.CreatedAt, &t.UpdatedAt, ) if err == sql.ErrNoRows { return nil, ErrTournamentNotFound } if err != nil { return nil, fmt.Errorf("tournament: load: %w", err) } if templateID.Valid { t.TemplateID = &templateID.Int64 } if pointsFormulaID.Valid { t.PointsFormulaID = &pointsFormulaID.Int64 } if maxPlayers.Valid { v := int(maxPlayers.Int64) t.MaxPlayers = &v } if earlySignupCutoff.Valid { t.EarlySignupCutoff = &earlySignupCutoff.String } if startedAt.Valid { t.StartedAt = &startedAt.Int64 } if endedAt.Valid { t.EndedAt = &endedAt.Int64 } t.IsPKO = isPKO != 0 return t, nil } func (s *Service) scanTournaments(rows *sql.Rows) ([]Tournament, error) { var tournaments []Tournament for rows.Next() { t := Tournament{} var templateID, pointsFormulaID sql.NullInt64 var maxPlayers sql.NullInt64 var earlySignupCutoff sql.NullString var isPKO int var startedAt, endedAt sql.NullInt64 if err := rows.Scan( &t.ID, &t.Name, &templateID, &t.ChipSetID, &t.BlindStructureID, &t.PayoutStructureID, &t.BuyinConfigID, &pointsFormulaID, &t.Status, &t.MinPlayers, &maxPlayers, &t.EarlySignupBonusChips, &earlySignupCutoff, &t.PunctualityBonusChips, &isPKO, &t.CurrentLevel, &t.ClockState, &startedAt, &endedAt, &t.CreatedAt, &t.UpdatedAt, ); err != nil { return nil, fmt.Errorf("tournament: scan: %w", err) } if templateID.Valid { t.TemplateID = &templateID.Int64 } if pointsFormulaID.Valid { t.PointsFormulaID = &pointsFormulaID.Int64 } if maxPlayers.Valid { v := int(maxPlayers.Int64) t.MaxPlayers = &v } if earlySignupCutoff.Valid { t.EarlySignupCutoff = &earlySignupCutoff.String } if startedAt.Valid { t.StartedAt = &startedAt.Int64 } if endedAt.Valid { t.EndedAt = &endedAt.Int64 } t.IsPKO = isPKO != 0 tournaments = append(tournaments, t) } return tournaments, rows.Err() } func (s *Service) countActivePlayers(ctx context.Context, tournamentID string) int { var count int s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status IN ('active', 'registered')`, tournamentID, ).Scan(&count) return count } func (s *Service) countTables(ctx context.Context, tournamentID string) int { var count int s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tables WHERE tournament_id = ? AND is_active = 1`, tournamentID, ).Scan(&count) return count } func (s *Service) getPlayerSummary(ctx context.Context, tournamentID string) PlayerSummary { ps := PlayerSummary{} rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM tournament_players WHERE tournament_id = ? GROUP BY status`, tournamentID, ) if err != nil { return ps } defer rows.Close() for rows.Next() { var status string var count int if err := rows.Scan(&status, &count); err != nil { continue } switch status { case "registered": ps.Registered = count case "active": ps.Active = count case "busted": ps.Busted = count case "deal": ps.Deal = count } ps.Total += count } return ps } func (s *Service) getRecentActivity(ctx context.Context, tournamentID string, limit int) []audit.AuditEntry { rows, err := s.db.QueryContext(ctx, `SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id, previous_state, new_state, metadata FROM audit_entries WHERE tournament_id = ? ORDER BY timestamp DESC LIMIT ?`, tournamentID, limit, ) if err != nil { return nil } defer rows.Close() var entries []audit.AuditEntry for rows.Next() { var e audit.AuditEntry var tournID sql.NullString var prevState, newState, meta sql.NullString if err := rows.Scan( &e.ID, &tournID, &e.Timestamp, &e.OperatorID, &e.Action, &e.TargetType, &e.TargetID, &prevState, &newState, &meta, ); err != nil { continue } if tournID.Valid { e.TournamentID = &tournID.String } if prevState.Valid { e.PreviousState = json.RawMessage(prevState.String) } if newState.Valid { e.NewState = json.RawMessage(newState.String) } if meta.Valid { e.Metadata = json.RawMessage(meta.String) } entries = append(entries, e) } return entries } func (s *Service) assignFinalPositions(ctx context.Context, tournamentID string) { // Get remaining active players ordered by chip count (descending) rows, err := s.db.QueryContext(ctx, `SELECT player_id, current_chips FROM tournament_players WHERE tournament_id = ? AND status = 'active' ORDER BY current_chips DESC`, tournamentID, ) if err != nil { return } defer rows.Close() // Count busted players to determine starting position var bustedCount int s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status = 'busted'`, tournamentID, ).Scan(&bustedCount) position := 1 for rows.Next() { var playerID string var chips int64 if err := rows.Scan(&playerID, &chips); err != nil { continue } // Assign position based on chip count order _, _ = s.db.ExecContext(ctx, `UPDATE tournament_players SET finishing_position = ?, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, position, tournamentID, playerID, ) position++ } } func (s *Service) loadLevelsFromDB(tournamentID string) ([]clock.Level, error) { var structureID int err := s.db.QueryRow( "SELECT blind_structure_id FROM tournaments WHERE id = ?", tournamentID, ).Scan(&structureID) if err != nil { return nil, err } rows, err := s.db.Query( `SELECT position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, chip_up_denomination_value, notes FROM blind_levels WHERE structure_id = ? ORDER BY position`, structureID, ) if err != nil { return nil, err } defer rows.Close() var levels []clock.Level for rows.Next() { var l clock.Level var chipUpDenom sql.NullInt64 var notes sql.NullString err := rows.Scan( &l.Position, &l.LevelType, &l.GameType, &l.SmallBlind, &l.BigBlind, &l.Ante, &l.BBAnte, &l.DurationSeconds, &chipUpDenom, ¬es, ) if err != nil { return nil, err } if chipUpDenom.Valid { v := chipUpDenom.Int64 l.ChipUpDenominationVal = &v } if notes.Valid { l.Notes = notes.String } levels = append(levels, l) } return levels, rows.Err() } func (s *Service) persistClockState(tournamentID string, snap clock.ClockSnapshot) { _, err := s.db.Exec( `UPDATE tournaments SET current_level = ?, clock_state = ?, clock_remaining_ns = ?, total_elapsed_ns = ?, updated_at = unixepoch() WHERE id = ?`, snap.CurrentLevel, snap.State, snap.RemainingMs*int64(1000000), snap.TotalElapsedMs*int64(1000000), tournamentID, ) if err != nil { log.Printf("tournament: persist clock state error: %v", err) } } func (s *Service) recordAudit(ctx context.Context, tournamentID, action, targetType, targetID string, data interface{}) { if s.trail == nil { return } var newState json.RawMessage if data != nil { newState, _ = json.Marshal(data) } tidPtr := &tournamentID _, err := s.trail.Record(ctx, audit.AuditEntry{ TournamentID: tidPtr, Action: action, TargetType: targetType, TargetID: targetID, NewState: newState, }) if err != nil { log.Printf("tournament: audit record failed: %v", err) } } func (s *Service) broadcast(tournamentID, eventType string, data interface{}) { if s.hub == nil { return } payload, err := json.Marshal(data) if err != nil { log.Printf("tournament: broadcast marshal error: %v", err) return } s.hub.Broadcast(tournamentID, eventType, payload) } // generateUUID generates a v4 UUID. func generateUUID() string { b := make([]byte, 16) _, _ = rand.Read(b) b[6] = (b[6] & 0x0f) | 0x40 // Version 4 b[8] = (b[8] & 0x3f) | 0x80 // Variant 1 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) }