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:
parent
ba7bd90399
commit
75ccb6f735
7 changed files with 2783 additions and 0 deletions
|
|
@ -1 +1,674 @@
|
||||||
package financial
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,259 @@
|
||||||
package financial
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
323
internal/server/routes/tournaments.go
Normal file
323
internal/server/routes/tournaments.go
Normal 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)
|
||||||
|
}
|
||||||
21
internal/store/migrations/007_deal_proposals.sql
Normal file
21
internal/store/migrations/007_deal_proposals.sql
Normal 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);
|
||||||
|
|
@ -1 +1,225 @@
|
||||||
package tournament
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,273 @@
|
||||||
package tournament
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue