- 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>
225 lines
7.6 KiB
Go
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
|
|
}
|