Mythic Plus Score Computation

From Warcraft Wiki
Jump to navigation Jump to search

There are a few reference implementations to calculate Mythic+ scores.[1]

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))
  • C_MythicPlus.GetSeasonBestAffixScoreInfoForMap() returns rounded values for the weekly affixes
  • C_ChallengeMode.GetOverallDungeonScore() 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

  1. ^ Discord #wowuidev - Trinova - 2022.04.16