- 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>
273 lines
7 KiB
Go
273 lines
7 KiB
Go
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)
|
|
}
|