Difference between revisions of "Module:Loot table"

From Idlescape Wiki
Jump to navigation Jump to search
m (Fix make Gold tradeable)
(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.)
(6 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.id == 1 or item2.tradeable) and "Yes" or "No") --Gold is tradeable
+
             :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