Module:Loot table

From Idlescape Wiki
Revision as of 09:24, 10 April 2025 by Demcookies (talk | contribs) (Fix type heigth to height)
Jump to navigation Jump to search

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")
        --:node(createThs({ "⚙️", "Item", "Quantity", "Rarity", "Value", "Tradeable", "Leagues" }))
        :node(createThs({ " ", "Item", "Quantity", "Rarity", "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