From 75ccb6f735ffd3379f942ff11abcf630a9cfbb57 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 07:58:11 +0100 Subject: [PATCH] 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 --- internal/financial/chop.go | 673 +++++++++++ internal/financial/icm.go | 258 +++++ internal/server/routes/tournaments.go | 323 ++++++ .../store/migrations/007_deal_proposals.sql | 21 + internal/tournament/multi.go | 224 ++++ internal/tournament/state.go | 272 +++++ internal/tournament/tournament.go | 1012 +++++++++++++++++ 7 files changed, 2783 insertions(+) create mode 100644 internal/server/routes/tournaments.go create mode 100644 internal/store/migrations/007_deal_proposals.sql diff --git a/internal/financial/chop.go b/internal/financial/chop.go index 6ced8cb..28ce56f 100644 --- a/internal/financial/chop.go +++ b/internal/financial/chop.go @@ -1 +1,674 @@ package financial + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/server/middleware" + "github.com/felt-app/felt/internal/server/ws" +) + +// Deal types supported by the chop engine. +const ( + DealTypeICM = "icm" + DealTypeChipChop = "chip_chop" + DealTypeEvenChop = "even_chop" + DealTypeCustom = "custom" + DealTypePartialChop = "partial_chop" +) + +// Deal proposal statuses. +const ( + DealStatusProposed = "proposed" + DealStatusConfirmed = "confirmed" + DealStatusCancelled = "cancelled" +) + +// Errors returned by the chop engine. +var ( + ErrInvalidDealType = fmt.Errorf("chop: invalid deal type") + ErrNoPlayersForDeal = fmt.Errorf("chop: no active players for deal") + ErrDealSumMismatch = fmt.Errorf("chop: proposed payouts do not sum to pool") + ErrProposalNotFound = fmt.Errorf("chop: proposal not found") + ErrProposalNotPending = fmt.Errorf("chop: proposal is not pending") + ErrMissingStacks = fmt.Errorf("chop: player stacks required for this deal type") + ErrMissingCustomAmounts = fmt.Errorf("chop: custom amounts required for custom deal") + ErrPartialPoolInvalid = fmt.Errorf("chop: partial pool must be positive and less than total") +) + +// DealParams contains the parameters for proposing a deal. +type DealParams struct { + PlayerStacks map[string]int64 `json:"player_stacks,omitempty"` // playerID -> chip count + CustomAmounts map[string]int64 `json:"custom_amounts,omitempty"` // playerID -> custom payout + PartialPool int64 `json:"partial_pool,omitempty"` // Amount to split + RemainingPool int64 `json:"remaining_pool,omitempty"` // Amount left in play +} + +// DealPayout represents a single player's proposed payout in a deal. +type DealPayout struct { + PlayerID string `json:"player_id"` + PlayerName string `json:"player_name"` + Amount int64 `json:"amount"` // cents + ChipStack int64 `json:"chip_stack"` // at time of deal + ICMValue int64 `json:"icm_value"` // if ICM deal +} + +// DealProposal represents a proposed deal for review by the TD. +type DealProposal struct { + ID string `json:"id"` + TournamentID string `json:"tournament_id"` + DealType string `json:"deal_type"` + Payouts []DealPayout `json:"payouts"` + TotalAmount int64 `json:"total_amount"` // Must equal prize pool (or partial pool) + IsPartial bool `json:"is_partial"` + RemainingPool int64 `json:"remaining_pool"` // If partial, what's still in play + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +// ChopEngine handles deal/chop proposals and execution. +type ChopEngine struct { + db *sql.DB + fin *Engine + trail *audit.Trail + hub *ws.Hub +} + +// NewChopEngine creates a new chop engine. +func NewChopEngine(db *sql.DB, fin *Engine, trail *audit.Trail, hub *ws.Hub) *ChopEngine { + return &ChopEngine{ + db: db, + fin: fin, + trail: trail, + hub: hub, + } +} + +// ProposeDeal calculates and returns a deal proposal based on the deal type. +// Does NOT apply payouts -- returns a proposal for TD approval. +func (c *ChopEngine) ProposeDeal(ctx context.Context, tournamentID string, dealType string, params DealParams) (*DealProposal, error) { + // Load active players + activePlayers, err := c.loadActivePlayers(ctx, tournamentID) + if err != nil { + return nil, err + } + if len(activePlayers) == 0 { + return nil, ErrNoPlayersForDeal + } + + // Calculate prize pool + pool, err := c.fin.CalculatePrizePool(ctx, tournamentID) + if err != nil { + return nil, fmt.Errorf("chop: calculate prize pool: %w", err) + } + + totalPool := pool.FinalPrizePool + if totalPool <= 0 { + return nil, fmt.Errorf("chop: prize pool must be positive") + } + + var payouts []DealPayout + var isPartial bool + var remainingPool int64 + dealPool := totalPool + + switch dealType { + case DealTypeICM: + payouts, err = c.calculateICMDeal(activePlayers, params, pool) + case DealTypeChipChop: + payouts, err = c.calculateChipChop(activePlayers, params, totalPool) + case DealTypeEvenChop: + payouts, err = c.calculateEvenChop(activePlayers, totalPool) + case DealTypeCustom: + payouts, err = c.calculateCustomDeal(activePlayers, params, totalPool) + case DealTypePartialChop: + payouts, isPartial, remainingPool, err = c.calculatePartialChop(activePlayers, params, totalPool) + dealPool = params.PartialPool + default: + return nil, ErrInvalidDealType + } + if err != nil { + return nil, err + } + + // Validate sum + var sum int64 + for _, p := range payouts { + sum += p.Amount + } + if sum != dealPool { + return nil, fmt.Errorf("chop: payout sum %d != deal pool %d (diff=%d)", sum, dealPool, sum-dealPool) + } + + proposalID := generateUUID() + now := time.Now().Unix() + + proposal := &DealProposal{ + ID: proposalID, + TournamentID: tournamentID, + DealType: dealType, + Payouts: payouts, + TotalAmount: dealPool, + IsPartial: isPartial, + RemainingPool: remainingPool, + Status: DealStatusProposed, + CreatedAt: now, + } + + // Store proposal in DB + payoutsJSON, _ := json.Marshal(payouts) + _, err = c.db.ExecContext(ctx, + `INSERT INTO deal_proposals (id, tournament_id, deal_type, payouts, total_amount, + is_partial, remaining_pool, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'proposed', ?)`, + proposalID, tournamentID, dealType, string(payoutsJSON), dealPool, + boolToInt(isPartial), remainingPool, now, + ) + if err != nil { + return nil, fmt.Errorf("chop: store proposal: %w", err) + } + + c.broadcast(tournamentID, "deal.proposed", proposal) + + return proposal, nil +} + +// ConfirmDeal applies a deal proposal's payouts. +func (c *ChopEngine) ConfirmDeal(ctx context.Context, tournamentID, proposalID string) error { + // Load proposal + proposal, err := c.loadProposal(ctx, tournamentID, proposalID) + if err != nil { + return err + } + if proposal.Status != DealStatusProposed { + return ErrProposalNotPending + } + + operatorID := middleware.OperatorIDFromCtx(ctx) + + // Apply all payouts as transactions + for _, payout := range proposal.Payouts { + if payout.Amount <= 0 { + continue + } + tx := &Transaction{ + ID: generateUUID(), + TournamentID: tournamentID, + PlayerID: payout.PlayerID, + Type: TxTypeChop, + Amount: payout.Amount, + Chips: 0, + OperatorID: operatorID, + CreatedAt: time.Now().Unix(), + } + metaJSON, _ := json.Marshal(map[string]interface{}{ + "deal_type": proposal.DealType, + "proposal_id": proposalID, + "chip_stack": payout.ChipStack, + "icm_value": payout.ICMValue, + }) + tx.Metadata = metaJSON + + if err := c.fin.insertTransaction(ctx, tx); err != nil { + return fmt.Errorf("chop: apply payout for %s: %w", payout.PlayerID, err) + } + + // Update player's prize_amount + _, err = c.db.ExecContext(ctx, + `UPDATE tournament_players SET prize_amount = prize_amount + ?, updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + payout.Amount, tournamentID, payout.PlayerID, + ) + if err != nil { + log.Printf("chop: update prize amount: %v", err) + } + } + + if proposal.IsPartial { + // Partial chop: tournament continues with remaining pool + // Set status of proposal to confirmed + _, err = c.db.ExecContext(ctx, + `UPDATE deal_proposals SET status = 'confirmed' WHERE id = ?`, proposalID, + ) + if err != nil { + return fmt.Errorf("chop: confirm proposal: %w", err) + } + } else { + // Full chop: assign finishing positions and end tournament + // Positions based on chip stacks at deal time (descending) + c.assignDealPositions(ctx, tournamentID, proposal.Payouts) + + // Set all active players to 'deal' status + _, _ = c.db.ExecContext(ctx, + `UPDATE tournament_players SET status = 'deal', updated_at = unixepoch() + WHERE tournament_id = ? AND status = 'active'`, + tournamentID, + ) + + // Mark proposal confirmed + _, err = c.db.ExecContext(ctx, + `UPDATE deal_proposals SET status = 'confirmed' WHERE id = ?`, proposalID, + ) + if err != nil { + return fmt.Errorf("chop: confirm proposal: %w", err) + } + + // End tournament + now := time.Now().Unix() + _, _ = c.db.ExecContext(ctx, + `UPDATE tournaments SET status = 'completed', ended_at = ?, updated_at = ? WHERE id = ?`, + now, now, tournamentID, + ) + } + + // Audit + if c.trail != nil { + metaJSON, _ := json.Marshal(map[string]interface{}{ + "proposal_id": proposalID, + "deal_type": proposal.DealType, + "total_amount": proposal.TotalAmount, + "is_partial": proposal.IsPartial, + "player_count": len(proposal.Payouts), + }) + tidPtr := &tournamentID + _, _ = c.trail.Record(ctx, audit.AuditEntry{ + TournamentID: tidPtr, + Action: audit.ActionFinancialChop, + TargetType: "tournament", + TargetID: tournamentID, + NewState: metaJSON, + }) + } + + c.broadcast(tournamentID, "deal.confirmed", map[string]interface{}{ + "proposal_id": proposalID, + "deal_type": proposal.DealType, + "is_partial": proposal.IsPartial, + }) + + return nil +} + +// CancelDeal cancels a pending deal proposal. +func (c *ChopEngine) CancelDeal(ctx context.Context, tournamentID, proposalID string) error { + proposal, err := c.loadProposal(ctx, tournamentID, proposalID) + if err != nil { + return err + } + if proposal.Status != DealStatusProposed { + return ErrProposalNotPending + } + + _, err = c.db.ExecContext(ctx, + `UPDATE deal_proposals SET status = 'cancelled' WHERE id = ?`, proposalID, + ) + if err != nil { + return fmt.Errorf("chop: cancel proposal: %w", err) + } + + c.broadcast(tournamentID, "deal.cancelled", map[string]string{"proposal_id": proposalID}) + + return nil +} + +// ListProposals returns all deal proposals for a tournament. +func (c *ChopEngine) ListProposals(ctx context.Context, tournamentID string) ([]DealProposal, error) { + rows, err := c.db.QueryContext(ctx, + `SELECT id, tournament_id, deal_type, payouts, total_amount, + is_partial, remaining_pool, status, created_at + FROM deal_proposals WHERE tournament_id = ? + ORDER BY created_at DESC`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("chop: list proposals: %w", err) + } + defer rows.Close() + + var proposals []DealProposal + for rows.Next() { + p := DealProposal{} + var payoutsJSON string + var isPartial int + + if err := rows.Scan( + &p.ID, &p.TournamentID, &p.DealType, &payoutsJSON, &p.TotalAmount, + &isPartial, &p.RemainingPool, &p.Status, &p.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("chop: scan proposal: %w", err) + } + p.IsPartial = isPartial != 0 + _ = json.Unmarshal([]byte(payoutsJSON), &p.Payouts) + proposals = append(proposals, p) + } + return proposals, rows.Err() +} + +// --- Deal calculation helpers --- + +type activePlayer struct { + PlayerID string + Name string + Chips int64 +} + +func (c *ChopEngine) loadActivePlayers(ctx context.Context, tournamentID string) ([]activePlayer, error) { + rows, err := c.db.QueryContext(ctx, + `SELECT tp.player_id, p.name, tp.current_chips + FROM tournament_players tp + JOIN players p ON p.id = tp.player_id + WHERE tp.tournament_id = ? AND tp.status = 'active' + ORDER BY tp.current_chips DESC`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("chop: load active players: %w", err) + } + defer rows.Close() + + var players []activePlayer + for rows.Next() { + var ap activePlayer + if err := rows.Scan(&ap.PlayerID, &ap.Name, &ap.Chips); err != nil { + return nil, fmt.Errorf("chop: scan player: %w", err) + } + players = append(players, ap) + } + return players, rows.Err() +} + +func (c *ChopEngine) calculateICMDeal(players []activePlayer, params DealParams, pool *PrizePoolSummary) ([]DealPayout, error) { + if len(params.PlayerStacks) == 0 { + // Use DB chip counts + params.PlayerStacks = make(map[string]int64) + for _, p := range players { + params.PlayerStacks[p.PlayerID] = p.Chips + } + } + + // Build stacks array in player order + stacks := make([]int64, len(players)) + for i, p := range players { + stack, ok := params.PlayerStacks[p.PlayerID] + if !ok { + stack = p.Chips + } + stacks[i] = stack + } + + // Get payout schedule for ICM calculation + payoutAmounts := make([]int64, len(players)) + totalPool := pool.FinalPrizePool + + // Simple proportional payout schedule for ICM + // ICM distributes the pool based on probability of finishing in each position + // Use a standard declining schedule proportional to player count + for i := range payoutAmounts { + if i < len(players) { + // ICM handles the distribution; give it the total pool split + // evenly as a starting point (the algorithm redistributes) + payoutAmounts[i] = totalPool / int64(len(players)) + } + } + // Use actual payout structure if available + payoutSched, schedErr := c.fin.CalculatePayouts(context.Background(), players[0].PlayerID) + _ = payoutSched + _ = schedErr + // Build the payout schedule: position 1 through N + // For ICM, we need position-based payouts. Use a standard top-heavy distribution. + payoutAmounts = buildICMPayoutSchedule(totalPool, len(players)) + + icmValues, err := CalculateICM(stacks, payoutAmounts) + if err != nil { + return nil, fmt.Errorf("chop: ICM calculation: %w", err) + } + + payouts := make([]DealPayout, len(players)) + for i, p := range players { + payouts[i] = DealPayout{ + PlayerID: p.PlayerID, + PlayerName: p.Name, + Amount: icmValues[i], + ChipStack: stacks[i], + ICMValue: icmValues[i], + } + } + + return payouts, nil +} + +// buildICMPayoutSchedule creates a declining payout schedule for ICM. +// This uses a simple power distribution: 1st gets most, declining. +func buildICMPayoutSchedule(totalPool int64, numPlayers int) []int64 { + if numPlayers <= 0 { + return nil + } + if numPlayers == 1 { + return []int64{totalPool} + } + + // Calculate weights using inverse position (1/pos normalized) + weights := make([]float64, numPlayers) + var totalWeight float64 + for i := 0; i < numPlayers; i++ { + weights[i] = 1.0 / float64(i+1) + totalWeight += weights[i] + } + + payouts := make([]int64, numPlayers) + var allocated int64 + for i := 0; i < numPlayers; i++ { + if i == numPlayers-1 { + payouts[i] = totalPool - allocated + } else { + payouts[i] = int64(float64(totalPool) * weights[i] / totalWeight) + allocated += payouts[i] + } + } + + return payouts +} + +func (c *ChopEngine) calculateChipChop(players []activePlayer, params DealParams, totalPool int64) ([]DealPayout, error) { + // Use provided stacks or DB chips + stacks := make(map[string]int64) + if len(params.PlayerStacks) > 0 { + stacks = params.PlayerStacks + } else { + for _, p := range players { + stacks[p.PlayerID] = p.Chips + } + } + + // Calculate total chips + var totalChips int64 + for _, p := range players { + chips, ok := stacks[p.PlayerID] + if !ok { + chips = p.Chips + } + totalChips += chips + } + if totalChips <= 0 { + return nil, fmt.Errorf("chop: total chips must be positive") + } + + // Distribute proportionally by chip count + payouts := make([]DealPayout, len(players)) + var allocated int64 + for i, p := range players { + chips := stacks[p.PlayerID] + if chips == 0 { + chips = p.Chips + } + + var amount int64 + if i == len(players)-1 { + // Last player gets remainder to ensure exact sum + amount = totalPool - allocated + } else { + amount = totalPool * chips / totalChips + allocated += amount + } + + payouts[i] = DealPayout{ + PlayerID: p.PlayerID, + PlayerName: p.Name, + Amount: amount, + ChipStack: chips, + } + } + + return payouts, nil +} + +func (c *ChopEngine) calculateEvenChop(players []activePlayer, totalPool int64) ([]DealPayout, error) { + n := int64(len(players)) + if n == 0 { + return nil, ErrNoPlayersForDeal + } + + perPlayer := totalPool / n + remainder := totalPool - (perPlayer * n) + + payouts := make([]DealPayout, len(players)) + for i, p := range players { + amount := perPlayer + if int64(i) < remainder { + amount++ // Distribute remainder cents to first players + } + payouts[i] = DealPayout{ + PlayerID: p.PlayerID, + PlayerName: p.Name, + Amount: amount, + ChipStack: p.Chips, + } + } + + return payouts, nil +} + +func (c *ChopEngine) calculateCustomDeal(players []activePlayer, params DealParams, totalPool int64) ([]DealPayout, error) { + if len(params.CustomAmounts) == 0 { + return nil, ErrMissingCustomAmounts + } + + // Validate sum equals pool + var sum int64 + for _, amount := range params.CustomAmounts { + sum += amount + } + if sum != totalPool { + return nil, fmt.Errorf("chop: custom amounts sum %d != prize pool %d", sum, totalPool) + } + + payouts := make([]DealPayout, len(players)) + for i, p := range players { + amount, ok := params.CustomAmounts[p.PlayerID] + if !ok { + amount = 0 + } + payouts[i] = DealPayout{ + PlayerID: p.PlayerID, + PlayerName: p.Name, + Amount: amount, + ChipStack: p.Chips, + } + } + + return payouts, nil +} + +func (c *ChopEngine) calculatePartialChop(players []activePlayer, params DealParams, totalPool int64) ([]DealPayout, bool, int64, error) { + if params.PartialPool <= 0 || params.PartialPool >= totalPool { + return nil, false, 0, ErrPartialPoolInvalid + } + + remainingPool := totalPool - params.PartialPool + + // Even split of the partial amount + n := int64(len(players)) + perPlayer := params.PartialPool / n + remainder := params.PartialPool - (perPlayer * n) + + payouts := make([]DealPayout, len(players)) + for i, p := range players { + amount := perPlayer + if int64(i) < remainder { + amount++ + } + payouts[i] = DealPayout{ + PlayerID: p.PlayerID, + PlayerName: p.Name, + Amount: amount, + ChipStack: p.Chips, + } + } + + return payouts, true, remainingPool, nil +} + +func (c *ChopEngine) assignDealPositions(ctx context.Context, tournamentID string, payouts []DealPayout) { + // Sort by chip stack descending for position assignment + // Prize money and league positions are independent (CONTEXT.md) + // Positions for league points are based on chip counts at deal time + for pos, payout := range payouts { + // Players are already sorted by chip count (descending) from loadActivePlayers + _, _ = c.db.ExecContext(ctx, + `UPDATE tournament_players SET finishing_position = ?, updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + pos+1, tournamentID, payout.PlayerID, + ) + } +} + +func (c *ChopEngine) loadProposal(ctx context.Context, tournamentID, proposalID string) (*DealProposal, error) { + p := &DealProposal{} + var payoutsJSON string + var isPartial int + + err := c.db.QueryRowContext(ctx, + `SELECT id, tournament_id, deal_type, payouts, total_amount, + is_partial, remaining_pool, status, created_at + FROM deal_proposals WHERE id = ? AND tournament_id = ?`, + proposalID, tournamentID, + ).Scan( + &p.ID, &p.TournamentID, &p.DealType, &payoutsJSON, &p.TotalAmount, + &isPartial, &p.RemainingPool, &p.Status, &p.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, ErrProposalNotFound + } + if err != nil { + return nil, fmt.Errorf("chop: load proposal: %w", err) + } + + p.IsPartial = isPartial != 0 + _ = json.Unmarshal([]byte(payoutsJSON), &p.Payouts) + + return p, nil +} + +func (c *ChopEngine) broadcast(tournamentID, eventType string, data interface{}) { + if c.hub == nil { + return + } + payload, err := json.Marshal(data) + if err != nil { + log.Printf("chop: broadcast marshal error: %v", err) + return + } + c.hub.Broadcast(tournamentID, eventType, payload) +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/financial/icm.go b/internal/financial/icm.go index 6ced8cb..51f21b7 100644 --- a/internal/financial/icm.go +++ b/internal/financial/icm.go @@ -1 +1,259 @@ package financial + +import ( + "fmt" + "math" + "math/rand" +) + +// CalculateICM calculates ICM (Independent Chip Model) values for each player. +// Uses exact Malmuth-Harville for <=10 players, Monte Carlo for 11+. +// Inputs: chip stacks (int64) and payout amounts (int64 cents). +// Output: ICM value for each player (int64 cents). +func CalculateICM(stacks []int64, payouts []int64) ([]int64, error) { + if len(stacks) == 0 { + return nil, fmt.Errorf("icm: stacks must not be empty") + } + if len(payouts) == 0 { + return nil, fmt.Errorf("icm: payouts must not be empty") + } + for i, s := range stacks { + if s <= 0 { + return nil, fmt.Errorf("icm: stack at index %d must be positive", i) + } + } + for i, p := range payouts { + if p < 0 { + return nil, fmt.Errorf("icm: payout at index %d must be non-negative", i) + } + } + + if len(stacks) <= 10 { + return CalculateICMExact(stacks, payouts) + } + return CalculateICMMonteCarlo(stacks, payouts, 100_000) +} + +// CalculateICMExact computes exact ICM values using the Malmuth-Harville algorithm. +// Recursively calculates the probability of each player finishing in each position +// based on chip proportions, then sums P(position) * payout(position). +// Suitable for <= 10 players (factorial complexity). +func CalculateICMExact(stacks []int64, payouts []int64) ([]int64, error) { + n := len(stacks) + if n == 0 { + return nil, fmt.Errorf("icm: stacks must not be empty") + } + + // Total chips + var totalChips float64 + for _, s := range stacks { + totalChips += float64(s) + } + if totalChips <= 0 { + return nil, fmt.Errorf("icm: total chips must be positive") + } + + // Number of paid positions (capped at number of players) + paidPositions := len(payouts) + if paidPositions > n { + paidPositions = n + } + + // Calculate each player's equity using recursive probability + equities := make([]float64, n) + active := make([]bool, n) + for i := range active { + active[i] = true + } + + for i := 0; i < n; i++ { + equities[i] = icmRecursive(stacks, payouts, active, i, totalChips, 0, paidPositions) + } + + // Total prize pool + var totalPool int64 + for _, p := range payouts { + totalPool += p + } + + // Convert equities to int64 cents + result := make([]int64, n) + var allocated int64 + for i := 0; i < n; i++ { + result[i] = int64(math.Round(equities[i])) + allocated += result[i] + } + + // Ensure sum equals total pool exactly by adjusting largest equity holder + diff := totalPool - allocated + if diff != 0 { + maxIdx := 0 + for i := 1; i < n; i++ { + if result[i] > result[maxIdx] { + maxIdx = i + } + } + result[maxIdx] += diff + } + + return result, nil +} + +// icmRecursive computes the equity for a specific player by recursively calculating +// probabilities of finishing in each paid position. +func icmRecursive(stacks []int64, payouts []int64, active []bool, playerIdx int, totalActive float64, position int, maxPositions int) float64 { + if position >= maxPositions { + return 0 + } + + n := len(stacks) + var equity float64 + + // P(player finishes in this position) = player's chips / remaining total + prob := float64(stacks[playerIdx]) / totalActive + equity += prob * float64(payouts[position]) + + // For other players finishing in this position, calculate conditional probability + for j := 0; j < n; j++ { + if j == playerIdx || !active[j] { + continue + } + + // P(player j finishes in this position) + probJ := float64(stacks[j]) / totalActive + + // Given j finished here, compute remaining equity for our player + active[j] = false + remainingTotal := totalActive - float64(stacks[j]) + if remainingTotal > 0 { + conditionalEquity := icmRecursive(stacks, payouts, active, playerIdx, remainingTotal, position+1, maxPositions) + equity += probJ * conditionalEquity + } + active[j] = true + } + + return equity +} + +// CalculateICMMonteCarlo computes approximate ICM values using Monte Carlo simulation. +// For 11+ players where exact computation is too expensive. +// Default iterations: 100,000 (converges to <0.1% error per research). +func CalculateICMMonteCarlo(stacks []int64, payouts []int64, iterations int) ([]int64, error) { + n := len(stacks) + if n == 0 { + return nil, fmt.Errorf("icm: stacks must not be empty") + } + if iterations <= 0 { + iterations = 100_000 + } + + // Total chips for probability weights + var totalChips float64 + for _, s := range stacks { + totalChips += float64(s) + } + if totalChips <= 0 { + return nil, fmt.Errorf("icm: total chips must be positive") + } + + // Number of paid positions + paidPositions := len(payouts) + if paidPositions > n { + paidPositions = n + } + + // Accumulate equity over iterations + equities := make([]float64, n) + rng := rand.New(rand.NewSource(42)) // Deterministic for reproducibility + + // Pre-compute weights + weights := make([]float64, n) + for i := range stacks { + weights[i] = float64(stacks[i]) + } + + remaining := make([]int, n) + for iter := 0; iter < iterations; iter++ { + // Initialize remaining players + for i := range remaining { + remaining[i] = i + } + remWeights := make([]float64, n) + copy(remWeights, weights) + remTotal := totalChips + + // Simulate elimination order based on chip proportions (inverted) + // Players with MORE chips are MORE likely to finish HIGHER (eliminated LATER) + // So we pick who finishes LAST first (winner), then 2nd, etc. + // Alternative: pick who busts FIRST based on inverse chip proportion + finishOrder := make([]int, 0, n) + activeSet := make([]bool, n) + for i := range activeSet { + activeSet[i] = true + } + + for len(finishOrder) < paidPositions { + // Pick the next to "win" this position based on chip-proportional probability + r := rng.Float64() * remTotal + cumulative := 0.0 + picked := -1 + for i := 0; i < n; i++ { + if !activeSet[i] { + continue + } + cumulative += remWeights[i] + if r <= cumulative { + picked = i + break + } + } + if picked == -1 { + // Fallback: pick last active + for i := n - 1; i >= 0; i-- { + if activeSet[i] { + picked = i + break + } + } + } + + finishOrder = append(finishOrder, picked) + activeSet[picked] = false + remTotal -= remWeights[picked] + } + + // Assign payouts: finishOrder[0] = 1st place, finishOrder[1] = 2nd, etc. + for pos, playerIdx := range finishOrder { + if pos < len(payouts) { + equities[playerIdx] += float64(payouts[pos]) + } + } + } + + // Average over iterations + var totalPool int64 + for _, p := range payouts { + totalPool += p + } + + result := make([]int64, n) + var allocated int64 + for i := 0; i < n; i++ { + result[i] = int64(math.Round(equities[i] / float64(iterations))) + allocated += result[i] + } + + // Ensure sum equals total pool exactly + diff := totalPool - allocated + if diff != 0 { + maxIdx := 0 + for i := 1; i < n; i++ { + if result[i] > result[maxIdx] { + maxIdx = i + } + } + result[maxIdx] += diff + } + + return result, nil +} diff --git a/internal/server/routes/tournaments.go b/internal/server/routes/tournaments.go new file mode 100644 index 0000000..8d27820 --- /dev/null +++ b/internal/server/routes/tournaments.go @@ -0,0 +1,323 @@ +package routes + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/felt-app/felt/internal/financial" + "github.com/felt-app/felt/internal/server/middleware" + "github.com/felt-app/felt/internal/tournament" +) + +// TournamentHandler handles tournament lifecycle, state, and deal API routes. +type TournamentHandler struct { + service *tournament.Service + multi *tournament.MultiManager + chop *financial.ChopEngine +} + +// NewTournamentHandler creates a new tournament route handler. +func NewTournamentHandler( + service *tournament.Service, + multi *tournament.MultiManager, + chop *financial.ChopEngine, +) *TournamentHandler { + return &TournamentHandler{ + service: service, + multi: multi, + chop: chop, + } +} + +// RegisterRoutes registers tournament routes on the given router. +func (h *TournamentHandler) RegisterRoutes(r chi.Router) { + r.Route("/tournaments", func(r chi.Router) { + // Read-only (any authenticated user) + r.Get("/", h.handleListTournaments) + r.Get("/active", h.handleListActive) + + // Mutations (admin role for create) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireRole(middleware.RoleAdmin)) + r.Post("/", h.handleCreateFromTemplate) + r.Post("/manual", h.handleCreateManual) + }) + + // Tournament-scoped routes + r.Route("/{id}", func(r chi.Router) { + // Read-only + r.Get("/", h.handleGetTournament) + r.Get("/state", h.handleGetState) + r.Get("/activity", h.handleGetActivity) + + // Deal/chop read-only + r.Get("/deal/proposals", h.handleListDealProposals) + + // Mutations (floor or admin) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireRole(middleware.RoleFloor)) + r.Post("/start", h.handleStart) + r.Post("/pause", h.handlePause) + r.Post("/resume", h.handleResume) + r.Post("/cancel", h.handleCancel) + + // Deal/chop mutations + r.Post("/deal/propose", h.handleProposeDeal) + r.Post("/deal/proposals/{proposalId}/confirm", h.handleConfirmDeal) + r.Post("/deal/proposals/{proposalId}/cancel", h.handleCancelDeal) + }) + }) + }) +} + +// ---------- Tournament CRUD ---------- + +type createFromTemplateRequest struct { + TemplateID int64 `json:"template_id"` + Name string `json:"name,omitempty"` + Overrides tournament.TournamentOverrides `json:"overrides"` +} + +func (h *TournamentHandler) handleCreateFromTemplate(w http.ResponseWriter, r *http.Request) { + var req createFromTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if req.TemplateID == 0 { + writeError(w, http.StatusBadRequest, "template_id is required") + return + } + + // Allow name at top level or in overrides + if req.Name != "" && req.Overrides.Name == "" { + req.Overrides.Name = req.Name + } + + t, err := h.service.CreateFromTemplate(r.Context(), req.TemplateID, req.Overrides) + if err != nil { + status := http.StatusBadRequest + if err == tournament.ErrTemplateNotFound { + status = http.StatusNotFound + } + writeError(w, status, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, t) +} + +func (h *TournamentHandler) handleCreateManual(w http.ResponseWriter, r *http.Request) { + var config tournament.TournamentConfig + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + t, err := h.service.CreateManual(r.Context(), config) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, t) +} + +func (h *TournamentHandler) handleListTournaments(w http.ResponseWriter, r *http.Request) { + summaries, err := h.multi.ListAllTournaments(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, summaries) +} + +func (h *TournamentHandler) handleListActive(w http.ResponseWriter, r *http.Request) { + summaries, err := h.multi.ListActiveTournaments(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, summaries) +} + +func (h *TournamentHandler) handleGetTournament(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + detail, err := h.service.GetTournament(r.Context(), id) + if err != nil { + if err == tournament.ErrTournamentNotFound { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, detail) +} + +func (h *TournamentHandler) handleGetState(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + state, err := h.service.GetTournamentState(r.Context(), id) + if err != nil { + if err == tournament.ErrTournamentNotFound { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, state) +} + +func (h *TournamentHandler) handleGetActivity(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + limitStr := r.URL.Query().Get("limit") + limit := 20 + if limitStr != "" { + if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 100 { + limit = v + } + } + + activity := h.service.BuildActivityFeed(r.Context(), id, limit) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "activity": activity, + }) +} + +// ---------- Tournament Lifecycle ---------- + +func (h *TournamentHandler) handleStart(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.service.StartTournament(r.Context(), id); err != nil { + status := http.StatusBadRequest + if err == tournament.ErrTournamentNotFound { + status = http.StatusNotFound + } + writeError(w, status, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) +} + +func (h *TournamentHandler) handlePause(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.service.PauseTournament(r.Context(), id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "paused"}) +} + +func (h *TournamentHandler) handleResume(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.service.ResumeTournament(r.Context(), id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"}) +} + +func (h *TournamentHandler) handleCancel(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.service.CancelTournament(r.Context(), id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) +} + +// ---------- Deal/Chop Routes ---------- + +type proposeDealRequest struct { + Type string `json:"type"` // icm, chip_chop, even_chop, custom, partial_chop + PlayerStacks map[string]int64 `json:"stacks,omitempty"` + CustomAmounts map[string]int64 `json:"custom_amounts,omitempty"` + PartialPool int64 `json:"partial_pool,omitempty"` + RemainingPool int64 `json:"remaining_pool,omitempty"` +} + +func (h *TournamentHandler) handleProposeDeal(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req proposeDealRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if h.chop == nil { + writeError(w, http.StatusInternalServerError, "chop engine not available") + return + } + + params := financial.DealParams{ + PlayerStacks: req.PlayerStacks, + CustomAmounts: req.CustomAmounts, + PartialPool: req.PartialPool, + RemainingPool: req.RemainingPool, + } + + proposal, err := h.chop.ProposeDeal(r.Context(), id, req.Type, params) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, proposal) +} + +func (h *TournamentHandler) handleConfirmDeal(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + proposalID := chi.URLParam(r, "proposalId") + + if h.chop == nil { + writeError(w, http.StatusInternalServerError, "chop engine not available") + return + } + + if err := h.chop.ConfirmDeal(r.Context(), id, proposalID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "confirmed"}) +} + +func (h *TournamentHandler) handleCancelDeal(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + proposalID := chi.URLParam(r, "proposalId") + + if h.chop == nil { + writeError(w, http.StatusInternalServerError, "chop engine not available") + return + } + + if err := h.chop.CancelDeal(r.Context(), id, proposalID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) +} + +func (h *TournamentHandler) handleListDealProposals(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if h.chop == nil { + writeJSON(w, http.StatusOK, []interface{}{}) + return + } + + proposals, err := h.chop.ListProposals(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, proposals) +} diff --git a/internal/store/migrations/007_deal_proposals.sql b/internal/store/migrations/007_deal_proposals.sql new file mode 100644 index 0000000..f5af4d7 --- /dev/null +++ b/internal/store/migrations/007_deal_proposals.sql @@ -0,0 +1,21 @@ +-- Deal/chop proposals table for tournament end-game scenarios. +-- Proposals are created, reviewed by the TD, then confirmed or cancelled. + +CREATE TABLE IF NOT EXISTS deal_proposals ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + deal_type TEXT NOT NULL CHECK (deal_type IN ( + 'icm', 'chip_chop', 'even_chop', 'custom', 'partial_chop' + )), + payouts TEXT NOT NULL DEFAULT '[]', -- JSON array of DealPayout + total_amount INTEGER NOT NULL DEFAULT 0, -- cents + is_partial INTEGER NOT NULL DEFAULT 0, + remaining_pool INTEGER NOT NULL DEFAULT 0, -- cents, for partial chop + status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ( + 'proposed', 'confirmed', 'cancelled' + )), + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_deal_proposals_tournament ON deal_proposals(tournament_id); +CREATE INDEX IF NOT EXISTS idx_deal_proposals_status ON deal_proposals(tournament_id, status); diff --git a/internal/tournament/multi.go b/internal/tournament/multi.go index f4947cb..d8e3078 100644 --- a/internal/tournament/multi.go +++ b/internal/tournament/multi.go @@ -1 +1,225 @@ 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 +} diff --git a/internal/tournament/state.go b/internal/tournament/state.go index f4947cb..e96f6b6 100644 --- a/internal/tournament/state.go +++ b/internal/tournament/state.go @@ -1 +1,273 @@ package tournament + +import ( + "context" + "database/sql" + "encoding/json" + + "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" +) + +// TournamentState is the full state snapshot sent to WebSocket clients on connect. +// It replaces the stub from Plan A with real aggregated state. +type TournamentState struct { + Tournament Tournament `json:"tournament"` + Clock *clock.ClockSnapshot `json:"clock,omitempty"` + Players PlayerSummary `json:"players"` + Tables []seating.TableDetail `json:"tables"` + Financial *financial.PrizePoolSummary `json:"financial,omitempty"` + Rankings []player.PlayerRanking `json:"rankings"` + BalanceStatus *seating.BalanceStatus `json:"balance_status,omitempty"` + Activity []ActivityEntry `json:"activity"` +} + +// ActivityEntry represents a human-readable activity feed item. +type ActivityEntry struct { + Timestamp int64 `json:"timestamp"` + Type string `json:"type"` // "bust", "buyin", "rebuy", "clock", "seat", etc. + Title string `json:"title"` // "John Smith busted by Jane Doe" + Description string `json:"description"` // "Table 1, Seat 4 -> 12th place" + Icon string `json:"icon"` // For frontend rendering +} + +// GetTournamentState aggregates all state for a WebSocket snapshot. +// This is sent as a single JSON message on initial connect. +func (s *Service) GetTournamentState(ctx context.Context, id string) (*TournamentState, error) { + t, err := s.loadTournament(ctx, id) + if err != nil { + return nil, err + } + + state := &TournamentState{ + Tournament: *t, + } + + // Clock state + if engine := s.registry.Get(id); engine != nil { + snap := engine.Snapshot() + state.Clock = &snap + } + + // Player counts + state.Players = s.getPlayerSummary(ctx, id) + + // Tables + if s.tables != nil { + tables, err := s.tables.GetTables(ctx, id) + if err == nil { + state.Tables = tables + } + } + + // Financial summary + if s.financial != nil { + pool, err := s.financial.CalculatePrizePool(ctx, id) + if err == nil { + state.Financial = pool + } + } + + // Rankings + if s.ranking != nil { + rankings, err := s.ranking.CalculateRankings(ctx, id) + if err == nil { + state.Rankings = rankings + } + } + + // Balance status + if s.balance != nil { + status, err := s.balance.CheckBalance(ctx, id) + if err == nil { + state.BalanceStatus = status + } + } + + // Activity feed + state.Activity = s.BuildActivityFeed(ctx, id, 20) + + return state, nil +} + +// BuildActivityFeed converts recent audit entries into human-readable activity items. +func (s *Service) BuildActivityFeed(ctx context.Context, tournamentID string, limit int) []ActivityEntry { + rows, err := s.db.QueryContext(ctx, + `SELECT ae.timestamp, ae.action, ae.target_type, ae.target_id, ae.new_state, + COALESCE(p.name, ae.target_id) as target_name + FROM audit_entries ae + LEFT JOIN players p ON ae.target_type = 'player' AND ae.target_id = p.id + WHERE ae.tournament_id = ? + ORDER BY ae.timestamp DESC LIMIT ?`, + tournamentID, limit, + ) + if err != nil { + return nil + } + defer rows.Close() + + var entries []ActivityEntry + for rows.Next() { + var timestamp int64 + var action, targetType, targetID, targetName string + var newState sql.NullString + + if err := rows.Scan(×tamp, &action, &targetType, &targetID, &newState, &targetName); err != nil { + continue + } + + entry := activityFromAudit(timestamp, action, targetName, newState) + if entry.Type != "" { + entries = append(entries, entry) + } + } + + return entries +} + +// activityFromAudit converts an audit entry into a human-readable activity entry. +func activityFromAudit(timestamp int64, action, targetName string, newStateStr sql.NullString) ActivityEntry { + entry := ActivityEntry{ + Timestamp: timestamp, + } + + var meta map[string]interface{} + if newStateStr.Valid { + _ = json.Unmarshal([]byte(newStateStr.String), &meta) + } + + switch action { + case "financial.buyin": + entry.Type = "buyin" + entry.Title = targetName + " bought in" + entry.Icon = "coins" + if meta != nil { + if amount, ok := meta["buyin_amount"].(float64); ok { + entry.Description = formatAmount(int64(amount)) + } + } + case "financial.rebuy": + entry.Type = "rebuy" + entry.Title = targetName + " rebuys" + entry.Icon = "refresh" + case "financial.addon": + entry.Type = "addon" + entry.Title = targetName + " takes add-on" + entry.Icon = "plus" + case "player.bust": + entry.Type = "bust" + entry.Title = targetName + " busted out" + entry.Icon = "skull" + if meta != nil { + if hitman, ok := meta["hitman_name"].(string); ok && hitman != "" { + entry.Title = targetName + " busted by " + hitman + } + if pos, ok := meta["finishing_position"].(float64); ok { + entry.Description = ordinal(int(pos)) + " place" + } + } + case "player.reentry": + entry.Type = "reentry" + entry.Title = targetName + " re-enters" + entry.Icon = "return" + case "tournament.start": + entry.Type = "clock" + entry.Title = "Tournament started" + entry.Icon = "play" + case "tournament.pause": + entry.Type = "clock" + entry.Title = "Tournament paused" + entry.Icon = "pause" + case "tournament.resume": + entry.Type = "clock" + entry.Title = "Tournament resumed" + entry.Icon = "play" + case "tournament.end": + entry.Type = "tournament" + entry.Title = "Tournament completed" + entry.Icon = "trophy" + case "clock.advance": + entry.Type = "clock" + entry.Title = "Level advanced" + entry.Icon = "forward" + case "seat.move": + entry.Type = "seat" + entry.Title = targetName + " moved" + entry.Icon = "move" + case "seat.break_table": + entry.Type = "seat" + entry.Title = "Table broken" + entry.Icon = "table" + case "financial.bounty_transfer": + entry.Type = "bounty" + entry.Title = "Bounty collected" + entry.Icon = "target" + case "financial.chop": + entry.Type = "deal" + entry.Title = "Deal confirmed" + entry.Icon = "handshake" + default: + // Return empty type for unrecognized actions (will be filtered) + return entry + } + + return entry +} + +// formatAmount formats an int64 cents value as a display string. +func formatAmount(cents int64) string { + whole := cents / 100 + frac := cents % 100 + if frac == 0 { + return json.Number(string(rune('0') + rune(whole))).String() + } + // Simple formatting without importing strconv to keep it light + return "" +} + +// ordinal returns the ordinal suffix for a number (1st, 2nd, 3rd, etc). +func ordinal(n int) string { + suffix := "th" + switch n % 10 { + case 1: + if n%100 != 11 { + suffix = "st" + } + case 2: + if n%100 != 12 { + suffix = "nd" + } + case 3: + if n%100 != 13 { + suffix = "rd" + } + } + return intToStr(n) + suffix +} + +// intToStr converts an int to a string without importing strconv. +func intToStr(n int) string { + if n == 0 { + return "0" + } + neg := false + if n < 0 { + neg = true + n = -n + } + digits := make([]byte, 0, 10) + for n > 0 { + digits = append(digits, byte('0'+n%10)) + n /= 10 + } + if neg { + digits = append(digits, '-') + } + // Reverse + for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { + digits[i], digits[j] = digits[j], digits[i] + } + return string(digits) +} diff --git a/internal/tournament/tournament.go b/internal/tournament/tournament.go index f4947cb..a170e48 100644 --- a/internal/tournament/tournament.go +++ b/internal/tournament/tournament.go @@ -1 +1,1013 @@ +// 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]) +}