Difference between revisions of "Module:Infobox Monster"

From Idlescape Wiki
Jump to navigation Jump to search
(Fix Updated links for species that share names with monsters (Goblin, Shrimp) to point to 'Name (species)' instead. Update how capitalization works.)
m (Change Species link name to exclude (species))
Line 470: Line 470:
 
     local args = {}
 
     local args = {}
 
     local replace = {
 
     local replace = {
         {from = "shrimp", to = "shrimp (species)"},
+
         {from = "shrimp", to = "shrimp (species)|Shrimp"},
         {from = "goblin", to = "goblin (species)"},
+
         {from = "goblin", to = "goblin (species)|Goblin"},
 
     }
 
     }
 
     local species = replaceArrayElements(monsterStats.species, replace)
 
     local species = replaceArrayElements(monsterStats.species, replace)

Revision as of 18:52, 30 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("( %a)([%w_']*)", tchelper)
        :gsub(" Of ", " of ")
        :gsub(" The ", " the ")
        :gsub(" A(n?) ", " a%1 ")
        :gsub("Ii+$", string.upper)
        :gsub("Ii+[ <]+", string.upper)
    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 = 150
    }
    local img = mw.html.create('img'):attr(attrs)
    img = tostring(img):gsub("<img(.-) */>", "<img%1>")
    return 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


---Creates a Wikitext link for a list of titles.
---@param titles string[]|string # The list of titles to link to.
---@param capitalize_ boolean|nil # If true, capitalizes the first letter of each word.
---@return string # The Wikitext links.
local function createWikitextLinks(titles, capitalize_)
    if type(titles) == "string" then
        titles = {titles}
    end
    local s = ""
    if titles then
        for index, value in ipairs(titles) do
            local title = capitalize_ and capitalize(value) or value
            s = s .. "[[" .. title .. "]]"
            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()
    e = tostring(e):gsub("<img(.-) */>", "<img%1>")
    return string.format('[[%s|%s]]', title, 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

---Replaces all instances of values in an array with other values.
---@param array any[] # The array to search.
---@param tbl { from: any, to: any }[] # The table of values to replace.
---@return any[] # The modified array.
local function replaceArrayElements(array, tbl)
    local newArray = {}
    for i, v in ipairs(array or {}) do
        for _, t in ipairs(tbl or {}) do
            if v == t.from then
                newArray[i] = t.to
            else
                newArray[i] = v
            end
        end
    end
    return newArray
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 = {}
    local replace = {
        {from = "shrimp", to = "shrimp (species)|Shrimp"},
        {from = "goblin", to = "goblin (species)|Goblin"},
    }
    local species = replaceArrayElements(monsterStats.species, replace)
    species = (species[1] or ""):gsub("^%l", string.upper)

    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(species)
    if monsterStats.abilities then
        args[h()] = unique and "Ability List" or "Ability Rotation"
        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) .. "x"
    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