Statistics
Profile statistical formulas — LeafStats, ProfileLeafStats, ChampionLeafStats, powerCurve and streaming ProfileAnalyzer.
Statistics — formulas
Sources:
src/main/java/com/safjnest/lol/model/statistics/shared/LeafStats.java·ProfileLeafStats.java·ChampionLeafStats.java·ProfileStatistics.java·src/main/java/com/safjnest/lol/service/ProfileAnalyzer.java
1. LeafStats — common base
src/main/java/com/safjnest/lol/model/statistics/shared/LeafStats.java:5-43:
public class LeafStats {
public long games; public long wins;
public long kills; public long deaths; public long assists;
public long losses() { return games - wins; }
public double winrate() { return games == 0 ? 0 : (double) wins / games; }
public double kda() { return deaths == 0 ? kills + assists : (double)(kills+assists)/deaths; }
public void merge(LeafStats o) {
if (o==null) return;
games+=o.games; wins+=o.wins; kills+=o.kills; deaths+=o.deaths; assists+=o.assists;
}
}
losses = games - wins— derived, not persisted.winrate ∈ [0,1](fraction),kdawith fallbackK+Aifdeaths==0.mergesums all counters — used to aggregate leaves into totals.
2. ProfileLeafStats — profile
src/main/java/com/safjnest/lol/model/statistics/shared/ProfileLeafStats.java:10-169:
Raw fields
| Field | Type | Accumulation (accumulate:67-98) |
|---|---|---|
blueGames/blueWins | long | team==BLUE |
redGames/redWins | long | team==RED |
damage/damageBuilding | long | participant.damage |
damageTaken | Long nullable | sum nullable-safe |
healing/vision/ward/wardKilled | long | direct |
cs/gold/lpGain | long | cs, goldEarned, rankProgress.gain |
championLevelTotal | Long nullable | sum |
doubles/triples/quadruples/pentas | long | direct |
q/w/e/r/d/f | long | summoner spells / ability counters |
arenaFirst/Second/Third/PlacementSum | long | subTeamPlacement 1/2/3 only if arena==true |
playtime | long | max(0, timeEnd-timeStart) |
lastPlayedAt | long | max(lastPlayedAt, timeStart) |
killParticipationSum | double | Σ (K+A)/teamKills*100 if teamKills>0 |
deathShareSum | double | Σ deaths/enemyTeamKills*100 if enemyTeamKills>0 |
isOtp | Boolean | set by OPTUtils.refresh, null if not OTP |
accumulate (:67-98) is called for each canonical Match (ProfileStatistics.matchesFilter passes):
public void accumulate(Participant p, long timeStart, long timeEnd, int teamKills, int enemyTeamKills, boolean arena) {
games++; if (p.win) wins++;
// blue/red split
kills += KdaUtils.parse(p.kda)[0]; // KdaUtils.parse extracts K/D/A from string "k/d/a"
// ... cs, gold, damage, etc.
playtime += max(0, timeEnd - timeStart);
lastPlayedAt = max(lastPlayedAt, timeStart);
if (teamKills > 0) killParticipationSum += (double)(K+A)/teamKills*100;
if (enemyTeamKills > 0) deathShareSum += (double)deaths/enemyTeamKills*100;
}
teamKills/enemyTeamKills are computed by ProfileAnalyzer.ProfileMatchContext (ProfileAnalyzer.java:119-121) summing KdaUtils.getKills(kda) over teammates/enemies (arena: subTeam).
Derived
ProfileLeafStats.java:100-122:
public double winratePercent() { return games==0?0:(double)wins/games*100; }
public double kdaVal() { return deaths==0?kills+assists:(double)(kills+assists)/deaths; }
public double avgKills() { return games==0?0:(double)kills/games; }
public double avgDeaths() { return games==0?0:(double)deaths/games; }
public double avgAssists() { return games==0?0:(double)assists/games; }
public double avgDamage() { return games==0?0:(double)damage/games; }
public double avgDamageBuilding() { return games==0?0:(double)damageBuilding/games; }
public double avgHealing() { return games==0?0:(double)healing/games; }
public double avgVision() { return games==0?0:(double)vision/games; }
public double avgWard() { return games==0?0:(double)ward/games; }
public double avgWardKilled() { return games==0?0:(double)wardKilled/games; }
public double avgCs() { return games==0?0:(double)cs/games; }
public double avgGold() { return games==0?0:(double)gold/games; }
public double avgLpGain() { return games==0?0:(double)lpGain/games; }
public double avgArenaPlacement() { return games==0?0:(double)arenaPlacementSum/games; }
public double avgKillParticipation() { return games==0?0:killParticipationSum/games; }
public double avgDeathShare() { return games==0?0:deathShareSum/games; }
All avg* are simple averages Σ / games with guard games==0.
merge (:125-162) sums all fields, including blue/red, nullable damageTaken, arena*, playtime, killParticipationSum/deathShareSum.
3. ChampionLeafStats — champion
src/main/java/com/safjnest/lol/model/statistics/shared/ChampionLeafStats.java:6-88:
public class ChampionLeafStats extends LeafStats {
public double csm; public long csmGames; // CS per minute
public double gpm; public long gpmGames; // Gold per minute
public TrendStats trend;
public Map<Integer, MatchupStats> matchups = new LinkedHashMap<>();
public Map<String, Map<Integer, WinLossStats>> synergies = new LinkedHashMap<>();
public Map<String, WinLossStats> powerCurve = new LinkedHashMap<>();
public Double csPerMinute() { return csmGames==0?null:csm/csmGames; }
public Double goldPerMinute() { return gpmGames==0?null:gpm/gpmGames; }
}
csPerMinute/goldPerMinute—nullif no game with valid data (avoidsNaNin JSON —@JsonInclude NON_NULL).mergesumscsm/gpm+ mapsmatchups/synergies/powerCurve(deep merge withcopy).
powerCurve — time buckets
ChampionAnalyzer (src/main/java/com/safjnest/lol/service/ChampionAnalyzer.java) populates powerCurve with bucket keys:
"0-15" // 0-15 minutes
"15-25"
"25-35"
"35-45"
"45+" // 45+ minutes (overtime)
Value per bucket: WinLossStats(games, wins) — winrate of the champion when the match ends in that bucket. Used for power-curve chart in the frontend.
4. ProfileStatistics — structure and aggregation
src/main/java/com/safjnest/lol/model/statistics/ProfileStatistics.java:21-276:
public class ProfileStatistics {
public long timeStart, timeEnd, lastUpdate, oldestMatchAt, newestMatchAt;
public Map<Integer, Map<CanonicalQueue, Map<String, ProfileLeafStats>>> champions = new LinkedHashMap<>();
public Map<String, Long> pings = new LinkedHashMap<>();
public Map<Integer, Long> spellOne, spellTwo;
@JsonIgnore public ProfileLeafStats total;
@JsonIgnore public List<ProfileLeafStats> queueStats, laneStats, championStats;
}
- Key —
champions: { championId → { CanonicalQueue → { lane → ProfileLeafStats } } }. Lane"UNKNOWN"ifNONE. leaf(champion, queue, lane)(:186-190) — triplecomputeIfAbsent.finish()(:88-106) — callsOPTUtils.refresh(champions)then recomputestotal = merge(all leaves)and projectionsqueueStats/laneStats/championStats(transient,@JsonIgnore, rebuilt on load).forEachLeaf(:202-212) — iteration helper over all leaves fortotal().
Filters:
public static boolean matchesFilter(Match match, Participant player, Filter filter) {
if (filter==null) return true;
if (filter.queue()!=null && filter.queue()!=match.queue) return false;
if (filter.region()!=null && filter.region()!=match.leagueShard) return false;
if (filter.champion()!=0 && filter.champion()!=player.champion) return false;
if (filter.lane()!=null && filter.lane()!=player.lane) return false;
if (filter.patch()!=null && !matchesPatch(match.patch, filter.patch())) return false;
if (!matchesRank(match.rank, filter)) return false;
if (filter.opponent()!=0 && !hasOpponent(...)) return false;
if (filter.duo()!=0 && !hasDuo(...)) return false;
return (timeStart==0 || match.timeStart>=filter.timeStart()) && (timeEnd==0 || match.timeEnd<=filter.timeEnd());
}
matchesRank (:249-253): EXACT → rank==filter.rank, otherwise rank.ordinal() <= filter.rank.ordinal() (rank included and above). hasOpponent requires equal lane and opposite team; hasDuo requires same team + lane BOT/UTILITY (or same subTeam in arena).
5. ProfileAnalyzer — streaming forEach
src/main/java/com/safjnest/lol/service/ProfileAnalyzer.java:14-147:
public static final class ProfileRefreshAccumulator {
private final ProfileStatistics statistics;
private final ProfileActivity.Accumulator activity;
private final ProfileMatchups.Accumulator matchups;
public void accept(Match match) {
ProfileMatchContext ctx = ProfileMatchContext.from(match, puuid, statisticsFilter);
activity.accept(match, ctx.player()); // always (even non-canonical)
if (!ctx.isCanonical()) return; // statistics/matchups only if matchesFilter
statistics.accumulate(match, ctx.player(), ctx.teamKills(), ctx.enemyTeamKills(), ctx.arena());
matchups.accept(match, ctx.player(), ctx.teamKills(), ctx.enemyTeamKills(), ctx.arena());
}
public ProfileRefresh finish() {
statistics.finish(); // → OPTUtils.refresh + totals
return new ProfileRefresh(statistics, activity.finish(), matchups.finish().withLastUpdate(now));
}
}
private record ProfileMatchContext(Match match, Participant player, boolean isCanonical, boolean arena, int teamKills, int enemyTeamKills) {
static ProfileMatchContext from(Match match, String puuid, Filter filter) {
Participant player = participant(match, puuid);
if (player==null || !ProfileStatistics.matchesFilter(match, player, filter))
return new ProfileMatchContext(match, player, false, false, 0, 0);
boolean arena = GameQueueTypeUtils.isCherry(match.queue);
int teamKills = kills(match, player, arena, false);
int enemyTeamKills = arena ? 0 : kills(match, player, false, true);
return new ProfileMatchContext(match, player, true, arena, teamKills, enemyTeamKills);
}
}
Flow called by ProfileService.generateStatistics via MongoDB.forEachProfileStatisticsMatch(puuid, filter, accumulator::accept) — streaming cursor, not in-memory list. activity always accumulates (even non-canonical matches for timeline), statistics and matchups only if matchesFilter passes. isCherry identifies CHERRY (Arena) where teamKills uses subTeam instead of team.
MatchupsAccumulator (:40-60) is identical but only for ProfileMatchups.
6. Invariants
- Each
accumulateincrementsgamesby 1 — never batch. mergeis commutative/associative —total()is independent of iteration order.csPerMinute/goldPerMinutearenullon 0 games — not 0 — to distinguish "missing data" from "0 CSM".powerCurvebucket"45+"captures overtime; empty buckets are absent from the map.ProfileAnalyzerdoes not touch Mongo/Redis — pure; persistence only inProfileService.
