felt/internal/financial/chop.go
Mikkel Georgsen 75ccb6f735 feat(01-09): implement tournament lifecycle, multi-tournament, ICM, and chop/deal
- TournamentService with create-from-template, start, pause, resume, end, cancel
- Auto-close when 1 player remains, with CheckAutoClose hook
- TournamentState aggregation for WebSocket full-state snapshot
- ActivityEntry feed converting audit entries to human-readable items
- MultiManager with ListActiveTournaments for lobby view (MULTI-01/02)
- ICM calculator: exact Malmuth-Harville for <=10, Monte Carlo for 11+ (FIN-11)
- ChopEngine with ICM, chip-chop, even-chop, custom, and partial-chop deals
- DealProposal workflow: propose, confirm, cancel with audit trail
- Tournament API routes for lifecycle, state, activity, and deal endpoints
- deal_proposals migration (007) for storing chop proposals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:58:11 +01:00

674 lines
19 KiB
Go

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
}