package player import ( "context" "database/sql" "encoding/json" "fmt" "log" "github.com/felt-app/felt/internal/server/ws" ) // PlayerRanking represents a player's current ranking position. type PlayerRanking struct { Position int `json:"position"` PlayerID string `json:"player_id"` PlayerName string `json:"player_name"` Status string `json:"status"` // active, busted, deal ChipCount int64 `json:"chip_count"` BustOutTime *int64 `json:"bust_out_time,omitempty"` HitmanName *string `json:"hitman_name,omitempty"` BountiesCollected int `json:"bounties_collected"` Prize int64 `json:"prize"` Points int64 `json:"points"` } // RankingEngine derives rankings from the ordered bust-out list. // Rankings are never stored independently -- they are always computed. type RankingEngine struct { db *sql.DB hub *ws.Hub } // NewRankingEngine creates a new ranking engine. func NewRankingEngine(db *sql.DB, hub *ws.Hub) *RankingEngine { return &RankingEngine{db: db, hub: hub} } // CalculateRankings computes current rankings for a tournament. // Active players share the same "current position" = remaining player count. // Busted players are ranked in reverse bust order (last busted = highest, first busted = last). func (r *RankingEngine) CalculateRankings(ctx context.Context, tournamentID string) ([]PlayerRanking, error) { // Count total unique entries (for finishing_position calculation) var totalEntries int err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tournamentID, ).Scan(&totalEntries) if err != nil { return nil, fmt.Errorf("ranking: count entries: %w", err) } // Load all players with ranking-relevant data rows, err := r.db.QueryContext(ctx, `SELECT tp.player_id, p.name, tp.status, tp.current_chips, tp.bust_out_at, tp.bust_out_order, tp.finishing_position, tp.bounties_collected, tp.prize_amount, tp.points_awarded, hp.name FROM tournament_players tp JOIN players p ON p.id = tp.player_id LEFT JOIN players hp ON hp.id = tp.hitman_player_id WHERE tp.tournament_id = ? ORDER BY CASE tp.status WHEN 'active' THEN 1 WHEN 'deal' THEN 2 WHEN 'busted' THEN 3 WHEN 'registered' THEN 4 END, -- Active players by chip count desc, then name CASE WHEN tp.status = 'active' THEN -tp.current_chips ELSE 0 END, -- Busted players by bust order desc (most recent first) CASE WHEN tp.status = 'busted' THEN -COALESCE(tp.bust_out_order, 0) ELSE 0 END, p.name`, tournamentID, ) if err != nil { return nil, fmt.Errorf("ranking: query players: %w", err) } defer rows.Close() // Count active players for position calculation var activeCount int err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status = 'active'`, tournamentID, ).Scan(&activeCount) if err != nil { return nil, fmt.Errorf("ranking: count active: %w", err) } var rankings []PlayerRanking pos := 1 for rows.Next() { var pr PlayerRanking var bustOutAt, bustOutOrder, finishingPos sql.NullInt64 var hitmanName sql.NullString if err := rows.Scan( &pr.PlayerID, &pr.PlayerName, &pr.Status, &pr.ChipCount, &bustOutAt, &bustOutOrder, &finishingPos, &pr.BountiesCollected, &pr.Prize, &pr.Points, &hitmanName, ); err != nil { return nil, fmt.Errorf("ranking: scan: %w", err) } if bustOutAt.Valid { pr.BustOutTime = &bustOutAt.Int64 } if hitmanName.Valid { pr.HitmanName = &hitmanName.String } switch pr.Status { case "active": // All active players share the same position pr.Position = 1 case "deal": // Deal players get their manually assigned finishing position if finishingPos.Valid { pr.Position = int(finishingPos.Int64) } else { pr.Position = pos } case "busted": // Busted: position = totalEntries - bustOutOrder + 1 if bustOutOrder.Valid { pr.Position = totalEntries - int(bustOutOrder.Int64) + 1 } else { pr.Position = pos } default: // Registered (not yet playing) pr.Position = 0 } rankings = append(rankings, pr) pos++ } return rankings, rows.Err() } // RecalculateAllRankings recalculates ALL bust_out_order values from timestamps. // Called after any undo operation to ensure consistency. func (r *RankingEngine) RecalculateAllRankings(ctx context.Context, tournamentID string) error { // Load all busted players ordered by bust_out_at timestamp rows, err := r.db.QueryContext(ctx, `SELECT player_id, bust_out_at FROM tournament_players WHERE tournament_id = ? AND status = 'busted' AND bust_out_at IS NOT NULL ORDER BY bust_out_at ASC`, tournamentID, ) if err != nil { return fmt.Errorf("ranking: query busted: %w", err) } defer rows.Close() type bustedPlayer struct { PlayerID string BustOutAt int64 } var busted []bustedPlayer for rows.Next() { var bp bustedPlayer if err := rows.Scan(&bp.PlayerID, &bp.BustOutAt); err != nil { return fmt.Errorf("ranking: scan busted: %w", err) } busted = append(busted, bp) } if err := rows.Err(); err != nil { return err } // Count total entries for finishing_position calculation var totalEntries int err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tournamentID, ).Scan(&totalEntries) if err != nil { return fmt.Errorf("ranking: count entries: %w", err) } // Update each busted player's bust_out_order and finishing_position for i, bp := range busted { bustOutOrder := i + 1 // 1-based, chronological finishingPosition := totalEntries - bustOutOrder + 1 _, err := r.db.ExecContext(ctx, `UPDATE tournament_players SET bust_out_order = ?, finishing_position = ?, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, bustOutOrder, finishingPosition, tournamentID, bp.PlayerID, ) if err != nil { return fmt.Errorf("ranking: update bust order for %s: %w", bp.PlayerID, err) } } // Broadcast updated rankings rankings, err := r.CalculateRankings(ctx, tournamentID) if err != nil { log.Printf("ranking: broadcast recalculated: %v", err) } else { r.broadcast(tournamentID, "rankings.updated", rankings) } return nil } // GetRankings returns current rankings for display. func (r *RankingEngine) GetRankings(ctx context.Context, tournamentID string) ([]PlayerRanking, error) { return r.CalculateRankings(ctx, tournamentID) } func (r *RankingEngine) broadcast(tournamentID, eventType string, data interface{}) { if r.hub == nil { return } payload, err := json.Marshal(data) if err != nil { log.Printf("ranking: broadcast marshal error: %v", err) return } r.hub.Broadcast(tournamentID, eventType, payload) }