IodidDOCUMENTATION

formulas/otp.mdx

/doc/formulas/otp

PUBLIC DOCUMENTATION

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:

java
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;
ConstantValueRole
MIN_GAMES20minimum total games in the queue
BASE_THRESHOLD0.50asymptotic share (inf games → 50%)
INITIAL_THRESHOLD0.30initial extra (0 games → +30%)
DECAY_GAMES250exponential decay constant
MIN_GAP0.15minimum top − second gap (15%)

2. Threshold — exponential decay

OPTUtils.java:51-53:

java
public static double threshold(long totalGames) {
    return BASE_THRESHOLD + INITIAL_THRESHOLD * Math.exp(-totalGames / DECAY_GAMES);
}

Formula:

text
threshold(g) = 0.50 + 0.30 · exp(-g / 250)
g (totalGames)exp(-g/250)thresholdMeaning
200.92310.777with 20 games need 77.7% on main
500.81870.746
1000.67030.701100 games → 70.1%
1500.54880.665
2500.36790.610250 games → 61.0%
3500.24660.574
5000.13530.540500 games → 54.0%
10000.01830.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:

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

  1. topShare >= threshold(g) — share of champion #1 above the curve.
  2. topShare - secondShare >= 0.15 — gap of at least 15 percentage points over #2.

Examples:

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

java
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 — champions is ProfileStatistics.champions: Map<championId, Map<CanonicalQueue, Map<lane, ProfileLeafStats>>>.
  • Grouping — for each championId × CanonicalQueue × lane where lane is playable (LaneTypeUtils.playables() → TOP,JUNGLE,MID,BOT,UTILITY, OPTUtils.playableLane:74) it accumulates into ChampionScope per CanonicalQueue. CanonicalQueue.from(queue) normalizes TEAM_BUILDER_RANKED_SOLO → RANKED_SOLO_5X5.
  • Counting — totalGames sums games of all champions in the queue; topGames and secondGames are the two maxima.
  • At most one OTP per queue — if isOtp passes, all playable leaves of the top champion (e.g. MID + TOP if flex) receive isOtp = Boolean.TRUE; others stay null (not false) for JSON compactness (@JsonIgnore on isOtp when merging, implicit @JsonInclude NON_NULL).
  • Reset — every refresh zeroes isOtp before recomputing, so a former OTP decays if the gap closes.
  • Call site — ProfileStatistics.finish() (ProfileStatistics.java:89) invokes OPTUtils.refresh(champions) after each accumulate batch; thus isOtp is 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 isOtp per CanonicalQueue (never 0 or 2 when the condition holds).
  • isOtp depends only on games counts in playable lanes — ignores winrate, KDA, LP.
  • Persisted as a transient flag — recomputed on every ProfileStatistics.finish().