Difference between revisions of "Module:Loot table"

From Idlescape Wiki
Jump to navigation Jump to search
m (Update Value to Vendor value)
(Fix incorrect handling of combat locations (quick fix before sleep, could probably use some refactoring))
 
(3 intermediate revisions by the same user not shown)
Line 2: Line 2:
 
local findId = require("Module:FindId")
 
local findId = require("Module:FindId")
 
local lootData = mw.loadData("Module:Loot/data")
 
local lootData = mw.loadData("Module:Loot/data")
local itemData = mw.loadData("Module:Items/data")
+
local itemsData = mw.loadData("Module:Items/data")
local leagueData = mw.loadData("Module:Leagues/data")
+
local leaguesData = mw.loadData("Module:Leagues/data")
 +
local locationData = mw.loadData("Module:Location/data")
 +
local locationLootData = mw.loadData("Module:LocationLoot/data")
  
 +
 +
---Creates a deep copy of a tables and functions.
 +
---Does not work with functions with upvalues and maybe userdata.
 +
---@generic T
 +
---@param t T The value to copy.
 +
---@return T # A new value that is a deep copy of the original.
 +
local function copyTable(t)
 +
    if type(t) ~= "table" then
 +
        return t
 +
    elseif type(t) == "function" then
 +
        return loadstring(string.dump(t))
 +
    end
 +
    local copy = {}
 +
    for k, v in pairs(t) do
 +
        copy[k] = copyTable(v)
 +
    end
 +
    return copy
 +
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
  
 
---Creates a mw.html 'DIV' element containing an "error" message.
 
---Creates a mw.html 'DIV' element containing an "error" message.
 
---@param message string Message as wikitext.
 
---@param message string Message as wikitext.
---@return mw.html The message wrapped in a mw.html DIV element as red text.
+
---@return table|mw.html # The message wrapped in a mw.html DIV element as red text.
 
local function createErrorMessage(message)
 
local function createErrorMessage(message)
 
     local e = mw.html.create("div")
 
     local e = mw.html.create("div")
Line 18: Line 48:
 
---Finds the source ID for a given name.
 
---Finds the source ID for a given name.
 
---@param name string The name of the source.
 
---@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 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.
+
---@return string|nil # The source type ("monster" or "location"), or nil if the source is not found.
 
local function findSourceId(name)
 
local function findSourceId(name)
 
     for _, source in ipairs({ "monster", "location" }) do
 
     for _, source in ipairs({ "monster", "location" }) do
Line 29: Line 59:
 
end
 
end
  
---@class Item
+
---Finds the first source in the lootData that matches the given sourceId.
---@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 id string|number The ID of the source to find.
 
---@param sourceType string String representing the type of the source ("monster" or "location").
 
---@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.
 
---@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 LootDrop[]|nil # The source data, or nil if the source is empty or 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.
+
---@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)
+
local function getFirstSourceMatch(id, sourceType, locationId)
 
     id = tostring(id)
 
     id = tostring(id)
 
     locationId = locationId and tostring(locationId) or nil
 
     locationId = locationId and tostring(locationId) or nil
  
 
     if sourceType == "location" then
 
     if sourceType == "location" then
         local location = loot[id]
+
         local location = lootData[id]
 
         if not location then
 
         if not location then
 
             return
 
             return
Line 81: Line 84:
 
     --  otherwise the loot would be from first matching ID and could be wrong
 
     --  otherwise the loot would be from first matching ID and could be wrong
 
     if locationId then
 
     if locationId then
         local location = loot[locationId]
+
         local location = lootData[locationId]
 
         for sourceId, source in pairs(location) do
 
         for sourceId, source in pairs(location) do
 
             if sourceId == id then
 
             if sourceId == id then
Line 90: Line 93:
 
     end
 
     end
  
     for locationId, location in pairs(loot) do
+
     for locationId2, location in pairs(lootData) do
 
         for sourceId, source in pairs(location) do
 
         for sourceId, source in pairs(location) do
 
             if sourceId == id then
 
             if sourceId == id then
                 return source, locationId
+
                 return source, locationId2
 
             end
 
             end
 
         end
 
         end
Line 104: Line 107:
 
---@param num number The floating-point number to convert.
 
---@param num number The floating-point number to convert.
 
---@param maxDenominator number The maximum denominator for the fraction.
 
---@param maxDenominator number The maximum denominator for the fraction.
---@return number The numerator of the fraction.
+
---@return number # The numerator of the fraction.
---@return number The denominator of the fraction.
+
---@return number # The denominator of the fraction.
 
local function floatToFraction(num, maxDenominator)
 
local function floatToFraction(num, maxDenominator)
 
     local sign = num < 0 and -1 or 1
 
     local sign = num < 0 and -1 or 1
Line 130: Line 133:
 
---@param num number The floating-point number to convert.
 
---@param num number The floating-point number to convert.
 
---@param maxDenominator number The maximum denominator for the fraction.
 
---@param maxDenominator number The maximum denominator for the fraction.
---@return string The fraction as string ('numerator/denominator').
+
---@return string # The fraction as string ('numerator/denominator').
 
local function floatToFractionString(num, maxDenominator)
 
local function floatToFractionString(num, maxDenominator)
 
     local numerator, denominator = floatToFraction(num, maxDenominator)
 
     local numerator, denominator = floatToFraction(num, maxDenominator)
Line 139: Line 142:
 
end
 
end
  
---Formats a number up to a given number of decimal places, removing trailing zeros.
+
local function normalizeSortString(str)
---@param num number The number to format.
+
    local function normalizeNumber(n)
---@param digits number The number of decimal places to include (must be a non-negative integer).
+
        local base = 1000000
---@return string The formatted number as a string.
+
        local num = tonumber(n) or -base
local function toFixed(num, digits)
+
        return base + num
     digits = math.max(0, math.floor(digits))
+
    end
     local formatted = string.format("%." .. digits .. "f", num):gsub("%.?0+$", "")
+
 
    return formatted
+
    local function normalizeRange(s)
 +
        local from, to = string.match(s, "^(.-)–(.-)$")
 +
        if from and from ~= "" and to and to ~= "" then
 +
            return normalizeNumber(from) .. "–" .. normalizeNumber(to)
 +
        else
 +
            return normalizeNumber(s)
 +
        end
 +
    end
 +
 
 +
    str = str:gsub(",", "")
 +
    return normalizeRange(str)
 +
end
 +
 
 +
---Formats the given chance as a string presentation of percentage or 10k/100k fraction.
 +
---@param chance number The chance to format. 1 equals 100%.
 +
---@return string # The formatted chance as a string.
 +
local function formatRarity(chance)
 +
     local sign = chance < 0 and "-" or ""
 +
    local abs = math.abs(chance)
 +
 
 +
     if abs < 0.001 then
 +
        return sign .. toFixed(abs * 10000, 1) .. "/10k"
 +
    elseif abs < 0.0001 then
 +
        return sign .. toFixed(abs * 100000, 1) .. "/100k"
 +
    end
 +
 
 +
    return toFixed(chance * 100, 3) .. "%"
 
end
 
end
  
 
---Finds the ID of a monster that is overridden by another monster with the given name.
 
---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.
 
---@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.
+
---@return string|nil # The ID of the monster that is overridden, or nil if not found.
 
local function findIdByOverrideName(name)
 
local function findIdByOverrideName(name)
 
     local monsterStatsData = mw.loadData("Module:Monsters_stats/data")
 
     local monsterStatsData = mw.loadData("Module:Monsters_stats/data")
Line 163: Line 192:
 
end
 
end
  
 +
local function createDottedTooltip(name, message)
 +
    return string.format('<span class="rt-commentedText tooltip tooltip-dotted" title="%s">%s</span>', message, name)
 +
end
  
function p.lootTable(frame)
+
---@param name string Wikitext.
    local args = frame:getParent().args
+
---@return table|mw.html # mw.html TH element.
    return tostring(p._lootTable(args))
+
local function createTh(name)
 +
    return mw.html.create("th")
 +
        :addClass("headerSort")
 +
        :attr("tabindex", 0)
 +
        :attr("title", "Sort ascending")
 +
        :wikitext(name)
 
end
 
end
  
function p._lootTable(args)
+
---@param names string[] Array of wikitext.
    local sourceName = args.name or args.title or mw.title.getCurrentTitle().text
+
---@return mw.html # TR element containing names as TH elements.
    --Some monsters use same id as other monsters so we need to specify location for them
+
local function createThs(names)
     local locationId = args["zone"] and findSourceId(args["zone"])
+
     local tr = mw.html.create("tr")
    local sourceId
+
 
     local sourceType
+
     for _, name in ipairs(names) do
    if locationId then
+
         if name ~= "" then
        sourceId = findIdByOverrideName(sourceName)
+
            tr:node(createTh(name))
         sourceType = "monster"
+
         end
    else
 
         sourceId, sourceType = findSourceId(sourceName)
 
 
     end
 
     end
     if not sourceId then
+
 
        return locationId and
+
     return tr
            createErrorMessage(
+
end
                "Found no monsters overridden by '" .. sourceName ..
+
 
                "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
+
---Creates a mw.html TABLE element with the given columns and caption.
             ) or
+
---@param columns string[] Array of column names.
             createErrorMessage(
+
---@param caption string|nil Optional caption for the table.
                "No monster or location named '" .. sourceName ..
+
---@param collapsed boolean|nil If true, the table will be collapsed by default.
                "'. The Module:Loot/data may be outdated. <br>Try adding parameter 'location' to the template, e.g.:" ..
+
---@return table|mw.html # mw.html TABLE element.
                "<br><code>{{Loot_table|location=Palace of Flame}}</code>."
+
local function createTable(columns, caption, collapsed)
             )
+
    local element = mw.html.create("table")
 +
        :addClass("wikitable sortable jquery-tablesorter mw-collapsible" .. (collapsed and " mw-collapsed" or ""))
 +
 
 +
    if caption then
 +
        element
 +
             :tag("caption")
 +
             :css("width", "fit-content") --Might not be needed, depends on the wiki css
 +
            :wikitext(caption .. " ")
 +
             :done()
 
     end
 
     end
  
     local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
+
    return element:node(createThs(columns))
     if not loot then
+
end
         return createErrorMessage(
+
 
             "No loot found for monster or location named '" .. sourceName ..
+
---Creates an Wikitext link with an image.
             "' The Module:Loot/data may be outdated."
+
---@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
 +
 
 +
---Formats the value of an item as a string.
 +
---@param item table|Item Item table from Module:Items/data.
 +
---@param min number|nil The minimum amount of the item. Default 1 if both min & max are missing.
 +
---@param max number|nil The maximum amount of the item.
 +
---@return string # The formatted value of the item as a string, or '-' if no value.
 +
local function createValueText(item, min, max)
 +
     if not (min or max) then min = 1 end
 +
 
 +
    local lang = mw.language.getContentLanguage()
 +
    local value = item.id == 1 and 1 or item.value
 +
 
 +
    if value then
 +
         local minValue = min and min * value
 +
        local maxValue = max and max * value
 +
 
 +
        if minValue == maxValue then
 +
            return minValue and lang:formatNum(minValue) or "NA"
 +
        elseif minValue and maxValue then
 +
             return lang:formatNum(minValue) .. "" .. lang:formatNum(maxValue)
 +
        elseif minValue or maxValue then
 +
             return lang:formatNum(minValue or maxValue)
 +
         end
 
     end
 
     end
 +
 +
    return "NA"
 +
end
 +
 +
---Formats the given min & max values as a string.
 +
---@param min number|nil The minimum value.
 +
---@param max number|nil The maximum value.
 +
---@return string # The formatted range as a string.
 +
local function createRangeText(min, max)
 +
    if not (min or max) then min = 1 end
  
 
     local lang = mw.language.getContentLanguage()
 
     local lang = mw.language.getContentLanguage()
     local imageWidth = args.width == nil and 30 or args.width
+
     if min == max then
     local imageHeight = args.height == nil and imageWidth or args.height
+
        return min and lang:formatNum(min) or "NA"
 +
    elseif min and max then
 +
        return lang:formatNum(min) .. "–" .. lang:formatNum(max)
 +
    end
 +
 
 +
     return lang:formatNum(min or max)
 +
end
 +
 
 +
local function createFrequencyText(min, max)
 +
    if not min then min = 0 end
  
     ---@param name string Wikitext.
+
     if min == max then
    ---@return mw.html mw.html TH element.
+
        return tostring(min)
     local function createTh(name)
+
     elseif min and max then
         local th = mw.html.create("th")
+
         return min .. "" .. max
            :addClass("headerSort")
 
            :attr("tabindex", 0)
 
            :attr("title", "Sort ascending")
 
            :wikitext(name)
 
        return th
 
 
     end
 
     end
  
     ---@param names string[] Array of wikitext.
+
     return tostring(min or max)
    ---@return mw.html TR element containing names as TH elements.
+
end
    local function createThs(names)
+
 
        local tr = mw.html.create("tr")
+
---Calculates the basic frequencies of an items.
        for _, name in ipairs(names) do
+
---@param items table[] The list of items to calculate frequencies for.
             tr:node(createTh(name))
+
---@param skill string The skill to use for the calculation.
 +
---@param bonus number|nil The bonus rarity, used to normalize nodes with Bonus Rarity requirements.
 +
---@return number[] # The list of frequencies for each item.
 +
---@return number # The total frequency of the items.
 +
local function getFrequencies(items, skill, bonus)
 +
    bonus = bonus or 0
 +
    local frequencies = {}
 +
    local totalFrequency = 0
 +
 
 +
    for _, item in ipairs(items) do
 +
        local frequency
 +
        if skill == "fishing" then
 +
            frequency = item.frequency or 0
 +
            frequency = frequency + bonus
 +
            if frequency > 0 then
 +
                --frequency = frequency * (1 + 1/360) -- multiplier for effective level of 1
 +
                if item.maxFrequency and frequency > item.maxFrequency then
 +
                    local bonusRate = ((frequency - item.maxFrequency) * item.maxFrequency) / 50
 +
                    frequency = item.maxFrequency + bonusRate
 +
                end
 +
            end
 +
        else
 +
             frequency = math.max(0, math.min(item.frequency or 0, item.maxFrequency or math.huge))
 
         end
 
         end
         return tr
+
 
 +
         table.insert(frequencies, frequency)
 +
        totalFrequency = totalFrequency + math.max(0, frequency or 0)
 
     end
 
     end
  
     ---Creates a mw.html A element containing an image.
+
     return frequencies, totalFrequency
    ---@param name string The name of the item.
+
end
    ---@param src string The URI to the item image.
+
 
    ---@param width? string|integer The width of the item image as integer.
+
---Creates a mw.html TABLE elements for gathering location drops.
    ---@param height? string|integer The height of the item image as integer.
+
---@param sourceId string|number The ID of the location.
    ---@return mw.html mw.html A element containing an image.
+
---@param imageWidth number|string The width of the item image.
    local function createImg(name, src, alt, width, height)
+
---@param imageHeight number|string The height of the item image.
        local e = mw.html.create("a")
+
---@param collapsed boolean|nil If true, the table will be collapsed by default.
            :attr("href", tostring(mw.uri.localUrl(name)))
+
---@return table|mw.html # An empty mw.html containing the created TABLE elements.
            :attr("title", name)
+
local function createGatheringTable(sourceId, imageWidth, imageHeight, collapsed)
            :tag("img")
+
    sourceId = tostring(sourceId)
            :attr("src", src)
+
 
             :attr("alt", alt)
+
    local location = locationData[sourceId]
        if width then
+
    local locationLoot = copyTable(locationLootData[sourceId])
            e:attr("width", width)
+
    local name = locationData[sourceId].name
        end
+
    if not (location and locationLoot) then
        if height then
+
        return createErrorMessage(
            e:attr("height", height)
+
             "No loot found for location: '" .. name .. "'. The Module:LocationLoot/data may be outdated."
         end
+
         )
        return e:done()
 
 
     end
 
     end
  
     local function createImgText(name, src, alt, width, height)
+
     --location's node list from Module:LocationLoot/data
        return string.format(
+
    --nodes are in 'nodes' if location has nodes (foraging, fishing) or 'loot' if not (mining)
            '[[%s|<img src="%s" alt="%s"%s%s>]]',
+
    ---@type table<string, LocationLoot[]>
            name,
+
    local lootNodes = locationLoot.nodes or (locationLoot.loot and {[location.name] = locationLoot.loot} or nil)
            src,
+
    if not lootNodes then
            alt,
+
        return createErrorMessage(
            width and ' width="' .. width .. '"' or "",
+
             "No loot found for location: '" .. name .. "'. The Module:LocationLoot/data may be outdated."
             height and ' height="' .. height .. '"' or ""
 
 
         )
 
         )
 
     end
 
     end
  
    ---Creates an Wikitext link with an image.
+
     local function keyPos(tbl, key, val)
    ---@param title string The title of the link.
+
         for i, v in ipairs(tbl) do
    ---@param imageAttributes table The html attributes for the image.
+
            if v[key] == val then return i end
    ---@return string Wikitext link with the image.
+
        end
     local function createWikitextImage(title, imageAttributes)
+
         return 0
         local image = mw.html.create("img"):attr(imageAttributes)
 
         return string.format('[[%s|%s]]', title, tostring(image):gsub("<img(.-) */>", "<img%1>"))
 
 
     end
 
     end
 +
    local nodes = location.nodes -- location's node list from Module:Location/data
 +
    ---@type {name: string, nodes: LocationLoot[]}[]
 +
    local lootNodeArray = {}
 +
    for k,v in pairs(lootNodes) do
 +
        table.insert(lootNodeArray, {name = k, nodes = v})
 +
    end
 +
    table.sort(lootNodeArray, function(a, b)
 +
        return keyPos(nodes, "nodeID", a.name) < keyPos(nodes, "nodeID", b.name)
 +
    end)
 +
 +
    local action = (location.actionType or ""):lower():gsub("action%-", "")
 +
    local container = mw.html.create()
 +
 +
    local rarityLabel = action == "fishing"
 +
        and createDottedTooltip(
 +
            "Frequency",
 +
            "Min–max frequency of the item. Negative minimum frequency is the item's inversed Rarity."
 +
            .. " Drop chance is calculated from item's frequency and total frequency of the node's items."
 +
            .. " Frequncies depends on effective level, Bonus Rarity and Deadliest Catch & Fiber Finder enchantments."
 +
        ) or "Rarity"
 +
    local reqRarityLabel = action == "fishing"
 +
        and createDottedTooltip(
 +
            "Req. Rarity",
 +
            "To gather an item, Fishing Bonus Rarity must be greater than the required Rarity."
 +
            .. " For example, a Bonus Rarity of exactly 5 cannot gather an item with Rarity 5,"
 +
            .. " but a Bonus Rarity of 5.1 can."
 +
        )
 +
        or ""
 +
    local columns = {
 +
        " ",
 +
        "Item",
 +
        "Quantity",
 +
        rarityLabel,
 +
        reqRarityLabel,
 +
        "Vendor value"
 +
    }
 +
 +
    for i, node in ipairs(lootNodeArray) do
 +
        local tableHtml = createTable(columns, node.name, collapsed)
 +
        local frequencies, totalFrequency = getFrequencies(node.nodes, action)
 +
 +
        for ii, item in ipairs(node.nodes) do
 +
            local item_ = itemsData[tostring(item.id)]
 +
            local src = item_.itemImage
 +
            src = src:sub(1, 1) ~= "/" and "/" .. src or src
  
    ---Formats the value of an item as string.
+
            local frequencyPercent = math.max(0, frequencies[ii] / totalFrequency)
    ---@param item table Item table from Module:Items/data
+
            local minBonusRarity = (item.frequency or 0) <= 0 and (math.abs(item.frequency) or 0) or 0
    ---@return string The formatted value of the item as string, or '-' if no value.
+
            local valueText = createValueText(item_, item.minAmount, item.maxAmount)
    local function createValueText(item)
+
            local rarityText = action == "fishing"
        if item.id == 1 then -- Gold, has no value in the data
+
                and createFrequencyText(item.frequency, item.maxFrequency)
             return "1"
+
                or formatRarity(frequencyPercent)
 +
            local rarityValue = action == "fishing"
 +
                and createFrequencyText(item.frequency, item.maxFrequency)
 +
                or string.format("%f", frequencyPercent)
 +
 
 +
            local imageHtml = mw.html.create("td")
 +
                :wikitext(createWikitextImage(
 +
                    item_.name,
 +
                    {
 +
                        src = "https://www.play.idlescape.com" .. src,
 +
                        alt = item_.name .. (item_.extraTooltip and ". " .. item_.extraTooltip or ""),
 +
                        width = imageWidth,
 +
                        height = imageHeight
 +
                    }
 +
                ))
 +
            local itemHtml = mw.html.create("td")
 +
                :wikitext('[[' .. item_.name .. ']]')
 +
            local quantityHtml = mw.html.create("td")
 +
                :css("text-align", "center")
 +
                :wikitext(createRangeText(item.minAmount, item.maxAmount))
 +
            local rarityHtml = mw.html.create("td")
 +
                :css("text-align", "center")
 +
                :wikitext(rarityText)
 +
                :attr("data-sort-value", normalizeSortString(rarityValue))
 +
            local reqBonusRarityHtml = action == "fishing"
 +
                and mw.html.create("td")
 +
                    :css("text-align", "center")
 +
                    :wikitext(tostring(minBonusRarity))
 +
                or nil
 +
            local valueHtml = mw.html.create("td")
 +
                :css("text-align", "right")
 +
                :attr("data-sort-value", normalizeSortString(valueText))
 +
                :wikitext(valueText)
 +
 
 +
             local row = mw.html.create("tr")
 +
                :node(imageHtml)
 +
                :node(itemHtml)
 +
                :node(quantityHtml)
 +
                :node(rarityHtml)
 +
                :node(reqBonusRarityHtml)
 +
                :node(valueHtml)
 +
 
 +
            tableHtml:node(row)
 
         end
 
         end
         return item.value and lang:formatNum(item.value) or "-"
+
         container:node(tableHtml)
 
     end
 
     end
  
     ---Formats the Item drop quantities as string.
+
     return container
    ---@param item Item Item table from Module:Loot/data
+
end
     ---@return string The formatted quantity range as string.
+
 
     local function createQuantityText(item)
+
---Creates TABLE elements for monster and combat location drops.
         if item.minAmount == item.maxAmount then
+
---@param sourceId string|number The ID of the source (monster or location).
             return tostring(item.minAmount)
+
---@param sourceName string The name of the source.
 +
---@param sourceType string The type of the source ("monster" or "location").
 +
---@param overrideLocId string|number|nil The ID of the location to override the sourceId.
 +
---@param imageWidth string|number The width of the item image.
 +
---@param imageHeight string|number The height of the item image.
 +
---@param collapsed boolean|nil If true, the table will be collapsed by default.
 +
---@return table|mw.html # An empty mw.html containing the created TABLE elements.
 +
local function createMonsterTable(sourceId, sourceName, sourceType, overrideLocId, imageWidth, imageHeight, collapsed)
 +
    local loot = getFirstSourceMatch(sourceId, sourceType, overrideLocId)
 +
    if not loot then
 +
        return createErrorMessage(
 +
            "No loot found for monster or location named '" .. sourceName ..
 +
            "'. The Module:Loot/data may be outdated. <br>Please add loot drops to Module:Loot/data " ..
 +
            "if possible or try manually making a table.</div>"
 +
        )
 +
    end
 +
     loot = copyTable(loot) --loot data is read-only from Module so we need to copy it
 +
     table.sort(loot, function(a, b)
 +
         if a.allowedLeagues and not b.allowedLeagues then
 +
            return false
 +
        elseif not a.allowedLeagues and b.allowedLeagues then
 +
             return true
 +
        else
 +
            return a.chance > b.chance
 
         end
 
         end
        return lang:formatNum(item.minAmount) .. "–" .. lang:formatNum(item.maxAmount)
+
     end)
     end
 
  
 
     --TODO: Add option to show percentages as fractions (open options from the gear icon)
 
     --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?
 
     --      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 mw-collapsible")
 
        --:node(createThs({ "⚙️", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
 
        :node(createThs({ " ", "Item", "Quantity", "Rarity", "Vendor value", "Tradeable", "Leagues" }))
 
  
     for _, item in ipairs(loot) do
+
    local tables = {
         local item2 = itemData[tostring(item.id)]
+
        ["Normal drops"] = {},
 +
        ["Junk drops"] = {},
 +
        ["League specific drops"] = {}
 +
    }
 +
 
 +
     for _, drop in ipairs(loot) do
 +
         local item = itemsData[tostring(drop.id)]
 
         --IS itemList.ts has some incorrect URIs, e.g. for Feather
 
         --IS itemList.ts has some incorrect URIs, e.g. for Feather
         local src = item2.itemImage
+
         local src = item.itemImage
 
         src = src:sub(1, 1) ~= "/" and "/" .. src or src
 
         src = src:sub(1, 1) ~= "/" and "/" .. src or src
 +
        local valueText = createValueText(item, drop.minAmount, drop.maxAmount)
 +
 
         local imageHtml = mw.html.create("td")
 
         local imageHtml = mw.html.create("td")
 
             :wikitext(createWikitextImage(
 
             :wikitext(createWikitextImage(
                 item2.name,
+
                 item.name,
 
                 {
 
                 {
 
                     src = "https://www.play.idlescape.com" .. src,
 
                     src = "https://www.play.idlescape.com" .. src,
                     alt = item2.name .. (item2.extraTooltip and ". " .. item2.extraTooltip or ""),
+
                     alt = item.name .. (item.extraTooltip and ". " .. item.extraTooltip or ""),
 
                     width = imageWidth,
 
                     width = imageWidth,
 
                     height = imageHeight
 
                     height = imageHeight
Line 312: Line 533:
 
             ))
 
             ))
 
         local itemHtml = mw.html.create("td")
 
         local itemHtml = mw.html.create("td")
             :wikitext('[[' .. item2.name .. ']]')
+
             :wikitext('[[' .. item.name .. ']]')
 
         local quantityHtml = mw.html.create("td")
 
         local quantityHtml = mw.html.create("td")
 
             :css("text-align", "center")
 
             :css("text-align", "center")
             :wikitext(createQuantityText(item))
+
             :wikitext(createRangeText(drop.minAmount, drop.maxAmount))
 
         local chanceHtml = mw.html.create("td")
 
         local chanceHtml = mw.html.create("td")
 
             :css("text-align", "center")
 
             :css("text-align", "center")
             :wikitext(toFixed(item.chance * 100, 3) .. "%")
+
            :attr("data-sort-value", string.format("%f", drop.chance))
         local priceHtml = mw.html.create("td")
+
             :wikitext(formatRarity(drop.chance))
 +
         local valueHtml = mw.html.create("td")
 
             :css("text-align", "right")
 
             :css("text-align", "right")
             :wikitext(createValueText(item2))
+
             :attr("data-sort-value", normalizeSortString(valueText))
        local tradeableHtml = mw.html.create("td")
+
             :wikitext(valueText)
            :css("text-align", "center")
+
         local leagueHtml
             :wikitext((item2.id == 1 or item2.tradeable) and "Yes" or "No") --Gold is tradeable
 
         local leagueHtml = mw.html.create("td")
 
            :css("text-align", "center")
 
 
         --TODO: Add indicator if league is inactive, the field is present in
 
         --TODO: Add indicator if league is inactive, the field is present in
 
         --      leagueList.ts / Module:Leagues/data but some are incorrect
 
         --      leagueList.ts / Module:Leagues/data but some are incorrect
         if item.allowedLeagues then
+
         if drop.allowedLeagues then
             for _, leagueId in ipairs(item.allowedLeagues) do
+
            leagueHtml = mw.html.create("td")
                 local league = leagueData[tostring(leagueId)]
+
                :css("text-align", "center")
 +
             for _, leagueId in ipairs(drop.allowedLeagues) do
 +
                 local league = leaguesData[tostring(leagueId)]
 
                 if league then
 
                 if league then
 
                     leagueHtml:wikitext(createWikitextImage(
 
                     leagueHtml:wikitext(createWikitextImage(
Line 346: Line 567:
 
                 end
 
                 end
 
             end
 
             end
        else
 
            leagueHtml:wikitext("All")
 
 
         end
 
         end
  
         local rowHtml = mw.html.create("tr")
+
         local row = mw.html.create("tr")
 
             :node(imageHtml)
 
             :node(imageHtml)
 
             :node(itemHtml)
 
             :node(itemHtml)
 
             :node(quantityHtml)
 
             :node(quantityHtml)
 
             :node(chanceHtml)
 
             :node(chanceHtml)
             :node(priceHtml)
+
             :node(valueHtml)
             :node(tradeableHtml)
+
 
             :node(leagueHtml)
+
        if leagueHtml then
 +
             row:node(leagueHtml)
 +
        end
 +
 
 +
        local caption = "Normal drops"
 +
        if drop.allowedLeagues then
 +
            caption = "League specific drops"
 +
        elseif item.class == "junk" then
 +
             caption = "Junk drops"
 +
        end
 +
 
 +
        table.insert(tables[caption], row)
 +
    end
 +
 
 +
    local tableHtml = mw.html.create()
  
         tableHtml:node(rowHtml)
+
    for caption, rowList in pairs(tables) do
 +
         if next(rowList) ~= nil then
 +
            local isLeagues = caption == "League specific drops"
 +
            local columns = {
 +
                " ",
 +
                "Item",
 +
                "Quantity",
 +
                "Rarity",
 +
                "Vendor value",
 +
                isLeagues and "Leagues" or nil
 +
            }
 +
            local tableHtml2 = createTable(columns, caption, collapsed)
 +
            for _, row in ipairs(rowList) do
 +
                tableHtml2:node(row)
 +
            end
 +
            tableHtml:node(tableHtml2)
 +
        end
 
     end
 
     end
  
 
     return tableHtml
 
     return tableHtml
 +
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
 +
    local collapsed = args.collapsed or false
 +
    local imageWidth = args.width == nil and 30 or args.width
 +
    local imageHeight = args.height == nil and imageWidth or args.height
 +
    --Some monsters use same id as other monsters so we need to specify location for them
 +
    local overrideLocId = args["zone"] and findSourceId(args["zone"])
 +
 +
    local sourceId
 +
    local sourceType
 +
    if overrideLocId then
 +
        sourceId = findIdByOverrideName(sourceName)
 +
        sourceType = "monster"
 +
    else
 +
        sourceId, sourceType = findSourceId(sourceName)
 +
 +
    end
 +
    if not (sourceId and sourceType) then
 +
        return createErrorMessage(
 +
            "Found no monsters overridden by '" .. sourceName ..
 +
            "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
 +
        )
 +
    end
 +
 +
    local location = sourceType == "location" and locationData[tostring(sourceId)] or nil
 +
    local isCombatLocation = location and location.actionType == "Action-Combat"
 +
 +
    if sourceType == "monster" or isCombatLocation then
 +
        return createMonsterTable(sourceId, sourceName, sourceType, overrideLocId, imageWidth, imageHeight, collapsed)
 +
    elseif sourceType == "location" then
 +
        return createGatheringTable(sourceId, imageWidth, imageHeight, collapsed)
 +
    end
 
end
 
end
  
 
return p
 
return p

Latest revision as of 00:49, 22 May 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 locationLootData = mw.loadData("Module:LocationLoot/data")


---Creates a deep copy of a tables and functions.
---Does not work with functions with upvalues and maybe userdata.
---@generic T
---@param t T The value to copy.
---@return T # A new value that is a deep copy of the original.
local function copyTable(t)
    if type(t) ~= "table" then
        return t
    elseif type(t) == "function" then
        return loadstring(string.dump(t))
    end
    local copy = {}
    for k, v in pairs(t) do
        copy[k] = copyTable(v)
    end
    return copy
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

---Creates a mw.html 'DIV' element containing an "error" message.
---@param message string Message as wikitext.
---@return table|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

---Finds the first source in the lootData that matches the given sourceId.
---@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 LootDrop[]|nil # The source data, or nil if the source is empty or 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(id, sourceType, locationId)
    id = tostring(id)
    locationId = locationId and tostring(locationId) or nil

    if sourceType == "location" then
        local location = lootData[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 = lootData[locationId]
        for sourceId, source in pairs(location) do
            if sourceId == id then
                return source, locationId
            end
        end
        return
    end

    for locationId2, location in pairs(lootData) do
        for sourceId, source in pairs(location) do
            if sourceId == id then
                return source, locationId2
            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

local function normalizeSortString(str)
    local function normalizeNumber(n)
        local base = 1000000
        local num = tonumber(n) or -base
        return base + num
    end

    local function normalizeRange(s)
        local from, to = string.match(s, "^(.-)–(.-)$")
        if from and from ~= "" and to and to ~= "" then
            return normalizeNumber(from) .. "–" .. normalizeNumber(to)
        else
            return normalizeNumber(s)
        end
    end

    str = str:gsub(",", "")
    return normalizeRange(str)
end

---Formats the given chance as a string presentation of percentage or 10k/100k fraction.
---@param chance number The chance to format. 1 equals 100%.
---@return string # The formatted chance as a string.
local function formatRarity(chance)
    local sign = chance < 0 and "-" or ""
    local abs = math.abs(chance)

    if abs < 0.001 then
        return sign .. toFixed(abs * 10000, 1) .. "/10k"
    elseif abs < 0.0001 then
        return sign .. toFixed(abs * 100000, 1) .. "/100k"
    end

    return toFixed(chance * 100, 3) .. "%"
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

local function createDottedTooltip(name, message)
    return string.format('<span class="rt-commentedText tooltip tooltip-dotted" title="%s">%s</span>', message, name)
end

---@param name string Wikitext.
---@return table|mw.html # mw.html TH element.
local function createTh(name)
    return mw.html.create("th")
        :addClass("headerSort")
        :attr("tabindex", 0)
        :attr("title", "Sort ascending")
        :wikitext(name)
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
        if name ~= "" then
            tr:node(createTh(name))
        end
    end

    return tr
end

---Creates a mw.html TABLE element with the given columns and caption.
---@param columns string[] Array of column names.
---@param caption string|nil Optional caption for the table.
---@param collapsed boolean|nil If true, the table will be collapsed by default.
---@return table|mw.html # mw.html TABLE element.
local function createTable(columns, caption, collapsed)
    local element = mw.html.create("table")
        :addClass("wikitable sortable jquery-tablesorter mw-collapsible" .. (collapsed and " mw-collapsed" or ""))

    if caption then
        element
            :tag("caption")
            :css("width", "fit-content") --Might not be needed, depends on the wiki css
            :wikitext(caption .. " ")
            :done()
    end

    return element:node(createThs(columns))
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

---Formats the value of an item as a string.
---@param item table|Item Item table from Module:Items/data.
---@param min number|nil The minimum amount of the item. Default 1 if both min & max are missing.
---@param max number|nil The maximum amount of the item.
---@return string # The formatted value of the item as a string, or '-' if no value.
local function createValueText(item, min, max)
    if not (min or max) then min = 1 end

    local lang = mw.language.getContentLanguage()
    local value = item.id == 1 and 1 or item.value

    if value then
        local minValue = min and min * value
        local maxValue = max and max * value

        if minValue == maxValue then
            return minValue and lang:formatNum(minValue) or "NA"
        elseif minValue and maxValue then
            return lang:formatNum(minValue) .. "–" .. lang:formatNum(maxValue)
        elseif minValue or maxValue then
            return lang:formatNum(minValue or maxValue)
        end
    end

    return "NA"
end

---Formats the given min & max values as a string.
---@param min number|nil The minimum value.
---@param max number|nil The maximum value.
---@return string # The formatted range as a string.
local function createRangeText(min, max)
    if not (min or max) then min = 1 end

    local lang = mw.language.getContentLanguage()
    if min == max then
        return min and lang:formatNum(min) or "NA"
    elseif min and max then
        return lang:formatNum(min) .. "–" .. lang:formatNum(max)
    end

    return lang:formatNum(min or max)
end

local function createFrequencyText(min, max)
    if not min then min = 0 end

    if min == max then
        return tostring(min)
    elseif min and max then
        return min .. "–" .. max
    end

    return tostring(min or max)
end

---Calculates the basic frequencies of an items.
---@param items table[] The list of items to calculate frequencies for.
---@param skill string The skill to use for the calculation.
---@param bonus number|nil The bonus rarity, used to normalize nodes with Bonus Rarity requirements.
---@return number[] # The list of frequencies for each item.
---@return number # The total frequency of the items.
local function getFrequencies(items, skill, bonus)
    bonus = bonus or 0
    local frequencies = {}
    local totalFrequency = 0

    for _, item in ipairs(items) do
        local frequency
        if skill == "fishing" then
            frequency = item.frequency or 0
            frequency = frequency + bonus
            if frequency > 0 then
                --frequency = frequency * (1 + 1/360) -- multiplier for effective level of 1
                if item.maxFrequency and frequency > item.maxFrequency then
                    local bonusRate = ((frequency - item.maxFrequency) * item.maxFrequency) / 50
                    frequency = item.maxFrequency + bonusRate
                end
            end
        else
            frequency = math.max(0, math.min(item.frequency or 0, item.maxFrequency or math.huge))
        end

        table.insert(frequencies, frequency)
        totalFrequency = totalFrequency + math.max(0, frequency or 0)
    end

    return frequencies, totalFrequency
end

---Creates a mw.html TABLE elements for gathering location drops.
---@param sourceId string|number The ID of the location.
---@param imageWidth number|string The width of the item image.
---@param imageHeight number|string The height of the item image.
---@param collapsed boolean|nil If true, the table will be collapsed by default.
---@return table|mw.html # An empty mw.html containing the created TABLE elements.
local function createGatheringTable(sourceId, imageWidth, imageHeight, collapsed)
    sourceId = tostring(sourceId)

    local location = locationData[sourceId]
    local locationLoot = copyTable(locationLootData[sourceId])
    local name = locationData[sourceId].name
    if not (location and locationLoot) then
        return createErrorMessage(
            "No loot found for location: '" .. name .. "'. The Module:LocationLoot/data may be outdated."
        )
    end

    --location's node list from Module:LocationLoot/data
    --nodes are in 'nodes' if location has nodes (foraging, fishing) or 'loot' if not (mining)
    ---@type table<string, LocationLoot[]>
    local lootNodes = locationLoot.nodes or (locationLoot.loot and {[location.name] = locationLoot.loot} or nil)
    if not lootNodes then
        return createErrorMessage(
            "No loot found for location: '" .. name .. "'. The Module:LocationLoot/data may be outdated."
        )
    end

    local function keyPos(tbl, key, val)
        for i, v in ipairs(tbl) do
            if v[key] == val then return i end
        end
        return 0
    end
    local nodes = location.nodes -- location's node list from Module:Location/data
    ---@type {name: string, nodes: LocationLoot[]}[]
    local lootNodeArray = {}
    for k,v in pairs(lootNodes) do
        table.insert(lootNodeArray, {name = k, nodes = v})
    end
    table.sort(lootNodeArray, function(a, b)
        return keyPos(nodes, "nodeID", a.name) < keyPos(nodes, "nodeID", b.name)
    end)

    local action = (location.actionType or ""):lower():gsub("action%-", "")
    local container = mw.html.create()

    local rarityLabel = action == "fishing"
        and createDottedTooltip(
            "Frequency",
            "Min–max frequency of the item. Negative minimum frequency is the item's inversed Rarity."
            .. " Drop chance is calculated from item's frequency and total frequency of the node's items."
            .. " Frequncies depends on effective level, Bonus Rarity and Deadliest Catch & Fiber Finder enchantments."
        ) or "Rarity"
    local reqRarityLabel = action == "fishing"
        and createDottedTooltip(
            "Req. Rarity",
            "To gather an item, Fishing Bonus Rarity must be greater than the required Rarity."
            .. " For example, a Bonus Rarity of exactly 5 cannot gather an item with Rarity 5,"
            .. " but a Bonus Rarity of 5.1 can."
        )
        or ""
    local columns = {
        " ",
        "Item",
        "Quantity",
        rarityLabel,
        reqRarityLabel,
        "Vendor value"
    }

    for i, node in ipairs(lootNodeArray) do
        local tableHtml = createTable(columns, node.name, collapsed)
        local frequencies, totalFrequency = getFrequencies(node.nodes, action)

        for ii, item in ipairs(node.nodes) do
            local item_ = itemsData[tostring(item.id)]
            local src = item_.itemImage
            src = src:sub(1, 1) ~= "/" and "/" .. src or src

            local frequencyPercent = math.max(0, frequencies[ii] / totalFrequency)
            local minBonusRarity = (item.frequency or 0) <= 0 and (math.abs(item.frequency) or 0) or 0
            local valueText = createValueText(item_, item.minAmount, item.maxAmount)
            local rarityText = action == "fishing"
                and createFrequencyText(item.frequency, item.maxFrequency)
                or formatRarity(frequencyPercent)
            local rarityValue = action == "fishing"
                and createFrequencyText(item.frequency, item.maxFrequency)
                or string.format("%f", frequencyPercent)

            local imageHtml = mw.html.create("td")
                :wikitext(createWikitextImage(
                    item_.name,
                    {
                        src = "https://www.play.idlescape.com" .. src,
                        alt = item_.name .. (item_.extraTooltip and ". " .. item_.extraTooltip or ""),
                        width = imageWidth,
                        height = imageHeight
                    }
                ))
            local itemHtml = mw.html.create("td")
                :wikitext('[[' .. item_.name .. ']]')
            local quantityHtml = mw.html.create("td")
                :css("text-align", "center")
                :wikitext(createRangeText(item.minAmount, item.maxAmount))
            local rarityHtml = mw.html.create("td")
                :css("text-align", "center")
                :wikitext(rarityText)
                :attr("data-sort-value", normalizeSortString(rarityValue))
            local reqBonusRarityHtml = action == "fishing"
                and mw.html.create("td")
                    :css("text-align", "center")
                    :wikitext(tostring(minBonusRarity))
                or nil
            local valueHtml = mw.html.create("td")
                :css("text-align", "right")
                :attr("data-sort-value", normalizeSortString(valueText))
                :wikitext(valueText)

            local row = mw.html.create("tr")
                :node(imageHtml)
                :node(itemHtml)
                :node(quantityHtml)
                :node(rarityHtml)
                :node(reqBonusRarityHtml)
                :node(valueHtml)

            tableHtml:node(row)
        end
        container:node(tableHtml)
    end

    return container
end

---Creates TABLE elements for monster and combat location drops.
---@param sourceId string|number The ID of the source (monster or location).
---@param sourceName string The name of the source.
---@param sourceType string The type of the source ("monster" or "location").
---@param overrideLocId string|number|nil The ID of the location to override the sourceId.
---@param imageWidth string|number The width of the item image.
---@param imageHeight string|number The height of the item image.
---@param collapsed boolean|nil If true, the table will be collapsed by default.
---@return table|mw.html # An empty mw.html containing the created TABLE elements.
local function createMonsterTable(sourceId, sourceName, sourceType, overrideLocId, imageWidth, imageHeight, collapsed)
    local loot = getFirstSourceMatch(sourceId, sourceType, overrideLocId)
    if not loot then
        return createErrorMessage(
            "No loot found for monster or location named '" .. sourceName ..
            "'. The Module:Loot/data may be outdated. <br>Please add loot drops to Module:Loot/data " ..
            "if possible or try manually making a table.</div>"
        )
    end
    loot = copyTable(loot) --loot data is read-only from Module so we need to copy it
    table.sort(loot, function(a, b)
        if a.allowedLeagues and not b.allowedLeagues then
            return false
        elseif not a.allowedLeagues and b.allowedLeagues then
            return true
        else
            return a.chance > b.chance
        end
    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 tables = {
        ["Normal drops"] = {},
        ["Junk drops"] = {},
        ["League specific drops"] = {}
    }

    for _, drop in ipairs(loot) do
        local item = itemsData[tostring(drop.id)]
        --IS itemList.ts has some incorrect URIs, e.g. for Feather
        local src = item.itemImage
        src = src:sub(1, 1) ~= "/" and "/" .. src or src
        local valueText = createValueText(item, drop.minAmount, drop.maxAmount)

        local imageHtml = mw.html.create("td")
            :wikitext(createWikitextImage(
                item.name,
                {
                    src = "https://www.play.idlescape.com" .. src,
                    alt = item.name .. (item.extraTooltip and ". " .. item.extraTooltip or ""),
                    width = imageWidth,
                    height = imageHeight
                }
            ))
        local itemHtml = mw.html.create("td")
            :wikitext('[[' .. item.name .. ']]')
        local quantityHtml = mw.html.create("td")
            :css("text-align", "center")
            :wikitext(createRangeText(drop.minAmount, drop.maxAmount))
        local chanceHtml = mw.html.create("td")
            :css("text-align", "center")
            :attr("data-sort-value", string.format("%f", drop.chance))
            :wikitext(formatRarity(drop.chance))
        local valueHtml = mw.html.create("td")
            :css("text-align", "right")
            :attr("data-sort-value", normalizeSortString(valueText))
            :wikitext(valueText)
        local leagueHtml
        --TODO: Add indicator if league is inactive, the field is present in
        --      leagueList.ts / Module:Leagues/data but some are incorrect
        if drop.allowedLeagues then
            leagueHtml = mw.html.create("td")
                :css("text-align", "center")
            for _, leagueId in ipairs(drop.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 = imageWidth,
                            height = imageHeight
                        }
                    ))
                else
                    leagueHtml:wikitext("?")
                end
            end
        end

        local row = mw.html.create("tr")
            :node(imageHtml)
            :node(itemHtml)
            :node(quantityHtml)
            :node(chanceHtml)
            :node(valueHtml)

        if leagueHtml then
            row:node(leagueHtml)
        end

        local caption = "Normal drops"
        if drop.allowedLeagues then
            caption = "League specific drops"
        elseif item.class == "junk" then
            caption = "Junk drops"
        end

        table.insert(tables[caption], row)
    end

    local tableHtml = mw.html.create()

    for caption, rowList in pairs(tables) do
        if next(rowList) ~= nil then
            local isLeagues = caption == "League specific drops"
            local columns = {
                " ",
                "Item",
                "Quantity",
                "Rarity",
                "Vendor value",
                isLeagues and "Leagues" or nil
            }
            local tableHtml2 = createTable(columns, caption, collapsed)
            for _, row in ipairs(rowList) do
                tableHtml2:node(row)
            end
            tableHtml:node(tableHtml2)
        end
    end

    return tableHtml
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
    local collapsed = args.collapsed or false
    local imageWidth = args.width == nil and 30 or args.width
    local imageHeight = args.height == nil and imageWidth or args.height
    --Some monsters use same id as other monsters so we need to specify location for them
    local overrideLocId = args["zone"] and findSourceId(args["zone"])

    local sourceId
    local sourceType
    if overrideLocId then
        sourceId = findIdByOverrideName(sourceName)
        sourceType = "monster"
    else
        sourceId, sourceType = findSourceId(sourceName)

    end
    if not (sourceId and sourceType) then
        return createErrorMessage(
            "Found no monsters overridden by '" .. sourceName ..
            "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
        )
    end

    local location = sourceType == "location" and locationData[tostring(sourceId)] or nil
    local isCombatLocation = location and location.actionType == "Action-Combat"

    if sourceType == "monster" or isCombatLocation then
        return createMonsterTable(sourceId, sourceName, sourceType, overrideLocId, imageWidth, imageHeight, collapsed)
    elseif sourceType == "location" then
        return createGatheringTable(sourceId, imageWidth, imageHeight, collapsed)
    end
end

return p