|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module Api |
| 4 | + module V1 |
| 5 | + module Analytics |
| 6 | + # Competitive Player Analytics Controller |
| 7 | + # |
| 8 | + # Aggregates individual performance stats from professional/competitive matches |
| 9 | + # stored in CompetitiveMatch#our_picks. Useful for analysing how a specific |
| 10 | + # player performed across tournaments without querying Elasticsearch. |
| 11 | + # |
| 12 | + # Picks are extended with the full stat set after Fix-2; older records may |
| 13 | + # have only the 7-field format — run `rake competitive:backfill_picks` first. |
| 14 | + # |
| 15 | + # @example |
| 16 | + # GET /api/v1/analytics/competitive/player-stats?summoner_name=brTT&league=CBLOL&year=2025 |
| 17 | + # |
| 18 | + # Query parameters (all optional): |
| 19 | + # summoner_name — exact match against picks' summoner_name (required) |
| 20 | + # league — filter by tournament_name (e.g. "CBLOL") |
| 21 | + # year — filter by match_date year (integer) |
| 22 | + # include_opponent — also search opponent_picks (default: false) |
| 23 | + # |
| 24 | + class CompetitivePlayerController < Api::V1::BaseController |
| 25 | + def player_stats |
| 26 | + summoner_name = params[:summoner_name].to_s.strip |
| 27 | + return render_error('summoner_name is required', :bad_request) if summoner_name.blank? |
| 28 | + |
| 29 | + matches = scoped_matches(summoner_name) |
| 30 | + return render_success(empty_response(summoner_name)) if matches.empty? |
| 31 | + |
| 32 | + player_picks = extract_picks(matches, summoner_name) |
| 33 | + return render_success(empty_response(summoner_name)) if player_picks.empty? |
| 34 | + |
| 35 | + render_success({ |
| 36 | + summoner_name: summoner_name, |
| 37 | + games_played: player_picks.size, |
| 38 | + overall: build_overall_stats(player_picks), |
| 39 | + by_tournament: build_by_tournament(matches, summoner_name), |
| 40 | + champion_pool: build_champion_pool(player_picks), |
| 41 | + recent_games: build_recent_games(player_picks, matches) |
| 42 | + }) |
| 43 | + end |
| 44 | + |
| 45 | + private |
| 46 | + |
| 47 | + # --------------------------------------------------------------------------- |
| 48 | + # Data retrieval |
| 49 | + # --------------------------------------------------------------------------- |
| 50 | + |
| 51 | + def scoped_matches(summoner_name) |
| 52 | + # JSONB containment: find matches where our_picks (or opponent_picks) |
| 53 | + # contains at least one element with the given summoner_name. |
| 54 | + pick_filter = [{ 'summoner_name' => summoner_name }].to_json |
| 55 | + |
| 56 | + scope = CompetitiveMatch |
| 57 | + .where(organization: current_organization) |
| 58 | + .where('our_picks @> ?', pick_filter) |
| 59 | + |
| 60 | + if params[:include_opponent].to_s == 'true' |
| 61 | + scope = scope.or( |
| 62 | + CompetitiveMatch |
| 63 | + .where(organization: current_organization) |
| 64 | + .where('opponent_picks @> ?', pick_filter) |
| 65 | + ) |
| 66 | + end |
| 67 | + |
| 68 | + scope = scope.where(tournament_name: params[:league]) if params[:league].present? |
| 69 | + scope = scope.where('EXTRACT(YEAR FROM match_date) = ?', params[:year].to_i) if params[:year].present? |
| 70 | + |
| 71 | + scope.order(match_date: :desc) |
| 72 | + end |
| 73 | + |
| 74 | + # Flatten picks from all matches into a single array, annotating each |
| 75 | + # pick with match metadata for context (date, victory, tournament). |
| 76 | + def extract_picks(matches, summoner_name) |
| 77 | + matches.flat_map do |m| |
| 78 | + pick = find_pick_in_match(m, summoner_name) |
| 79 | + next unless pick |
| 80 | + |
| 81 | + pick.merge( |
| 82 | + '_match_id' => m.id, |
| 83 | + '_match_date' => m.match_date, |
| 84 | + '_victory' => m.victory, |
| 85 | + '_tournament_name' => m.tournament_name, |
| 86 | + '_tournament_stage' => m.tournament_stage |
| 87 | + ) |
| 88 | + end.compact |
| 89 | + end |
| 90 | + |
| 91 | + def find_pick_in_match(match, summoner_name) |
| 92 | + match.our_picks.find { |p| p['summoner_name']&.casecmp?(summoner_name) } || |
| 93 | + (if params[:include_opponent].to_s == 'true' |
| 94 | + match.opponent_picks.find do |p| |
| 95 | + p['summoner_name']&.casecmp?(summoner_name) |
| 96 | + end |
| 97 | + end) |
| 98 | + end |
| 99 | + |
| 100 | + # --------------------------------------------------------------------------- |
| 101 | + # Aggregation helpers |
| 102 | + # --------------------------------------------------------------------------- |
| 103 | + |
| 104 | + def build_overall_stats(picks) |
| 105 | + games = picks.size |
| 106 | + wins = picks.count { |p| p['win'] || p['_victory'] } |
| 107 | + |
| 108 | + { |
| 109 | + games: games, |
| 110 | + wins: wins, |
| 111 | + win_rate: pct(wins, games), |
| 112 | + avg_kills: avg(picks, 'kills'), |
| 113 | + avg_deaths: avg(picks, 'deaths'), |
| 114 | + avg_assists: avg(picks, 'assists'), |
| 115 | + avg_kda: compute_kda(picks), |
| 116 | + avg_cs: avg(picks, 'cs'), |
| 117 | + avg_gold: avg(picks, 'gold'), |
| 118 | + avg_damage: avg(picks, 'damage'), |
| 119 | + avg_damage_taken: avg(picks, 'damage_taken'), |
| 120 | + avg_vision_score: avg(picks, 'vision_score'), |
| 121 | + avg_wards_placed: avg(picks, 'wards_placed'), |
| 122 | + avg_wards_killed: avg(picks, 'wards_killed'), |
| 123 | + avg_cs_per_min: avg(picks, 'cs_per_min', round: 2), |
| 124 | + avg_gold_per_min: avg(picks, 'gold_per_min', round: 2), |
| 125 | + avg_damage_per_min: avg(picks, 'damage_per_min', round: 2) |
| 126 | + } |
| 127 | + end |
| 128 | + |
| 129 | + def build_by_tournament(matches, summoner_name) |
| 130 | + matches.group_by { |m| [m.tournament_name, m.tournament_stage] }.map do |(name, stage), group| |
| 131 | + picks = extract_picks(group, summoner_name) |
| 132 | + next if picks.empty? |
| 133 | + |
| 134 | + games = picks.size |
| 135 | + wins = picks.count { |p| p['win'] || p['_victory'] } |
| 136 | + |
| 137 | + { |
| 138 | + tournament_name: name, |
| 139 | + tournament_stage: stage, |
| 140 | + games: games, |
| 141 | + wins: wins, |
| 142 | + win_rate: pct(wins, games), |
| 143 | + avg_kills: avg(picks, 'kills'), |
| 144 | + avg_deaths: avg(picks, 'deaths'), |
| 145 | + avg_assists: avg(picks, 'assists'), |
| 146 | + avg_kda: compute_kda(picks), |
| 147 | + avg_cs: avg(picks, 'cs'), |
| 148 | + avg_gold: avg(picks, 'gold'), |
| 149 | + avg_damage: avg(picks, 'damage'), |
| 150 | + champion_pool: build_champion_pool(picks) |
| 151 | + } |
| 152 | + end.compact |
| 153 | + end |
| 154 | + |
| 155 | + def build_champion_pool(picks) |
| 156 | + picks |
| 157 | + .group_by { |p| p['champion'] } |
| 158 | + .map do |champion, champ_picks| |
| 159 | + games = champ_picks.size |
| 160 | + wins = champ_picks.count { |p| p['win'] || p['_victory'] } |
| 161 | + |
| 162 | + { |
| 163 | + champion: champion, |
| 164 | + games: games, |
| 165 | + wins: wins, |
| 166 | + win_rate: pct(wins, games), |
| 167 | + avg_kills: avg(champ_picks, 'kills'), |
| 168 | + avg_deaths: avg(champ_picks, 'deaths'), |
| 169 | + avg_assists: avg(champ_picks, 'assists'), |
| 170 | + avg_kda: compute_kda(champ_picks), |
| 171 | + avg_cs: avg(champ_picks, 'cs'), |
| 172 | + avg_damage: avg(champ_picks, 'damage') |
| 173 | + } |
| 174 | + end |
| 175 | + .sort_by { |c| -c[:games] } |
| 176 | + end |
| 177 | + |
| 178 | + def build_recent_games(picks, matches) |
| 179 | + match_map = matches.index_by(&:id) |
| 180 | + |
| 181 | + picks.first(20).map do |pick| |
| 182 | + m = match_map[pick['_match_id']] |
| 183 | + { |
| 184 | + match_id: pick['_match_id'], |
| 185 | + date: pick['_match_date'], |
| 186 | + tournament: pick['_tournament_name'], |
| 187 | + stage: pick['_tournament_stage'], |
| 188 | + champion: pick['champion'], |
| 189 | + role: pick['role'], |
| 190 | + kills: pick['kills'], |
| 191 | + deaths: pick['deaths'], |
| 192 | + assists: pick['assists'], |
| 193 | + cs: pick['cs'], |
| 194 | + gold: pick['gold'], |
| 195 | + damage: pick['damage'], |
| 196 | + vision_score: pick['vision_score'], |
| 197 | + items: pick['items'], |
| 198 | + victory: pick['win'] || pick['_victory'], |
| 199 | + our_team: m&.our_team_name, |
| 200 | + opponent_team: m&.opponent_team_name |
| 201 | + } |
| 202 | + end |
| 203 | + end |
| 204 | + |
| 205 | + # --------------------------------------------------------------------------- |
| 206 | + # Math helpers |
| 207 | + # --------------------------------------------------------------------------- |
| 208 | + |
| 209 | + def avg(picks, key, round: 1) |
| 210 | + values = picks.map { |p| p[key] }.compact |
| 211 | + return nil if values.empty? |
| 212 | + |
| 213 | + (values.sum.to_f / values.size).round(round) |
| 214 | + end |
| 215 | + |
| 216 | + def pct(numerator, denominator) |
| 217 | + return 0.0 if denominator.zero? |
| 218 | + |
| 219 | + ((numerator.to_f / denominator) * 100).round(1) |
| 220 | + end |
| 221 | + |
| 222 | + def compute_kda(picks) |
| 223 | + total_k = picks.sum { |p| p['kills'].to_f } |
| 224 | + total_d = picks.sum { |p| p['deaths'].to_f } |
| 225 | + total_a = picks.sum { |p| p['assists'].to_f } |
| 226 | + return nil if total_d.zero? && total_k.zero? |
| 227 | + |
| 228 | + denominator = total_d.zero? ? 1.0 : total_d |
| 229 | + ((total_k + total_a) / denominator).round(2) |
| 230 | + end |
| 231 | + |
| 232 | + def empty_response(summoner_name) |
| 233 | + { |
| 234 | + summoner_name: summoner_name, |
| 235 | + games_played: 0, |
| 236 | + overall: nil, |
| 237 | + by_tournament: [], |
| 238 | + champion_pool: [], |
| 239 | + recent_games: [], |
| 240 | + message: "No competitive matches found for '#{summoner_name}'" |
| 241 | + } |
| 242 | + end |
| 243 | + end |
| 244 | + end |
| 245 | + end |
| 246 | +end |
0 commit comments