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>
This commit is contained in:
Mikkel Georgsen 2026-03-01 07:58:11 +01:00
parent ba7bd90399
commit 75ccb6f735
7 changed files with 2783 additions and 0 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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);

View file

@ -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
}

View file

@ -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(&timestamp, &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)
}

File diff suppressed because it is too large Load diff