Skip to content

Commit 0787b26

Browse files
feat: implement aditional players stats
1 parent 6c83fa1 commit 0787b26

10 files changed

Lines changed: 441 additions & 13 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module Analytics
4+
module Controllers
5+
# Ping Profile Analytics Controller
6+
#
7+
# Returns a player's communication profile derived from ping usage across matches.
8+
# Requires ping data to be present (populated from Riot Match v5 API, patch 12.10+).
9+
#
10+
# @example
11+
# GET /api/v1/analytics/players/:player_id/ping-profile
12+
# GET /api/v1/analytics/players/:player_id/ping-profile?games=30
13+
class PingProfileController < Api::V1::BaseController
14+
def show
15+
player = organization_scoped(Player).find(params[:player_id])
16+
games = [params.fetch(:games, 20).to_i, 50].min
17+
18+
profile = PingProfileService.new(player, matches_limit: games).calculate
19+
20+
render_success({
21+
player: PlayerSerializer.render_as_hash(player),
22+
ping_profile: profile
23+
})
24+
end
25+
end
26+
end
27+
end
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# frozen_string_literal: true
2+
3+
# Calculates a player's ping communication profile from recent match history.
4+
# Uses ping data stored in player_match_stats.pings (jsonb) to derive
5+
# behavioral metrics useful for coaching: map awareness, leadership, and communication style.
6+
class PingProfileService
7+
AWARENESS_KEYS = %w[enemy_missing danger enemy_vision].freeze
8+
LEADERSHIP_KEYS = %w[command on_my_way push hold].freeze
9+
DEFENSIVE_KEYS = %w[get_back retreat danger].freeze
10+
ALL_PING_KEYS = %w[
11+
all_in assist_me bait basic command danger
12+
enemy_missing enemy_vision get_back hold need_vision
13+
on_my_way push retreat vision_cleared
14+
].freeze
15+
16+
def initialize(player, matches_limit: 20)
17+
@player = player
18+
@matches_limit = matches_limit
19+
end
20+
21+
def calculate
22+
stats = fetch_stats_with_pings
23+
return empty_profile if stats.empty?
24+
25+
ping_totals = aggregate_ping_totals(stats)
26+
total = ping_totals.values.sum
27+
28+
{
29+
player_id: @player.id,
30+
games_analyzed: stats.size,
31+
total_pings: total,
32+
avg_pings_per_game: total.zero? ? 0 : (total.to_f / stats.size).round(1),
33+
breakdown: ping_totals,
34+
scores: calculate_scores(ping_totals, total),
35+
profile_label: determine_profile_label(ping_totals, total)
36+
}
37+
end
38+
39+
private
40+
41+
def fetch_stats_with_pings
42+
PlayerMatchStat
43+
.where(player: @player)
44+
.where("pings != '{}'::jsonb")
45+
.order(created_at: :desc)
46+
.limit(@matches_limit)
47+
end
48+
49+
def aggregate_ping_totals(stats)
50+
totals = ALL_PING_KEYS.each_with_object({}) { |k, h| h[k] = 0 }
51+
stats.each do |stat|
52+
next if stat.pings.blank?
53+
54+
ALL_PING_KEYS.each { |key| totals[key] += stat.pings[key].to_i }
55+
end
56+
totals
57+
end
58+
59+
def calculate_scores(ping_totals, total)
60+
return zeroed_scores if total.zero?
61+
62+
{
63+
awareness: score_for_keys(ping_totals, AWARENESS_KEYS, total),
64+
leadership: score_for_keys(ping_totals, LEADERSHIP_KEYS, total),
65+
defensive: score_for_keys(ping_totals, DEFENSIVE_KEYS, total)
66+
}
67+
end
68+
69+
def score_for_keys(ping_totals, keys, total)
70+
category_total = keys.sum { |k| ping_totals[k].to_i }
71+
(category_total.to_f / total * 100).round(1)
72+
end
73+
74+
def determine_profile_label(ping_totals, total)
75+
return 'unknown' if total.zero?
76+
77+
scores = calculate_scores(ping_totals, total)
78+
max_category = scores.max_by { |_, v| v }
79+
80+
case max_category[0]
81+
when :awareness then 'map_caller'
82+
when :leadership then 'shotcaller'
83+
when :defensive then 'defensive_anchor'
84+
else 'balanced'
85+
end
86+
end
87+
88+
def empty_profile
89+
{
90+
player_id: @player&.id,
91+
games_analyzed: 0,
92+
total_pings: 0,
93+
avg_pings_per_game: 0,
94+
breakdown: ALL_PING_KEYS.each_with_object({}) { |k, h| h[k] = 0 },
95+
scores: zeroed_scores,
96+
profile_label: 'unknown'
97+
}
98+
end
99+
100+
def zeroed_scores
101+
{ awareness: 0, leadership: 0, defensive: 0 }
102+
end
103+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
require 'csv'
4+
5+
module Matches
6+
module Controllers
7+
# Export Controller
8+
#
9+
# Exports match player stats as JSON or CSV.
10+
# Scoped to the current organization.
11+
#
12+
# @example
13+
# GET /api/v1/matches/:id/export -> JSON
14+
# GET /api/v1/matches/:id/export?format=csv -> CSV
15+
class ExportController < Api::V1::BaseController
16+
EXPORT_FIELDS = %w[
17+
player_name champion role kills deaths assists
18+
cs neutral_minions_killed cs_at_10
19+
gold_earned damage_dealt_total damage_taken
20+
damage_to_turrets turret_plates_destroyed
21+
objectives_stolen crowd_control_score total_time_dead
22+
vision_score wards_placed wards_destroyed
23+
damage_shielded_teammates healing_to_teammates
24+
spell_q_casts spell_w_casts spell_e_casts spell_r_casts
25+
double_kills triple_kills quadra_kills penta_kills
26+
performance_score
27+
].freeze
28+
29+
def show
30+
match = organization_scoped(Match).find(params[:id])
31+
stats = match.player_match_stats.includes(:player)
32+
33+
respond_to do |format|
34+
format.json { render_json_export(match, stats) }
35+
format.csv { render_csv_export(match, stats) }
36+
format.any { render_json_export(match, stats) }
37+
end
38+
end
39+
40+
private
41+
42+
def render_json_export(match, stats)
43+
render_success({
44+
match_id: match.id,
45+
riot_match_id: match.riot_match_id,
46+
game_start: match.game_start,
47+
patch_version: match.patch_version,
48+
players: stats.map { |s| build_row_hash(s) }
49+
})
50+
end
51+
52+
def render_csv_export(match, stats)
53+
csv_data = build_csv(stats)
54+
filename = "match_#{match.riot_match_id || match.id}_#{Date.current}.csv"
55+
56+
send_data csv_data,
57+
type: 'text/csv; charset=utf-8',
58+
disposition: "attachment; filename=\"#{filename}\""
59+
end
60+
61+
def build_csv(stats)
62+
CSV.generate(headers: true) do |csv|
63+
csv << EXPORT_FIELDS
64+
stats.each { |s| csv << build_row_array(s) }
65+
end
66+
end
67+
68+
def build_row_hash(stat)
69+
EXPORT_FIELDS.each_with_object({}) do |field, hash|
70+
hash[field] = field == 'player_name' ? stat.player&.summoner_name : stat.public_send(field)
71+
end
72+
end
73+
74+
def build_row_array(stat)
75+
EXPORT_FIELDS.map do |field|
76+
field == 'player_name' ? stat.player&.summoner_name : stat.public_send(field)
77+
end
78+
end
79+
end
80+
end
81+
end

app/modules/matches/jobs/sync_match_job.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def create_stat_for_participant(match, player, participant_data, team_totals)
155155
damage_dealt_total: participant_data[:total_damage_dealt],
156156
damage_taken: participant_data[:total_damage_taken],
157157
cs: cs_total,
158+
neutral_minions_killed: participant_data[:neutral_minions_killed],
158159
vision_score: participant_data[:vision_score],
159160
wards_placed: participant_data[:wards_placed],
160161
wards_destroyed: participant_data[:wards_killed],
@@ -169,7 +170,22 @@ def create_stat_for_participant(match, player, participant_data, team_totals)
169170
summoner_spell_1: participant_data[:summoner_spell_1],
170171
summoner_spell_2: participant_data[:summoner_spell_2],
171172
damage_share: damage_share,
172-
gold_share: gold_share
173+
gold_share: gold_share,
174+
objectives_stolen: participant_data[:objectives_stolen],
175+
crowd_control_score: participant_data[:crowd_control_score],
176+
total_time_dead: participant_data[:total_time_dead],
177+
damage_to_turrets: participant_data[:damage_to_turrets],
178+
damage_shielded_teammates: participant_data[:damage_shielded_teammates],
179+
healing_to_teammates: participant_data[:healing_to_teammates],
180+
spell_q_casts: participant_data[:spell_q_casts],
181+
spell_w_casts: participant_data[:spell_w_casts],
182+
spell_e_casts: participant_data[:spell_e_casts],
183+
spell_r_casts: participant_data[:spell_r_casts],
184+
summoner_spell_1_casts: participant_data[:summoner_spell_1_casts],
185+
summoner_spell_2_casts: participant_data[:summoner_spell_2_casts],
186+
cs_at_10: participant_data[:cs_at_10],
187+
turret_plates_destroyed: participant_data[:turret_plates_destroyed],
188+
pings: participant_data[:pings] || {}
173189
)
174190
end
175191

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# frozen_string_literal: true
2+
3+
require 'csv'
4+
5+
module Players
6+
module Controllers
7+
# Stats Export Controller
8+
#
9+
# Exports a player's match stats history as JSON or CSV.
10+
# Supports date range filtering.
11+
#
12+
# @example
13+
# GET /api/v1/players/:id/stats/export
14+
# GET /api/v1/players/:id/stats/export?format=csv&from=2026-01-01&to=2026-03-06
15+
class StatsExportController < Api::V1::BaseController
16+
EXPORT_FIELDS = %w[
17+
match_date patch_version opponent champion role
18+
kills deaths assists kda_display cs cs_at_10 cs_per_min
19+
neutral_minions_killed gold_earned gold_per_min
20+
damage_dealt_total damage_to_turrets turret_plates_destroyed
21+
objectives_stolen crowd_control_score total_time_dead
22+
vision_score wards_placed wards_destroyed
23+
damage_shielded_teammates healing_to_teammates
24+
spell_q_casts spell_w_casts spell_e_casts spell_r_casts
25+
double_kills triple_kills quadra_kills penta_kills
26+
performance_score result
27+
].freeze
28+
29+
def show
30+
player = organization_scoped(Player).find(params[:player_id])
31+
stats = filtered_stats(player)
32+
33+
respond_to do |format|
34+
format.json { render_json_export(player, stats) }
35+
format.csv { render_csv_export(player, stats) }
36+
format.any { render_json_export(player, stats) }
37+
end
38+
end
39+
40+
private
41+
42+
def filtered_stats(player)
43+
scope = PlayerMatchStat.where(player: player)
44+
.joins(:match)
45+
.includes(:match)
46+
.order('matches.game_start DESC')
47+
scope = scope.where('matches.game_start >= ?', Date.parse(params[:from])) if params[:from].present?
48+
scope = scope.where('matches.game_start <= ?', Date.parse(params[:to]).end_of_day) if params[:to].present?
49+
scope
50+
end
51+
52+
def render_json_export(player, stats)
53+
render_success({
54+
player: PlayerSerializer.render_as_hash(player),
55+
total_games: stats.count,
56+
stats: stats.map { |s| build_row_hash(s) }
57+
})
58+
end
59+
60+
def render_csv_export(player, stats)
61+
csv_data = build_csv(stats)
62+
filename = "#{player.summoner_name}_stats_#{Date.current}.csv"
63+
64+
send_data csv_data,
65+
type: 'text/csv; charset=utf-8',
66+
disposition: "attachment; filename=\"#{filename}\""
67+
end
68+
69+
def build_csv(stats)
70+
CSV.generate(headers: true) do |csv|
71+
csv << EXPORT_FIELDS
72+
stats.each { |s| csv << build_row_array(s) }
73+
end
74+
end
75+
76+
def build_row_hash(stat)
77+
EXPORT_FIELDS.each_with_object({}) do |field, hash|
78+
hash[field] = export_field_value(stat, field)
79+
end
80+
end
81+
82+
def build_row_array(stat)
83+
EXPORT_FIELDS.map { |field| export_field_value(stat, field) }
84+
end
85+
86+
def export_field_value(stat, field)
87+
case field
88+
when 'match_date' then stat.match&.game_start&.strftime('%Y-%m-%d')
89+
when 'patch_version' then stat.match&.patch_version
90+
when 'opponent' then stat.match&.opponent_name
91+
when 'result' then stat.match&.victory? ? 'W' : 'L'
92+
when 'kda_display' then stat.kda_display
93+
when 'cs_per_min' then stat.cs_per_min&.round(2)
94+
when 'gold_per_min' then stat.gold_per_min&.round(0)
95+
else stat.public_send(field)
96+
end
97+
end
98+
end
99+
end
100+
end

app/modules/players/models/player_match_stat.rb

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -141,28 +141,33 @@ def calculate_performance_score
141141

142142
score = 0
143143

144-
# KDA component (40 points max)
145-
kda = kda_ratio
146-
score += [kda * 10, 40].min
144+
# KDA component (35 points max)
145+
score += [kda_ratio * 8.75, 35].min
147146

148147
# CS component (20 points max)
149-
cs_score = (cs_per_min || 0) * 2.5
150-
score += [cs_score, 20].min
148+
score += [(cs_per_min || 0) * 2.5, 20].min
151149

152-
# Damage component (20 points max)
153-
damage_score = (damage_share || 0) * 100 * 0.8
154-
score += [damage_score, 20].min
150+
# Damage component (15 points max)
151+
score += [(damage_share || 0) * 100 * 0.6, 15].min
155152

156153
# Vision component (10 points max)
157-
vision_score_normalized = vision_score.to_f / 100
158-
score += [vision_score_normalized * 10, 10].min
154+
score += [vision_score.to_f / 100 * 10, 10].min
155+
156+
# CC + objectives bonus (10 points max)
157+
score += cc_and_objectives_bonus
159158

160159
# Victory bonus (10 points max)
161160
score += 10 if match.victory?
162161

163162
[score, 100].min.round(2)
164163
end
165164

165+
def cc_and_objectives_bonus
166+
cc_bonus = crowd_control_score.present? ? [crowd_control_score.to_f / 100, 5].min : 0
167+
obj_bonus = objectives_stolen.to_i > 0 ? 5 : 0
168+
cc_bonus + obj_bonus
169+
end
170+
166171
def update_champion_pool
167172
pool = player.champion_pools.find_or_initialize_by(champion: champion)
168173

0 commit comments

Comments
 (0)