Difference between revisions of "Module:Loot table"

From Idlescape Wiki
Jump to navigation Jump to search
(Fix changes output from raw html to html with 'A' tags (images are still html but inside the wikitext template string) as wikitext, similar to Module:Img)
m (Update Value to Vendor value)
(9 intermediate revisions by the same user not shown)
Line 7: Line 7:
  
 
---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 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")
:css("color", "red")
+
        :css("color", "red")
:wikitext(message)
+
        :wikitext(message)
return e
+
    return e
 
end
 
end
  
 
---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|mw.html # The source type ("monster" or "location"), or mw.html element with error message 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
local id = findId._findId({ name, source })
+
        local id = findId._findId({ name, source })
if id ~= "id not found" then
+
        if id ~= "id not found" then
return id, source
+
            return id, source
end
+
        end
end
+
    end
local errorMessage = createErrorMessage("No monster or location named '" .. name .. "'. The Module:Loot/data may be outdated.")
 
return nil, errorMessage
 
 
end
 
end
  
 
---@class Item
 
---@class Item
---@field allowedLeagues? number[] # The leagues in which the item can be found. Found in all leagues if nil.
+
---@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 id integer The item ID.
---@field chance number # The base chance of the item being dropped.
+
---@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 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.
+
---@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.
 
---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 loot table The loot table from Module:Loot/data.
---@param id string|number # The ID of the item to find.
+
---@param id string|number The ID of the item to find.
---@return Item? # The item data, or nil if the item is not found.
+
---@return Item|nil The item data, or nil if the item is not found.
---@return string? # The monsterId where the item was found ("-1" for locations), 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? # The locationId where the item was found, 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)
 
local function getFirstLootMatch(loot, id)
id = tostring(id)
+
    id = tostring(id)
for locationId, location in pairs(loot) do
+
    for locationId, location in pairs(loot) do
for sourceId, source in pairs(location) do
+
        for sourceId, source in pairs(location) do
for _, item in ipairs(source) do
+
            for _, item in ipairs(source) do
if item.id == id then
+
                if item.id == id then
return item, sourceId, locationId
+
                    return item, sourceId, locationId
end
+
                end
end
+
            end
end
+
        end
end
+
    end
 
end
 
end
  
 
---Finds the first source in the loot table that matches the given sourceId.
 
---Finds the first source in the loot table that matches the given sourceId.
---@param loot table # The loot table from Module:Loot/data.
+
---@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.
---@return Item[]? # The source data, or nil if the source is not found.
+
---@param sourceType string String representing the type of the source ("monster" or "location").
---@return string? # The locationId where the source was found, or nil if the source is not found.
+
---@param locationId string|number|nil Optional ID for the location to find the source from. Use `id` and `sourceType` for location loot.
local function getFirstSourceMatch(loot, id)
+
---@return Item[]|nil The source data, 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)
 
     id = tostring(id)
 
     id = tostring(id)
for locationId, location in pairs(loot) do
+
    locationId = locationId and tostring(locationId) or nil
for sourceId, source in pairs(location) do
+
 
if sourceId == id then
+
    if sourceType == "location" then
return source, locationId
+
        local location = loot[id]
end
+
        if not location then
end
+
            return
end
+
        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 = loot[locationId]
 +
        for sourceId, source in pairs(location) do
 +
            if sourceId == id then
 +
                return source, locationId
 +
            end
 +
        end
 +
        return
 +
    end
 +
 
 +
    for locationId, location in pairs(loot) do
 +
        for sourceId, source in pairs(location) do
 +
            if sourceId == id then
 +
                return source, locationId
 +
            end
 +
        end
 +
    end
 
end
 
end
  
Line 76: Line 102:
  
 
---Converts a floating-point number to a approximation of fraction.
 
---Converts a floating-point number to a approximation of fraction.
---@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 88: Line 114:
  
 
     for d = 1, maxDenominator do
 
     for d = 1, maxDenominator do
         local n = math.floor(num*d + 0.5)
+
         local n = math.floor(num * d + 0.5)
 
         local approx = n / d
 
         local approx = n / d
 
         local difference = math.abs(num - approx)
 
         local difference = math.abs(num - approx)
Line 101: Line 127:
 
end
 
end
  
---Converts a floating-point number to a approximation of fraction as string.  
+
---Converts a floating-point number to a approximation of fraction as string.
---@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 114: Line 140:
  
 
---Formats a number up to a given number of decimal places, removing trailing zeros.
 
---Formats a number up to a given number of decimal places, removing trailing zeros.
---@param num number # The number to format.
+
---@param num number The number to format.
---@param digits number # The number of decimal places to include (must be a non-negative integer).
+
---@param digits number The number of decimal places to include (must be a non-negative integer).
---@return string # The formatted number as a string.
+
---@return string The formatted number as a string.
 
local function toFixed(num, digits)
 
local function toFixed(num, digits)
digits = math.max(0, math.floor(digits))
+
    digits = math.max(0, math.floor(digits))
local formatted = string.format("%." .. digits .. "f", num)
+
    local formatted = string.format("%." .. digits .. "f", num):gsub("%.?0+$", "")
formatted = formatted:gsub("%.?0+$", "")
+
    return formatted
return formatted
+
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
 
end
  
  
 
function p.lootTable(frame)
 
function p.lootTable(frame)
local args = frame:getParent().args
+
    local args = frame:getParent().args
return tostring(p._lootTable(args))
+
    return tostring(p._lootTable(args))
 
end
 
end
  
 
function p._lootTable(args)
 
function p._lootTable(args)
 
     local sourceName = args.name or args.title or mw.title.getCurrentTitle().text
 
     local sourceName = args.name or args.title or mw.title.getCurrentTitle().text
     local sourceId, sourceTypeOrError = findSourceId(sourceName)
+
    --Some monsters use same id as other monsters so we need to specify location for them
 +
     local locationId = args["zone"] and findSourceId(args["zone"])
 +
    local sourceId
 +
    local sourceType
 +
    if locationId then
 +
        sourceId = findIdByOverrideName(sourceName)
 +
        sourceType = "monster"
 +
    else
 +
        sourceId, sourceType = findSourceId(sourceName)
 +
    end
 
     if not sourceId then
 
     if not sourceId then
         return sourceTypeOrError
+
         return locationId and
 +
            createErrorMessage(
 +
                "Found no monsters overridden by '" .. sourceName ..
 +
                "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
 +
            ) or
 +
            createErrorMessage(
 +
                "No monster or location named '" .. sourceName ..
 +
                "'. The Module:Loot/data may be outdated. <br>Try adding parameter 'location' to the template, e.g.:" ..
 +
                "<br><code>{{Loot_table|location=Palace of Flame}}</code>."
 +
            )
 
     end
 
     end
  
     local loot = getFirstSourceMatch(lootData, sourceId)
+
     local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
 
     if not loot then
 
     if not loot then
         local div = mw.html.create("div")
+
         return createErrorMessage(
             :css("color", "red")
+
             "No loot found for monster or location named '" .. sourceName ..
            :wikitext("No loot found for monster or location named '" .. sourceName ..
+
            "' The Module:Loot/data may be outdated."
                "' The Module:Loot/data may be outdated.")
+
         )
         return div
 
 
     end
 
     end
  
 
     local lang = mw.language.getContentLanguage()
 
     local lang = mw.language.getContentLanguage()
 +
    local imageWidth = args.width == nil and 30 or args.width
 +
    local imageHeight = args.height == nil and imageWidth or args.height
  
     ---@param name string # Wikitext.
+
     ---@param name string Wikitext.
     ---@return mw.html # 'TH' element.
+
     ---@return mw.html mw.html TH element.
 
     local function createTh(name)
 
     local function createTh(name)
 
         local th = mw.html.create("th")
 
         local th = mw.html.create("th")
Line 159: Line 217:
 
     end
 
     end
  
     ---@param names string[] # Array of wikitext.
+
     ---@param names string[] Array of wikitext.
     ---@return mw.html # 'TR' element containing names as 'TH' elements.
+
     ---@return mw.html TR element containing names as TH elements.
 
     local function createThs(names)
 
     local function createThs(names)
 
         local tr = mw.html.create("tr")
 
         local tr = mw.html.create("tr")
Line 169: Line 227:
 
     end
 
     end
  
     ---Creates a 'A' element containing an image.
+
     ---Creates a mw.html A element containing an image.
     ---@param name string # The name of the item.
+
     ---@param name string The name of the item.
     ---@param src string # The URI to the item image.
+
     ---@param src string The URI to the item image.
     ---@param width? string|integer # The width of the item image as integer.
+
     ---@param width? string|integer The width of the item image as integer.
     ---@param height? string|integer # The height of the item image as integer.
+
     ---@param height? string|integer The height of the item image as integer.
     ---@return mw.html # mw.html 'A' element containing an image.
+
     ---@return mw.html mw.html A element containing an image.
 
     local function createImg(name, src, alt, width, height)
 
     local function createImg(name, src, alt, width, height)
 
         local e = mw.html.create("a")
 
         local e = mw.html.create("a")
Line 190: Line 248:
 
         return e:done()
 
         return e:done()
 
     end
 
     end
   
+
 
 
     local function createImgText(name, src, alt, width, height)
 
     local function createImgText(name, src, alt, width, height)
    return string.format(
+
        return string.format(
    '[[%s|<img src="%s" alt="%s"%s%s>]]',
+
            '[[%s|<img src="%s" alt="%s"%s%s>]]',
    name,
+
            name,
    src,
+
            src,
    alt,
+
            alt,
    width and ' width="' .. width .. '"' or "",
+
            width and ' width="' .. width .. '"' or "",
    height and ' heigth="' .. heigth .. '"' or ""
+
            height and ' height="' .. height .. '"' or ""
    )
+
        )
 +
    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 image = mw.html.create("img"):attr(imageAttributes)
 +
        return string.format('[[%s|%s]]', title, tostring(image):gsub("<img(.-) */>", "<img%1>"))
 
     end
 
     end
  
 
     ---Formats the value of an item as string.
 
     ---Formats the value of an item as string.
     ---@param item table # Item table from Module:Items/data
+
     ---@param item table Item table from Module:Items/data
     ---@return string # The formatted value of the item as string, or '-' if no value.
+
     ---@return string The formatted value of the item as string, or '-' if no value.
 
     local function createValueText(item)
 
     local function createValueText(item)
         if item.id == 1 then -- Gold
+
         if item.id == 1 then -- Gold, has no value in the data
 
             return "1"
 
             return "1"
 
         end
 
         end
    return item.value and lang:formatNum(item.value) or "-"
+
        return item.value and lang:formatNum(item.value) or "-"
 
     end
 
     end
   
+
 
 
     ---Formats the Item drop quantities as string.
 
     ---Formats the Item drop quantities as string.
     ---@param item Item # Item table from Module:Loot/data
+
     ---@param item Item Item table from Module:Loot/data
     ---@return string # The formatted quantity range as string.
+
     ---@return string The formatted quantity range as string.
 
     local function createQuantityText(item)
 
     local function createQuantityText(item)
    if item.minAmount == item.maxAmount then
+
        if item.minAmount == item.maxAmount then
    return tostring(item.minAmount)
+
            return tostring(item.minAmount)
    end
+
        end
    return lang:formatNum(item.minAmount) .. " - " .. lang:formatNum(item.maxAmount)
+
        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)
 +
    --      Saving preferences would require special permissions? MAybe with mw.user or javascript to localStorage?
 
     local tableHtml = mw.html.create("table")
 
     local tableHtml = mw.html.create("table")
         :addClass("wikitable sortable jquery-tablesorter")
+
         :addClass("wikitable sortable jquery-tablesorter mw-collapsible")
         :node(createThs({ "Image", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
+
         --:node(createThs({ "⚙️", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
 +
        :node(createThs({ " ", "Item", "Quantity", "Rarity", "Vendor value", "Tradeable", "Leagues" }))
  
 
     for _, item in ipairs(loot) do
 
     for _, item in ipairs(loot) do
Line 232: Line 302:
 
         src = src:sub(1, 1) ~= "/" and "/" .. src or src
 
         src = src:sub(1, 1) ~= "/" and "/" .. src or src
 
         local imageHtml = mw.html.create("td")
 
         local imageHtml = mw.html.create("td")
:wikitext(createImgText(
+
            :wikitext(createWikitextImage(
item2.name,
+
                item2.name,
"https://www.play.idlescape.com" .. src,
+
                {
item2.name .. (item2.extraTooltip and "\n" .. item2.extraTooltip or ""),
+
                    src = "https://www.play.idlescape.com" .. src,
args.width or 45,
+
                    alt = item2.name .. (item2.extraTooltip and ". " .. item2.extraTooltip or ""),
args.height or nil
+
                    width = imageWidth,
))
+
                    height = imageHeight
 +
                }
 +
            ))
 
         local itemHtml = mw.html.create("td")
 
         local itemHtml = mw.html.create("td")
        :wikitext('[[' .. item2.name .. ']]')
+
            :wikitext('[[' .. item2.name .. ']]')
            --[[:tag("a")
 
            :attr("href", tostring(mw.uri.localUrl(item2.name)))
 
            :attr("title", item2.name)
 
            :wikitext(item2.name)
 
            :done()]]
 
 
         local quantityHtml = mw.html.create("td")
 
         local quantityHtml = mw.html.create("td")
 +
            :css("text-align", "center")
 
             :wikitext(createQuantityText(item))
 
             :wikitext(createQuantityText(item))
 
         local chanceHtml = mw.html.create("td")
 
         local chanceHtml = mw.html.create("td")
 +
            :css("text-align", "center")
 
             :wikitext(toFixed(item.chance * 100, 3) .. "%")
 
             :wikitext(toFixed(item.chance * 100, 3) .. "%")
 
         local priceHtml = mw.html.create("td")
 
         local priceHtml = mw.html.create("td")
 +
            :css("text-align", "right")
 
             :wikitext(createValueText(item2))
 
             :wikitext(createValueText(item2))
 
         local tradeableHtml = mw.html.create("td")
 
         local tradeableHtml = mw.html.create("td")
             :wikitext(item2.tradeable and "Yes" or "No")
+
            :css("text-align", "center")
 +
             :wikitext((item2.id == 1 or item2.tradeable) and "Yes" or "No") --Gold is tradeable
 
         local leagueHtml = mw.html.create("td")
 
         local leagueHtml = mw.html.create("td")
--TODO: Add indicator if league is inactive, the field is present in
+
            :css("text-align", "center")
--      leagueList.ts / Module:Leagues/data but some are incorrect
+
        --TODO: Add indicator if league is inactive, the field is present in
 +
        --      leagueList.ts / Module:Leagues/data but some are incorrect
 
         if item.allowedLeagues then
 
         if item.allowedLeagues then
 
             for _, leagueId in ipairs(item.allowedLeagues) do
 
             for _, leagueId in ipairs(item.allowedLeagues) do
 
                 local league = leagueData[tostring(leagueId)]
 
                 local league = leagueData[tostring(leagueId)]
 
                 if league then
 
                 if league then
                leagueHtml:wikitext(createImgText(
+
                    leagueHtml:wikitext(createWikitextImage(
league.name,
+
                        league.name,
"https://www.play.idlescape.com" .. league.icon,
+
                        {
league.name,
+
                            src = "https://www.play.idlescape.com" .. league.icon,
args.width or 45,
+
                            alt = league.name,
args.height or nil
+
                            width = imageWidth,
))
+
                            height = imageHeight
 +
                        }
 +
                    ))
 
                 else
 
                 else
 
                     leagueHtml:wikitext("?")
 
                     leagueHtml:wikitext("?")
Line 286: Line 360:
  
 
         tableHtml:node(rowHtml)
 
         tableHtml:node(rowHtml)
end
+
    end
  
return tableHtml
+
    return tableHtml
 
end
 
end
  
 
return p
 
return p

Revision as of 19:11, 22 April 2025


local p = {}
local findId = require("Module:FindId")
local lootData = mw.loadData("Module:Loot/data")
local itemData = mw.loadData("Module:Items/data")
local leagueData = mw.loadData("Module:Leagues/data")


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

---@class Item
---@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 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 Item[]|nil The source data, 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)
    id = tostring(id)
    locationId = locationId and tostring(locationId) or nil

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

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

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

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


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
    --Some monsters use same id as other monsters so we need to specify location for them
    local locationId = args["zone"] and findSourceId(args["zone"])
    local sourceId
    local sourceType
    if locationId then
        sourceId = findIdByOverrideName(sourceName)
        sourceType = "monster"
    else
        sourceId, sourceType = findSourceId(sourceName)
    end
    if not sourceId then
        return locationId and
            createErrorMessage(
                "Found no monsters overridden by '" .. sourceName ..
                "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
            ) or
            createErrorMessage(
                "No monster or location named '" .. sourceName ..
                "'. The Module:Loot/data may be outdated. <br>Try adding parameter 'location' to the template, e.g.:" ..
                "<br><code>{{Loot_table|location=Palace of Flame}}</code>."
            )
    end

    local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
    if not loot then
        return createErrorMessage(
            "No loot found for monster or location named '" .. sourceName ..
            "' The Module:Loot/data may be outdated."
        )
    end

    local lang = mw.language.getContentLanguage()
    local imageWidth = args.width == nil and 30 or args.width
    local imageHeight = args.height == nil and imageWidth or args.height

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

    ---Creates a mw.html A element containing an image.
    ---@param name string The name of the item.
    ---@param src string The URI to the item image.
    ---@param width? string|integer The width of the item image as integer.
    ---@param height? string|integer The height of the item image as integer.
    ---@return mw.html mw.html A element containing an image.
    local function createImg(name, src, alt, width, height)
        local e = mw.html.create("a")
            :attr("href", tostring(mw.uri.localUrl(name)))
            :attr("title", name)
            :tag("img")
            :attr("src", src)
            :attr("alt", alt)
        if width then
            e:attr("width", width)
        end
        if height then
            e:attr("height", height)
        end
        return e:done()
    end

    local function createImgText(name, src, alt, width, height)
        return string.format(
            '[[%s|<img src="%s" alt="%s"%s%s>]]',
            name,
            src,
            alt,
            width and ' width="' .. width .. '"' or "",
            height and ' height="' .. height .. '"' or ""
        )
    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 image = mw.html.create("img"):attr(imageAttributes)
        return string.format('[[%s|%s]]', title, tostring(image):gsub("<img(.-) */>", "<img%1>"))
    end

    ---Formats the value of an item as string.
    ---@param item table Item table from Module:Items/data
    ---@return string The formatted value of the item as string, or '-' if no value.
    local function createValueText(item)
        if item.id == 1 then -- Gold, has no value in the data
            return "1"
        end
        return item.value and lang:formatNum(item.value) or "-"
    end

    ---Formats the Item drop quantities as string.
    ---@param item Item Item table from Module:Loot/data
    ---@return string The formatted quantity range as string.
    local function createQuantityText(item)
        if item.minAmount == item.maxAmount then
            return tostring(item.minAmount)
        end
        return lang:formatNum(item.minAmount) .. "–" .. lang:formatNum(item.maxAmount)
    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 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 item2 = itemData[tostring(item.id)]
        --IS itemList.ts has some incorrect URIs, e.g. for Feather
        local src = item2.itemImage
        src = src:sub(1, 1) ~= "/" and "/" .. src or src
        local imageHtml = mw.html.create("td")
            :wikitext(createWikitextImage(
                item2.name,
                {
                    src = "https://www.play.idlescape.com" .. src,
                    alt = item2.name .. (item2.extraTooltip and ". " .. item2.extraTooltip or ""),
                    width = imageWidth,
                    height = imageHeight
                }
            ))
        local itemHtml = mw.html.create("td")
            :wikitext('[[' .. item2.name .. ']]')
        local quantityHtml = mw.html.create("td")
            :css("text-align", "center")
            :wikitext(createQuantityText(item))
        local chanceHtml = mw.html.create("td")
            :css("text-align", "center")
            :wikitext(toFixed(item.chance * 100, 3) .. "%")
        local priceHtml = mw.html.create("td")
            :css("text-align", "right")
            :wikitext(createValueText(item2))
        local tradeableHtml = mw.html.create("td")
            :css("text-align", "center")
            :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
        --      leagueList.ts / Module:Leagues/data but some are incorrect
        if item.allowedLeagues then
            for _, leagueId in ipairs(item.allowedLeagues) do
                local league = leagueData[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
        else
            leagueHtml:wikitext("All")
        end

        local rowHtml = mw.html.create("tr")
            :node(imageHtml)
            :node(itemHtml)
            :node(quantityHtml)
            :node(chanceHtml)
            :node(priceHtml)
            :node(tradeableHtml)
            :node(leagueHtml)

        tableHtml:node(rowHtml)
    end

    return tableHtml
end

return p