IodidDOCUMENTATION

formulas/records.mdx

/doc/formulas/records

PUBLIC DOCUMENTATION

Records

Records — metrics, scoring, tie-break, per-Match accumulator and contextual ranking on Redis ZSET.

Records — formulas and ranking

Sources: src/main/java/com/safjnest/lol/model/record/RecordMetric.java · RecordOwner.java · RecordOrder.java:7 · ProfileRecord.java:33-67 · src/main/java/com/safjnest/lol/service/ProfileRecordAnalyzer.java:34-139 · ProfileRecordService.java:76-252 · MongoDB.forEachProfileRecordMatch

1. RecordMetric — 17 metrics

src/main/java/com/safjnest/lol/model/record/RecordMetric.java:3-21:

java
public enum RecordMetric {
    KILLS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    DEATHS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    ASSISTS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    FIRST_KILL_TIME(RecordOwner.PARTICIPANT, RecordOrder.LOWEST, true),
    FIRST_BLOOD_TIME(RecordOwner.PARTICIPANT, RecordOrder.LOWEST, true),
    PENTAKILLS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    CS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    DAMAGE_DEALT(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    DAMAGE_TAKEN(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, false),
    BARON_KILLS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, true),
    ELDER_KILLS(RecordOwner.PARTICIPANT, RecordOrder.HIGHEST, true),
    FIRST_DRAKE_TIME(RecordOwner.TEAM, RecordOrder.LOWEST, true),
    FIRST_BARON_TIME(RecordOwner.TEAM, RecordOrder.LOWEST, true),
    FIRST_ELDER_TIME(RecordOwner.TEAM, RecordOrder.LOWEST, true),
    BARONS_TAKEN(RecordOwner.TEAM, RecordOrder.HIGHEST, true),
    ELDERS_TAKEN(RecordOwner.TEAM, RecordOrder.HIGHEST, true),
    LONGEST_GAME(RecordOwner.MATCH, RecordOrder.HIGHEST, false);
}

Fields per entry:

  • owner — PARTICIPANT (single Participant value), TEAM (team objective of the player), MATCH (match duration).
  • order — HIGHEST (max wins) or LOWEST (min wins, for first-time metrics).
  • requiresEvents — true requires Match.eventData (champion_kills / monster_events), otherwise available from Participant/Match fields only.

RecordOwner.java:3 and RecordOrder.java:3:

java
public enum RecordOwner { PARTICIPANT, TEAM, MATCH }
public enum RecordOrder {
    HIGHEST, LOWEST;
    public long score(long value) { return this == HIGHEST ? value : -value; }
}

gameShared() (RecordMetric.java:44) is true if owner != PARTICIPANT — the record is shared by the whole team/match, not just the participant.

2. ProfileRecord — fields and score

src/main/java/com/safjnest/lol/model/record/ProfileRecord.java:12-67:

java
public class ProfileRecord {
    public String puuid;           // owner
    public String riotId;          // denormalized from Summoner (enrich)
    public Integer icon;
    public String filterKey;       // Filter.canonical().toSummonerKey()
    public RecordMetric metric;
    public long value;             // raw value (kills, ms, etc.)
    public long score;             // indexable value
    public String matchId;         // gameId where it was achieved
    public long occurredAt;        // match.timeStart
    public int championId;
    public LeagueShard region;
    public Integer mmr;            // from RankProgress via TierDivisionUtils.getMmr
    public TeamType team;
    public String actorPuuid;      // actual killer for FIRST_DRAKE/BARON/ELDER_TIME
    public Boolean gameShared;
    public Long globalRanking;     // resolved via Redis
    public Long regionRanking;
    public long lastUpdate;

    public static ProfileRecord from(..., RecordMetric metric, long value, ...) {
        record.score = metric.order().score(value); // HIGHEST: value, LOWEST: -value
        record.gameShared = metric.gameShared() ? Boolean.TRUE : null;
        applyMmr(record, participant.rankProgress);
    }
}

Scoring for ordering

RecordOrder.score (RecordOrder.java:7):

java
public long score(long value) { return this == HIGHEST ? value : -value; }
  • HIGHEST → score = value (ZSET ordered DESC on score — max at the top).
  • LOWEST → score = -value (so the minimum value has the maximum score: e.g. FIRST_KILL_TIME 60000ms → score -60000 beats 120000ms → -120000).

3. ProfileRecordAnalyzer.Accumulator — extraction per Match

src/main/java/com/safjnest/lol/service/ProfileRecordAnalyzer.java:24-133:

java
public void accept(Match match) {
    Participant player = participant(match, puuid);
    if (player == null) return;
    add(match, player, RecordMetric.KILLS, player.kills, null, null);
    add(match, player, RecordMetric.DEATHS, player.deaths, null, null);
    add(match, player, RecordMetric.ASSISTS, player.assists, null, null);
    add(match, player, RecordMetric.PENTAKILLS, player.pentas, null, null);
    add(match, player, RecordMetric.CS, player.cs, null, null);
    add(match, player, RecordMetric.DAMAGE_DEALT, player.damage, null, null);
    if (player.damageTaken != null) add(match, player, RecordMetric.DAMAGE_TAKEN, player.damageTaken, null, null);
    if (match.getDuration() > 0) add(match, player, RecordMetric.LONGEST_GAME, match.getDuration(), null, null);

    List<Map<String, Object>> kills = events(match, "champion_kills");
    List<Map<String, Object>> monsters = events(match, "monster_events");
    addFirstKill(match, player, kills);
    addFirstBlood(match, player, kills);
    addObjectiveKills(match, player, monsters);
    addTeamObjectives(match, player, monsters);
}

Event details

  • events(match, name) (:182-192) — reads match.eventData.get(name) as List<Map<String,Object>>. If absent → empty list (no requiresEvents record produced).
  • addFirstKill (:71-77) — iterates champion_kills, filters killer == participantId(player), for each kill of the player emits FIRST_KILL_TIME = timestamp (the before tie-break then selects the minimum).
  • addFirstBlood (:62-69) — as above but with kill_type == "first_blood".
  • addObjectiveKills (:79-90) — on monster_events filters killer == player, counts isBaron (monster == "BARON_NASHOR") and isElder (monster == "DRAGON" && subtype == "ELDER_DRAGON") → BARON_KILLS, ELDER_KILLS.
  • addTeamObjectives (:92-102) — on monster_events:
    • first(monsters, team, isDrake/isBaron/isElder) (:141-152) finds minimum timestamp per team → FIRST_DRAKE_TIME, FIRST_BARON_TIME, FIRST_ELDER_TIME (with team and actorPuuid of the killer).
    • count(monsters, team, isBaron/isElder) (:154-163) → BARONS_TAKEN, ELDERS_TAKEN (team-scoped + gameShared=true).
java
private static boolean isDrake(Map<String, Object> e) { return "DRAGON".equals(e.get("monster")) && !isElder(e); }
private static boolean isBaron(Map<String, Object> e) { return "BARON_NASHOR".equals(e.get("monster")); }
private static boolean isElder(Map<String, Object> e) { return "DRAGON".equals(e.get("monster")) && "ELDER_DRAGON".equals(e.get("subtype")); }
private static boolean sameTeam(Map<String, Object> e, TeamType t) { return t.name().equals(e.get("killer_team")); }

Values value <= 0 or blank gameId are discarded in add (:118).

Tie-break before

src/main/java/com/safjnest/lol/service/ProfileRecordAnalyzer.java:135-139:

java
private static boolean before(ProfileRecord candidate, ProfileRecord current) {
    if (candidate.score != current.score) return candidate.score > current.score;
    if (candidate.occurredAt != current.occurredAt) return candidate.occurredAt < current.occurredAt;
    return candidate.matchId.compareTo(current.matchId) < 0;
}

A candidate replaces the current if:

  1. score is greater (remember: for LOWEST higher score = lower value).
  2. On equal score, occurredAt smaller (older wins — historic record).
  3. On both equal, lexicographically smaller matchId (determinism).

EnumMap<RecordMetric, ProfileRecord> keeps at most 1 record per metric.

4. Persistence — MongoDB.forEachProfileRecordMatch + generate

ProfileRecordService.generate (src/main/java/com/safjnest/lol/service/ProfileRecordService.java:76-85):

java
public boolean generate(String puuid, LeagueShard shard, Filter filter) {
    List<ProfileRecord> previous = MongoDB.findProfileRecords(puuid, filter);
    ProfileRecordAnalyzer.Accumulator accumulator = ProfileRecordAnalyzer.accumulator(puuid, filter);
    MongoDB.forEachProfileRecordMatch(puuid, shard, filter, accumulator::accept);
    List<ProfileRecord> records = accumulator.finish();
    boolean saved = MongoDB.upsertProfileRecords(puuid, filter, records);
    if (saved) updateIndex(previous, records);
    return saved;
}
  • forEachProfileRecordMatch — streaming cursor over match filtered by ProfileStatistics.matchesFilter (same predicate as ProfileAnalyzer), with projection of match + participants + eventData needed for records. Invokes accumulator.accept for each match.
  • finish() (ProfileRecordAnalyzer.java:55-60) sets lastUpdate = now on all records.
  • upsertProfileRecords — atomic upsert per puuid + filterKey (unique index profile_records), replaces the entire record vector.
  • updateIndex (ProfileRecordService.java:165-183) applies incremental delta to Redis ZSETs: removes members of previous, adds those of next, only if the segment already exists.

5. Contextual ranking — ZSET + SHA-256

ProfileRecordService.resolveRankings (:87-115):

java
public static void resolveRankings(List<ProfileRecord> records) {
    // for each classifiable record:
    String globalKey   = RedisKey.CONTEXTUAL_RECORD_SEGMENT.of(filterKey, metric.name(), "GLOBAL");
    String regionalKey = RedisKey.CONTEXTUAL_RECORD_SEGMENT.of(filterKey, metric.name(), region.name());
    // member = SHA256(filterKey:metric:puuid)[0..16]  (128-bit)
    keys.add(globalKey); members.add(SHA256); scores.add((double) score);
    // ...
    ensureIndexesAsync(segments); // rebuild if missing via ComputeScheduler PROFILE
    List<Long> positions = RedisClient.reverseRanks(keys, members); // ZREVRANK
    List<Long> above     = RedisClient.countsAbove(keys, scores);    // ZCOUNT (score, +inf]
    record.globalRanking = ranking(positions, above); // above + 1
}

Details:

  • Redis key — RedisKey.CONTEXTUAL_RECORD_SEGMENT → record:{filterKey}:{metric}:{scope} with scope = GLOBAL | region (LeagueShardUtils.leaderboardScope).

  • Member — src/main/java/com/safjnest/lol/service/ProfileRecordService.java:245-252:

    java
    private static byte[] member(ProfileRecord r) {
        return Arrays.copyOf(SHA256((r.filterKey + ':' + r.metric.name() + ':' + r.puuid).getBytes(UTF_8)), 16);
    }
    

    First 16 bytes of SHA-256 — 128-bit, collision-safe, fixed size.

  • ZSET score — record.score (HIGHEST: value, LOWEST: -value), so ZREVRANK orders correctly for both directions.

  • Ranking — src/main/java/com/safjnest/lol/service/ProfileRecordService.java:232-234:

    java
    static Long ranking(Long memberPosition, Long above) { return memberPosition == null || above == null ? null : above + 1; }
    

    above = ZCOUNT(score Exclusive, +inf) counts how many have strictly greater score (handles ties: same rank for equal score). Final ranking = above + 1. null if ZSET or member missing (segment not yet built).

  • Rebuild — rebuildIndex (:206-230) uses RedisClient.buildSortedSet with cursor MongoDB.forEachProfileRecordRankingSegment(filterKey, metric, region, ...) → streaming batch → ZADD to temp → atomic RENAME + EXPIRE + SADD CONTEXTUAL_RECORD_SEGMENTS. Deduplicated via INDEX_BUILDS map + CompletableFuture.

  • Global overview / page — getGlobalOverview (:53-62) reads top-5 per metric from MongoDB.findGlobalProfileRecords (sort score DESC, occurredAt ASC, matchId ASC) + resolveRankings + enrich (join Summoner.riotId/icon).