felt/internal/tournament/tournament.go
Mikkel Georgsen 75ccb6f735 feat(01-09): implement tournament lifecycle, multi-tournament, ICM, and chop/deal
- TournamentService with create-from-template, start, pause, resume, end, cancel
- Auto-close when 1 player remains, with CheckAutoClose hook
- TournamentState aggregation for WebSocket full-state snapshot
- ActivityEntry feed converting audit entries to human-readable items
- MultiManager with ListActiveTournaments for lobby view (MULTI-01/02)
- ICM calculator: exact Malmuth-Harville for <=10, Monte Carlo for 11+ (FIN-11)
- ChopEngine with ICM, chip-chop, even-chop, custom, and partial-chop deals
- DealProposal workflow: propose, confirm, cancel with audit trail
- Tournament API routes for lifecycle, state, activity, and deal endpoints
- deal_proposals migration (007) for storing chop proposals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:58:11 +01:00

1013 lines
29 KiB
Go

// 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, &notes,
)
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])
}