OTP
One-Trick Pony formula — exponential threshold, minimum gap, per-queue grouping and isOtp flag.
OTP — One-Trick Pony
Source:
src/main/java/com/safjnest/lol/utils/OPTUtils.java
An OTP is the dominant champion of a player in a specific queue. The formula balances tolerance for few games (high threshold) and strictness on large volumes (low threshold) + a mandatory gap over the runner-up.
1. Constants
OPTUtils.java:14-18:
private static final int MIN_GAMES = 20;
private static final double BASE_THRESHOLD = 0.50;
private static final double INITIAL_THRESHOLD = 0.30;
private static final double DECAY_GAMES = 250.0;
private static final double MIN_GAP = 0.15;
| Constant | Value | Role |
|---|---|---|
MIN_GAMES | 20 | minimum total games in the queue |
BASE_THRESHOLD | 0.50 | asymptotic share (inf games → 50%) |
INITIAL_THRESHOLD | 0.30 | initial extra (0 games → +30%) |
DECAY_GAMES | 250 | exponential decay constant |
MIN_GAP | 0.15 | minimum top − second gap (15%) |
2. Threshold — exponential decay
OPTUtils.java:51-53:
public static double threshold(long totalGames) {
return BASE_THRESHOLD + INITIAL_THRESHOLD * Math.exp(-totalGames / DECAY_GAMES);
}
Formula:
threshold(g) = 0.50 + 0.30 · exp(-g / 250)
| g (totalGames) | exp(-g/250) | threshold | Meaning |
|---|---|---|---|
| 20 | 0.9231 | 0.777 | with 20 games need 77.7% on main |
| 50 | 0.8187 | 0.746 | |
| 100 | 0.6703 | 0.701 | 100 games → 70.1% |
| 150 | 0.5488 | 0.665 | |
| 250 | 0.3679 | 0.610 | 250 games → 61.0% |
| 350 | 0.2466 | 0.574 | |
| 500 | 0.1353 | 0.540 | 500 games → 54.0% |
| 1000 | 0.0183 | 0.505 | → asymptote 50% |
Smooth curve: tolerant on small samples, converges to 50% beyond ~750 games. Conceptual graph: starts at 0.80 (g=0), drops quickly to 0.65 within 150 games, then slowly to 0.50.
3. isOtp — double condition
OPTUtils.java:44-49:
public static boolean isOtp(long totalGames, long topGames, long secondGames) {
if (totalGames < MIN_GAMES || topGames <= 0) return false;
double topShare = (double) topGames / totalGames;
double secondShare = (double) secondGames / totalGames;
return topShare >= threshold(totalGames) && topShare - secondShare >= MIN_GAP;
}
Both must hold:
topShare >= threshold(g)— share of champion #1 above the curve.topShare - secondShare >= 0.15— gap of at least 15 percentage points over #2.
Examples:
isOtp(100, 75, 10) // top 75% vs 10% → 0.75>=0.701 ✓ gap 0.65>=0.15 ✓ → true
isOtp(100, 75, 65) // top 75% vs 65% → threshold ✓ gap 0.10 ✗ → false (contested)
isOtp(100, 60, 10) // top 60% vs 10% → 0.60>=0.701 ✗ → false (insufficient share)
isOtp( 15, 14, 1) // total<20 ✗ → false (sample too small)
isOtp(300, 180, 20) // 60% vs 6.6% threshold(300)=0.590 → 0.60>=0.590 ✓ gap 0.53 ✓ → true
MIN_GAP avoids false OTPs when two champions are both high but close.
4. refresh — grouping and marking
OPTUtils.java:22-42,54-72:
public static void refresh(Map<Integer, Map<CanonicalQueue, Map<String, ProfileLeafStats>>> champions) {
// 1. reset isOtp on all leaves
// 2. group by (CanonicalQueue × playable lane) → Map<CanonicalQueue, Map<championId, ChampionScope>>
// 3. for each CanonicalQueue → markOtp
}
private static void markOtp(Map<Integer, ChampionScope> values) {
// totalGames = sum(games), topGames/secondGames = top 2 by games
// if (isOtp(totalGames, topGames, secondGames)) → set isOtp=TRUE on ALL leaves of the top
}
private static class ChampionScope {
List<ProfileLeafStats> leaves = new ArrayList<>();
long games;
void add(ProfileLeafStats v) { games += v.games; leaves.add(v); }
}
Details
- Input —
championsisProfileStatistics.champions: Map<championId, Map<CanonicalQueue, Map<lane, ProfileLeafStats>>>. - Grouping — for each
championId × CanonicalQueue × lanewherelaneis playable (LaneTypeUtils.playables()→TOP,JUNGLE,MID,BOT,UTILITY,OPTUtils.playableLane:74) it accumulates intoChampionScopeperCanonicalQueue.CanonicalQueue.from(queue)normalizesTEAM_BUILDER_RANKED_SOLO → RANKED_SOLO_5X5. - Counting —
totalGamessumsgamesof all champions in the queue;topGamesandsecondGamesare the two maxima. - At most one OTP per queue — if
isOtppasses, all playable leaves of the top champion (e.g. MID + TOP if flex) receiveisOtp = Boolean.TRUE; others staynull(notfalse) for JSON compactness (@JsonIgnoreonisOtpwhen merging, implicit@JsonInclude NON_NULL). - Reset — every
refreshzeroesisOtpbefore recomputing, so a former OTP decays if the gap closes. - Call site —
ProfileStatistics.finish()(ProfileStatistics.java:89) invokesOPTUtils.refresh(champions)after eachaccumulatebatch; thusisOtpis always consistent on the persisted document and on transient aggregates.
5. primary lane and downstream use
CompetitiveService.primary (CompetitiveService.java:130-157) determines the primary lane as max(games) on LaneTypeUtils.playables() for the CanonicalQueue. OTP and primary are independent: a player can be OTP on Yone MID but have primary JUNGLE if they play more jungle overall — primary is for leaderboard role filter, otpChampionId for otp filter.
Both are written into competitive (CompetitiveService.entry:119) and indexed for findLeaderboardPage queries.
6. Invariants
- No OTP under 20 games.
- At most 1 champion with
isOtpperCanonicalQueue(never 0 or 2 when the condition holds). isOtpdepends only ongamescounts in playable lanes — ignores winrate, KDA, LP.- Persisted as a transient flag — recomputed on every
ProfileStatistics.finish().
