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:
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(singleParticipantvalue),TEAM(team objective of the player),MATCH(match duration).order—HIGHEST(max wins) orLOWEST(min wins, for first-time metrics).requiresEvents—truerequiresMatch.eventData(champion_kills/monster_events), otherwise available fromParticipant/Matchfields only.
RecordOwner.java:3 and RecordOrder.java:3:
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:
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):
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 minimumvaluehas the maximumscore: e.g.FIRST_KILL_TIME 60000ms → score -60000beats120000ms → -120000).
3. ProfileRecordAnalyzer.Accumulator — extraction per Match
src/main/java/com/safjnest/lol/service/ProfileRecordAnalyzer.java:24-133:
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) — readsmatch.eventData.get(name)asList<Map<String,Object>>. If absent → empty list (norequiresEventsrecord produced).addFirstKill(:71-77) — iterateschampion_kills, filterskiller == participantId(player), for each kill of the player emitsFIRST_KILL_TIME = timestamp(thebeforetie-break then selects the minimum).addFirstBlood(:62-69) — as above but withkill_type == "first_blood".addObjectiveKills(:79-90) — onmonster_eventsfilterskiller == player, countsisBaron(monster == "BARON_NASHOR") andisElder(monster == "DRAGON" && subtype == "ELDER_DRAGON") →BARON_KILLS,ELDER_KILLS.addTeamObjectives(:92-102) — onmonster_events:first(monsters, team, isDrake/isBaron/isElder)(:141-152) finds minimum timestamp per team →FIRST_DRAKE_TIME,FIRST_BARON_TIME,FIRST_ELDER_TIME(withteamandactorPuuidof the killer).count(monsters, team, isBaron/isElder)(:154-163) →BARONS_TAKEN,ELDERS_TAKEN(team-scoped +gameShared=true).
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:
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:
scoreis greater (remember: forLOWESThigher score = lower value).- On equal score,
occurredAtsmaller (older wins — historic record). - 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):
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 overmatchfiltered byProfileStatistics.matchesFilter(same predicate asProfileAnalyzer), with projection of match + participants +eventDataneeded for records. Invokesaccumulator.acceptfor each match.finish()(ProfileRecordAnalyzer.java:55-60) setslastUpdate = nowon all records.upsertProfileRecords— atomic upsert perpuuid + filterKey(unique indexprofile_records), replaces the entire record vector.updateIndex(ProfileRecordService.java:165-183) applies incremental delta to Redis ZSETs: removes members ofprevious, adds those ofnext, only if the segment already exists.
5. Contextual ranking — ZSET + SHA-256
ProfileRecordService.resolveRankings (:87-115):
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}withscope = GLOBAL | region(LeagueShardUtils.leaderboardScope). -
Member —
src/main/java/com/safjnest/lol/service/ProfileRecordService.java:245-252:javaprivate 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), soZREVRANKorders correctly for both directions. -
Ranking —
src/main/java/com/safjnest/lol/service/ProfileRecordService.java:232-234:javastatic 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.nullif ZSET or member missing (segment not yet built). -
Rebuild —
rebuildIndex(:206-230) usesRedisClient.buildSortedSetwith cursorMongoDB.forEachProfileRecordRankingSegment(filterKey, metric, region, ...)→ streaming batch →ZADDto temp → atomicRENAME+EXPIRE+SADD CONTEXTUAL_RECORD_SEGMENTS. Deduplicated viaINDEX_BUILDSmap +CompletableFuture. -
Global overview / page —
getGlobalOverview(:53-62) reads top-5 per metric fromMongoDB.findGlobalProfileRecords(sortscore DESC, occurredAt ASC, matchId ASC) +resolveRankings+enrich(joinSummoner.riotId/icon).
