Difference between revisions of "Module:Drop Sources"

From Idlescape Wiki
Jump to navigation Jump to search
(Feature collapse multiple drops from the same source under single row, can be toggled. Sorting the table also affects the collapsed rows. Rows expand relative to the sort order and not just under the "parent" row.)
m (Fix make whole [Toggle all drops] cell clickable)
Line 308: Line 308:
 
                         :attr("colspan", 3)
 
                         :attr("colspan", 3)
 
                         :css("text-align", "center")
 
                         :css("text-align", "center")
 +
                        :addClass("mw-customtoggle-" .. className)
 
                         :node(createSpan(
 
                         :node(createSpan(
 
                             "[Toggle all drops]",
 
                             "[Toggle all drops]",

Revision as of 20:33, 22 April 2025


local p = {}
local findId = require("Module:FindId")
local lootData = mw.loadData("Module:Loot/data")
local itemsData = mw.loadData("Module:Items/data")
local leaguesData = mw.loadData("Module:Leagues/data")
local locationData = mw.loadData("Module:Location/data")
local monstersData = mw.loadData("Module:Monsters/data")
local monstersStatsData = mw.loadData("Module:Monsters stats/data")


---@class LootData
---@field id number The ID of the item.
---@field chance number The chance of the item dropping.
---@field minAmount number The minimum amount of the item that can drop.
---@field maxAmount number The maximum amount of the item that can drop.
---@field allowedLeagues number[]|nil The IDs of the leagues that allow the item to drop, or nil if allowed everywhere.


---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

---Formats the given number range.
---@param min number The minimum of the range.
---@param max number The maximum of the range.
---@return string # The formatted range as string.
local function toNumberRange(min, max)
    local lang = mw.language.getContentLanguage()
    if min == max then
        return lang:formatNum(min)
    end
    return lang:formatNum(min) .. "–" .. lang:formatNum(max)
end

---Calculates the length of a table.
---@param t table
---@return number
local function getTableLength(t)
    local count = 0
    for _ in pairs(t) do
        count = count + 1
    end
    return count
end

---Creates a deep copy of a table.
---@param t table
---@return table
local function copyTable(t)
    local copy = {}
    for k, v in pairs(t) do
        if type(v) == "table" then
            copy[k] = copyTable(v)
        else
            copy[k] = v
        end
    end
    return copy
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 img = mw.html.create("img")
        :attr(imageAttributes)
    img = tostring(img):gsub("<img(.-) */>", "<img%1>")
    return string.format('[[%s|%s]]', title, img)
end

---Finds locations, sources and `LootData` for a given item ID.
---{ locationId = { sourceId = { LootData1, LootData2, ... }, ... }, ... }
---@param itemId string|number The ID of the item to search for.
---@return table<string, table<string, LootData[]>>|nil # A table containing locations, sources and `LootData` for the item, or nil if not found.
local function getSources(itemId)
    itemId = tostring(itemId)
    local sources = {}
    for locationId, location in pairs(lootData) do
        for sourceId, source in pairs(location) do
            for _, drop in ipairs(source) do
                if tostring(drop.id) == itemId then
                    sources[locationId] = sources[locationId] or {}
                    sources[locationId][sourceId] = sources[locationId][sourceId] or {}
                    table.insert(sources[locationId][sourceId], drop)
                end
            end
        end
    end
    return sources
end

---Gets monster or location object based on the source ID and optionally location ID for locations and override monster.
---@param sourceId string|number The ID of the source, -1 for locations.
---@param locationId string|number|nil The ID of the location (optional), required for locations.
---@return table|nil # The stats of the monster or nil if not found.
local function getSource(sourceId, locationId)
    sourceId = tostring(sourceId)
    if locationId then
        locationId = tostring(locationId)
        --If sourceId is "-1", it means we are looking for a location
        if sourceId == "-1" then
            local location = locationData[locationId]
            if location then
                return copyTable(location)
            end
        end
        local baseMonster = monstersStatsData[sourceId]
        for _, monster in pairs(baseMonster.overrides or {}) do
            for _, location in ipairs(monster.locations or {}) do
                if location and tostring(location.id) == locationId then
                    return copyTable(monster)
                end
            end
        end
    end
    --Tries to get the stats of base monster if not a location or no override is found
    --  e.g. no locationId or was given but no ovverides for the monster
    return copyTable(monstersStatsData[sourceId])
end

---Converts location-source-loot table to name-sourceStats-loot table.
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
---@return table<string, table> # A table containing the sources and their loot data and and optional Invalid IDs table.
--[[

    return {
        ["Source Name"] = {
            _loot = { LootData1, LootData2, ... },
            _id = sourceId,
            `unpack key-value pairs from Module monstersStatsData or locationData for the source`
        },
        ...,
        ["Invalid ID"] = { invalidSourceId1, invalidSourceId2, ... }
    }
]]
local function getSourceList(sources)
    local sourceList = {}
    for locationId, sources_ in pairs(sources) do
        for sourceId, loot in pairs(sources_) do
            local source = getSource(sourceId, locationId)
            if source then
                local name = source.name
                sourceList[name] = sourceList[name] or source
                sourceList[name]._loot = sourceList[name]._loot or loot
                sourceList[name]._id = sourceList[name]._id or sourceId
            else
                sourceList["Invalid ID"] = sourceList["Invalid ID"] or {}
                table.insert(sourceList["Invalid ID"], sourceId)
            end
        end
    end
    return sourceList
end

---Creates a mw.html TR element containing monster and loot data.
---@param name string The name of the monster or location.
---@param src string The source of the image.
---@param loot LootData The loot data for the item.
---@param imageWidth string|number The width of the image.
---@param imageHeight string|number The height of the image.
---@return mw.html|table # mw.html TR element containing monster and loot data.
local function createMonsterLootRow(name, src, loot, imageWidth, imageHeight)
    local imageHtml = mw.html.create("td")
        :wikitext(createWikitextImage(
            name,
            {
                src = "https://www.play.idlescape.com" .. src,
                alt = name,
                width = imageWidth,
                height = imageHeight
            }
        ))
    local itemHtml = mw.html.create("td")
        :wikitext('[[' .. name .. ']]')
    local quantityHtml = mw.html.create("td")
        :css("text-align", "center")
        :wikitext(toNumberRange(loot.minAmount, loot.maxAmount))
    local chanceHtml = mw.html.create("td")
        :css("text-align", "center")
        :wikitext(toFixed(loot.chance * 100, 3) .. "%")
    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 loot.allowedLeagues then
        for _, leagueId in ipairs(loot.allowedLeagues) do
            local league = leaguesData[tostring(leagueId)]
            if league then
                leagueHtml:wikitext(createWikitextImage(
                    league.name,
                    {
                        src = "https://www.play.idlescape.com" .. league.icon,
                        alt = league.name,
                        width = 30,
                        height = 30
                    }
                ))
            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(leagueHtml)

    return rowHtml
end

---Creates a table of drop sources for a given item.
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
---@param imageWidth string|number # The width of the images in the table.
---@param imageHeight string|number # The height of the images in the table.
local function createDropSourceTable(sources, imageWidth, imageHeight)
    ---@param names string[] Array of wikitext.
    ---@return mw.html|table # 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(mw.html.create("th")
                :addClass("headerSort")
                :attr("tabindex", 0)
                :attr("title", "Sort ascending")
                :wikitext(name)
        )
        end
        return tr
    end

    ---Creates a mw.html SPAN element with optional text, attributes, CSS and class.
    ---@param text string|nil The text to be added to the span.
    ---@param attr table|nil The attributes to be added to the span.
    ---@param css table|nil The CSS styles to be added to the span.
    ---@param class string|nil The classes to be added to the span.
    ---@return table|mw.html # mw.html SPAN element.
    local function createSpan(text, attr, css, class)
        local span = mw.html.create("span")
        if attr then span:attr(attr) end
        if css then span:css(css) end
        if class then span:addClass(class) end
        if text then span:wikitext(text) end
        return span
    end

    local tableHtml = mw.html.create("table")
        :addClass("wikitable sortable mw-collapsible")
        --[[ Adding caption and collapsing the table did sift the content after the table under infobox
               (normally it would occupy the space next to it), so captions are disabled for now
        :tag("caption")
        :wikitext("Monsters and locations")
        :done()
        ]]
        :node(createThs({ " ", "Source", "Quantity", "Rarity", "Leagues" }))

    for name, source in pairs(getSourceList(sources)) do
        local rows = {}

        if name == "Invalid ID" then
            table.insert(
                rows,
                mw.html.create("tr")
                    :tag("td")
                    :attr("colspan", 5)
                    :wikitext("No sources with IDs: " .. table.concat(source, ", "))
                    :done()
            )
        else
            local _loot = source._loot
            local multiDrop = getTableLength(_loot) > 1
            local relatedKey = itemsData[tostring(source.relatedKey)]
            local relatedKeyImage = relatedKey and relatedKey.itemImage
            local src = source.imageOverride or relatedKeyImage or (monstersData[tostring(source._id)] or {}).image
            src = src and src:sub(1, 1) ~= "/" and "/" .. src or src
            local className = name:gsub(" ", "_"):gsub("[^%w_]", "")

            if multiDrop then
                table.insert(
                    rows,
                    mw.html.create("tr")
                        :tag("td")
                        :wikitext(createWikitextImage(
                            name,
                            {
                                src = "https://www.play.idlescape.com" .. src,
                                alt = name,
                                width = imageWidth,
                                height = imageHeight
                            }
                        ))
                        :done()
                        :tag("td")
                        :wikitext("[[" .. name .. "]]")
                        :done()
                        :tag("td")
                        :attr("colspan", 3)
                        :css("text-align", "center")
                        :addClass("mw-customtoggle-" .. className)
                        :node(createSpan(
                            "[Toggle all drops]",
                            { ["role"] = "button" },
                            {
                                ["cursor"] = "pointer",
                                ["font-weight"] = "700",
                                ["color"] = "var(--color-progressive,#36c)"
                            },
                            "mw-customtoggle-" .. className
                        ))
                        :node(createSpan(
                            "▼",
                            { ["id"] = "mw-customcollapsible-" .. className },
                            { ["float"] = "right" },
                            "mw-collapsible"
                        ))
                        :node(createSpan(
                            "▲",
                            { ["id"] = "mw-customcollapsible-" .. className },
                            { ["float"] = "right" },
                            "mw-collapsible mw-collapsed"

                        ))
                        :done()
                )
            end

            for _, loot in ipairs(_loot) do
                local row = createMonsterLootRow(name, src, loot, imageWidth, imageHeight)
                if multiDrop then
                    row
                        :addClass("mw-collapsible mw-collapsed")
                        :attr("id", "mw-customcollapsible-" .. className)
                end
                table.insert(rows, row)
            end
        end

        for _, row in ipairs(rows) do
            tableHtml:node(row)
        end
    end

    return tableHtml
end


function p.dropSources(frame)
    local args = frame:getParent().args or {}
    return p._dropSources(args)
end

function p._dropSources(args)
    local name = args["name"] or args["title"] or mw.title.getCurrentTitle().text
    local itemId = findId._findId({ name, "item" })
    if itemId == "id not found" then
        return "No id found for item '" .. name .. "'."
    end
    local sources = getSources(itemId)
    if not sources then
        return "No sources found for '" .. name .. "', Modules: Loot/data or Monsters_stats/data may be outdated."
    end
    local html = createDropSourceTable(sources, args["width"] or 30, args["height"] or 30)
    return html
end


return p