Difference between revisions of "Module:Drop Sources"

From Idlescape Wiki
Jump to navigation Jump to search
m
(Fix rename [Toggle all drops] to [Collapse] / [Expand] and style to look more like a button. Add darker background to rows that are from specific leagues)
 
(12 intermediate revisions by 3 users not shown)
Line 1: Line 1:
 
local p = {}
 
local p = {}
 
local findId = require("Module:FindId")
 
local findId = require("Module:FindId")
local monstersDropsData = mw.loadData("Module:Monsters drops/data")
+
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 monstersData = mw.loadData("Module:Monsters/data")
 +
local monstersStatsData = mw.loadData("Module:Monsters stats/data")
  
local function pairsByKeys(t, f)
+
 
     local a = {}
+
---@class LootData
     local orgi_key_type
+
---@field id number The ID of the item.
     local orgi_key_numbered
+
---@field chance number The chance of the item dropping.
     for n in pairs(t) do
+
---@field minAmount number The minimum amount of the item that can drop.
        if tonumber(n) == nil then
+
---@field maxAmount number The maximum amount of the item that can drop.
            table.insert(a, n)
+
---@field allowedLeagues number[]|nil The IDs of the leagues that allow the item to drop, or nil if allowed everywhere.
            orgi_key_type = "word"
+
 
        elseif type(n) == "number" then
+
 
            table.insert(a, n)
+
---Formats a number up to a given number of decimal places, removing trailing zeros.
            orgi_key_type = "int"
+
---@param num number The number to format.
        elseif type(n) == "string" and type(tonumber(n) == "number") then
+
---@param digits number The number of decimal places to include (must be a non-negative integer).
             orgi_key_type = "number"
+
---@return string # The formatted number as a string.
             table.insert(a, tonumber(n))
+
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
 
     end
 
     end
     table.sort(a, f)
+
     return copy
     local key
+
end
    local value
+
 
     local i = 0            -- iterator variable
+
---Creates an Wikitext link with an image.
     local iter = function() -- iterator function
+
---@param title string The title of the link.
        i = i + 1
+
---@param imageAttributes table The html attributes for the image.
        if a[i] == nil then
+
---@return string # Wikitext link with the image.
             return nil
+
local function createWikitextImage(title, imageAttributes)
        elseif orgi_key_type == "word" or orgi_key_type == "int" then
+
     local img = mw.html.create("img")
            key = a[i]
+
        :attr(imageAttributes)
            value = t[a[i]]
+
     img = tostring(img):gsub("<img(.-) */>", "<img%1>")
        elseif orgi_key_type == "number" then
+
     return string.format('[[%s|%s]]', title, img)
            key = tostring(a[i])
+
end
             value = t[tostring(a[i])]
+
 
 +
---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 key, value
 
 
     end
 
     end
     return iter
+
     return sources
 
end
 
end
  
local function round(num)
+
---Gets monster or location object based on the source ID and optionally location ID for locations and override monster.
     return tostring(math.floor(num * 1000 + 0.5) / 1000)
+
---@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
 
end
  
local function getMonsters(itemId)
+
---Converts location-source-loot table to name-sourceStats-loot table.
     local a = {}
+
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
     local monsters = {}
+
---@return table<string, table> # A table containing the sources and their loot data and and optional Invalid IDs table.
     for monsterId, monsterDrops in pairsByKeys(monstersDropsData) do
+
--[[
         for _, drop in ipairs(monsterDrops) do
+
 
             if drop["id"] == itemId then
+
     return {
                 if not monsters[monsterId] then
+
        ["Source Name"] = {
                    monsters[monsterId] = {}
+
            _loot = { LootData1, LootData2, ... },
                    table.insert(monsters[monsterId],drop)
+
            _id = sourceId,
                 else
+
            `unpack key-value pairs from Module monstersStatsData or locationData for the source`
                    table.insert(monsters[monsterId],drop)
+
        },
                end
+
        ...,
 +
        ["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
 
         end
 
     end
 
     end
     return monsters
+
     return sourceList
 
end
 
end
  
local function getMonsterName(monsterId)
+
---Creates a mw.html TR element containing monster and loot data.
     local monster = monstersData[tostring(monsterId)]
+
---@param name string The name of the monster or location.
     if monster then
+
---@param src string The source of the image.
         return monster["name"]
+
---@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
 
     else
         return "Module:Monsters/data out of date"
+
         leagueHtml:wikitext("All")
 +
    end
 +
 
 +
    local rowHtml = mw.html.create("tr")
 +
        :node(imageHtml)
 +
        :node(itemHtml)
 +
        :node(quantityHtml)
 +
        :node(chanceHtml)
 +
        :node(leagueHtml)
 +
 
 +
    if loot.allowedLeagues then
 +
        rowHtml:css("background-color", "rgba(0, 0, 0, 0.15)")
 
     end
 
     end
 +
 +
    return rowHtml
 
end
 
end
  
local function genTable(monsters)
+
---Creates a table of drop sources for a given item.
     local s = "{|class=\"wikitable sortable\"\n"
+
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
    s = s .. "!Monster\n"
+
---@param imageWidth string|number # The width of the images in the table.
    s = s .. "!Quantity\n"
+
---@param imageHeight string|number # The height of the images in the table.
    s = s .. "!Rarity\n"
+
local function createDropSourceTable(sources, imageWidth, imageHeight)
     s = s .. "|-\n"
+
    ---@param names string[] Array of wikitext.
    for id, monsterDrops in pairsByKeys(monsters) do
+
    ---@return mw.html|table # mw.html TR element containing names as TH elements.
         for _, drop in ipairs(monsterDrops) do
+
     local function createThs(names)
             s = s .. "|[[" .. getMonsterName(id) .. "]]\n"
+
        local tr = mw.html.create("tr")
             if drop["minAmount"] == drop["maxAmount"] then
+
        for _, name in ipairs(names) do
                 s = s .. "|style=\"text-align:center;\"|" .. tostring(drop["minAmount"]) .. "\n"
+
            tr:node(mw.html.create("th")
            else
+
                :addClass("headerSort")
                s = s ..
+
                :attr("tabindex", 0)
                "|style=\"text-align:center;\"|" .. tostring(drop["minAmount"]) .. "-" .. tostring(drop["maxAmount"]) .. "\n"
+
                :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 function createCustomCollapsible(collapsed, className)
 +
        return mw.html.create("span")
 +
            :attr("id", "mw-customcollapsible-" .. className)
 +
            :css("font-weight", "700")
 +
            :addClass("mw-collapsible " .. (collapsed and " mw-collapsed" or ""))
 +
            :wikitext("[")
 +
            :node(createSpan(
 +
                collapsed and "Collapse" or "Expand",
 +
                { ["role"] = "button" },
 +
                {
 +
                    --["cursor"] = "pointer",
 +
                    --["font-weight"] = "700",
 +
                    ["color"] = "#8cabe6"
 +
                },
 +
                "mw-customtoggle-" .. className
 +
            ))
 +
            :wikitext("]")
 +
     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(createCustomCollapsible(
 +
                            false,
 +
                            className
 +
                        ))
 +
                        :node(createCustomCollapsible(
 +
                            true,
 +
                            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
             s = s .. "|style=\"text-align:center;\"|" .. round(drop["chance"] * 100) .. "%\n"
+
        end
            s = s .. "|-\n"
+
 
 +
        for _, row in ipairs(rows) do
 +
             tableHtml:node(row)
 
         end
 
         end
 
     end
 
     end
    s = s .. "|}"
+
 
     return s
+
     return tableHtml
 
end
 
end
 +
  
 
function p.dropSources(frame)
 
function p.dropSources(frame)
     return p._dropSources(frame:getParent().args)
+
     local args = frame:getParent().args or {}
 +
    return p._dropSources(args)
 
end
 
end
  
function p._dropSources(_args)
+
function p._dropSources(args)
     local itemId = findId._findId({ _args[1], "item" })
+
    local name = args["name"] or args["title"] or mw.title.getCurrentTitle().text
     local monsters = getMonsters(itemId)
+
     local itemId = findId._findId({ name, "item" })
     local s = ""
+
    if itemId == "id not found" then
     s = genTable(monsters)
+
        return "No id found for item '" .. name .. "'."
     return s
+
    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
 
end
 +
  
 
return p
 
return p

Latest revision as of 21:31, 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)

    if loot.allowedLeagues then
        rowHtml:css("background-color", "rgba(0, 0, 0, 0.15)")
    end

    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 function createCustomCollapsible(collapsed, className)
        return mw.html.create("span")
            :attr("id", "mw-customcollapsible-" .. className)
            :css("font-weight", "700")
            :addClass("mw-collapsible " .. (collapsed and " mw-collapsed" or ""))
            :wikitext("[")
            :node(createSpan(
                collapsed and "Collapse" or "Expand",
                { ["role"] = "button" },
                {
                    --["cursor"] = "pointer",
                    --["font-weight"] = "700",
                    ["color"] = "#8cabe6"
                },
                "mw-customtoggle-" .. className
            ))
            :wikitext("]")
    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(createCustomCollapsible(
                            false,
                            className
                        ))
                        :node(createCustomCollapsible(
                            true,
                            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