Skip to content

Commit ac72fa2

Browse files
committed
feat: implement tier thresholds
1 parent 915bab3 commit ac72fa2

5 files changed

Lines changed: 196 additions & 41 deletions

File tree

app/modules/players/models/player.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Player < ApplicationRecord
4040
# Associations
4141
# optional: true — self-registered free agents (ArenaBR) can exist without an org
4242
belongs_to :organization, optional: true
43+
belongs_to :scouted_from, class_name: 'ScoutingTarget', optional: true
4344
has_many :player_match_stats, dependent: :destroy
4445
has_many :matches, through: :player_match_stats
4546
has_many :champion_pools, dependent: :destroy

app/modules/players/services/roster_management_service.rb

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,15 @@ def self.hire_from_scouting(scouting_target:, organization:, contract_start:, co
8484
watchlist = scouting_target.scouting_watchlists.find_by(organization: organization)
8585
watchlist&.destroy
8686

87-
# Clean up the global target if no other org is watching it
88-
scouting_target.destroy if scouting_target.scouting_watchlists.none?
87+
# Mark the global target as signed (never destroy — it is permanent scouting history)
88+
scouting_target.update_columns(status: 'signed') if scouting_target.scouting_watchlists.none?
89+
90+
# Link the player back to the scouting record and store a snapshot of the data
91+
# that informed the hiring decision, so coaches can audit it later.
92+
player.update_columns(
93+
scouted_from_id: scouting_target.id,
94+
scouting_data_snapshot: RosterManagementService.build_scouting_snapshot(scouting_target)
95+
)
8996

9097
# Log the action
9198
log_roster_addition(player, scouting_target, current_user)
@@ -197,19 +204,23 @@ def find_or_build_scouting_target
197204
def assign_scouting_target_attributes(target)
198205
recent_perf = calculate_recent_performance(player)
199206
recent_perf[:champion_pool_stats] = calculate_champion_stats(player)
207+
pool = calculate_champion_pool_from_stats(player)
208+
tier = player.solo_queue_tier
200209

201210
target.assign_attributes(
202211
summoner_name: player.summoner_name,
203212
region: normalize_region(player.region),
204213
riot_puuid: player.riot_puuid,
205214
role: player.role,
206-
current_tier: player.solo_queue_tier,
215+
current_tier: tier,
207216
current_rank: player.solo_queue_rank,
208217
current_lp: player.solo_queue_lp,
209-
champion_pool: calculate_champion_pool_from_stats(player),
218+
champion_pool: pool,
210219
recent_performance: recent_perf,
211220
performance_trend: calculate_performance_trend(player),
212221
playstyle: extract_playstyle_from_notes(player.notes),
222+
strengths: derive_strengths(recent_perf, pool, player.role, tier),
223+
weaknesses: derive_weaknesses(recent_perf, pool, player.role, tier),
213224
twitter_handle: player.twitter_handle,
214225
status: 'free_agent',
215226
real_name: player.real_name,
@@ -396,6 +407,63 @@ def last_game_date_for(stats)
396407
match.game_start&.to_date
397408
end
398409

410+
# Returns stat thresholds adjusted to the player's ranked tier.
411+
# High elo players are held to a stricter standard — what is average
412+
# at Platinum is a weakness at Challenger.
413+
#
414+
# @param tier [String, nil] e.g. "CHALLENGER", "DIAMOND", "GOLD"
415+
# @return [Hash] threshold values for strengths and weaknesses
416+
def tier_thresholds(tier)
417+
case tier&.upcase
418+
when 'CHALLENGER', 'GRANDMASTER', 'MASTER'
419+
{ wr_strength: 53, wr_weakness: 49, kda_strength: 4.5, kda_weakness: 3.0,
420+
cs_strength: 9.0, cs_weakness: 7.5, vision_strength: 45, vision_weakness: 28 }
421+
when 'DIAMOND', 'EMERALD'
422+
{ wr_strength: 54, wr_weakness: 47, kda_strength: 4.0, kda_weakness: 2.5,
423+
cs_strength: 8.5, cs_weakness: 7.0, vision_strength: 42, vision_weakness: 24 }
424+
else
425+
{ wr_strength: 55, wr_weakness: 45, kda_strength: 3.5, kda_weakness: 2.0,
426+
cs_strength: 8.0, cs_weakness: 6.0, vision_strength: 40, vision_weakness: 20 }
427+
end
428+
end
429+
430+
# Derive positive traits from performance stats, calibrated to the player's tier.
431+
def derive_strengths(perf, pool, role, tier = nil)
432+
return [] if perf.blank?
433+
434+
t = tier_thresholds(tier)
435+
strengths = []
436+
strengths << 'Consistency' if perf[:win_rate].to_f >= t[:wr_strength]
437+
strengths << 'Mechanical skill' if perf[:avg_kda].to_f >= t[:kda_strength]
438+
strengths << 'CS discipline' if non_support?(role) && perf[:avg_cs_per_min].to_f >= t[:cs_strength]
439+
strengths << 'Map awareness' if vision_role?(role) && perf[:avg_vision_score].to_f >= t[:vision_strength]
440+
strengths << 'Team fighting' if perf[:avg_kill_participation].to_f >= 65.0
441+
strengths << 'Champion pool depth' if pool.size >= 6
442+
strengths
443+
end
444+
445+
# Derive areas for improvement, calibrated to the player's tier.
446+
def derive_weaknesses(perf, pool, role, tier = nil)
447+
return [] if perf.blank?
448+
449+
t = tier_thresholds(tier)
450+
weaknesses = []
451+
weaknesses << 'Inconsistent performance' if perf[:games_played].to_i >= 10 && perf[:win_rate].to_f < t[:wr_weakness]
452+
weaknesses << 'Death management' if perf[:avg_kda].to_f.positive? && perf[:avg_kda].to_f < t[:kda_weakness]
453+
weaknesses << 'CS discipline' if non_support?(role) && perf[:avg_cs_per_min].to_f.positive? && perf[:avg_cs_per_min].to_f < t[:cs_weakness]
454+
weaknesses << 'Vision control' if vision_role?(role) && perf[:avg_vision_score].to_f.positive? && perf[:avg_vision_score].to_f < t[:vision_weakness]
455+
weaknesses << 'Limited champion pool' if pool.size < 3
456+
weaknesses
457+
end
458+
459+
def non_support?(role)
460+
role.to_s != 'support'
461+
end
462+
463+
def vision_role?(role)
464+
%w[support jungle].include?(role.to_s)
465+
end
466+
399467
# Extract playstyle from player notes
400468
def extract_playstyle_from_notes(notes)
401469
return nil if notes.blank?
@@ -501,5 +569,28 @@ def self.addition_new_values(player, scouting_target)
501569
}
502570
end
503571

504-
private_class_method :find_or_restore_player, :log_roster_addition, :addition_old_values, :addition_new_values
572+
# Snapshot of the scouting target at the moment of hiring.
573+
# Stored in players.scouting_data_snapshot so the record is immutable even if
574+
# the ScoutingTarget is later re-synced or its status changes.
575+
def self.build_scouting_snapshot(target)
576+
{
577+
summoner_name: target.summoner_name,
578+
role: target.role,
579+
region: target.region,
580+
current_tier: target.current_tier,
581+
current_rank: target.current_rank,
582+
current_lp: target.current_lp,
583+
champion_pool: target.champion_pool,
584+
recent_performance: target.recent_performance,
585+
performance_trend: target.performance_trend,
586+
strengths: target.strengths,
587+
weaknesses: target.weaknesses,
588+
playstyle: target.playstyle,
589+
scouting_score: target.scouting_score,
590+
snapshotted_at: Time.current.iso8601
591+
}
592+
end
593+
594+
private_class_method :find_or_restore_player, :log_roster_addition, :addition_old_values,
595+
:addition_new_values, :build_scouting_snapshot
505596
end

app/modules/riot_integration/services/data_dragon_service.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ def fetch_champion_data
9494
champion_map = {}
9595
data['data'].each_value do |champion|
9696
champion_id = champion['key'].to_i
97-
champion_name = champion['id'] # This is the champion name like "Aatrox"
98-
champion_map[champion_id] = champion_name
97+
champion_map[champion_id] = champion['name'] # display name: "Wukong", "Lee Sin", etc.
9998
end
10099

101100
champion_map

app/modules/scouting/controllers/players_controller.rb

Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -201,14 +201,22 @@ def perform_sync_from_riot
201201
league_data = riot_service.get_league_entries_by_puuid(puuid: @target.riot_puuid, region: region)
202202
mastery_data = riot_service.get_champion_mastery(puuid: @target.riot_puuid, region: region)
203203

204+
pool = extract_champion_pool(mastery_data)
205+
perf = @target.recent_performance || {}
206+
tier = league_data[:solo_queue]&.dig(:tier) || @target.current_tier
207+
strengths = derive_strengths(perf, pool, @target.role, tier)
208+
weaknesses = derive_weaknesses(perf, pool, @target.role, tier)
209+
204210
@target.update!(
205211
# riot_summoner_id is no longer returned by Riot API
206212
summoner_name: "#{account_data[:game_name]}##{account_data[:tag_line]}",
207213
current_tier: league_data[:solo_queue]&.dig(:tier),
208214
current_rank: league_data[:solo_queue]&.dig(:rank),
209215
current_lp: league_data[:solo_queue]&.dig(:lp),
210-
champion_pool: extract_champion_pool(mastery_data),
211-
performance_trend: calculate_performance_trend(league_data)
216+
champion_pool: pool,
217+
performance_trend: calculate_performance_trend(league_data),
218+
strengths: strengths,
219+
weaknesses: weaknesses
212220
)
213221

214222
watchlist = @target.scouting_watchlists.find_by(organization: current_organization)
@@ -240,7 +248,11 @@ def apply_filters(targets)
240248
end
241249

242250
def apply_basic_filters(targets)
243-
targets = targets.by_role(params[:role]) if params[:role].present?
251+
# role param is comma-separated lowercase: "mid,top" → ["mid", "top"]
252+
if params[:role].present?
253+
roles = params[:role].split(',').map(&:strip).reject(&:blank?)
254+
targets = targets.by_role(roles) if roles.any?
255+
end
244256
targets = targets.by_status(params[:status]) if params[:status].present?
245257
targets = targets.by_region(params[:region]) if params[:region].present?
246258

@@ -256,18 +268,19 @@ def apply_basic_filters(targets)
256268
end
257269

258270
def apply_age_range_filter(targets)
259-
return targets unless params[:age_range].present? && params[:age_range].is_a?(Array)
271+
min_age = params[:age_min].presence&.to_i
272+
max_age = params[:age_max].presence&.to_i
273+
return targets unless min_age && max_age
260274

261-
min_age, max_age = params[:age_range]
262-
min_age && max_age ? targets.where(age: min_age..max_age) : targets
275+
targets.where(age: min_age..max_age)
263276
end
264277

265278
def apply_rank_range_filter(targets)
266-
return targets unless params[:rank_range].present?
279+
min_lp = params[:lp_min].presence&.to_i
280+
max_lp = params[:lp_max].presence&.to_i
281+
return targets unless min_lp && max_lp
267282

268-
# Rank range filtering by LP
269-
min_lp, max_lp = params[:rank_range]
270-
min_lp && max_lp ? targets.where(current_lp: min_lp..max_lp) : targets
283+
targets.where(current_lp: min_lp..max_lp)
271284
end
272285

273286
def apply_search_filter(targets)
@@ -367,33 +380,69 @@ def target_params
367380
)
368381
end
369382

370-
# Extract top champions from mastery data
383+
# Thresholds calibrated by tier. Mirrors RosterManagementService#tier_thresholds.
384+
# JSONB from DB returns string keys, so we use with_indifferent_access throughout.
385+
def tier_thresholds(tier)
386+
case tier&.upcase
387+
when 'CHALLENGER', 'GRANDMASTER', 'MASTER'
388+
{ wr_strength: 53, wr_weakness: 49, kda_strength: 4.5, kda_weakness: 3.0,
389+
cs_strength: 9.0, cs_weakness: 7.5, vision_strength: 45, vision_weakness: 28 }
390+
when 'DIAMOND', 'EMERALD'
391+
{ wr_strength: 54, wr_weakness: 47, kda_strength: 4.0, kda_weakness: 2.5,
392+
cs_strength: 8.5, cs_weakness: 7.0, vision_strength: 42, vision_weakness: 24 }
393+
else
394+
{ wr_strength: 55, wr_weakness: 45, kda_strength: 3.5, kda_weakness: 2.0,
395+
cs_strength: 8.0, cs_weakness: 6.0, vision_strength: 40, vision_weakness: 20 }
396+
end
397+
end
398+
399+
def derive_strengths(perf, pool, role, tier = nil)
400+
return [] if perf.blank?
401+
402+
p = perf.with_indifferent_access
403+
t = tier_thresholds(tier)
404+
strengths = []
405+
strengths << 'Consistency' if p[:win_rate].to_f >= t[:wr_strength]
406+
strengths << 'Mechanical skill' if p[:avg_kda].to_f >= t[:kda_strength]
407+
strengths << 'CS discipline' if non_support?(role) && p[:avg_cs_per_min].to_f >= t[:cs_strength]
408+
strengths << 'Map awareness' if vision_role?(role) && p[:avg_vision_score].to_f >= t[:vision_strength]
409+
strengths << 'Team fighting' if p[:avg_kill_participation].to_f >= 65.0
410+
strengths << 'Champion pool depth' if pool.size >= 6
411+
strengths
412+
end
413+
414+
def derive_weaknesses(perf, pool, role, tier = nil)
415+
return [] if perf.blank?
416+
417+
p = perf.with_indifferent_access
418+
t = tier_thresholds(tier)
419+
weaknesses = []
420+
weaknesses << 'Inconsistent performance' if p[:games_played].to_i >= 10 && p[:win_rate].to_f < t[:wr_weakness]
421+
weaknesses << 'Death management' if p[:avg_kda].to_f.positive? && p[:avg_kda].to_f < t[:kda_weakness]
422+
weaknesses << 'CS discipline' if non_support?(role) && p[:avg_cs_per_min].to_f.positive? && p[:avg_cs_per_min].to_f < t[:cs_weakness]
423+
weaknesses << 'Vision control' if vision_role?(role) && p[:avg_vision_score].to_f.positive? && p[:avg_vision_score].to_f < t[:vision_weakness]
424+
weaknesses << 'Limited champion pool' if pool.size < 3
425+
weaknesses
426+
end
427+
428+
def non_support?(role)
429+
role.to_s != 'support'
430+
end
431+
432+
def vision_role?(role)
433+
%w[support jungle].include?(role.to_s)
434+
end
435+
436+
# Extract top champions from mastery data using DataDragonService for full champion coverage.
437+
# Falls back to "Champion_<id>" only when Data Dragon is unreachable.
371438
def extract_champion_pool(mastery_data)
372439
return [] if mastery_data.blank?
373440

374-
# Get top 10 champions by mastery points
375-
mastery_data.first(10).map do |mastery|
376-
champion_id_to_name(mastery[:champion_id])
377-
end.compact
378-
end
379-
380-
# Simple champion ID to name mapping (top champions)
381-
def champion_id_to_name(champion_id)
382-
# This is a simplified mapping - in production you'd want a complete mapping
383-
# or fetch from Data Dragon API
384-
champion_map = {
385-
1 => 'Annie', 2 => 'Olaf', 3 => 'Galio', 4 => 'Twisted Fate',
386-
5 => 'Xin Zhao', 6 => 'Urgot', 7 => 'LeBlanc', 8 => 'Vladimir',
387-
9 => 'Fiddlesticks', 10 => 'Kayle', 11 => 'Master Yi', 12 => 'Alistar',
388-
13 => 'Ryze', 14 => 'Sion', 15 => 'Sivir', 16 => 'Soraka',
389-
17 => 'Teemo', 18 => 'Tristana', 19 => 'Warwick', 20 => 'Nunu',
390-
21 => 'Miss Fortune', 22 => 'Ashe', 23 => 'Tryndamere', 24 => 'Jax',
391-
25 => 'Morgana', 26 => 'Zilean', 27 => 'Singed', 28 => 'Evelynn',
392-
29 => 'Twitch', 30 => 'Karthus', 31 => 'Cho\'Gath', 32 => 'Amumu',
393-
33 => 'Rammus', 34 => 'Anivia', 35 => 'Shaco', 36 => 'Dr. Mundo'
394-
# Add more as needed or fetch from Data Dragon
395-
}
396-
champion_map[champion_id] || "Champion_#{champion_id}"
441+
id_map = DataDragonService.new.champion_id_map
442+
443+
mastery_data.first(10).filter_map do |mastery|
444+
id_map[mastery[:champion_id].to_i]
445+
end
397446
end
398447

399448
# Calculate performance trend based on win/loss ratio
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
# Preserves the link between a hired player and the ScoutingTarget they came from.
4+
# Also stores a snapshot of the scouting data at the time of hiring so that even if
5+
# the ScoutingTarget is later updated or the status changes, the coach can always see
6+
# what data drove the hiring decision.
7+
class AddScoutingOriginToPlayers < ActiveRecord::Migration[7.1]
8+
def change
9+
add_column :players, :scouted_from_id, :uuid, null: true
10+
add_column :players, :scouting_data_snapshot, :jsonb, null: false, default: {}
11+
12+
add_index :players, :scouted_from_id
13+
add_foreign_key :players, :scouting_targets, column: :scouted_from_id, on_delete: :nullify
14+
end
15+
end

0 commit comments

Comments
 (0)