Skip to content

Commit d078bea

Browse files
committed
feat: improve competitive dataset module
1 parent 7357639 commit d078bea

11 files changed

Lines changed: 648 additions & 18 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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

Comments
 (0)