felt/internal/tournament/multi.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

225 lines
7.6 KiB
Go

package tournament
import (
"context"
"database/sql"
"fmt"
)
// TournamentSummary is a lightweight summary for the tournament lobby view.
type TournamentSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
PlayerCount int `json:"player_count"`
ActivePlayers int `json:"active_players"`
CurrentLevel int `json:"current_level"`
SmallBlind int64 `json:"small_blind"`
BigBlind int64 `json:"big_blind"`
RemainingMs int64 `json:"remaining_ms"`
TotalElapsedMs int64 `json:"total_elapsed_ms"`
PrizePool int64 `json:"prize_pool"`
StartedAt *int64 `json:"started_at,omitempty"`
CreatedAt int64 `json:"created_at"`
}
// MultiManager manages multiple simultaneous tournaments.
// It provides lobby views and tournament switching.
// Independence guarantee: every piece of state (clock, players, tables,
// financials) is scoped by tournament_id. No global singletons.
type MultiManager struct {
service *Service
}
// NewMultiManager creates a new multi-tournament manager.
func NewMultiManager(service *Service) *MultiManager {
return &MultiManager{service: service}
}
// ListActiveTournaments returns all tournaments with active statuses
// (registering, running, paused, final_table) for the lobby view.
// This powers the multi-tournament switching UI (MULTI-02).
func (m *MultiManager) ListActiveTournaments(ctx context.Context) ([]TournamentSummary, error) {
rows, err := m.service.db.QueryContext(ctx,
`SELECT t.id, t.name, t.status, t.current_level, t.clock_state,
t.clock_remaining_ns, t.total_elapsed_ns,
t.started_at, t.created_at,
(SELECT COUNT(*) FROM tournament_players tp
WHERE tp.tournament_id = t.id) as player_count,
(SELECT COUNT(*) FROM tournament_players tp
WHERE tp.tournament_id = t.id AND tp.status = 'active') as active_players,
COALESCE(
(SELECT SUM(amount) FROM transactions tx
WHERE tx.tournament_id = t.id
AND tx.type IN ('buyin', 'rebuy', 'addon', 'reentry')
AND tx.undone = 0), 0
) -
COALESCE(
(SELECT SUM(amount) FROM transactions tx
WHERE tx.tournament_id = t.id
AND tx.type = 'rake'
AND tx.undone = 0), 0
) as prize_pool
FROM tournaments t
WHERE t.status IN ('created', 'registering', 'running', 'paused', 'final_table')
ORDER BY t.created_at DESC`)
if err != nil {
return nil, fmt.Errorf("tournament: list active: %w", err)
}
defer rows.Close()
return m.scanSummaries(ctx, rows)
}
// ListAllTournaments returns all tournaments (active + recent completed) for display.
func (m *MultiManager) ListAllTournaments(ctx context.Context) ([]TournamentSummary, error) {
rows, err := m.service.db.QueryContext(ctx,
`SELECT t.id, t.name, t.status, t.current_level, t.clock_state,
t.clock_remaining_ns, t.total_elapsed_ns,
t.started_at, t.created_at,
(SELECT COUNT(*) FROM tournament_players tp
WHERE tp.tournament_id = t.id) as player_count,
(SELECT COUNT(*) FROM tournament_players tp
WHERE tp.tournament_id = t.id AND tp.status = 'active') as active_players,
COALESCE(
(SELECT SUM(amount) FROM transactions tx
WHERE tx.tournament_id = t.id
AND tx.type IN ('buyin', 'rebuy', 'addon', 'reentry')
AND tx.undone = 0), 0
) -
COALESCE(
(SELECT SUM(amount) FROM transactions tx
WHERE tx.tournament_id = t.id
AND tx.type = 'rake'
AND tx.undone = 0), 0
) as prize_pool
FROM tournaments t
ORDER BY t.created_at DESC
LIMIT 50`)
if err != nil {
return nil, fmt.Errorf("tournament: list all: %w", err)
}
defer rows.Close()
return m.scanSummaries(ctx, rows)
}
// GetTournamentSummary returns a lightweight summary for a single tournament.
func (m *MultiManager) GetTournamentSummary(ctx context.Context, id string) (*TournamentSummary, error) {
row := m.service.db.QueryRowContext(ctx,
`SELECT t.id, t.name, t.status, t.current_level, t.clock_state,
t.clock_remaining_ns, t.total_elapsed_ns,
t.started_at, t.created_at,
(SELECT COUNT(*) FROM tournament_players tp
WHERE tp.tournament_id = t.id) as player_count,
(SELECT COUNT(*) FROM tournament_players tp
WHERE tp.tournament_id = t.id AND tp.status = 'active') as active_players,
COALESCE(
(SELECT SUM(amount) FROM transactions tx
WHERE tx.tournament_id = t.id
AND tx.type IN ('buyin', 'rebuy', 'addon', 'reentry')
AND tx.undone = 0), 0
) -
COALESCE(
(SELECT SUM(amount) FROM transactions tx
WHERE tx.tournament_id = t.id
AND tx.type = 'rake'
AND tx.undone = 0), 0
) as prize_pool
FROM tournaments t
WHERE t.id = ?`, id)
summary, err := m.scanSingleSummary(ctx, row)
if err == sql.ErrNoRows {
return nil, ErrTournamentNotFound
}
if err != nil {
return nil, fmt.Errorf("tournament: get summary: %w", err)
}
return summary, nil
}
func (m *MultiManager) scanSummaries(ctx context.Context, rows *sql.Rows) ([]TournamentSummary, error) {
var summaries []TournamentSummary
for rows.Next() {
var ts TournamentSummary
var clockState string
var clockRemainingNs, totalElapsedNs int64
var startedAt sql.NullInt64
var playerCount, activePlayers int
var prizePool int64
if err := rows.Scan(
&ts.ID, &ts.Name, &ts.Status, &ts.CurrentLevel, &clockState,
&clockRemainingNs, &totalElapsedNs,
&startedAt, &ts.CreatedAt,
&playerCount, &activePlayers,
&prizePool,
); err != nil {
return nil, fmt.Errorf("tournament: scan summary: %w", err)
}
ts.PlayerCount = playerCount
ts.ActivePlayers = activePlayers
ts.RemainingMs = clockRemainingNs / 1_000_000
ts.TotalElapsedMs = totalElapsedNs / 1_000_000
ts.PrizePool = prizePool
if startedAt.Valid {
ts.StartedAt = &startedAt.Int64
}
// Get live clock data from registry if available
if engine := m.service.registry.Get(ts.ID); engine != nil {
snap := engine.Snapshot()
ts.RemainingMs = snap.RemainingMs
ts.TotalElapsedMs = snap.TotalElapsedMs
ts.CurrentLevel = snap.CurrentLevel
ts.SmallBlind = snap.Level.SmallBlind
ts.BigBlind = snap.Level.BigBlind
}
summaries = append(summaries, ts)
}
return summaries, rows.Err()
}
func (m *MultiManager) scanSingleSummary(ctx context.Context, row *sql.Row) (*TournamentSummary, error) {
ts := &TournamentSummary{}
var clockState string
var clockRemainingNs, totalElapsedNs int64
var startedAt sql.NullInt64
var playerCount, activePlayers int
var prizePool int64
if err := row.Scan(
&ts.ID, &ts.Name, &ts.Status, &ts.CurrentLevel, &clockState,
&clockRemainingNs, &totalElapsedNs,
&startedAt, &ts.CreatedAt,
&playerCount, &activePlayers,
&prizePool,
); err != nil {
return nil, err
}
ts.PlayerCount = playerCount
ts.ActivePlayers = activePlayers
ts.RemainingMs = clockRemainingNs / 1_000_000
ts.TotalElapsedMs = totalElapsedNs / 1_000_000
ts.PrizePool = prizePool
if startedAt.Valid {
ts.StartedAt = &startedAt.Int64
}
// Get live clock data from registry if available
if engine := m.service.registry.Get(ts.ID); engine != nil {
snap := engine.Snapshot()
ts.RemainingMs = snap.RemainingMs
ts.TotalElapsedMs = snap.TotalElapsedMs
ts.CurrentLevel = snap.CurrentLevel
ts.SmallBlind = snap.Level.SmallBlind
ts.BigBlind = snap.Level.BigBlind
}
return ts, nil
}