Difference between revisions of "Module:Infobox Monster"

From Idlescape Wiki
Jump to navigation Jump to search
(Fix correct handling of monsters that share an id with other monsters; needs to be included in the table "overrides" key points in the monster's Module:Monsters_stats/data table.)
(Undo revision 14538 by Demcookies (talk) Revert added Module:Loot_table data)
Tag: Undo
Line 1: Line 1:
 
local p = {}
 
local p = {}
 
local findId = require("Module:FindId")
 
local findId = require("Module:FindId")
local lootData = mw.loadData("Module:Loot/data")
+
local infoboxModule = require('Module:Infobox')
local itemData = mw.loadData("Module:Items/data")
+
local abilitiesData = mw.loadData("Module:Abilities/data")
local leagueData = mw.loadData("Module:Leagues/data")
+
local monstersData = mw.loadData("Module:Monsters/data")
 +
local monstersStats = mw.loadData("Module:Monsters stats/data")
 +
local headerCount = 1
 +
local labelCount = 1
 +
local dataCount = 1
  
 +
local defaultAffinities = {
 +
  {Melee = 1},
 +
  {Magic = 1},
 +
  {Range = 1},
 +
  {Piercing = 1},
 +
  {Blunt = 1},
 +
  {Slashing = 1},
 +
  {Fire = 1},
 +
  {Ice = 1},
 +
  {Nature = 1},
 +
  {Chaos = 1},
 +
  {Posion = 1},
 +
  {Lightning = 1}
 +
}
 +
local affinitiesIcon = {
 +
  [1] = "[[File:Melee splash.png|20px|link=Combat#Affinities]]",
 +
  [2] = "[[File:Magic splash.png|20px|link=Combat#Affinities]]",
 +
  [3] = "[[File:Range splash.png|20px|link=Combat#Affinities]]",
 +
  [4] = "[[File:Stab splash.png|20px|link=Combat#Affinities]]",
 +
  [5] = "[[File:Crush splash.png|20px|link=Combat#Affinities]]",
 +
  [6] = "[[File:Slash splash.png|20px|link=Combat#Affinities]]",
 +
  [7] = "[[File:Fire_splash.png|20px|link=Combat#Affinities]]",
 +
  [8] = "[[File:Ice_splash.png|20px|link=Combat#Affinities]]",
 +
  [9] = "[[File:Nature_splash.png|20px|link=Combat#Affinities]]",
 +
  [10] = "[[File:Chaos_splash.png|20px|link=Combat#Affinities]]",
 +
  [11] = "[[File:Poison_splash.png|20px|link=Combat#Affinities]]",
 +
  [12] = "[[File:Lightning_splash.png|20px|link=Combat#Affinities]]"
 +
}
  
---Creates a mw.html 'DIV' element containing an "error" message.
+
-- Convert from CSV string to table (converts a single line of a CSV file)
---@param message string Message as wikitext.
+
local function fromCSV(s)
---@return mw.html The message wrapped in a mw.html DIV element as red text.
+
  if string.sub(s, -1) ~= ',' then
local function createErrorMessage(message)
+
    s = s .. ',' -- ending comma
   local e = mw.html.create("div")
+
  end
     :css("color", "red")
+
  s = string.gsub(s, "[%[%]]", ""):gsub(",%s", ",")
     :wikitext(message)
+
  local t = {} -- table to collect fields
   return e
+
  local fieldstart = 1
 +
   repeat
 +
    local nexti = string.find(s, ',', fieldstart)
 +
     table.insert(t, string.sub(s, fieldstart, nexti - 1))
 +
     fieldstart = nexti + 1
 +
  until fieldstart > string.len(s)
 +
   return t
 
end
 
end
  
---Finds the source ID for a given name.
+
local function pairsByKeys(t, f)
---@param name string The name of the source.
+
  local a = {}
---@return number|nil The source ID (monsterID or locationID), or nil if the source is not found.
+
  local orgi_key_type
---@return string|nil The source type ("monster" or "location"), or nil if the source is not found.
+
  for n in pairs(t) do
local function findSourceId(name)
+
    if tonumber(n) == nil then
  for _, source in ipairs({ "monster", "location" }) do
+
      table.insert(a, n)
     local id = findId._findId({ name, source })
+
      orgi_key_type = "word"
     if id ~= "id not found" then
+
    elseif type(n) == "number" then
       return id, source
+
      table.insert(a, n)
 +
      orgi_key_type = "int"
 +
    elseif type(n) == "string" and type(tonumber(n) == "number") then
 +
      orgi_key_type = "number"
 +
      table.insert(a, tonumber(n))
 +
     end
 +
  end
 +
  table.sort(a, f)
 +
  local key
 +
  local value
 +
  local i = 0 -- iterator variable
 +
  local iter = function() -- iterator function
 +
    i = i + 1
 +
     if a[i] == nil then
 +
      return nil
 +
    elseif orgi_key_type == "word" or orgi_key_type == "int" then
 +
      key = a[i]
 +
      value = t[a[i]]
 +
    elseif orgi_key_type == "number" then
 +
       key = tostring(a[i])
 +
      value = t[tostring(a[i])]
 
     end
 
     end
 +
    return key, value
 
   end
 
   end
 +
  return iter
 +
end
 +
 +
local function tchelper(first, rest)
 +
  return first:upper() .. rest:lower()
 
end
 
end
  
---@class Item
+
local function capitalize(s)
---@field allowedLeagues number[]|nil The leagues in which the item can be found. Found in all leagues if nil.
+
  s = s:gsub("(%a)([%w_']*)", tchelper):gsub(" Of ", " of "):gsub(" The ", " the "):gsub("Ii", "II")
---@field id integer The item ID.
+
  return s
---@field chance number The base chance of the item being dropped.
+
end
---@field minAmount integer The minimum base amount of the item that can be dropped.
 
---@field maxAmount integer The maximum base amount of the item that can be dropped.
 
  
---Finds the first item in the loot table that matches the given item ID.
+
local function tablelength(T)
---@param loot table The loot table from Module:Loot/data.
+
   local count = 0
---@param id string|number The ID of the item to find.
+
   for _ in pairs(T) do
---@return Item|nil The item data, or nil if the item is not found.
+
     count = count + 1
---@return string|nil The monsterId where the item was found ("-1" for locations), or nil if the item is not found.
 
---@return string|nil The locationId where the item was found, or nil if the item is not found.
 
local function getFirstLootMatch(loot, id)
 
   id = tostring(id)
 
   for locationId, location in pairs(loot) do
 
     for sourceId, source in pairs(location) do
 
      for _, item in ipairs(source) do
 
        if item.id == id then
 
          return item, sourceId, locationId
 
        end
 
      end
 
    end
 
 
   end
 
   end
 +
  return count
 
end
 
end
  
---Finds the first source in the loot table that matches the given sourceId.
+
local function h()
---@param loot table The loot table from Module:Loot/data.
+
  local s = "header" .. headerCount
---@param id string|number The ID of the source to find.
+
  headerCount = headerCount + 1
---@param sourceType string String representing the type of the source ("monster" or "location").
+
   labelCount = headerCount
---@param locationId string|number|nil Optional ID for the location to find the source from. Use `id` and `sourceType` for location loot.
+
   dataCount = headerCount
---@return Item[]|nil The source data, or nil if the source is not found.
+
  return s
---@return string|nil The locationId where the source was found (`id` if `sourceType` is "location"), or nil if the source is not found.
+
end
local function getFirstSourceMatch(loot, id, sourceType, locationId)
 
   id = tostring(id)
 
   locationId = locationId and tostring(locationId) or nil
 
  
   if sourceType == "location" then
+
local function sbreak()
    local location = loot[id]
+
   local s = "sbreak" .. headerCount
    if not location then
+
  headerCount = headerCount + 1
      return
+
  labelCount = headerCount
    end
+
  dataCount = headerCount
    local source = location["-1"]
+
  return s
    if not source then
+
end
      return
 
    end
 
    return source, id
 
  end
 
  
   -- Needed for 'override' monsters that share ID with other monsters
+
local function l()
   --  otherwise the loot would be from first matching ID and could be wrong
+
   local s = "label" .. labelCount
   if locationId then
+
   dataCount = labelCount
    local location = loot[locationId]
+
   labelCount = labelCount + 1
    for sourceId, source in pairs(location) do
+
  headerCount = labelCount
      if sourceId == id then
+
  return s
        return source, locationId
+
end
      end
 
    end
 
    return
 
  end
 
  
  for locationId, location in pairs(loot) do
+
local function d()
    for sourceId, source in pairs(location) do
+
  local s = "data" .. dataCount
      if sourceId == id then
+
  dataCount = dataCount + 1
        return source, locationId
+
  headerCount = dataCount
      end
+
  labelCount = dataCount
    end
+
   return s
   end
 
 
end
 
end
  
--TODO: Add option to show percentages as fractions
+
local function sl()
 +
  local s = "s" .. l {}
 +
  return s
 +
end
  
---Converts a floating-point number to a approximation of fraction.
+
local function sd()
---@param num number The floating-point number to convert.
+
   local s = "s" .. d {}
---@param maxDenominator number The maximum denominator for the fraction.
+
   return s
---@return number The numerator of the fraction.
+
end
---@return number The denominator of the fraction.
 
local function floatToFraction(num, maxDenominator)
 
   local sign = num < 0 and -1 or 1
 
   num = math.abs(num)
 
  
  local bestNumerator, bestDenominator = 1, 1
+
local function rc()
   local minDifference = math.huge
+
   local s = "rowclass" .. labelCount
 +
  return s
 +
end
  
  for d = 1, maxDenominator do
+
---Fetches a monster object from Module:Monsters/data.
    local n = math.floor(num * d + 0.5)
+
---@param id number # The monster's ID.
    local approx = n / d
+
---@return table? # The monster object, or nil if not found.
     local difference = math.abs(num - approx)
+
local function getMonster(id)
 +
  local monster = monstersData[tostring(id)]
 +
  if monster then
 +
     return monster
 +
  end
 +
end
  
     if difference < minDifference then
+
local function getMonsterStats(id)
       bestNumerator, bestDenominator = n, d
+
  local monsterStats = monstersStats[tostring(id)]
       minDifference = difference
+
  if not monsterStats then
 +
    local dungeonMonsters = mw.loadData("Module:Monsters stats dungeon/data")
 +
    monsterStats = dungeonMonsters[tostring(id)]
 +
     if not monsterStats then
 +
       monsterStats = mw.loadData("Module:Monsters stats normal/data")
 +
       if not monsterStats then
 +
        return nil
 +
      end
 
     end
 
     end
 
   end
 
   end
 +
  return monsterStats
 +
end
  
   return sign * bestNumerator, bestDenominator
+
local function calcThreat(monster)
 +
  local damageThreat = 2
 +
  local weaponThreat = 3
 +
  local armorThreat = 5
 +
  local attackSpeedThreat = 3
 +
  local attackSpeedThreatLevel = 2.4 / monster.attackSpeed
 +
  local attackSpeedThreatFinal = attackSpeedThreatLevel * attackSpeedThreat
 +
  local potentialDamageThreatFinal = (monster.attack + monster.strength + monster.magic + monster.range) * damageThreat
 +
  local weaponThreatFinal = (monster.weapon.dexterity + monster.weapon.intellect + monster.weapon.strength) * weaponThreat
 +
  local targetArmorRating = monster.armor.protection + monster.armor.resistance + monster.armor.agility * 1.5
 +
  local armorThreatFinal = (targetArmorRating + monster.defense * 10) * armorThreat
 +
  local baseThreat = (potentialDamageThreatFinal + weaponThreatFinal) * attackSpeedThreatFinal + armorThreatFinal
 +
   return math.floor(baseThreat)
 
end
 
end
  
---Converts a floating-point number to a approximation of fraction as string.
+
local function fullUrl(url)
---@param num number The floating-point number to convert.
+
   local newUrl = url
---@param maxDenominator number The maximum denominator for the fraction.
+
  if url:sub(1, 5) == "https" then
---@return string The fraction as string ('numerator/denominator').
+
     return newUrl
local function floatToFractionString(num, maxDenominator)
+
  end
   local numerator, denominator = floatToFraction(num, maxDenominator)
+
  if url:sub(1, 1) ~= "/" then
  if denominator == 1 then
+
    newUrl = "/" .. newUrl
     return tostring(numerator)
 
 
   end
 
   end
   return string.format("%d/%d", numerator, denominator)
+
   newUrl = "https://www.play.idlescape.com" .. newUrl
 +
  return newUrl
 +
end
 +
 
 +
local function createImgTag(monster)
 +
  local attrs = {
 +
    src = fullUrl(monster.image),
 +
    alt = monster.name,
 +
    width = "150px"
 +
  }
 +
  local img = mw.html.create('img'):attr(attrs)
 +
  return tostring(img)
 
end
 
end
  
---Formats a number up to a given number of decimal places, removing trailing zeros.
+
--- Formats a number up to a given number of decimal places, removing trailing zeros.
---@param num number The number to format.
+
--- @param num number The number to format.
---@param digits number The number of decimal places to include (must be a non-negative integer).
+
--- @param digits number The number of decimal places to include (must be a non-negative integer).
---@return string The formatted number as a string.
+
--- @return string The formatted number as a string.
 
local function toFixed(num, digits)
 
local function toFixed(num, digits)
 
   digits = math.max(0, math.floor(digits))
 
   digits = math.max(0, math.floor(digits))
Line 149: Line 225:
 
end
 
end
  
---Finds the ID of a monster that is overridden by another monster with the given name.
+
local function createOffAffinity(args, monsterStats)
---@param name string The name of the monster that overrides another monster.
+
  for index, value in pairsByKeys(defaultAffinities) do
---@return string|nil The ID of the monster that is overridden, or nil if not found.
+
    for affinity, affinityValue in pairs(value) do
local function findIdByOverrideName(name)
+
      if monsterStats.offensiveDamageAffinity[affinity] then
  local monsterStatsData = mw.loadData("Module:Monsters_stats/data")
+
        affinityValue = monsterStats.offensiveDamageAffinity[affinity]
   for id, monster in pairs(monsterStatsData) do
+
        args[sl()] = affinitiesIcon[index]
     for overrideName, overrideMonster in pairs(monster.overrides or {}) do
+
        if affinityValue > 1 then
       if overrideName == name then
+
          args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
         return id
+
        elseif affinityValue < 1 then
 +
          args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
 +
        else
 +
          args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
 +
        end
 +
      else
 +
        args[sl()] = affinitiesIcon[index]
 +
        args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
 +
      end
 +
      if index % 3 == 0 then
 +
        args["bodyclass"] = "equal-space"
 +
        args[sbreak()] = "yes"
 +
      end
 +
    end
 +
  end
 +
  return args
 +
end
 +
 
 +
local function createDeffAffinity(args, monsterStats)
 +
   for index, value in pairsByKeys(defaultAffinities) do
 +
     for affinity, affinityValue in pairs(value) do
 +
      if monsterStats.defensiveDamageAffinity[affinity] then
 +
        affinityValue = monsterStats.defensiveDamageAffinity[affinity]
 +
        args[sl()] = affinitiesIcon[index]
 +
        if affinityValue > 1 then
 +
          args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
 +
        elseif affinityValue < 1 then
 +
          args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
 +
        else
 +
          args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
 +
        end
 +
      else
 +
        args[sl()] = affinitiesIcon[index]
 +
        args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
 +
      end
 +
       if index % 3 == 0 then
 +
         args["bodyclass"] = "equal-space"
 +
        args[sbreak()] = "yes"
 
       end
 
       end
 
     end
 
     end
 
   end
 
   end
 +
  return args
 
end
 
end
  
 +
local function createWikitextLinks(titles)
 +
  local s = ""
 +
  if titles then
 +
    for index, value in ipairs(titles) do
 +
      s = s .. "[[" .. capitalize(value) .. "]]"
 +
      if tablelength(titles) > index then
 +
        s = s .. ", "
 +
      end
 +
    end
 +
  end
 +
  return s
 +
end
  
function p.lootTable(frame)
+
---Creates an Wikitext link with an image inside a div.
   local args = frame:getParent().args
+
---@param title string # The title of the link.
   return tostring(p._lootTable(args))
+
---@param imageAttributes table # The html attributes for the image.
 +
---@param divCSS table # The CSS styles for the div.
 +
---@return string # The Wikitext link with the image.
 +
local function createWikitextImage(title, imageAttributes, divCSS)
 +
  divCSS = divCSS or {}
 +
  divCSS["display"] = divCSS["display"] or "inline-block" --Makes the div same size as the image
 +
   local e = mw.html.create("div")
 +
    :css(divCSS)
 +
    :tag("img")
 +
    :attr(imageAttributes)
 +
    :done()
 +
   return string.format('[[%s|%s]]', title, tostring(e))
 
end
 
end
  
function p._lootTable(args)
+
---Creates a Wikitext of links and images for the abilities.
  local sourceName = args.name or args.title or mw.title.getCurrentTitle().text
+
---@param abilities table[] # Array of abilities, from monsters stats.
  --Some monsters use same id as other monsters so we need to specify location for them
+
---@param unique? boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
  local locationId = args["location"] and findSourceId(args["location"])
+
---@return string # The Wikitext of links and images for the abilities.
   local sourceId
+
local function createAbilityRotation(abilities, unique)
  local sourceType
+
   local colors = {
  if locationId then
+
    Melee = "red",
     sourceId = findIdByOverrideName(sourceName)
+
     Range = "green",
     sourceType = "monster"
+
     Magic = "blue",
   else
+
   }
    sourceId, sourceType = findSourceId(sourceName)
+
 
   end
+
  local seenIds = {}
   if not sourceId then
+
   local images = {}
    return locationId and
+
   for _, ability in ipairs(abilities) do
       createErrorMessage(
+
    if not seenIds[ability.id] then
         "Found no monsters overridden by '" .. sourceName ..
+
      seenIds[ability.id] = unique and true or false
         "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
+
       local a = abilitiesData[tostring(ability.id)]
       ) or
+
      local attributes = {
       createErrorMessage(
+
         src="https://www.play.idlescape.com" .. a.abilityImage,
         "No monster or location named '" .. sourceName ..
+
         alt=a.abilityName .. ". " .. a.description,
         "'. The Module:Loot/data may be outdated."
+
        width=30
       )
+
       }
 +
       local css = {
 +
         ["box-shadow"]=string.format("0 0 2px 1px %s", colors[a.damageType] or "white"),
 +
        background=colors[a.damageType] or "white",
 +
         margin="5px"
 +
       }
 +
      table.insert(images, createWikitextImage(a.abilityName, attributes, css))
 +
    end
 
   end
 
   end
  
   local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
+
  return table.concat(images, "")
  if not loot then
+
end
    return createErrorMessage(
+
 
       "No loot found for monster or location named '" .. sourceName ..
+
---Gets the monster's DPS and damage type. Converted to Lua from CombatStats.tsx getAbilityInfo()
       "' The Module:Loot/data may be outdated."
+
---@param stats table # ICombatStatsData
    )
+
---@return {dps: number, primaryDamageType: string}
  end
+
local function getDamageInfo(stats)
 +
  local dps = 0
 +
  local timeLeft = 1
 +
  local attackSpeed = stats.attackSpeed
 +
  local str = stats.strength * 2 + (stats.masteryStrength or 0) + stats.weapon.strength * 2
 +
  local mgc = stats.magic * 2 + (stats.masteryMagic or 0) + stats.weapon.intellect * 2
 +
   local rng = stats.range * 2 + (stats.masteryRange or 0) + stats.weapon.dexterity * 2
 +
  local minHitMult = stats.hitMults.minimum
 +
  local maxHitMult = stats.hitMults.maximum
 +
  local meleeAbilityCount = 0
 +
  local magicAbilityCount = 0
 +
  local rangeAbilityCount = 0
 +
  local bestOffensiveScaling = {[1]="none", [2]=0}
 +
  local critChance = stats.offensiveCritical.chance
 +
  local critDamage = stats.offensiveCritical.damageMultiplier
 +
 
 +
  for _, value in pairs(stats.abilities) do
 +
    local ability = abilitiesData[tostring(value.id)]
 +
    if ability then
 +
      local min = minHitMult * ability.baseMinimumDamageCoeff
 +
      local max = maxHitMult * ability.baseMaximumDamageCoeff
 +
      local damage = 0
 +
 
 +
      if ability.damageType == "Magic" then
 +
        damage = (min * mgc + max * mgc) / 2
 +
        magicAbilityCount = magicAbilityCount + 1
 +
      elseif ability.damageType == "Melee" then
 +
        damage = (min * str + max * str) / 2
 +
        meleeAbilityCount = meleeAbilityCount + 1
 +
       elseif ability.damageType == "Range" then
 +
        damage = (min * rng + max * rng) / 2
 +
        rangeAbilityCount = rangeAbilityCount + 1
 +
      end
 +
 
 +
      local damageBonus = 0
 +
      for _, damageScale in ipairs(ability.damageScaling) do
 +
        local scalingHere = (stats.offensiveDamageAffinity[damageScale.affinity] or 1) * damageScale.scaling
 +
        if scalingHere > 1 then
 +
          damageBonus = damageBonus + scalingHere
 +
        end
 +
        if scalingHere > bestOffensiveScaling[2] then
 +
          bestOffensiveScaling = {damageScale.affinity, scalingHere}
 +
        end
 +
      end
 +
 
 +
       damageBonus = damageBonus / (#ability.damageScaling > 0 and #ability.damageScaling or 1)
 +
      damage = damage * damageBonus
 +
      damage = damage * (critChance * critDamage + (1 - critChance))
  
  local lang = mw.language.getContentLanguage()
+
      local dot = ability.healthChangeEvent
 +
      if dot and not dot.targetsSelf and not ability.targetFriendly then
 +
        damage = damage + dot.dotCount * -dot.healthChange
 +
      end
  
  ---@param name string Wikitext.
+
      local abilitySpeed = attackSpeed * ability.baseSpeedCoeff
  ---@return mw.html mw.html TH element.
+
       local cooldown = (ability.cooldown or 0) / 1000
  local function createTh(name)
+
       if cooldown == 0 or abilitySpeed / cooldown > timeLeft then
    local th = mw.html.create("th")
+
        dps = dps + (damage / abilitySpeed) * timeLeft
       :addClass("headerSort")
+
        timeLeft = 0
       :attr("tabindex", 0)
+
        break
      :attr("title", "Sort ascending")
+
      end
      :wikitext(name)
 
    return th
 
  end
 
  
  ---@param names string[] Array of wikitext.
+
      dps = dps + damage / cooldown
  ---@return mw.html TR element containing names as TH elements.
+
       timeLeft = timeLeft - abilitySpeed / cooldown
  local function createThs(names)
 
    local tr = mw.html.create("tr")
 
    for _, name in ipairs(names) do
 
       tr:node(createTh(name))
 
 
     end
 
     end
    return tr
 
 
   end
 
   end
  
   ---Creates a mw.html A element containing an image.
+
   local rotationFocus
   ---@param name string The name of the item.
+
   if meleeAbilityCount > magicAbilityCount + rangeAbilityCount then
  ---@param src string The URI to the item image.
+
     rotationFocus = "Melee"
  ---@param width? string|integer The width of the item image as integer.
+
  elseif magicAbilityCount > meleeAbilityCount + rangeAbilityCount then
  ---@param height? string|integer The height of the item image as integer.
+
    rotationFocus = "Magic"
  ---@return mw.html mw.html A element containing an image.
+
  elseif rangeAbilityCount > meleeAbilityCount + magicAbilityCount then
  local function createImg(name, src, alt, width, height)
+
     rotationFocus = "Range"
     local e = mw.html.create("a")
+
  else
      :attr("href", tostring(mw.uri.localUrl(name)))
+
     rotationFocus = "Hybrid"
      :attr("title", name)
 
      :tag("img")
 
      :attr("src", src)
 
      :attr("alt", alt)
 
     if width then
 
      e:attr("width", width)
 
    end
 
     if height then
 
      e:attr("height", height)
 
    end
 
    return e:done()
 
 
   end
 
   end
  
   local function createImgText(name, src, alt, width, height)
+
   local dataToReturn = {dps = dps, primaryDamageType = rotationFocus}
    return string.format(
+
  return dataToReturn
      '[[%s|<img src="%s" alt="%s"%s%s>]]',
+
end
      name,
+
 
      src,
+
---Creates an infobox for the monster.
      alt,
+
---@param monster table # The monster object from Module:Monsters/data.
      width and ' width="' .. width .. '"' or "",
+
---@param monsterStats table # The monster stats object from Module:Monsters stats/data.
      height and ' heigth="' .. height .. '"' or ""
+
---@param zones string[] # The zones where the monster can be found.
    )
+
---@param dps string # The monster's DPS and damage type.
   end
+
---@param unique boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
 +
---@return table # mw.html element; the infobox table.
 +
local function createInfobox(monster, monsterStats, zones, dps, unique)
 +
   local args = {}
  
   ---Creates an Wikitext link with an image.
+
   args.autoheaders = "y"
   ---@param title string The title of the link.
+
   args.subbox = "no"
   ---@param imageAttributes table The html attributes for the image.
+
   args.bodystyle = " "
   ---@return string Wikitext link with the image.
+
   args.title = monster.name
   local function createWikitextImage(title, imageAttributes)
+
   args.image = createImgTag(monster)
    local image = mw.html.create("img"):attr(imageAttributes)
+
  args[l()] = "Zones"
    return string.format('[[%s|%s]]', title, tostring(image))
+
  args[d()] = createWikitextLinks(zones)
 +
  args[l()] = "Species"
 +
  args[d()] = createWikitextLinks(monsterStats.species)
 +
  args[h()] = "Abilities"
 +
  if not unique then
 +
    args[sl()] = "Ability Rotation"
 
   end
 
   end
 +
  args[sd()] = createAbilityRotation(monsterStats.abilities, unique)
 +
  args[h()] = "Offensive Stats"
 +
  args[sl()] = "Attack Speed"
 +
  args[sd()] = tostring(monsterStats.attackSpeed)
 +
  args[sl()] = "DPS"
 +
  args[sd()] = tostring(dps)
 +
  args[sl()] = "Crit Chance"
 +
  args[sd()] = tostring(monsterStats.offensiveCritical.chance * 100) .. "%"
 +
  args[sl()] = "Crit Multiplier"
 +
  args[sd()] = tostring(monsterStats.offensiveCritical.damageMultiplier * 100) .. "%"
 +
  args[h()] = "Offensive Affinities"
 +
  createOffAffinity(args, monsterStats)
 +
  args[h()] = "Defensive Stats"
 +
  args[sl()] = "Threat"
 +
  args[sd()] = calcThreat(monsterStats)
 +
  args[sl()] = "Crit Avoidance"
 +
  args[sd()] = tostring(monsterStats.defensiveCritical.chance * 100) .. "%"
 +
  args[sl()] = "Crit Reduction"
 +
  args[sd()] = tostring(monsterStats.defensiveCritical.damageMultiplier * 100) .. "%"
 +
  args[h()] = "Defensive Affinities"
 +
  createDeffAffinity(args, monsterStats)
  
   ---Formats the value of an item as string.
+
   for key, data in pairs(args) do
  ---@param item table Item table from Module:Items/data
+
     if string.find(key, "data") then
  ---@return string The formatted value of the item as string, or '-' if no value.
+
       args[key] = tostring(data)
  local function createValueText(item)
 
     if item.id == 1 then -- Gold, has no value in the data
 
       return "1"
 
 
     end
 
     end
    return item.value and lang:formatNum(item.value) or "-"
 
 
   end
 
   end
  
   ---Formats the Item drop quantities as string.
+
   return infoboxModule.infobox(args)
  ---@param item Item Item table from Module:Loot/data
+
end
  ---@return string The formatted quantity range as string.
+
 
  local function createQuantityText(item)
+
---Finds the overriding monster with the given name.
     if item.minAmount == item.maxAmount then
+
---@param name string The name of the monster that overrides another monster.
       return tostring(item.minAmount)
+
---@return string|nil The ID of the monsters, or nil if not found.
 +
---@return table|nil The stats for monster that overrides, or nil if not found.
 +
local function findMonsterByOverrideName(name)
 +
  for id, monster in pairs(monstersStats) do
 +
     for overrideName, overrideMonster in pairs(monster.overrides or {}) do
 +
      if overrideName == name then
 +
        return id, overrideMonster
 +
       end
 
     end
 
     end
    return lang:formatNum(item.minAmount) .. "–" .. lang:formatNum(item.maxAmount)
 
 
   end
 
   end
 +
end
 +
 +
 +
function p.infoboxMonster(frame)
 +
  local args = frame:getParent().args
 +
  return p._infoboxMonster(args)
 +
end
 +
 +
function p._infoboxMonster(_args)
 +
  local name = _args[1] or _args["name"] or mw.title.getCurrentTitle().text
 +
  local zones = _args["zones"] and fromCSV(_args["zones"]) or nil
 +
  local unique = (_args["unique"] or _args["abilityList"]) and true or false
  
  --TODO: Add option to show percentages as fractions (open options from the gear icon)
+
   local monsterError = "<div style=\"color:red\"> No monster named '" .. name ..
  --      Saving preferences would require special permissions? MAybe with mw.user or javascript to localStorage?
+
     "'</div>. Some of the Modules: Monsters/data, Monsters_Ids/data or Monsters_stats/data might be outdated."
   local tableHtml = mw.html.create("table")
 
    :addClass("wikitable sortable jquery-tablesorter")
 
     --:node(createThs({ "⚙️", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
 
    :node(createThs({ " ", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
 
  
   for _, item in ipairs(loot) do
+
   local overrideStats
    local item2 = itemData[tostring(item.id)]
+
  local id = findId._findId({name, "monster"})
    --IS itemList.ts has some incorrect URIs, e.g. for Feather
+
  if id == "id not found" then
    local src = item2.itemImage
+
     --Some monsters use same id as other monsters so we need to fetch their info differently
    src = src:sub(1, 1) ~= "/" and "/" .. src or src
+
     id, overrideStats = findMonsterByOverrideName(name)
    local imageHtml = mw.html.create("td")
+
     if not id then
      :wikitext(createWikitextImage(
+
       return monsterError
        item2.name,  
 
        {
 
          src="https://www.play.idlescape.com" .. src,
 
          alt=item2.name .. (item2.extraTooltip and ".\n" .. item2.extraTooltip or ""),
 
          width=args.width or 30,
 
          heigth=args.height or nil
 
        }
 
      ))
 
    local itemHtml = mw.html.create("td")
 
      :wikitext('[[' .. item2.name .. ']]')
 
    local quantityHtml = mw.html.create("td")
 
      :css("text-align", "center")
 
      :wikitext(createQuantityText(item))
 
     local chanceHtml = mw.html.create("td")
 
      :css("text-align", "center")
 
      :wikitext(toFixed(item.chance * 100, 3) .. "%")
 
    local priceHtml = mw.html.create("td")
 
      :css("text-align", "right")
 
      :wikitext(createValueText(item2))
 
     local tradeableHtml = mw.html.create("td")
 
      :css("text-align", "center")
 
      :wikitext(item2.tradeable and "Yes" or "No")
 
    local leagueHtml = mw.html.create("td")
 
      :css("text-align", "center")
 
    --TODO: Add indicator if league is inactive, the field is present in
 
    --      leagueList.ts / Module:Leagues/data but some are incorrect
 
     if item.allowedLeagues then
 
       for _, leagueId in ipairs(item.allowedLeagues) do
 
        local league = leagueData[tostring(leagueId)]
 
        if league then
 
          leagueHtml:wikitext(createWikitextImage(
 
            league.name,
 
            {
 
              src="https://www.play.idlescape.com" .. league.icon,
 
              alt=league.name,
 
              width=args.width or 30,
 
              heigth=args.height or nil
 
            }
 
          ))
 
        else
 
          leagueHtml:wikitext("?")
 
        end
 
      end
 
    else
 
      leagueHtml:wikitext("All")
 
 
     end
 
     end
 +
  end
 +
  local monster = overrideStats and {name=name, image=overrideStats.imageOverride} or getMonster(id)
 +
  if not monster then
 +
    return monsterError
 +
  end
 +
  local stats = overrideStats or getMonsterStats(id)
 +
  if not stats then
 +
    return monsterError
 +
  end
  
     local rowHtml = mw.html.create("tr")
+
  local dps
      :node(imageHtml)
+
  if _args["DPS"] then
      :node(itemHtml)
+
     dps = _args["DPS"]
      :node(quantityHtml)
+
  else
      :node(chanceHtml)
+
    local damageInfo = getDamageInfo(stats)
      :node(priceHtml)
+
    dps = string.format("%d (%s)", damageInfo.dps, damageInfo.primaryDamageType)
      :node(tradeableHtml)
 
      :node(leagueHtml)
 
 
 
    tableHtml:node(rowHtml)
 
 
   end
 
   end
  
   return tableHtml
+
  local infobox = createInfobox(monster, stats, zones, dps, unique)
 +
   return infobox
 
end
 
end
  
 
return p
 
return p

Revision as of 02:42, 10 April 2025


local p = {}
local findId = require("Module:FindId")
local infoboxModule = require('Module:Infobox')
local abilitiesData = mw.loadData("Module:Abilities/data")
local monstersData = mw.loadData("Module:Monsters/data")
local monstersStats = mw.loadData("Module:Monsters stats/data")
local headerCount = 1
local labelCount = 1
local dataCount = 1

local defaultAffinities = {
  {Melee = 1},
  {Magic = 1},
  {Range = 1},
  {Piercing = 1},
  {Blunt = 1},
  {Slashing = 1},
  {Fire = 1},
  {Ice = 1},
  {Nature = 1},
  {Chaos = 1},
  {Posion = 1},
  {Lightning = 1}
}
local affinitiesIcon = {
  [1] = "[[File:Melee splash.png|20px|link=Combat#Affinities]]",
  [2] = "[[File:Magic splash.png|20px|link=Combat#Affinities]]",
  [3] = "[[File:Range splash.png|20px|link=Combat#Affinities]]",
  [4] = "[[File:Stab splash.png|20px|link=Combat#Affinities]]",
  [5] = "[[File:Crush splash.png|20px|link=Combat#Affinities]]",
  [6] = "[[File:Slash splash.png|20px|link=Combat#Affinities]]",
  [7] = "[[File:Fire_splash.png|20px|link=Combat#Affinities]]",
  [8] = "[[File:Ice_splash.png|20px|link=Combat#Affinities]]",
  [9] = "[[File:Nature_splash.png|20px|link=Combat#Affinities]]",
  [10] = "[[File:Chaos_splash.png|20px|link=Combat#Affinities]]",
  [11] = "[[File:Poison_splash.png|20px|link=Combat#Affinities]]",
  [12] = "[[File:Lightning_splash.png|20px|link=Combat#Affinities]]"
}

-- Convert from CSV string to table (converts a single line of a CSV file)
local function fromCSV(s)
  if string.sub(s, -1) ~= ',' then
    s = s .. ',' -- ending comma
  end
  s = string.gsub(s, "[%[%]]", ""):gsub(",%s", ",")
  local t = {} -- table to collect fields
  local fieldstart = 1
  repeat
    local nexti = string.find(s, ',', fieldstart)
    table.insert(t, string.sub(s, fieldstart, nexti - 1))
    fieldstart = nexti + 1
  until fieldstart > string.len(s)
  return t
end

local function pairsByKeys(t, f)
  local a = {}
  local orgi_key_type
  for n in pairs(t) do
    if tonumber(n) == nil then
      table.insert(a, n)
      orgi_key_type = "word"
    elseif type(n) == "number" then
      table.insert(a, n)
      orgi_key_type = "int"
    elseif type(n) == "string" and type(tonumber(n) == "number") then
      orgi_key_type = "number"
      table.insert(a, tonumber(n))
    end
  end
  table.sort(a, f)
  local key
  local value
  local i = 0 -- iterator variable
  local iter = function() -- iterator function
    i = i + 1
    if a[i] == nil then
      return nil
    elseif orgi_key_type == "word" or orgi_key_type == "int" then
      key = a[i]
      value = t[a[i]]
    elseif orgi_key_type == "number" then
      key = tostring(a[i])
      value = t[tostring(a[i])]
    end
    return key, value
  end
  return iter
end

local function tchelper(first, rest)
  return first:upper() .. rest:lower()
end

local function capitalize(s)
  s = s:gsub("(%a)([%w_']*)", tchelper):gsub(" Of ", " of "):gsub(" The ", " the "):gsub("Ii", "II")
  return s
end

local function tablelength(T)
  local count = 0
  for _ in pairs(T) do
    count = count + 1
  end
  return count
end

local function h()
  local s = "header" .. headerCount
  headerCount = headerCount + 1
  labelCount = headerCount
  dataCount = headerCount
  return s
end

local function sbreak()
  local s = "sbreak" .. headerCount
  headerCount = headerCount + 1
  labelCount = headerCount
  dataCount = headerCount
  return s
end

local function l()
  local s = "label" .. labelCount
  dataCount = labelCount
  labelCount = labelCount + 1
  headerCount = labelCount
  return s
end

local function d()
  local s = "data" .. dataCount
  dataCount = dataCount + 1
  headerCount = dataCount
  labelCount = dataCount
  return s
end

local function sl()
  local s = "s" .. l {}
  return s
end

local function sd()
  local s = "s" .. d {}
  return s
end

local function rc()
  local s = "rowclass" .. labelCount
  return s
end

---Fetches a monster object from Module:Monsters/data.
---@param id number # The monster's ID.
---@return table? # The monster object, or nil if not found.
local function getMonster(id)
  local monster = monstersData[tostring(id)]
  if monster then
    return monster
  end
end

local function getMonsterStats(id)
  local monsterStats = monstersStats[tostring(id)]
  if not monsterStats then
    local dungeonMonsters = mw.loadData("Module:Monsters stats dungeon/data")
    monsterStats = dungeonMonsters[tostring(id)]
    if not monsterStats then
      monsterStats = mw.loadData("Module:Monsters stats normal/data")
      if not monsterStats then
        return nil
      end
    end
  end
  return monsterStats
end

local function calcThreat(monster)
  local damageThreat = 2
  local weaponThreat = 3
  local armorThreat = 5
  local attackSpeedThreat = 3
  local attackSpeedThreatLevel = 2.4 / monster.attackSpeed
  local attackSpeedThreatFinal = attackSpeedThreatLevel * attackSpeedThreat
  local potentialDamageThreatFinal = (monster.attack + monster.strength + monster.magic + monster.range) * damageThreat
  local weaponThreatFinal = (monster.weapon.dexterity + monster.weapon.intellect + monster.weapon.strength) * weaponThreat
  local targetArmorRating = monster.armor.protection + monster.armor.resistance + monster.armor.agility * 1.5
  local armorThreatFinal = (targetArmorRating + monster.defense * 10) * armorThreat
  local baseThreat = (potentialDamageThreatFinal + weaponThreatFinal) * attackSpeedThreatFinal + armorThreatFinal
  return math.floor(baseThreat)
end

local function fullUrl(url)
  local newUrl = url
  if url:sub(1, 5) == "https" then
    return newUrl
  end
  if url:sub(1, 1) ~= "/" then
    newUrl = "/" .. newUrl
  end
  newUrl = "https://www.play.idlescape.com" .. newUrl
  return newUrl
end

local function createImgTag(monster)
  local attrs = {
    src = fullUrl(monster.image),
    alt = monster.name,
    width = "150px"
  }
  local img = mw.html.create('img'):attr(attrs)
  return tostring(img)
end

--- Formats a number up to a given number of decimal places, removing trailing zeros.
--- @param num number The number to format.
--- @param digits number The number of decimal places to include (must be a non-negative integer).
--- @return string The formatted number as a string.
local function toFixed(num, digits)
  digits = math.max(0, math.floor(digits))
  local formatted = string.format("%." .. digits .. "f", num):gsub("%.?0+$", "")
  return formatted
end

local function createOffAffinity(args, monsterStats)
  for index, value in pairsByKeys(defaultAffinities) do
    for affinity, affinityValue in pairs(value) do
      if monsterStats.offensiveDamageAffinity[affinity] then
        affinityValue = monsterStats.offensiveDamageAffinity[affinity]
        args[sl()] = affinitiesIcon[index]
        if affinityValue > 1 then
          args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
        elseif affinityValue < 1 then
          args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
        else
          args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
        end
      else
        args[sl()] = affinitiesIcon[index]
        args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
      end
      if index % 3 == 0 then
        args["bodyclass"] = "equal-space"
        args[sbreak()] = "yes"
      end
    end
  end
  return args
end

local function createDeffAffinity(args, monsterStats)
  for index, value in pairsByKeys(defaultAffinities) do
    for affinity, affinityValue in pairs(value) do
      if monsterStats.defensiveDamageAffinity[affinity] then
        affinityValue = monsterStats.defensiveDamageAffinity[affinity]
        args[sl()] = affinitiesIcon[index]
        if affinityValue > 1 then
          args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
        elseif affinityValue < 1 then
          args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
        else
          args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
        end
      else
        args[sl()] = affinitiesIcon[index]
        args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
      end
      if index % 3 == 0 then
        args["bodyclass"] = "equal-space"
        args[sbreak()] = "yes"
      end
    end
  end
  return args
end

local function createWikitextLinks(titles)
  local s = ""
  if titles then
    for index, value in ipairs(titles) do
      s = s .. "[[" .. capitalize(value) .. "]]"
      if tablelength(titles) > index then
        s = s .. ", "
      end
    end
  end
  return s
end

---Creates an Wikitext link with an image inside a div.
---@param title string # The title of the link.
---@param imageAttributes table # The html attributes for the image.
---@param divCSS table # The CSS styles for the div.
---@return string # The Wikitext link with the image.
local function createWikitextImage(title, imageAttributes, divCSS)
  divCSS = divCSS or {}
  divCSS["display"] = divCSS["display"] or "inline-block" --Makes the div same size as the image
  local e = mw.html.create("div")
    :css(divCSS)
    :tag("img")
    :attr(imageAttributes)
    :done()
  return string.format('[[%s|%s]]', title, tostring(e))
end

---Creates a Wikitext of links and images for the abilities.
---@param abilities table[] # Array of abilities, from monsters stats.
---@param unique? boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
---@return string # The Wikitext of links and images for the abilities.
local function createAbilityRotation(abilities, unique)
  local colors = {
    Melee = "red",
    Range = "green",
    Magic = "blue",
  }

  local seenIds = {}
  local images = {}
  for _, ability in ipairs(abilities) do
    if not seenIds[ability.id] then
      seenIds[ability.id] = unique and true or false
      local a = abilitiesData[tostring(ability.id)]
      local attributes = {
        src="https://www.play.idlescape.com" .. a.abilityImage,
        alt=a.abilityName .. ". " .. a.description,
        width=30
      }
      local css = {
        ["box-shadow"]=string.format("0 0 2px 1px %s", colors[a.damageType] or "white"),
        background=colors[a.damageType] or "white",
        margin="5px"
      }
      table.insert(images, createWikitextImage(a.abilityName, attributes, css))
    end
  end

  return table.concat(images, "")
end

---Gets the monster's DPS and damage type. Converted to Lua from CombatStats.tsx getAbilityInfo()
---@param stats table # ICombatStatsData
---@return {dps: number, primaryDamageType: string}
local function getDamageInfo(stats)
  local dps = 0
  local timeLeft = 1
  local attackSpeed = stats.attackSpeed
  local str = stats.strength * 2 + (stats.masteryStrength or 0) + stats.weapon.strength * 2
  local mgc = stats.magic * 2 + (stats.masteryMagic or 0) + stats.weapon.intellect * 2
  local rng = stats.range * 2 + (stats.masteryRange or 0) + stats.weapon.dexterity * 2
  local minHitMult = stats.hitMults.minimum
  local maxHitMult = stats.hitMults.maximum
  local meleeAbilityCount = 0
  local magicAbilityCount = 0
  local rangeAbilityCount = 0
  local bestOffensiveScaling = {[1]="none", [2]=0}
  local critChance = stats.offensiveCritical.chance
  local critDamage = stats.offensiveCritical.damageMultiplier

  for _, value in pairs(stats.abilities) do
    local ability = abilitiesData[tostring(value.id)]
    if ability then
      local min = minHitMult * ability.baseMinimumDamageCoeff
      local max = maxHitMult * ability.baseMaximumDamageCoeff
      local damage = 0

      if ability.damageType == "Magic" then
        damage = (min * mgc + max * mgc) / 2
        magicAbilityCount = magicAbilityCount + 1
      elseif ability.damageType == "Melee" then
        damage = (min * str + max * str) / 2
        meleeAbilityCount = meleeAbilityCount + 1
      elseif ability.damageType == "Range" then
        damage = (min * rng + max * rng) / 2
        rangeAbilityCount = rangeAbilityCount + 1
      end

      local damageBonus = 0
      for _, damageScale in ipairs(ability.damageScaling) do
        local scalingHere = (stats.offensiveDamageAffinity[damageScale.affinity] or 1) * damageScale.scaling
        if scalingHere > 1 then
          damageBonus = damageBonus + scalingHere
        end
        if scalingHere > bestOffensiveScaling[2] then
          bestOffensiveScaling = {damageScale.affinity, scalingHere}
        end
      end

      damageBonus = damageBonus / (#ability.damageScaling > 0 and #ability.damageScaling or 1)
      damage = damage * damageBonus
      damage = damage * (critChance * critDamage + (1 - critChance))

      local dot = ability.healthChangeEvent
      if dot and not dot.targetsSelf and not ability.targetFriendly then
        damage = damage + dot.dotCount * -dot.healthChange
      end

      local abilitySpeed = attackSpeed * ability.baseSpeedCoeff
      local cooldown = (ability.cooldown or 0) / 1000
      if cooldown == 0 or abilitySpeed / cooldown > timeLeft then
        dps = dps + (damage / abilitySpeed) * timeLeft
        timeLeft = 0
        break
      end

      dps = dps + damage / cooldown
      timeLeft = timeLeft - abilitySpeed / cooldown
    end
  end

  local rotationFocus
  if meleeAbilityCount > magicAbilityCount + rangeAbilityCount then
    rotationFocus = "Melee"
  elseif magicAbilityCount > meleeAbilityCount + rangeAbilityCount then
    rotationFocus = "Magic"
  elseif rangeAbilityCount > meleeAbilityCount + magicAbilityCount then
    rotationFocus = "Range"
  else
    rotationFocus = "Hybrid"
  end

  local dataToReturn = {dps = dps, primaryDamageType = rotationFocus}
  return dataToReturn
end

---Creates an infobox for the monster.
---@param monster table # The monster object from Module:Monsters/data.
---@param monsterStats table # The monster stats object from Module:Monsters stats/data.
---@param zones string[] # The zones where the monster can be found.
---@param dps string # The monster's DPS and damage type.
---@param unique boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
---@return table # mw.html element; the infobox table.
local function createInfobox(monster, monsterStats, zones, dps, unique)
  local args = {}

  args.autoheaders = "y"
  args.subbox = "no"
  args.bodystyle = " "
  args.title = monster.name
  args.image = createImgTag(monster)
  args[l()] = "Zones"
  args[d()] = createWikitextLinks(zones)
  args[l()] = "Species"
  args[d()] = createWikitextLinks(monsterStats.species)
  args[h()] = "Abilities"
  if not unique then
    args[sl()] = "Ability Rotation"
  end
  args[sd()] = createAbilityRotation(monsterStats.abilities, unique)
  args[h()] = "Offensive Stats"
  args[sl()] = "Attack Speed"
  args[sd()] = tostring(monsterStats.attackSpeed)
  args[sl()] = "DPS"
  args[sd()] = tostring(dps)
  args[sl()] = "Crit Chance"
  args[sd()] = tostring(monsterStats.offensiveCritical.chance * 100) .. "%"
  args[sl()] = "Crit Multiplier"
  args[sd()] = tostring(monsterStats.offensiveCritical.damageMultiplier * 100) .. "%"
  args[h()] = "Offensive Affinities"
  createOffAffinity(args, monsterStats)
  args[h()] = "Defensive Stats"
  args[sl()] = "Threat"
  args[sd()] = calcThreat(monsterStats)
  args[sl()] = "Crit Avoidance"
  args[sd()] = tostring(monsterStats.defensiveCritical.chance * 100) .. "%"
  args[sl()] = "Crit Reduction"
  args[sd()] = tostring(monsterStats.defensiveCritical.damageMultiplier * 100) .. "%"
  args[h()] = "Defensive Affinities"
  createDeffAffinity(args, monsterStats)

  for key, data in pairs(args) do
    if string.find(key, "data") then
      args[key] = tostring(data)
    end
  end

  return infoboxModule.infobox(args)
end

---Finds the overriding monster with the given name.
---@param name string The name of the monster that overrides another monster.
---@return string|nil The ID of the monsters, or nil if not found.
---@return table|nil The stats for monster that overrides, or nil if not found.
local function findMonsterByOverrideName(name)
  for id, monster in pairs(monstersStats) do
    for overrideName, overrideMonster in pairs(monster.overrides or {}) do
      if overrideName == name then
        return id, overrideMonster
      end
    end
  end
end


function p.infoboxMonster(frame)
  local args = frame:getParent().args
  return p._infoboxMonster(args)
end

function p._infoboxMonster(_args)
  local name = _args[1] or _args["name"] or mw.title.getCurrentTitle().text
  local zones = _args["zones"] and fromCSV(_args["zones"]) or nil
  local unique = (_args["unique"] or _args["abilityList"]) and true or false

  local monsterError = "<div style=\"color:red\"> No monster named '" .. name ..
    "'</div>. Some of the Modules: Monsters/data, Monsters_Ids/data or Monsters_stats/data might be outdated."

  local overrideStats
  local id = findId._findId({name, "monster"})
  if id == "id not found" then
    --Some monsters use same id as other monsters so we need to fetch their info differently
    id, overrideStats = findMonsterByOverrideName(name)
    if not id then
      return monsterError
    end
  end
  local monster = overrideStats and {name=name, image=overrideStats.imageOverride} or getMonster(id)
  if not monster then
    return monsterError
  end
  local stats = overrideStats or getMonsterStats(id)
  if not stats then
    return monsterError
  end

  local dps
  if _args["DPS"] then
    dps = _args["DPS"]
  else
    local damageInfo = getDamageInfo(stats)
    dps = string.format("%d (%s)", damageInfo.dps, damageInfo.primaryDamageType)
  end

  local infobox = createInfobox(monster, stats, zones, dps, unique)
  return infobox
end

return p