Difference between revisions of "Module:Infobox Monster"

From Idlescape Wiki
Jump to navigation Jump to search
(Fix fetch override info automatically, no need for "override" parameter.)
(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.)
Line 1: Line 1:
 
local p = {}
 
local p = {}
 
local findId = require("Module:FindId")
 
local findId = require("Module:FindId")
local infoboxModule = require('Module:Infobox')
+
local lootData = mw.loadData("Module:Loot/data")
local abilitiesData = mw.loadData("Module:Abilities/data")
+
local itemData = mw.loadData("Module:Items/data")
local monstersData = mw.loadData("Module:Monsters/data")
+
local leagueData = mw.loadData("Module:Leagues/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)
+
---Creates a mw.html 'DIV' element containing an "error" message.
local function fromCSV(s)
+
---@param message string Message as wikitext.
  if string.sub(s, -1) ~= ',' then
+
---@return mw.html The message wrapped in a mw.html DIV element as red text.
    s = s .. ',' -- ending comma
+
local function createErrorMessage(message)
  end
+
   local e = mw.html.create("div")
  s = string.gsub(s, "[%[%]]", ""):gsub(",%s", ",")
+
     :css("color", "red")
  local t = {} -- table to collect fields
+
     :wikitext(message)
  local fieldstart = 1
+
   return e
   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
  
local function pairsByKeys(t, f)
+
---Finds the source ID for a given name.
  local a = {}
+
---@param name string The name of the source.
  local orgi_key_type
+
---@return number|nil The source ID (monsterID or locationID), or nil if the source is not found.
  for n in pairs(t) do
+
---@return string|nil The source type ("monster" or "location"), or nil if the source is not found.
    if tonumber(n) == nil then
+
local function findSourceId(name)
      table.insert(a, n)
+
  for _, source in ipairs({ "monster", "location" }) do
      orgi_key_type = "word"
+
     local id = findId._findId({ name, source })
    elseif type(n) == "number" then
+
     if id ~= "id not found" then
      table.insert(a, n)
+
       return id, source
      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
 
end
  
local function tchelper(first, rest)
+
---@class Item
  return first:upper() .. rest:lower()
+
---@field allowedLeagues number[]|nil The leagues in which the item can be found. Found in all leagues if nil.
end
+
---@field id integer The item ID.
 +
---@field chance number The base chance of the item being dropped.
 +
---@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.
  
local function capitalize(s)
+
---Finds the first item in the loot table that matches the given item ID.
  s = s:gsub("(%a)([%w_']*)", tchelper):gsub(" Of ", " of "):gsub(" The ", " the "):gsub("Ii", "II")
+
---@param loot table The loot table from Module:Loot/data.
  return s
+
---@param id string|number The ID of the item to find.
end
+
---@return Item|nil The item data, or nil if the item is not found.
 
+
---@return string|nil The monsterId where the item was found ("-1" for locations), or nil if the item is not found.
local function tablelength(T)
+
---@return string|nil The locationId where the item was found, or nil if the item is not found.
   local count = 0
+
local function getFirstLootMatch(loot, id)
   for _ in pairs(T) do
+
   id = tostring(id)
     count = count + 1
+
   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
  
local function h()
+
---Finds the first source in the loot table that matches the given sourceId.
  local s = "header" .. headerCount
+
---@param loot table The loot table from Module:Loot/data.
  headerCount = headerCount + 1
+
---@param id string|number The ID of the source to find.
   labelCount = headerCount
+
---@param sourceType string String representing the type of the source ("monster" or "location").
   dataCount = headerCount
+
---@param locationId string|number|nil Optional ID for the location to find the source from. Use `id` and `sourceType` for location loot.
  return s
+
---@return Item[]|nil The source data, or nil if the source is not found.
end
+
---@return string|nil The locationId where the source was found (`id` if `sourceType` is "location"), or nil if the source is not found.
 +
local function getFirstSourceMatch(loot, id, sourceType, locationId)
 +
   id = tostring(id)
 +
   locationId = locationId and tostring(locationId) or nil
  
local function sbreak()
+
  if sourceType == "location" then
  local s = "sbreak" .. headerCount
+
    local location = loot[id]
  headerCount = headerCount + 1
+
    if not location then
  labelCount = headerCount
+
      return
  dataCount = headerCount
+
    end
  return s
+
    local source = location["-1"]
end
+
    if not source then
 +
      return
 +
    end
 +
    return source, id
 +
  end
  
local function l()
+
  -- Needed for 'override' monsters that share ID with other monsters
  local s = "label" .. labelCount
+
  --  otherwise the loot would be from first matching ID and could be wrong
  dataCount = labelCount
+
  if locationId then
  labelCount = labelCount + 1
+
    local location = loot[locationId]
  headerCount = labelCount
+
    for sourceId, source in pairs(location) do
  return s
+
      if sourceId == id then
end
+
        return source, locationId
 +
      end
 +
    end
 +
    return
 +
  end
  
local function d()
+
  for locationId, location in pairs(loot) do
  local s = "data" .. dataCount
+
    for sourceId, source in pairs(location) do
  dataCount = dataCount + 1
+
      if sourceId == id then
  headerCount = dataCount
+
        return source, locationId
  labelCount = dataCount
+
      end
   return s
+
    end
 +
   end
 
end
 
end
  
local function sl()
+
--TODO: Add option to show percentages as fractions
  local s = "s" .. l {}
 
  return s
 
end
 
  
local function sd()
+
---Converts a floating-point number to a approximation of fraction.
   local s = "s" .. d {}
+
---@param num number The floating-point number to convert.
   return s
+
---@param maxDenominator number The maximum denominator for the fraction.
end
+
---@return number The numerator of the fraction.
 +
---@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 function rc()
+
  local bestNumerator, bestDenominator = 1, 1
   local s = "rowclass" .. labelCount
+
   local minDifference = math.huge
  return s
 
end
 
  
---Fetches a monster object from Module:Monsters/data.
+
  for d = 1, maxDenominator do
---@param id number # The monster's ID.
+
    local n = math.floor(num * d + 0.5)
---@return table? # The monster object, or nil if not found.
+
    local approx = n / d
local function getMonster(id)
+
    local difference = math.abs(num - approx)
  local monster = monstersData[tostring(id)]
 
  if monster then
 
    return monster
 
  end
 
end
 
  
local function getMonsterStats(id)
+
     if difference < minDifference then
  local monsterStats = monstersStats[tostring(id)]
+
       bestNumerator, bestDenominator = n, d
  if not monsterStats then
+
       minDifference = difference
    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
 
  
local function calcThreat(monster)
+
   return sign * bestNumerator, bestDenominator
   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
  
local function fullUrl(url)
+
---Converts a floating-point number to a approximation of fraction as string.
   local newUrl = url
+
---@param num number The floating-point number to convert.
   if url:sub(1, 5) == "https" then
+
---@param maxDenominator number The maximum denominator for the fraction.
     return newUrl
+
---@return string The fraction as string ('numerator/denominator').
  end
+
local function floatToFractionString(num, maxDenominator)
  if url:sub(1, 1) ~= "/" then
+
   local numerator, denominator = floatToFraction(num, maxDenominator)
    newUrl = "/" .. newUrl
+
   if denominator == 1 then
 +
     return tostring(numerator)
 
   end
 
   end
   newUrl = "https://www.play.idlescape.com" .. newUrl
+
   return string.format("%d/%d", numerator, denominator)
  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 225: Line 149:
 
end
 
end
  
local function createOffAffinity(args, monsterStats)
+
---Finds the ID of a monster that is overridden by another monster with the given name.
  for index, value in pairsByKeys(defaultAffinities) do
+
---@param name string The name of the monster that overrides another monster.
    for affinity, affinityValue in pairs(value) do
+
---@return string|nil The ID of the monster that is overridden, or nil if not found.
      if monsterStats.offensiveDamageAffinity[affinity] then
+
local function findIdByOverrideName(name)
        affinityValue = monsterStats.offensiveDamageAffinity[affinity]
+
  local monsterStatsData = mw.loadData("Module:Monsters_stats/data")
        args[sl()] = affinitiesIcon[index]
+
  for id, monster in pairs(monsterStatsData) do
        if affinityValue > 1 then
+
    for overrideName, overrideMonster in pairs(monster.overrides or {}) do
          args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
+
       if overrideName == name then
        elseif affinityValue < 1 then
+
         return id
          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 createDeffAffinity(args, monsterStats)
+
 
   for index, value in pairsByKeys(defaultAffinities) do
+
function p.lootTable(frame)
    for affinity, affinityValue in pairs(value) do
+
   local args = frame:getParent().args
      if monsterStats.defensiveDamageAffinity[affinity] then
+
  return tostring(p._lootTable(args))
        affinityValue = monsterStats.defensiveDamageAffinity[affinity]
+
end
        args[sl()] = affinitiesIcon[index]
+
 
        if affinityValue > 1 then
+
function p._lootTable(args)
          args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
+
  local sourceName = args.name or args.title or mw.title.getCurrentTitle().text
        elseif affinityValue < 1 then
+
  --Some monsters use same id as other monsters so we need to specify location for them
          args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%<span/>"
+
  local locationId = args["location"] and findSourceId(args["location"])
        else
+
  local sourceId
          args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
+
  local sourceType
        end
+
  if locationId then
       else
+
    sourceId = findIdByOverrideName(sourceName)
         args[sl()] = affinitiesIcon[index]
+
    sourceType = "monster"
         args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
+
  else
       end
+
    sourceId, sourceType = findSourceId(sourceName)
       if index % 3 == 0 then
+
  end
         args["bodyclass"] = "equal-space"
+
  if not sourceId then
         args[sbreak()] = "yes"
+
    return locationId and
       end
+
       createErrorMessage(
    end
+
         "Found no monsters overridden by '" .. sourceName ..
 +
         "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
 +
       ) or
 +
       createErrorMessage(
 +
         "No monster or location named '" .. sourceName ..
 +
         "'. The Module:Loot/data may be outdated."
 +
       )
 
   end
 
   end
  return args
 
end
 
  
local function createWikitextLinks(titles)
+
  local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
  local s = ""
+
   if not loot then
   if titles then
+
     return createErrorMessage(
     for index, value in ipairs(titles) do
+
       "No loot found for monster or location named '" .. sourceName ..
       s = s .. "[[" .. capitalize(value) .. "]]"
+
       "' The Module:Loot/data may be outdated."
       if tablelength(titles) > index then
+
     )
        s = s .. ", "
 
      end
 
     end
 
 
   end
 
   end
  return s
 
end
 
  
---Creates an Wikitext link with an image inside a div.
+
   local lang = mw.language.getContentLanguage()
---@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 name string Wikitext.
---@param abilities table[] # Array of abilities, from monsters stats.
+
  ---@return mw.html mw.html TH element.
---@param unique? boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
+
  local function createTh(name)
---@return string # The Wikitext of links and images for the abilities.
+
    local th = mw.html.create("th")
local function createAbilityRotation(abilities, unique)
+
      :addClass("headerSort")
  local colors = {
+
      :attr("tabindex", 0)
    Melee = "red",
+
      :attr("title", "Sort ascending")
    Range = "green",
+
      :wikitext(name)
    Magic = "blue",
+
    return th
   }
+
   end
  
   local seenIds = {}
+
   ---@param names string[] Array of wikitext.
   local images = {}
+
   ---@return mw.html TR element containing names as TH elements.
  for _, ability in ipairs(abilities) do
+
  local function createThs(names)
    if not seenIds[ability.id] then
+
    local tr = mw.html.create("tr")
      seenIds[ability.id] = unique and true or false
+
    for _, name in ipairs(names) do
      local a = abilitiesData[tostring(ability.id)]
+
       tr:node(createTh(name))
      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 tr
 
   end
 
   end
  
   return table.concat(images, "")
+
   ---Creates a mw.html A element containing an image.
end
+
  ---@param name string The name of the item.
 
+
  ---@param src string The URI to the item image.
---Gets the monster's DPS and damage type. Converted to Lua from CombatStats.tsx getAbilityInfo()
+
  ---@param width? string|integer The width of the item image as integer.
---@param stats table # ICombatStatsData
+
  ---@param height? string|integer The height of the item image as integer.
---@return {dps: number, primaryDamageType: string}
+
   ---@return mw.html mw.html A element containing an image.
local function getDamageInfo(stats)
+
   local function createImg(name, src, alt, width, height)
  local dps = 0
+
    local e = mw.html.create("a")
  local timeLeft = 1
+
      :attr("href", tostring(mw.uri.localUrl(name)))
  local attackSpeed = stats.attackSpeed
+
       :attr("title", name)
   local str = stats.strength * 2 + (stats.masteryStrength or 0) + stats.weapon.strength * 2
+
       :tag("img")
   local mgc = stats.magic * 2 + (stats.masteryMagic or 0) + stats.weapon.intellect * 2
+
       :attr("src", src)
  local rng = stats.range * 2 + (stats.masteryRange or 0) + stats.weapon.dexterity * 2
+
       :attr("alt", alt)
  local minHitMult = stats.hitMults.minimum
+
    if width then
  local maxHitMult = stats.hitMults.maximum
+
       e:attr("width", width)
  local meleeAbilityCount = 0
+
    end
  local magicAbilityCount = 0
+
    if height then
  local rangeAbilityCount = 0
+
       e:attr("height", height)
  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
 +
    return e:done()
 
   end
 
   end
  
   local rotationFocus
+
   local function createImgText(name, src, alt, width, height)
  if meleeAbilityCount > magicAbilityCount + rangeAbilityCount then
+
    return string.format(
    rotationFocus = "Melee"
+
      '[[%s|<img src="%s" alt="%s"%s%s>]]',
  elseif magicAbilityCount > meleeAbilityCount + rangeAbilityCount then
+
      name,
    rotationFocus = "Magic"
+
      src,
  elseif rangeAbilityCount > meleeAbilityCount + magicAbilityCount then
+
      alt,
    rotationFocus = "Range"
+
      width and ' width="' .. width .. '"' or "",
  else
+
      height and ' heigth="' .. height .. '"' or ""
    rotationFocus = "Hybrid"
+
    )
 
   end
 
   end
  
   local dataToReturn = {dps = dps, primaryDamageType = rotationFocus}
+
  ---Creates an Wikitext link with an image.
  return dataToReturn
+
  ---@param title string The title of the link.
end
+
  ---@param imageAttributes table The html attributes for the image.
 +
  ---@return string Wikitext link with the image.
 +
   local function createWikitextImage(title, imageAttributes)
 +
    local image = mw.html.create("img"):attr(imageAttributes)
 +
    return string.format('[[%s|%s]]', title, tostring(image))
 +
  end
  
---Creates an infobox for the monster.
+
  ---Formats the value of an item as string.
---@param monster table # The monster object from Module:Monsters/data.
+
  ---@param item table Item table from Module:Items/data
---@param monsterStats table # The monster stats object from Module:Monsters stats/data.
+
  ---@return string The formatted value of the item as string, or '-' if no value.
---@param zones string[] # The zones where the monster can be found.
+
  local function createValueText(item)
---@param dps string # The monster's DPS and damage type.
+
    if item.id == 1 then -- Gold, has no value in the data
---@param unique boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
+
      return "1"
---@return table # mw.html element; the infobox table.
+
    end
local function createInfobox(monster, monsterStats, zones, dps, unique)
+
    return item.value and lang:formatNum(item.value) or "-"
  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
 
   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
+
   ---Formats the Item drop quantities as string.
     if string.find(key, "data") then
+
  ---@param item Item Item table from Module:Loot/data
       args[key] = tostring(data)
+
  ---@return string The formatted quantity range as string.
 +
  local function createQuantityText(item)
 +
     if item.minAmount == item.maxAmount then
 +
       return tostring(item.minAmount)
 
     end
 
     end
 +
    return lang:formatNum(item.minAmount) .. "–" .. lang:formatNum(item.maxAmount)
 
   end
 
   end
  
   return infoboxModule.infobox(args)
+
   --TODO: Add option to show percentages as fractions (open options from the gear icon)
end
+
  --      Saving preferences would require special permissions? MAybe with mw.user or javascript to localStorage?
 +
  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" }))
  
---Finds the overriding monster with the given name.
+
  for _, item in ipairs(loot) do
---@param name string The name of the monster that overrides another monster.
+
    local item2 = itemData[tostring(item.id)]
---@return string|nil The ID of the monsters, or nil if not found.
+
    --IS itemList.ts has some incorrect URIs, e.g. for Feather
---@return table|nil The stats for monster that overrides, or nil if not found.
+
    local src = item2.itemImage
local function findMonsterByOverrideName(name)
+
    src = src:sub(1, 1) ~= "/" and "/" .. src or src
  for id, monster in pairs(monstersStats) do
+
    local imageHtml = mw.html.create("td")
     for overrideName, overrideMonster in pairs(monster.overrides or {}) do
+
      :wikitext(createWikitextImage(
      if overrideName == name then
+
        item2.name,
         return id, overrideMonster
+
        {
 +
          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
 
       end
 +
    else
 +
      leagueHtml:wikitext("All")
 
     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 rowHtml = mw.html.create("tr")
  local id = findId._findId({name, "monster"})
+
      :node(imageHtml)
  if id == "id not found" then
+
      :node(itemHtml)
    --Some monsters use same id as other monsters so we need to fetch their info differently
+
      :node(quantityHtml)
    id, overrideStats = findMonsterByOverrideName(name)
+
      :node(chanceHtml)
    if not id then
+
       :node(priceHtml)
       return monsterError
+
      :node(tradeableHtml)
    end
+
      :node(leagueHtml)
  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
+
     tableHtml:node(rowHtml)
  if _args["DPS"] then
 
     dps = _args["DPS"]
 
  else
 
    local damageInfo = getDamageInfo(stats)
 
    dps = string.format("%d (%s)", damageInfo.dps, damageInfo.primaryDamageType)
 
 
   end
 
   end
  
  local infobox = createInfobox(monster, stats, zones, dps, unique)
+
   return tableHtml
   return infobox
 
 
end
 
end
  
 
return p
 
return p

Revision as of 02:38, 10 April 2025


local p = {}
local findId = require("Module:FindId")
local lootData = mw.loadData("Module:Loot/data")
local itemData = mw.loadData("Module:Items/data")
local leagueData = mw.loadData("Module:Leagues/data")


---Creates a mw.html 'DIV' element containing an "error" message.
---@param message string Message as wikitext.
---@return mw.html The message wrapped in a mw.html DIV element as red text.
local function createErrorMessage(message)
  local e = mw.html.create("div")
    :css("color", "red")
    :wikitext(message)
  return e
end

---Finds the source ID for a given name.
---@param name string The name of the source.
---@return number|nil The source ID (monsterID or locationID), or nil if the source is not found.
---@return string|nil The source type ("monster" or "location"), or nil if the source is not found.
local function findSourceId(name)
  for _, source in ipairs({ "monster", "location" }) do
    local id = findId._findId({ name, source })
    if id ~= "id not found" then
      return id, source
    end
  end
end

---@class Item
---@field allowedLeagues number[]|nil The leagues in which the item can be found. Found in all leagues if nil.
---@field id integer The item ID.
---@field chance number The base chance of the item being dropped.
---@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.
---@param loot table The loot table from Module:Loot/data.
---@param id string|number The ID of the item to find.
---@return Item|nil The item data, or nil if the item is not found.
---@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

---Finds the first source in the loot table that matches the given sourceId.
---@param loot table The loot table from Module:Loot/data.
---@param id string|number The ID of the source to find.
---@param sourceType string String representing the type of the source ("monster" or "location").
---@param locationId string|number|nil Optional ID for the location to find the source from. Use `id` and `sourceType` for location loot.
---@return Item[]|nil The source data, or nil if the source is not found.
---@return string|nil The locationId where the source was found (`id` if `sourceType` is "location"), or nil if the source is not found.
local function getFirstSourceMatch(loot, id, sourceType, locationId)
  id = tostring(id)
  locationId = locationId and tostring(locationId) or nil

  if sourceType == "location" then
    local location = loot[id]
    if not location then
      return
    end
    local source = location["-1"]
    if not source then
      return
    end
    return source, id
  end

  -- Needed for 'override' monsters that share ID with other monsters
  --   otherwise the loot would be from first matching ID and could be wrong
  if locationId then
    local location = loot[locationId]
    for sourceId, source in pairs(location) do
      if sourceId == id then
        return source, locationId
      end
    end
    return
  end

  for locationId, location in pairs(loot) do
    for sourceId, source in pairs(location) do
      if sourceId == id then
        return source, locationId
      end
    end
  end
end

--TODO: Add option to show percentages as fractions

---Converts a floating-point number to a approximation of fraction.
---@param num number The floating-point number to convert.
---@param maxDenominator number The maximum denominator for the fraction.
---@return number The numerator of the fraction.
---@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 minDifference = math.huge

  for d = 1, maxDenominator do
    local n = math.floor(num * d + 0.5)
    local approx = n / d
    local difference = math.abs(num - approx)

    if difference < minDifference then
      bestNumerator, bestDenominator = n, d
      minDifference = difference
    end
  end

  return sign * bestNumerator, bestDenominator
end

---Converts a floating-point number to a approximation of fraction as string.
---@param num number The floating-point number to convert.
---@param maxDenominator number The maximum denominator for the fraction.
---@return string The fraction as string ('numerator/denominator').
local function floatToFractionString(num, maxDenominator)
  local numerator, denominator = floatToFraction(num, maxDenominator)
  if denominator == 1 then
    return tostring(numerator)
  end
  return string.format("%d/%d", numerator, denominator)
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

---Finds the ID of a monster that is overridden by another monster with the given name.
---@param name string The name of the monster that overrides another monster.
---@return string|nil The ID of the monster that is overridden, or nil if not found.
local function findIdByOverrideName(name)
  local monsterStatsData = mw.loadData("Module:Monsters_stats/data")
  for id, monster in pairs(monsterStatsData) do
    for overrideName, overrideMonster in pairs(monster.overrides or {}) do
      if overrideName == name then
        return id
      end
    end
  end
end


function p.lootTable(frame)
  local args = frame:getParent().args
  return tostring(p._lootTable(args))
end

function p._lootTable(args)
  local sourceName = args.name or args.title or mw.title.getCurrentTitle().text
  --Some monsters use same id as other monsters so we need to specify location for them
  local locationId = args["location"] and findSourceId(args["location"])
  local sourceId
  local sourceType
  if locationId then
    sourceId = findIdByOverrideName(sourceName)
    sourceType = "monster"
  else
    sourceId, sourceType = findSourceId(sourceName)
  end
  if not sourceId then
    return locationId and
      createErrorMessage(
        "Found no monsters overridden by '" .. sourceName ..
        "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
      ) or
      createErrorMessage(
        "No monster or location named '" .. sourceName ..
        "'. The Module:Loot/data may be outdated."
      )
  end

  local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
  if not loot then
    return createErrorMessage(
      "No loot found for monster or location named '" .. sourceName ..
      "' The Module:Loot/data may be outdated."
    )
  end

  local lang = mw.language.getContentLanguage()

  ---@param name string Wikitext.
  ---@return mw.html mw.html TH element.
  local function createTh(name)
    local th = mw.html.create("th")
      :addClass("headerSort")
      :attr("tabindex", 0)
      :attr("title", "Sort ascending")
      :wikitext(name)
    return th
  end

  ---@param names string[] Array of wikitext.
  ---@return mw.html TR element containing names as TH elements.
  local function createThs(names)
    local tr = mw.html.create("tr")
    for _, name in ipairs(names) do
      tr:node(createTh(name))
    end
    return tr
  end

  ---Creates a mw.html A element containing an image.
  ---@param name string The name of the item.
  ---@param src string The URI to the item image.
  ---@param width? string|integer The width of the item image as integer.
  ---@param height? string|integer The height of the item image as integer.
  ---@return mw.html mw.html A element containing an image.
  local function createImg(name, src, alt, width, height)
    local e = mw.html.create("a")
      :attr("href", tostring(mw.uri.localUrl(name)))
      :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

  local function createImgText(name, src, alt, width, height)
    return string.format(
      '[[%s|<img src="%s" alt="%s"%s%s>]]',
      name,
      src,
      alt,
      width and ' width="' .. width .. '"' or "",
      height and ' heigth="' .. height .. '"' or ""
    )
  end

  ---Creates an Wikitext link with an image.
  ---@param title string The title of the link.
  ---@param imageAttributes table The html attributes for the image.
  ---@return string Wikitext link with the image.
  local function createWikitextImage(title, imageAttributes)
    local image = mw.html.create("img"):attr(imageAttributes)
    return string.format('[[%s|%s]]', title, tostring(image))
  end

  ---Formats the value of an item as string.
  ---@param item table Item table from Module:Items/data
  ---@return string The formatted value of the item as string, or '-' if no value.
  local function createValueText(item)
    if item.id == 1 then -- Gold, has no value in the data
      return "1"
    end
    return item.value and lang:formatNum(item.value) or "-"
  end

  ---Formats the Item drop quantities as string.
  ---@param item Item Item table from Module:Loot/data
  ---@return string The formatted quantity range as string.
  local function createQuantityText(item)
    if item.minAmount == item.maxAmount then
      return tostring(item.minAmount)
    end
    return lang:formatNum(item.minAmount) .. "–" .. lang:formatNum(item.maxAmount)
  end

  --TODO: Add option to show percentages as fractions (open options from the gear icon)
  --      Saving preferences would require special permissions? MAybe with mw.user or javascript to localStorage?
  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 item2 = itemData[tostring(item.id)]
    --IS itemList.ts has some incorrect URIs, e.g. for Feather
    local src = item2.itemImage
    src = src:sub(1, 1) ~= "/" and "/" .. src or src
    local imageHtml = mw.html.create("td")
      :wikitext(createWikitextImage(
        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

    local rowHtml = mw.html.create("tr")
      :node(imageHtml)
      :node(itemHtml)
      :node(quantityHtml)
      :node(chanceHtml)
      :node(priceHtml)
      :node(tradeableHtml)
      :node(leagueHtml)

    tableHtml:node(rowHtml)
  end

  return tableHtml
end

return p