- 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>
674 lines
19 KiB
Go
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
|
|
}
|