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