Difference between revisions of "Module:Loot table"

From Idlescape Wiki
Jump to navigation Jump to search
(Update text alignments,)
(Update split main, junk and league drops to seperate tables. Add captions to the tables. Change Vendor value from single item value to quantity * value range.)
(7 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")
  
 +
 +
---Creates a deep copy of a table.
 +
---@param t table
 +
---@return table
 +
local function copyTable(t)
 +
    local copy = {}
 +
    for k, v in pairs(t) do
 +
        if type(v) == "table" then
 +
            copy[k] = copyTable(v)
 +
        else
 +
            copy[k] = v
 +
        end
 +
    end
 +
    return copy
 +
end
  
 
---Creates 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")
Line 17: Line 32:
  
 
---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
Line 27: Line 42:
 
         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
+
---Finds the first item in the drop table that matches the given item ID.
---@field allowedLeagues? number[] # The leagues in which the item can be found. Found in all leagues if nil.
+
---@param drop table The drop table from Module:Loot/data.
---@field id integer # The item ID.
+
---@param id string|number The ID of the item to find.
---@field chance number # The base chance of the item being dropped.
+
---@return Item|nil The item data, or nil if the item is not found.
---@field minAmount integer # The minimum base amount of the item that can be dropped.
+
---@return string|nil # The monsterId where the item was found ("-1" for locations), or nil if the item is not found.
---@field maxAmount integer # The maximum base amount of the item that can be dropped.
+
---@return string|nil # The locationId where the item was found, or nil if the item is not found.
 
+
local function getFirstLootMatch(drop, 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 id string|number # The ID of the item to find.
 
---@return Item? # 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? # The locationId where the item was found, or nil if the item is not found.
 
local function getFirstLootMatch(loot, id)
 
 
     id = tostring(id)
 
     id = tostring(id)
     for locationId, location in pairs(loot) do
+
     for locationId, location in pairs(drop) 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
Line 58: Line 63:
 
end
 
end
  
---Finds the first source in the loot table that matches the given sourceId.
+
---Finds the first source in the drop table that matches the given sourceId.
---@param loot table # The loot table from Module:Loot/data.
+
---@param drop table|Drop The drop 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").
---@return Item[]? # The source data, 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.
---@return string? # The locationId where the source was found (`id` if `sourceType` is "location"), or nil if the source is not found.
+
---@return Item[]|nil # The source data, or nil if the source is not found.
local function getFirstSourceMatch(loot, id, sourceType)
+
---@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(drop, id, sourceType, locationId)
 
     id = tostring(id)
 
     id = tostring(id)
 +
    locationId = locationId and tostring(locationId) or nil
 +
 
     if sourceType == "location" then
 
     if sourceType == "location" then
         local location = loot[id]
+
         local location = drop[id]
 
         if not location then
 
         if not location then
 
             return
 
             return
Line 77: Line 85:
 
         return source, id
 
         return source, id
 
     end
 
     end
     for locationId, location in pairs(loot) do
+
 
 +
     --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 = drop[locationId]
 
         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, locationId
 +
            end
 +
        end
 +
        return
 +
    end
 +
 +
    for locationId2, location in pairs(drop) do
 +
        for sourceId, source in pairs(location) do
 +
            if sourceId == id then
 +
                return source, locationId2
 
             end
 
             end
 
         end
 
         end
Line 89: Line 110:
  
 
---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.
Line 115: Line 136:
  
 
---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)
Line 127: Line 148:
  
 
---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)
Line 136: Line 157:
 
end
 
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)
+
---@param name string Wikitext.
     local args = frame:getParent().args
+
---@return mw.html # mw.html TH element.
    return tostring(p._lootTable(args))
+
local function createTh(name)
 +
     local th = mw.html.create("th")
 +
        :addClass("headerSort")
 +
        :attr("tabindex", 0)
 +
        :attr("title", "Sort ascending")
 +
        :wikitext(name)
 +
    return th
 
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.
     local sourceId, sourceTypeOrError = findSourceId(sourceName)
+
local function createThs(names)
    if not sourceId then
+
     local tr = mw.html.create("tr")
         return sourceTypeOrError
+
     for _, name in ipairs(names) do
 +
         tr:node(createTh(name))
 
     end
 
     end
 +
    return tr
 +
end
  
    local loot = getFirstSourceMatch(lootData, sourceId, sourceTypeOrError)
+
---Creates a mw.html A element containing an image.
     if not loot then
+
---@param name string The name of the item.
        local div = mw.html.create("div")
+
---@param src string The URI to the item image.
            :css("color", "red")
+
---@param width? string|integer The width of the item image as integer.
            :wikitext("No loot found for monster or location named '" .. sourceName ..
+
---@param height? string|integer The height of the item image as integer.
                "' The Module:Loot/data may be outdated.")
+
---@return mw.html # mw.html A element containing an image.
         return div
+
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
 
     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 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 string.
 +
---@param item table|Item Item table from Module:Items/data
 +
---@param drop table|Drop Drop table from Module:Loot/data
 +
---@return string # The formatted value of the item as string, or '-' if no value.
 +
local function createValueText(item, drop)
 
     local lang = mw.language.getContentLanguage()
 
     local lang = mw.language.getContentLanguage()
 +
    local value = item.id == 1 and 1 or item.value
 +
    if value then
 +
        local minValue = drop.minAmount and drop.minAmount * value
 +
        local maxValue = drop.maxAmount and drop.maxAmount * value
 +
        if minValue == maxValue then
 +
            return lang:formatNum(minValue)
 +
        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
  
    ---@param name string # Wikitext.
+
---Formats the Item drop quantities as string.
    ---@return mw.html # 'TH' element.
+
---@param drop table|Drop Item drop table from Module:Loot/data
    local function createTh(name)
+
---@return string # The formatted quantity range as string.
        local th = mw.html.create("th")
+
local function createQuantityText(drop)
            :addClass("headerSort")
+
    local lang = mw.language.getContentLanguage()
            :attr("tabindex", 0)
+
    if drop.minAmount == drop.maxAmount then
            :attr("title", "Sort ascending")
+
        return lang:formatNum(drop.minAmount)
            :wikitext(name)
 
        return th
 
 
     end
 
     end
 +
    return lang:formatNum(drop.minAmount) .. "–" .. lang:formatNum(drop.maxAmount)
 +
end
  
     ---@param names string[] # Array of wikitext.
+
 
     ---@return mw.html # 'TR' element containing names as 'TH' elements.
+
function p.lootTable(frame)
    local function createThs(names)
+
     local args = frame:getParent().args
        local tr = mw.html.create("tr")
+
     return tostring(p._lootTable(args))
        for _, name in ipairs(names) do
+
end
            tr:node(createTh(name))
+
 
         end
+
function p._lootTable(args)
         return tr
+
    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
 
     end
 
+
     if not sourceId then
     ---Creates a 'A' element containing an image.
+
        return locationId and
    ---@param name string # The name of the item.
+
            createErrorMessage(
    ---@param src string # The URI to the item image.
+
                "Found no monsters overridden by '" .. sourceName ..
    ---@param width? string|integer # The width of the item image as integer.
+
                "'. The Modules: Monsters_stats/data or Loot/data may be outdated."
    ---@param height? string|integer # The height of the item image as integer.
+
             ) or
    ---@return mw.html # mw.html 'A' element containing an image.
+
             createErrorMessage(
    local function createImg(name, src, alt, width, height)
+
                "No monster or location named '" .. sourceName ..
        local e = mw.html.create("a")
+
                "'. The Module:Loot/data may be outdated. <br>Try adding parameter 'location' to the template, e.g.:" ..
             :attr("href", tostring(mw.uri.localUrl(name)))
+
                "<br><code>{{Loot_table|location=Palace of Flame}}</code>."
             :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
 
     end
  
     local function createImgText(name, src, alt, width, height)
+
    ---@diagnostic disable-next-line: param-type-mismatch
         return string.format(
+
     local loot = getFirstSourceMatch(lootData, sourceId, sourceType, locationId)
             '[[%s|<img src="%s" alt="%s"%s%s>]]',
+
    if not loot then
            name,
+
         return createErrorMessage(
            src,
+
             "No loot found for monster or location named '" .. sourceName ..
            alt,
+
             "'. The Module:Loot/data may be outdated. <br>Please add loot drops to Module:Loot/data " ..  
            width and ' width="' .. width .. '"' or "",
+
            "if possible or try manually making a table.</div>"
             height and ' heigth="' .. height .. '"' or ""
 
 
         )
 
         )
 
     end
 
     end
 
+
     --loot data is read-only from Module so we need to copy it
     ---Formats the value of an item as string.
+
     loot = copyTable(loot)
    ---@param item table # Item table from Module:Items/data
+
     table.sort(loot, function(a, b)
     ---@return string # The formatted value of the item as string, or '-' if no value.
+
         if a.allowedLeagues and not b.allowedLeagues then
     local function createValueText(item)
+
            return false
         if item.id == 1 then -- Gold, has no value in the data
+
        elseif not a.allowedLeagues and b.allowedLeagues then
             return "1"
+
             return true
 +
        else
 +
            return a.chance > b.chance
 
         end
 
         end
        return item.value and lang:formatNum(item.value) or "-"
+
     end)
     end
 
  
     ---Formats the Item drop quantities as string.
+
     local imageWidth = args.width == nil and 30 or args.width
    ---@param item Item # Item table from Module:Loot/data
+
     local imageHeight = args.height == nil and imageWidth or args.height
    ---@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)
 
     --TODO: Add option to show percentages as fractions (open options from the gear icon)
     --      Saving preferences would require permissions to change user preferences
+
     --      Saving preferences would require special permissions? MAybe with mw.user or javascript to localStorage?
    --      which
 
  
     local tableHtml = mw.html.create("table")
+
     local rows = {
         :addClass("wikitable sortable jquery-tablesorter")
+
         ["Normal drops"] = {},
         --:node(createThs({ "⚙️", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
+
         ["Junk drops"] = {},
        :node(createThs({ " ", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
+
        ["League specific drops"] = {}
 +
    }
  
     for _, item in ipairs(loot) do
+
     for _, drop in ipairs(loot) do
         local item2 = itemData[tostring(item.id)]
+
         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 imageHtml = mw.html.create("td")
 
         local imageHtml = mw.html.create("td")
             :wikitext(createImgText(
+
             :wikitext(createWikitextImage(
                 item2.name,
+
                 item.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 30,
+
                    alt = item.name .. (item.extraTooltip and ". " .. item.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('[[' .. 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(createQuantityText(drop))
 
         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) .. "%")
+
             :wikitext(toFixed(drop.chance * 100, 3) .. "%")
         local priceHtml = mw.html.create("td")
+
         local valueHtml = mw.html.create("td")
 
             :css("text-align", "right")
 
             :css("text-align", "right")
             :wikitext(createValueText(item2))
+
             :wikitext(createValueText(item, drop))
 
         local tradeableHtml = mw.html.create("td")
 
         local tradeableHtml = mw.html.create("td")
 
             :css("text-align", "center")
 
             :css("text-align", "center")
             :wikitext(item2.tradeable and "Yes" or "No")
+
             :wikitext((item.id == 1 or item.tradeable) and "Yes" or "No") --Gold is tradeable
         local leagueHtml = mw.html.create("td")
+
         local leagueHtml
            :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(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 30,
+
                            alt = league.name,
                         args.height or nil
+
                            width = imageWidth,
 +
                            height = imageHeight
 +
                         }
 
                     ))
 
                     ))
 
                 else
 
                 else
Line 289: Line 383:
 
                 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(tradeableHtml)
            :node(leagueHtml)
 
  
         tableHtml:node(rowHtml)
+
         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(rows[caption], row)
 +
    end
 +
 
 +
    ---@param caption string|nil Optional caption for the table.
 +
    ---@param isLeagues boolean|nil If true, show leagues in the table.
 +
    ---@return table|mw.html # mw.html TABLE element.
 +
    local function createTable(caption, isLeagues)
 +
        local columns = {
 +
            " ",
 +
            "Item",
 +
            "Quantity",
 +
            "Rarity",
 +
            "Vendor value",
 +
            "Tradeable",
 +
            isLeagues and "Leagues" or nil
 +
        }
 +
        local element = mw.html.create("table")
 +
            :addClass("wikitable sortable jquery-tablesorter mw-collapsible")
 +
        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
 +
 
 +
    local tableHtml = mw.html.create()
 +
 
 +
    for caption, rowList in pairs(rows) do
 +
        if next(rowList) ~= nil then
 +
            local isLeagues = caption == "League specific drops"
 +
            local tableHtml2 = createTable(caption, isLeagues)
 +
            for _, row in ipairs(rowList) do
 +
                tableHtml2:node(row)
 +
            end
 +
            tableHtml:node(tableHtml2)
 +
        end
 
     end
 
     end
  

Revision as of 21:15, 27 April 2025


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


---Creates a deep copy of a table.
---@param t table
---@return table
local function copyTable(t)
    local copy = {}
    for k, v in pairs(t) do
        if type(v) == "table" then
            copy[k] = copyTable(v)
        else
            copy[k] = v
        end
    end
    return copy
end

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

---Finds the first item in the drop table that matches the given item ID.
---@param drop table The drop 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(drop, id)
    id = tostring(id)
    for locationId, location in pairs(drop) 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 drop table that matches the given sourceId.
---@param drop table|Drop The drop 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(drop, id, sourceType, locationId)
    id = tostring(id)
    locationId = locationId and tostring(locationId) or nil

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

    for locationId2, location in pairs(drop) 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

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

---@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 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 string.
---@param item table|Item Item table from Module:Items/data
---@param drop table|Drop Drop table from Module:Loot/data
---@return string # The formatted value of the item as string, or '-' if no value.
local function createValueText(item, drop)
    local lang = mw.language.getContentLanguage()
    local value = item.id == 1 and 1 or item.value
    if value then
        local minValue = drop.minAmount and drop.minAmount * value
        local maxValue = drop.maxAmount and drop.maxAmount * value
        if minValue == maxValue then
            return lang:formatNum(minValue)
        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 Item drop quantities as string.
---@param drop table|Drop Item drop table from Module:Loot/data
---@return string # The formatted quantity range as string.
local function createQuantityText(drop)
    local lang = mw.language.getContentLanguage()
    if drop.minAmount == drop.maxAmount then
        return lang:formatNum(drop.minAmount)
    end
    return lang:formatNum(drop.minAmount) .. "–" .. lang:formatNum(drop.maxAmount)
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

    ---@diagnostic disable-next-line: param-type-mismatch
    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. <br>Please add loot drops to Module:Loot/data " .. 
            "if possible or try manually making a table.</div>"
        )
    end
    --loot data is read-only from Module so we need to copy it
    loot = copyTable(loot)
    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)

    local imageWidth = args.width == nil and 30 or args.width
    local imageHeight = args.height == nil and imageWidth or args.height

    --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 rows = {
        ["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 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(createQuantityText(drop))
        local chanceHtml = mw.html.create("td")
            :css("text-align", "center")
            :wikitext(toFixed(drop.chance * 100, 3) .. "%")
        local valueHtml = mw.html.create("td")
            :css("text-align", "right")
            :wikitext(createValueText(item, drop))
        local tradeableHtml = mw.html.create("td")
            :css("text-align", "center")
            :wikitext((item.id == 1 or item.tradeable) and "Yes" or "No") --Gold is tradeable
        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)
            :node(tradeableHtml)

        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(rows[caption], row)
    end

    ---@param caption string|nil Optional caption for the table.
    ---@param isLeagues boolean|nil If true, show leagues in the table.
    ---@return table|mw.html # mw.html TABLE element.
    local function createTable(caption, isLeagues)
        local columns = {
            " ",
            "Item",
            "Quantity",
            "Rarity",
            "Vendor value",
            "Tradeable",
            isLeagues and "Leagues" or nil
        }
        local element = mw.html.create("table")
            :addClass("wikitable sortable jquery-tablesorter mw-collapsible")
        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

    local tableHtml = mw.html.create()

    for caption, rowList in pairs(rows) do
        if next(rowList) ~= nil then
            local isLeagues = caption == "League specific drops"
            local tableHtml2 = createTable(caption, isLeagues)
            for _, row in ipairs(rowList) do
                tableHtml2:node(row)
            end
            tableHtml:node(tableHtml2)
        end
    end

    return tableHtml
end

return p