- 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>
1013 lines
29 KiB
Go
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, ¬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])
|
|
}
|