Skip to content

Commit eb0768a

Browse files
committed
feat: implement target season history
1 parent 1d31af1 commit eb0768a

6 files changed

Lines changed: 236 additions & 7 deletions

File tree

app/modules/scouting/controllers/players_controller.rb

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -202,23 +202,38 @@ def perform_sync_from_riot
202202
mastery_data = riot_service.get_champion_mastery(puuid: @target.riot_puuid, region: region)
203203

204204
pool = extract_champion_pool(mastery_data)
205-
perf = @target.recent_performance || {}
205+
perf = PerformanceAggregator.new(riot_service: riot_service)
206+
.call(puuid: @target.riot_puuid, region: region) ||
207+
@target.recent_performance || {}
206208
tier = league_data[:solo_queue]&.dig(:tier) || @target.current_tier
209+
lp = league_data[:solo_queue]&.dig(:lp)
207210
strengths = derive_strengths(perf, pool, @target.role, tier)
208211
weaknesses = derive_weaknesses(perf, pool, @target.role, tier)
209212

213+
new_peak_tier, new_peak_rank = resolve_peak(
214+
current_tier: tier,
215+
current_lp: lp,
216+
stored_peak_tier: @target.peak_tier,
217+
stored_peak_rank: @target.peak_rank
218+
)
219+
210220
@target.update!(
211-
# riot_summoner_id is no longer returned by Riot API
212221
summoner_name: "#{account_data[:game_name]}##{account_data[:tag_line]}",
213-
current_tier: league_data[:solo_queue]&.dig(:tier),
222+
current_tier: tier,
214223
current_rank: league_data[:solo_queue]&.dig(:rank),
215-
current_lp: league_data[:solo_queue]&.dig(:lp),
224+
current_lp: lp,
225+
peak_tier: new_peak_tier,
226+
peak_rank: new_peak_rank,
216227
champion_pool: pool,
228+
recent_performance: perf,
217229
performance_trend: calculate_performance_trend(league_data),
218230
strengths: strengths,
219-
weaknesses: weaknesses
231+
weaknesses: weaknesses,
232+
last_api_sync_at: Time.current
220233
)
221234

235+
SeasonHistoryUpdater.call(target: @target, league_data: league_data)
236+
222237
watchlist = @target.scouting_watchlists.find_by(organization: current_organization)
223238
render_success(
224239
{ scouting_target: JSON.parse(ScoutingTargetSerializer.render(@target, watchlist: watchlist)) },
@@ -278,9 +293,11 @@ def apply_age_range_filter(targets)
278293
def apply_rank_range_filter(targets)
279294
min_lp = params[:lp_min].presence&.to_i
280295
max_lp = params[:lp_max].presence&.to_i
281-
return targets unless min_lp && max_lp
296+
return targets unless min_lp || max_lp
282297

283-
targets.where(current_lp: min_lp..max_lp)
298+
targets = targets.where('current_lp >= ?', min_lp) if min_lp
299+
targets = targets.where('current_lp <= ?', max_lp) if max_lp
300+
targets
284301
end
285302

286303
def apply_search_filter(targets)
@@ -380,6 +397,29 @@ def target_params
380397
)
381398
end
382399

400+
# Ordered list of tiers from lowest to highest for peak comparison.
401+
TIER_ORDER = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER].freeze
402+
403+
# Returns [peak_tier, peak_rank] — keeps the stored peak unless the current rank is provably higher.
404+
# Master+ has no divisions so LP is the tiebreaker; below Master, roman numeral rank I > II > III > IV.
405+
def resolve_peak(current_tier:, current_lp:, stored_peak_tier:, stored_peak_rank:)
406+
return [current_tier, nil] if stored_peak_tier.blank?
407+
408+
current_idx = TIER_ORDER.index(current_tier&.upcase) || 0
409+
stored_idx = TIER_ORDER.index(stored_peak_tier&.upcase) || 0
410+
411+
return [stored_peak_tier, stored_peak_rank] if current_idx < stored_idx
412+
413+
if current_idx == stored_idx
414+
# Same tier — for Master+ LP is the signal but we don't have stored peak LP here,
415+
# so leave peak unchanged (it was set by a prior sync at equal or higher LP)
416+
return [stored_peak_tier, stored_peak_rank]
417+
end
418+
419+
# current_idx > stored_idx — new tier is strictly higher
420+
[current_tier, nil]
421+
end
422+
383423
# Thresholds calibrated by tier. Mirrors RosterManagementService#tier_thresholds.
384424
# JSONB from DB returns string keys, so we use with_indifferent_access throughout.
385425
def tier_thresholds(tier)

app/modules/scouting/jobs/sync_scouting_target_job.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def perform(scouting_target_id, organization_id)
2222
sync_account_name!(target, riot_service)
2323
sync_league_entries!(target, riot_service)
2424
sync_mastery_data!(target, riot_service)
25+
sync_recent_performance!(target, riot_service)
2526

2627
target.update!(last_sync_at: Time.current)
2728
Rails.logger.info("Successfully synced scouting target #{target.id}")
@@ -111,6 +112,7 @@ def update_rank_info(target, league_data)
111112
end
112113

113114
target.update!(update_attributes) if update_attributes.present?
115+
SeasonHistoryUpdater.call(target: target, league_data: league_data)
114116
end
115117

116118
def update_champion_pool(target, mastery_data)
@@ -125,5 +127,11 @@ def update_champion_pool(target, mastery_data)
125127
def load_champion_id_map
126128
DataDragonService.new.champion_id_map
127129
end
130+
131+
def sync_recent_performance!(target, riot_service)
132+
perf = PerformanceAggregator.new(riot_service: riot_service)
133+
.call(puuid: target.riot_puuid, region: target.region)
134+
target.update!(recent_performance: perf) if perf
135+
end
128136
end
129137
end

app/modules/scouting/serializers/scouting_target_serializer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ScoutingTargetSerializer < Blueprinter::Base
2424
end
2525
fields :real_name, :avatar_url, :profile_icon_id
2626
fields :peak_tier, :peak_rank, :last_api_sync_at
27+
fields :season_history
2728

2829
# Computed fields
2930
field :status_text do |target|
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# frozen_string_literal: true
2+
3+
# Fetches recent match history for a scouting target and aggregates
4+
# per-champion and overall performance stats.
5+
#
6+
# Used by both SyncScoutingTargetJob (background) and the inline sync
7+
# action in Scouting::PlayersController (synchronous response).
8+
class PerformanceAggregator
9+
MATCH_COUNT = 20
10+
11+
def initialize(riot_service:)
12+
@riot = riot_service
13+
end
14+
15+
# Returns a hash ready to be stored in target.recent_performance.
16+
# Returns nil if the PUUID is missing or no match data is available.
17+
def call(puuid:, region:)
18+
return nil if puuid.blank?
19+
20+
match_ids = @riot.get_match_history(puuid: puuid, region: region, count: MATCH_COUNT)
21+
return nil if match_ids.empty?
22+
23+
stats = collect_stats(match_ids, puuid, region)
24+
return nil if stats.empty?
25+
26+
build_summary(stats)
27+
rescue RiotApiService::RiotApiError => e
28+
Rails.logger.warn("[PerformanceAggregator] Skipping match fetch: #{e.message}")
29+
nil
30+
end
31+
32+
private
33+
34+
def collect_stats(match_ids, puuid, region)
35+
match_ids.filter_map do |match_id|
36+
details = @riot.get_match_details(match_id: match_id, region: region)
37+
details[:participants].find { |p| p[:puuid] == puuid }
38+
rescue RiotApiService::RiotApiError => e
39+
Rails.logger.warn("[PerformanceAggregator] Could not fetch #{match_id}: #{e.message}")
40+
nil
41+
end
42+
end
43+
44+
def build_summary(stats)
45+
aggregate_overall(stats).merge(
46+
champion_pool_stats: aggregate_per_champion(stats),
47+
matches_analyzed: stats.size
48+
)
49+
end
50+
51+
def aggregate_overall(stats)
52+
totals = sum_stats(stats)
53+
wins = stats.count { |p| p[:win] }
54+
total = stats.size
55+
56+
overall_hash(totals, wins, total)
57+
end
58+
59+
def overall_hash(totals, wins, total) # rubocop:disable Metrics/AbcSize
60+
{
61+
games_played: total,
62+
win_rate: (wins.to_f / total * 100).round(1),
63+
avg_kda: kda_ratio(totals[:kills], totals[:deaths], totals[:assists], total).round(2),
64+
avg_kills: (totals[:kills].to_f / total).round(1),
65+
avg_deaths: (totals[:deaths].to_f / total).round(1),
66+
avg_assists: (totals[:assists].to_f / total).round(1),
67+
avg_vision_score: (totals[:vision].to_f / total).round(1),
68+
avg_cs_per_min: (totals[:cs].to_f / total).round(1)
69+
}
70+
end
71+
72+
def aggregate_per_champion(stats)
73+
stats.group_by { |p| p[:champion_name] }
74+
.map { |champion, games| champion_row(champion, games) }
75+
.sort_by { |c| -c[:games] }
76+
end
77+
78+
def champion_row(champion, games) # rubocop:disable Metrics/AbcSize
79+
totals = sum_stats(games)
80+
wins = games.count { |p| p[:win] }
81+
total = games.size
82+
83+
{
84+
champion: champion,
85+
games: total,
86+
wins: wins,
87+
winrate: (wins.to_f / total * 100).round(1),
88+
kda_ratio: kda_ratio(totals[:kills], totals[:deaths], totals[:assists], total).round(2),
89+
avg_kills: (totals[:kills].to_f / total).round(1),
90+
avg_deaths: (totals[:deaths].to_f / total).round(1),
91+
avg_assists: (totals[:assists].to_f / total).round(1),
92+
avg_cs_per_min: (totals[:cs].to_f / total).round(1)
93+
}
94+
end
95+
96+
def sum_stats(stats)
97+
{
98+
kills: stats.sum { |p| p[:kills].to_i },
99+
deaths: stats.sum { |p| p[:deaths].to_i },
100+
assists: stats.sum { |p| p[:assists].to_i },
101+
vision: stats.sum { |p| p[:vision_score].to_i },
102+
cs: stats.sum { |p| p[:minions_killed].to_i + p[:neutral_minions_killed].to_i }
103+
}
104+
end
105+
106+
def kda_ratio(kills, deaths, assists, total)
107+
avg_deaths = deaths.to_f / total
108+
return (kills + assists).to_f / total if avg_deaths.zero?
109+
110+
(kills + assists).to_f / deaths
111+
end
112+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
# Maintains a cumulative season history on a scouting target.
4+
#
5+
# Each call records the current season's ranked stats (wins, losses, tier, LP).
6+
# If an entry for the current season already exists it is updated in place.
7+
# Older entries are preserved so history accumulates across syncs.
8+
#
9+
# Season numbering follows Riot's convention: Season N = year - 2010
10+
# (2024=S14, 2025=S15, 2026=S16, …)
11+
class SeasonHistoryUpdater
12+
def self.call(target:, league_data:)
13+
new(target: target, league_data: league_data).call
14+
end
15+
16+
def initialize(target:, league_data:)
17+
@target = target
18+
@league_data = league_data
19+
end
20+
21+
def call
22+
solo = @league_data[:solo_queue]
23+
return unless solo.present?
24+
25+
entry = build_entry(solo)
26+
history = (@target.season_history || []).map(&:symbolize_keys)
27+
28+
existing_idx = history.find_index { |e| e[:season] == entry[:season] }
29+
if existing_idx
30+
history[existing_idx] = entry
31+
else
32+
history.unshift(entry)
33+
end
34+
35+
@target.update!(season_history: history)
36+
end
37+
38+
private
39+
40+
def build_entry(solo)
41+
wins = solo[:wins].to_i
42+
losses = solo[:losses].to_i
43+
total = wins + losses
44+
wr = total.positive? ? (wins.to_f / total * 100).round(1) : nil
45+
46+
{
47+
season: current_season_label,
48+
tier: solo[:tier],
49+
rank: solo[:rank],
50+
lp: solo[:lp].to_i,
51+
wins: wins,
52+
losses: losses,
53+
win_rate: wr,
54+
date: Time.current.to_date.iso8601
55+
}
56+
end
57+
58+
def current_season_label
59+
"S#{Time.current.year - 2010}"
60+
end
61+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class AddSeasonHistoryToScoutingTargets < ActiveRecord::Migration[7.2]
4+
def change
5+
add_column :scouting_targets, :season_history, :jsonb, default: []
6+
end
7+
end

0 commit comments

Comments
 (0)