IodidDOCUMENTATION

formulas/statistics.mdx

/doc/formulas/statistics

PUBLIC DOCUMENTATION

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:

java
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), kda with fallback K+A if deaths==0.
  • merge sums 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

FieldTypeAccumulation (accumulate:67-98)
blueGames/blueWinslongteam==BLUE
redGames/redWinslongteam==RED
damage/damageBuildinglongparticipant.damage
damageTakenLong nullablesum nullable-safe
healing/vision/ward/wardKilledlongdirect
cs/gold/lpGainlongcs, goldEarned, rankProgress.gain
championLevelTotalLong nullablesum
doubles/triples/quadruples/pentaslongdirect
q/w/e/r/d/flongsummoner spells / ability counters
arenaFirst/Second/Third/PlacementSumlongsubTeamPlacement 1/2/3 only if arena==true
playtimelongmax(0, timeEnd-timeStart)
lastPlayedAtlongmax(lastPlayedAt, timeStart)
killParticipationSumdoubleΣ (K+A)/teamKills*100 if teamKills>0
deathShareSumdoubleΣ deaths/enemyTeamKills*100 if enemyTeamKills>0
isOtpBooleanset by OPTUtils.refresh, null if not OTP

accumulate (:67-98) is called for each canonical Match (ProfileStatistics.matchesFilter passes):

java
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:

java
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:

java
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 — null if no game with valid data (avoids NaN in JSON — @JsonInclude NON_NULL).
  • merge sums csm/gpm + maps matchups/synergies/powerCurve (deep merge with copy).

powerCurve — time buckets

ChampionAnalyzer (src/main/java/com/safjnest/lol/service/ChampionAnalyzer.java) populates powerCurve with bucket keys:

text
"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:

java
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" if NONE.
  • leaf(champion, queue, lane) (:186-190) — triple computeIfAbsent.
  • finish() (:88-106) — calls OPTUtils.refresh(champions) then recomputes total = merge(all leaves) and projections queueStats/laneStats/championStats (transient, @JsonIgnore, rebuilt on load).
  • forEachLeaf (:202-212) — iteration helper over all leaves for total().

Filters:

java
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:

java
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 accumulate increments games by 1 — never batch.
  • merge is commutative/associative — total() is independent of iteration order.
  • csPerMinute/goldPerMinute are null on 0 games — not 0 — to distinguish "missing data" from "0 CSM".
  • powerCurve bucket "45+" captures overtime; empty buckets are absent from the map.
  • ProfileAnalyzer does not touch Mongo/Redis — pure; persistence only in ProfileService.