Difference between revisions of "Module:Infobox Monster"

From Idlescape Wiki
Jump to navigation Jump to search
(Remove Abilities column subheader, use "Abilities" as header for ability rotation and "Ability list" for the unique list)
(Update crit multiplier to follow the same notation as ingame)
Line 455: Line 455:
 
   args[sd()] = tostring(monsterStats.offensiveCritical.chance * 100) .. "%"
 
   args[sd()] = tostring(monsterStats.offensiveCritical.chance * 100) .. "%"
 
   args[sl()] = "Crit Multiplier"
 
   args[sl()] = "Crit Multiplier"
   args[sd()] = tostring(monsterStats.offensiveCritical.damageMultiplier * 100) .. "%"
+
   args[sd()] = tostring(monsterStats.offensiveCritical.damageMultiplier)
 
   args[h()] = "Offensive Affinities"
 
   args[h()] = "Offensive Affinities"
 
   createOffAffinity(args, monsterStats)
 
   createOffAffinity(args, monsterStats)

Revision as of 11:01, 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 string|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
    monsterStats =  mw.loadData("Module:Monsters stats dungeon/data")[tostring(id)]
    if not monsterStats then
      monsterStats = mw.loadData("Module:Monsters stats normal/data")[tostring(id)]
      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 or {}) 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)
  if monsterStats.abilities then
    args[h()] = unique and "Ability list" or "Abilities"
    args[sd()] = createAbilityRotation(monsterStats.abilities, unique)
  end
  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)
  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 ..
    "'. Some of the Modules: Monsters/data, Monsters_Ids/data or Monsters_stats/data might be outdated.</div>"

  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 .. "<div style=\"color:red\">Please add monster stats to Module:Monsters_stats/data " .. 
      "if possible or try manually making <code><nowiki>{{Infobox}}</nowiki></code></div>"
  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