Module:Infobox ability

From Idlescape Wiki
Jump to navigation Jump to search

Documentation for this module may be created at Module:Infobox ability/doc

--#region Variables and Imports
local p = {}

local monstersData = mw.loadData("Module:Monsters/data")
---@type table<string, Ability>
local abilitiesData = mw.loadData("Module:Abilities/data")
---@type table<string, Item>
local itemsData = mw.loadData("Module:Items/data")
---@type table<string, Talent>
local talentsData = mw.loadData("Module:Talents/data")
---@type table<string, Enchantment>
local enchantmentsData = require("Module:Enchantment/data")
local infobox = require("Module:Infobox")

local headerCount = 1
local labelCount = 1
local dataCount = 1

local targetLabelMap = {
    ["lowestHP"] = "Lowest HP",
    ["lowestDef"] = "Lowest Defence",
    ["lowestHPPercent"] = "Lowest HP %",
}
--#endregion

--#region Elements
local function clearInfoboxInstance()
    infobox.clear()
    headerCount = 1
    labelCount = 1
    dataCount = 1
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
--#endregion

--#region Utility Functions
---Returns the length of a table.
---@param tbl table # The table to count the length of.
---@return number # The length of the table.
local function tableLength(tbl)
    local count = 0
    for _ in pairs(tbl) do
        count = count + 1
    end
    return count
end

---Rounds a number to the specified number of decimal places.
---@param num number # The number to round.
---@param digits number # The number of decimal places to round to.
---@param keepTrailing boolean|nil # Whether to keep trailing zeros and dot. Default: false.
---@return string # The rounded number as a string.
local function toFixed(num, digits, keepTrailing)
    keepTrailing = keepTrailing or false
    local mult = 10^digits
    local rounded = num >= 0 and math.floor(num * mult + 0.5) or math.ceil(num * mult - 0.5)
    local formatted = string.format("%." .. digits .. "f", rounded / mult)
    if not keepTrailing and digits > 0 then
        formatted = formatted:gsub("%.?0+$", "")
    end
    return formatted
end

---Finds the first table entry in the given table with the given key and value.
---@generic T
---@param tbl table<any, T> # The table to search.
---@param key any # The key to search for.
---@param val any # The value to search for.
---@return T|nil # The first table entry with the given key and value, or nil if not found.
local function findByKeyVal(tbl, key, val)
    if type(tbl) ~= "table" then return nil end
    for k, v in pairs(tbl) do
        if v[key] == val then
            return v
        end
    end
end

---Checks if a table contains a specific value.
---@param t table
---@param value any
---@return boolean
local function hasValue(t, value)
    if type(t) ~= "table" then return false end
    for _, v in pairs(t) do
        if value == v then
            return true
        end
    end
    return false
end

---Converts a CSV string into a table.
---@param s string # The CSV string to convert.
---@return table
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
--#endregion

--#region Generic Module Functions
---Formats the given URL to full play.idlescape.com URL.
local function fullUrl(url)
    local newUrl = url
    if url:sub(1, 4) == "http" or url:sub(1,2) == "[[" then
        return newUrl
    end

    if url:sub(1, 1) ~= "/" then
        newUrl = "/" .. newUrl
    end

    return "https://www.play.idlescape.com" .. newUrl
end

---Creates an image link for the given name and URL.
---@param name string # The name of the link and image.
---@param url string # The URL of the image.
---@param word boolean # Whether to include the name in the link, if falsy it will be an image only.
---@param size number|string # The size of the image.
---@return string
local function image(name, url, word, size)
    size = size or 20
    url = fullUrl(url)
    return string.format(
        '[[%s|<img src="%s" alt="%s" width="%d" height="%d"><span style="position:relative;top:1px;">%s</span>]]',
        name, url, name, size, size, word and name or ""
    )
end
--#endregion

--#region Infobox Elements
---Creates a string containing formatted list of the given runes and their costs.
---@param runeCosts table # The rune costs from ability.runeCost.
---@return string
local function createRuneList(runeCosts)
    if runeCosts == nil then return "" end
    local runes = {}
    for _, rune in ipairs(runeCosts) do
        local item = itemsData[tostring(rune.itemID)]
        if item then
            local e = mw.html.create("div")
                :css("width", "max-content")
                :wikitext(rune.amount .. " " .. image(item.name, item.itemImage, true, 20))
            table.insert(runes, tostring(e))
        end
    end
    return table.concat(runes, " ")
end

---Creates a string for the given damage over time effect.
---@param dot table # The damage over time effect from ability.healthChangeEvent.
---@return string
local function createDOT(dot)
    if dot == nil then return "" end
    local lang = mw.language.getContentLanguage()
    return string.format(
        "%sHP / %ds x %d%s",
        lang:formatNum(dot.healthChange),
        toFixed(dot.healthChangeDelay / 1000, 2),
        dot.dotCount,
        dot.targetsSelf and " to self" or ""
    )
end

--- Adds the given buff to the infobox.
---@param args table
---@param buff table # The buff data to add.
local function createBuff(args, buff)
    if buff == nil then return end
    local enchant = enchantmentsData[tostring(buff.enchantmentApply)]
    if enchant == nil then return end

    args[l()] = "Buff"
    args[d()] = image(enchant.name, enchant.buffIcon, true, 20)

    args[l()] = "Strength"
    args[d()] = tostring(buff.enchantmentStrength)

    args[l()] = "Amount"
    args[d()] = tostring(buff.enchantmentAmount)

    args[l()] = "Refresh to Amount"
    args[d()] = buff.refreshToAmount and tostring(buff.refreshToAmount) or ""

    args[l()] = "Condition to Apply"
    args[d()] = buff.onlyOnHit and "On Hit" or "Always"

    args[l()] = "Chance to Apply"
    args[d()] = buff.enchantmentChanceToApply and toFixed(buff.enchantmentChanceToApply * 100, 0) .. "%" or ""

    args[l()] = "Tooltip"
    args[d()] = enchant.getTooltip(buff.enchantmentStrength, enchant.strengthPerLevel)
end

---Adds info for the given summoned monster into the infobox.
---@param args table # The infobox args.
---@param summon table # The summon data to add.
local function createSummon(args, summon)
    if summon == nil then return end
    local monster = monstersData[tostring(summon.id)]
    if monster == nil then return end

    args[l()] = "Name"
    args[d()] = image(monster.name, monster.image, true, 20)

    args[l()] = "Count"
    args[d()] = summon.count and tostring(summon.count) or ""
end

---Adds the given messages into the infobox.
---@param args table # The infobox args.
---@param messages table # The messages to add from ability data.
local function createMessages(args, messages)
    if messages == nil then return end

    local length = tableLength(messages)
    local count = 1

    for trigger, v in pairs(messages) do
        args[l()] = "Trigger"
        args[d()] = trigger == "onCast" and "On Cast" or "On Kill"

        args[l()] = "Chat Channel"
        args[d()] = v.type:gsub("^%l", string.upper)

        --args[sl()] = "Message"
        local text = '<p style="margin:auto;font-style:italic;font-size:1.2em">' .. v.message .. "</p>"
        if length > 1 and count < length then
            count = count + 1
            text = text .. "<hr>"
        end
        args[sd()] = text
    end
end

local function createCoeff(coeff)
    if coeff == nil then return "" end
    --return toFixed(coeff * 100, 0) .. "%"
    return coeff .. "x"
end

local function createCritical(args, critical)
    if critical == nil then return "" end

    if critical.chanceAdditive or critical.chanceMult then
        args[l()] = "Critical Chance"
        if critical.chanceAdditive then
            args[d()] = "+" .. toFixed(critical.chanceAdditive * 100, 0)
        elseif critical.chanceMult then
            args[l()] = critical.chanceMult .. "x"
        end
    end

    if critical.damageAdditive or critical.damageMult then
        args[l()] = "Critical Damage"
        if critical.damageAdditive then
            args[d()] = "+" .. toFixed(critical.damageAdditive * 100, 0)
        elseif critical.damageMult then
            args[l()] = critical.damageMult .. "x"
        end
    end
end

---Creates a wikitext links for the ability's sources
---@param id string|number|nil The ability ID to find sources for.
---@return string # A string containing the sources of the ability, formatted as wikitext links.
local function getSources(id)
    id = tonumber(id)
    if not id then return "" end

    local sources = {}
    for _, item in pairs(itemsData) do
        if item.equipmentStats and item.equipmentStats.grantedAbility then
            for _, abilityID in ipairs(item.equipmentStats.grantedAbility) do
                if abilityID == id then
                    sources[item.name] = {
                        id = item.id,
                        type = 2,
                        image = image(item.name, item.itemIcon or item.itemImage, true, 20)
                    }
                end
            end
        end
        if item.relatedAbility == id then
            sources[item.name] = {
                id = item.id,
                type = 1,
                image = image(item.name, item.itemIcon or item.itemImage, true, 20)
            }
        end
    end

    local talentSrc
    for _, talent in pairs(talentsData) do
        if hasValue(talent.abilities, id) then
            talentSrc = talentSrc or mw.getCurrentFrame()
                :callParserFunction("filepath:Total_level_mastery_icon_center.png")
            sources[talent.name] = {
                id = talent.id,
                type = 0,
                image = image(talent.name, talentSrc, true, 20)
            }
        end
    end

    local sourceArray = {}

    -- From set to array
    for _, source in pairs(sources) do
        table.insert(sourceArray, source)
    end

    -- Sort by type: talent < ability book < item, then by id
    table.sort(sourceArray, function(a, b)
        if a.type == b.type then
            return a.id < b.id
        end
        return a.type < b.type
    end)

    -- Remove type needed for sorting
    for i, source in ipairs(sourceArray) do
        sourceArray[i] = source.image
    end

    return table.concat(sourceArray, "<br>")
end

---Creates an infobox for the given ability.
---@param ability Ability
---@param sources string[]|nil
---@return string
local function createInfobox(ability, sources)
    local args = {}
    args.autoheaders = "y"
    args.subbox = "no"
    args.bodystyle = " "
    args.title = ability.abilityName
    args.image = image(ability.abilityName, ability.abilityImage, false, 150)

    local sources_ = sources and "[[" .. table.concat(sources, "]]<br>[[") .. "]]" or ""
    sources_ = sources_ .. getSources(ability.id)
    args[l()] = "Sources"
    args[d()] = sources_

    args[l()] = "Cost"
    if ability.useRangedAmmo then
        args[d()] = tostring(
            mw.html.create("div")
                :css("width", "max-content")
                :wikitext("Uses ranged ammo")
        )
    else
        args[d()] = createRuneList(ability.runeCost)
    end

    args[l()] = "Attack Type"
    args[d()] = (ability.dealsNoDamage and "Support / " or "") .. ability.damageType

    args[l()] = "Attack Speed"
    args[d()] = tostring(
        mw.html.create("div")
            :css("width", "max-content")
            :wikitext(ability.baseSpeedCoeff .. "x Weapon speed")
    )

    args[l()] = "Cooldown"
    args[d()] = toFixed(ability.cooldown and ability.cooldown / 1000 or 0, 2) .. "s"

    --args[l()] = '<span class="rt-commentedText tooltip tooltip-dotted" title="???">Skip Reactives</span>'
    --args[d()] = ability.skipReactives and "No" or "Yes"

    args[l()] = "Effect Over Time"
    args[d()] = ability.healthChangeEvent and createDOT(ability.healthChangeEvent)


    args[h()] = "Summons"
    createSummon(args, ability.summonFriendly)

    args[h()] = "Damage Stats"
    args[l()] = "Deals No Damage"
    args[d()] = ability.dealsNoDamage and "Yes" or ""

    args[l()] = "Crit Damage Multiplier"
    args[d()] = createCritical(args, ability.critical)

    args[l()] = '<span class="rt-commentedText tooltip tooltip-dotted" title="Damage Loss Per Subsequent Targets">AOE Damage Multiplier</span>'
    args[d()] = createCoeff(ability.diminishingAOEDamageMult)

    args[l()] = "Min Damage Multiplier"
    args[d()] = createCoeff(ability.baseMinimumDamageCoeff)

    args[l()] = "Max Damage Multiplier"
    args[d()] = createCoeff(ability.baseMaximumDamageCoeff)

    for _, stat in ipairs(ability.damageScaling or {}) do
        args[l()] = stat.affinity
        args[d()] = stat.scaling .. "x"
    end

    args[h()] = "Accuracy Stats"
    args[l()] = "Accuracy Multiplier"
    args[d()] = createCoeff(ability.baseAccuracyCoeff)

    for _, stat in ipairs(ability.accuracyScaling or {}) do
        args[l()] = stat.affinity
        args[d()] = stat.scaling .. "x"
    end

    args[h()] = "Target"
    args[l()] = "Max Targets"
    args[d()] = tostring(ability.maxTargets)

    args[l()] = "Targets Self"
    args[d()] = ability.canTargetSelf and "Yes" or "No"

    args[l()] = "Targets Friendly"
    args[d()] = ability.targetFriendly and "Yes" or "No"

    args[l()] = "Targeting Override"
    args[d()] = ability.target and targetLabelMap[ability.target] or ""

    args[h()] = "Self Buff / Debuff"
    createBuff(args, ability.selfBuff)

    args[h()] = "Target Buff / Debuff"
    createBuff(args, ability.targetBuff)

    args[h()] = "Description"
    args[d()] = '<p style="margin:auto;font-style:italic;font-size:1.2em">' .. ability.description .. "</p>"

    args[h()] = "Chat Messages"
    createMessages(args, ability.messages)

    return infobox.infobox(args)
end
--#endregion

--#region Module Functions
function p.ability(frame)
    local args = frame:getParent().args
    return p._ability(args)
end

---Creates an infobox for the given ability or abilities found in a item.
---@param args {[1]: string|nil, name: string|nil, title: string|nil, item: string|nil, sources: string|nil}
---@return string
function p._ability(args)
    local itemName = args.item
    local item = findByKeyVal(itemsData, "name", itemName)

    if item then
        local count = 0
        local td = mw.html.create("td")
        local abilities = item.equipmentStats and item.equipmentStats.grantedAbility or {}

        for i= tableLength(abilities), 1, -1 do
            local ability = abilitiesData[tostring(abilities[i])]
            if ability then
                td:node(createInfobox(ability))
                clearInfoboxInstance()
                count = count + 1
            end
        end

        if count > 0 then
            local parent = mw.html.create("table")
                :addClass("mw-collapsible")
                :tag("caption")
                :css("text-align", "left")
                :wikitext(item.name .. " grants the following abilities when worn: ")
                :done()

            return parent:node(mw.html.create("tr"):node(td))
        end

        return "<div style=\"color:red\">No abilities found for item '" .. itemName .. "'.</div>"
    else
        local name = args.name or args.title or args[1] or mw.title.getCurrentTitle().text
        local ability = findByKeyVal(abilitiesData, "abilityName", name)

        if not ability then
            return "<div style=\"color:red\">No ability named '" .. name .. "'. The Module:Abilities/data may be outdated.</div>"
        end

        local sources = args.sources and fromCSV(args.sources)

        return createInfobox(ability, sources)
    end
end
--#endregion

return p