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 }