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