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

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

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(&timestamp, &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)
}