Mythic Plus Score Computation

There are a few reference implementations to calculate Mythic+ scores.Discord #wowuidev - Trinova - 2022.04.16
==Rule set==
===Key level Score===
: + 25 points base score
: + 5 per level
: + 5 per weekly affix
: + 10 for season affix
This results in the following hardcoded table for the first 10 key levels:
local DungeonBaseScores = { 0, 40, 45, 55, 60, 65, 75, 80, 85, 100 }
After this we only add 5 points per key level.
===Success score===
* Not timed immediately results in a -5 penalty
===Timer related score===
* Up to 5 points for being up to 40% faster (linear scaling)
* Up to -5 points penalty for being up to 40% slower (linear scaling)
* If we are slower than 140% par time the key score is set to 0 and we don't get any rating at all
* The adjusted affix score is computed by taking the higher weekly affix score time 1.5 (150%) and the lower time 0.5 (50%)
* The dungeon score is the rounded sum of the unrounded addjusted affix scores (round(adjustedTyrannical + adjustedFortified))
* () returns rounded values for the weekly affixes
* () is computed from the unrounded values
==Implementation==
-- Data
local DungeonAbbreviations = {
 [375] = "mots",
 [376] = "nw",
 [377] = "dos",
 [378] = "hoa",
 [379] = "pf",
 [380] = "sd",
 [381] = "soa",
 [382] = "top",
 [391] = "strt",
 [392] = "gmbt"
};
local DungeonBaseScores = { 0, 40, 45, 55, 60, 65, 75, 80, 85, 100 };
local TimerConstants = {
 ---@type number bonus and malus are capped at 40% faster or slower than par time
 Threshold = 0.4,
 ---@type number maximum bonus/malus points for being faster/slower than the par time
 MaxModifier = 5,
 ---@type number maximum bonus/malus points for being faster/slower than the par time
 DepletionPunishment = 5,
}
-- Math Functions
local function round(num)
 return num >= 0 and math.floor(num + 0.5) or math.ceil(num - 0.5)
end
---@param num number
---@param precision number
---@param num boolean
local function formatNumber(num, precision, noPrefix)
 if num == nil then
 return "nil"
 end
 local formatString = "%." .. precision .. "f"
 local absolutValueString = string.format(formatString, num)
 if noPrefix then
 return num < 0 and strsub(absolutValueString, 1) or absolutValueString
 else
 return (num > 0 and "+" or "") .. absolutValueString
 end
end
local function padString(str, length)
 local result = str .. ""
 while strlen(result) < length do
 result = " " .. result
 end
 return result
end
-- WoW Functions
local function computeTimeModifier(parTimePercentage)
 ---@type number if we took 130% time this is -30% (30% too slow)
 local percentageOffset = (1 - parTimePercentage)
 if percentageOffset > TimerConstants.Threshold then
 -- bonus is capped at 40% faster than par time
 return TimerConstants.MaxModifier;
 elseif percentageOffset > 0 then
 -- bonus is interpolated linear between 60% and 100% par time
 return percentageOffset * TimerConstants.MaxModifier / TimerConstants.Threshold;
 elseif percentageOffset == 0 then
 -- redundant special case
 return 0;
 elseif percentageOffset > -TimerConstants.Threshold then
 -- bonus is interpolated linear between 100% and 140% par time
 return percentageOffset * TimerConstants.MaxModifier / TimerConstants.Threshold - TimerConstants.DepletionPunishment;
 else
 -- key is hard set to 0 points, `nil` indicates this
 return nil;
 end
end
local function computeScores(dungeonId, level, timeInSeconds)
 local _, _, parTime, _, _ = C_ChallengeMode.GetMapUIInfo(dungeonId)
 
 local baseScore = DungeonBaseScores[math.min(level, 10)] + max(0, level - 10) * 5;
 local parTimeFraction = timeInSeconds / parTime;
 local timeScore = computeTimeModifier(parTimeFraction);
 formatNumber(parTimeFraction * 100, 2)
 return {
 baseScore = baseScore,
 -- 0 if critically over timed
 timeScore ~= nil and baseScore + timeScore or 0,
 timeBonus = timeScore,
 parTimePercentageString = padString(formatNumber(parTimeFraction * 100, 2, true), 7) .. "%"
 }
end
local function computeKeyBaseScore(affixScoreData)
 return affixScoreData.baseScore + affixScoreData.timeBonus;
end
local function computeAffixScoreSum(score1, score2)
 return max(score1, score2) * 1.5 + min(score1, score2) * 0.5;
end
local totalScore = 0
local function buildKeyDataString(blizzardScores, affixScoreData)
 local blizzardTyrannical = blizzardScores.Tyrannical.baseScore;
 local computedTyrannical = computeKeyBaseScore(affixScoreData.Tyrannical);
 local blizzardFortified = blizzardScores.Fortified.baseScore;
 local computedFortified = computeKeyBaseScore(affixScoreData.Fortified);
 local computedKeyScore = computeAffixScoreSum(computedTyrannical, computedFortified);
 totalScore = totalScore + computedKeyScore;
 return "Tyrannical " ..
 " " .. padString(blizzardTyrannical, 3) .. " | " .. padString(round(computedTyrannical), 3) .. " = " ..
 padString(affixScoreData.Tyrannical.baseScore, 3) .. padString(formatNumber(affixScoreData.Tyrannical.timeBonus, 2), 6) ..
 affixScoreData.Tyrannical.parTimePercentageString ..
 "\n" ..
 "Fortified " .. " " .. padString(blizzardFortified, 3) .. " | " .. padString(round(computedFortified), 3) .. " = " ..
 padString(affixScoreData.Fortified.baseScore, 3) .. padString(formatNumber(affixScoreData.Fortified.timeBonus, 2), 6) ..
 affixScoreData.Fortified.parTimePercentageString ..
 "\n" ..
 "complete " ..
 padString(blizzardScores.Complete, 3) .. " | " .. padString(round(computedKeyScore), 3)
end
local function computeTTEnhancement(dungeonId)
 local computedAffixScoreData = {
 Tyrannical = { baseScore = 0, timeScore = 0, timeBonus = 0, parTimePercentageString = " 0.00%" },
 Fortified = { baseScore = 0, timeScore = 0, timeBonus = 0, parTimePercentageString = " 0.00%" },
 }
 local blizzardScores = {
 Tyrannical = { baseScore = 0 },
 Fortified = { baseScore = 0 },
 Complete = 0
 }
 local blizzardAffixScoreData, blizzardTotalScore = C_MythicPlus.GetSeasonBestAffixScoreInfoForMap(dungeonId)
 if (blizzardAffixScoreData ~= nil) then
 for _, info in pairs(blizzardAffixScoreData) do
 computedAffixScoreData[info.name] = computeScores(dungeonId, info.level, info.durationSec)
 blizzardScores[info.name] = { baseScore = info.score }
 end
 end
 if blizzardTotalScore ~= nil then
 blizzardScores.Complete = blizzardTotalScore
 end
 return buildKeyDataString(blizzardScores, computedAffixScoreData)
end
local function printScoreTable()
 for dungeonId, abbreviation in pairs(DungeonAbbreviations) do
 print(abbreviation .. " - " .. C_ChallengeMode.GetMapUIInfo(dungeonId))
 print(" Blizzard | Computed")
 print(computeTTEnhancement(dungeonId))
 end
 local currentScore = C_ChallengeMode.GetOverallDungeonScore()
 print("========================================")
 print("Total " .. padString(currentScore, 3) .. " | " .. padString(round(totalScore), 3))
 print("========================================")
end
printScoreTable()
==References==