Difference between revisions of "Module:Drop Sources"

From Idlescape Wiki
Jump to navigation Jump to search
m (Add error if no id found for given item name)
(Feature collapse multiple drops from the same source under single row, can be toggled. Sorting the table also affects the collapsed rows. Rows expand relative to the sort order and not just under the "parent" row.)
Line 37: Line 37:
 
     end
 
     end
 
     return lang:formatNum(min) .. "–" .. lang:formatNum(max)
 
     return lang:formatNum(min) .. "–" .. lang:formatNum(max)
 +
end
 +
 +
---Calculates the length of a table.
 +
---@param t table
 +
---@return number
 +
local function getTableLength(t)
 +
    local count = 0
 +
    for _ in pairs(t) do
 +
        count = count + 1
 +
    end
 +
    return count
 +
end
 +
 +
---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
 
end
  
Line 51: Line 77:
  
 
---Finds locations, sources and `LootData` for a given item ID.
 
---Finds locations, sources and `LootData` for a given item ID.
---{locationId = { sourceId = { LootData1, LootData2, ... }, ... }, ... }
+
---{ locationId = { sourceId = { LootData1, LootData2, ... }, ... }, ... }
 
---@param itemId string|number The ID of the item to search for.
 
---@param itemId string|number The ID of the item to search for.
---@return table<string, table<string, LootData>>|nil # A table containing locations, sources and `LootData` for the item, or nil if not found.
+
---@return table<string, table<string, LootData[]>>|nil # A table containing locations, sources and `LootData` for the item, or nil if not found.
 
local function getSources(itemId)
 
local function getSources(itemId)
 
     itemId = tostring(itemId)
 
     itemId = tostring(itemId)
Line 62: Line 88:
 
                 if tostring(drop.id) == itemId then
 
                 if tostring(drop.id) == itemId then
 
                     sources[locationId] = sources[locationId] or {}
 
                     sources[locationId] = sources[locationId] or {}
                     sources[locationId][sourceId] = drop
+
                     sources[locationId][sourceId] = sources[locationId][sourceId] or {}
 +
                    table.insert(sources[locationId][sourceId], drop)
 
                 end
 
                 end
 
             end
 
             end
Line 82: Line 109:
 
             local location = locationData[locationId]
 
             local location = locationData[locationId]
 
             if location then
 
             if location then
                 return location
+
                 return copyTable(location)
 
             end
 
             end
 
         end
 
         end
Line 89: Line 116:
 
             for _, location in ipairs(monster.locations or {}) do
 
             for _, location in ipairs(monster.locations or {}) do
 
                 if location and tostring(location.id) == locationId then
 
                 if location and tostring(location.id) == locationId then
                     return monster
+
                     return copyTable(monster)
 
                 end
 
                 end
 
             end
 
             end
Line 96: Line 123:
 
     --Tries to get the stats of base monster if not a location or no override is found
 
     --Tries to get the stats of base monster if not a location or no override is found
 
     --  e.g. no locationId or was given but no ovverides for the monster
 
     --  e.g. no locationId or was given but no ovverides for the monster
     return monstersStatsData[sourceId]
+
     return copyTable(monstersStatsData[sourceId])
 +
end
 +
 
 +
---Converts location-source-loot table to name-sourceStats-loot table.
 +
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
 +
---@return table<string, table> # A table containing the sources and their loot data and and optional Invalid IDs table.
 +
--[[
 +
 
 +
    return {
 +
        ["Source Name"] = {
 +
            _loot = { LootData1, LootData2, ... },
 +
            _id = sourceId,
 +
            `unpack key-value pairs from Module monstersStatsData or locationData for the source`
 +
        },
 +
        ...,
 +
        ["Invalid ID"] = { invalidSourceId1, invalidSourceId2, ... }
 +
    }
 +
]]
 +
local function getSourceList(sources)
 +
    local sourceList = {}
 +
    for locationId, sources_ in pairs(sources) do
 +
        for sourceId, loot in pairs(sources_) do
 +
            local source = getSource(sourceId, locationId)
 +
            if source then
 +
                local name = source.name
 +
                sourceList[name] = sourceList[name] or source
 +
                sourceList[name]._loot = sourceList[name]._loot or loot
 +
                sourceList[name]._id = sourceList[name]._id or sourceId
 +
            else
 +
                sourceList["Invalid ID"] = sourceList["Invalid ID"] or {}
 +
                table.insert(sourceList["Invalid ID"], sourceId)
 +
            end
 +
        end
 +
    end
 +
    return sourceList
 
end
 
end
  
Line 125: Line 186:
 
         :css("text-align", "center")
 
         :css("text-align", "center")
 
         :wikitext(toFixed(loot.chance * 100, 3) .. "%")
 
         :wikitext(toFixed(loot.chance * 100, 3) .. "%")
        :css("text-align", "center")
 
 
     local leagueHtml = mw.html.create("td")
 
     local leagueHtml = mw.html.create("td")
 
         :css("text-align", "center")
 
         :css("text-align", "center")
Line 162: Line 222:
  
 
---Creates a table of drop sources for a given item.
 
---Creates a table of drop sources for a given item.
---@param sources table<string, table<string, LootData>> # The drop sources for the item.
+
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
 
---@param imageWidth string|number # The width of the images in the table.
 
---@param imageWidth string|number # The width of the images in the table.
 
---@param imageHeight string|number # The height of the images in the table.
 
---@param imageHeight string|number # The height of the images in the table.
Line 181: Line 241:
 
     end
 
     end
  
     local seen = {}
+
    ---Creates a mw.html SPAN element with optional text, attributes, CSS and class.
 +
    ---@param text string|nil The text to be added to the span.
 +
    ---@param attr table|nil The attributes to be added to the span.
 +
    ---@param css table|nil The CSS styles to be added to the span.
 +
    ---@param class string|nil The classes to be added to the span.
 +
    ---@return table|mw.html # mw.html SPAN element.
 +
     local function createSpan(text, attr, css, class)
 +
        local span = mw.html.create("span")
 +
        if attr then span:attr(attr) end
 +
        if css then span:css(css) end
 +
        if class then span:addClass(class) end
 +
        if text then span:wikitext(text) end
 +
        return span
 +
    end
 +
 
 
     local tableHtml = mw.html.create("table")
 
     local tableHtml = mw.html.create("table")
 
         :addClass("wikitable sortable mw-collapsible")
 
         :addClass("wikitable sortable mw-collapsible")
 +
        --[[ Adding caption and collapsing the table did sift the content after the table under infobox
 +
              (normally it would occupy the space next to it), so captions are disabled for now
 +
        :tag("caption")
 +
        :wikitext("Monsters and locations")
 +
        :done()
 +
        ]]
 
         :node(createThs({ " ", "Source", "Quantity", "Rarity", "Leagues" }))
 
         :node(createThs({ " ", "Source", "Quantity", "Rarity", "Leagues" }))
  
     for locationId, sources_ in pairs(sources) do
+
     for name, source in pairs(getSourceList(sources)) do
         for sourceId, loot in pairs(sources_) do
+
         local rows = {}
            local source =  getSource(sourceId, locationId)
+
 
            local hash = tostring(source and source.name)
+
        if name == "Invalid ID" then
             if not seen[hash] then
+
            table.insert(
                seen[hash] = true
+
                rows,
                local row
+
                mw.html.create("tr")
                if source then
+
                    :tag("td")
                    local name = source.name
+
                    :attr("colspan", 5)
                    local relatedKey = itemsData[tostring(source.relatedKey)]
+
                    :wikitext("No sources with IDs: " .. table.concat(source, ", "))
                    local relatedKeyImage = relatedKey and relatedKey.itemImage
+
                    :done()
                    local src = source.imageOverride or relatedKeyImage or monstersData[sourceId].image
+
             )
                    src = src and src:sub(1, 1) ~= "/" and "/" .. src or src
+
        else
                    row = createMonsterLootRow(name, src, loot, imageWidth, imageHeight)
+
            local _loot = source._loot
                 else
+
            local multiDrop = getTableLength(_loot) > 1
                     row = mw.html.create("tr")
+
            local relatedKey = itemsData[tostring(source.relatedKey)]
 +
            local relatedKeyImage = relatedKey and relatedKey.itemImage
 +
            local src = source.imageOverride or relatedKeyImage or (monstersData[tostring(source._id)] or {}).image
 +
            src = src and src:sub(1, 1) ~= "/" and "/" .. src or src
 +
            local className = name:gsub(" ", "_"):gsub("[^%w_]", "")
 +
 
 +
            if multiDrop then
 +
                 table.insert(
 +
                    rows,
 +
                     mw.html.create("tr")
 +
                        :tag("td")
 +
                        :wikitext(createWikitextImage(
 +
                            name,
 +
                            {
 +
                                src = "https://www.play.idlescape.com" .. src,
 +
                                alt = name,
 +
                                width = imageWidth,
 +
                                height = imageHeight
 +
                            }
 +
                        ))
 +
                        :done()
 +
                        :tag("td")
 +
                        :wikitext("[[" .. name .. "]]")
 +
                        :done()
 
                         :tag("td")
 
                         :tag("td")
                         :wikitext("No source with ID " .. sourceId)
+
                         :attr("colspan", 3)
 +
                        :css("text-align", "center")
 +
                        :node(createSpan(
 +
                            "[Toggle all drops]",
 +
                            { ["role"] = "button" },
 +
                            {
 +
                                ["cursor"] = "pointer",
 +
                                ["font-weight"] = "700",
 +
                                ["color"] = "var(--color-progressive,#36c)"
 +
                            },
 +
                            "mw-customtoggle-" .. className
 +
                        ))
 +
                        :node(createSpan(
 +
                            "▼",
 +
                            { ["id"] = "mw-customcollapsible-" .. className },
 +
                            { ["float"] = "right" },
 +
                            "mw-collapsible"
 +
                        ))
 +
                        :node(createSpan(
 +
                            "▲",
 +
                            { ["id"] = "mw-customcollapsible-" .. className },
 +
                            { ["float"] = "right" },
 +
                            "mw-collapsible mw-collapsed"
 +
 
 +
                        ))
 
                         :done()
 
                         :done()
 +
                )
 +
            end
 +
 +
            for _, loot in ipairs(_loot) do
 +
                local row = createMonsterLootRow(name, src, loot, imageWidth, imageHeight)
 +
                if multiDrop then
 +
                    row
 +
                        :addClass("mw-collapsible mw-collapsed")
 +
                        :attr("id", "mw-customcollapsible-" .. className)
 
                 end
 
                 end
                 tableHtml:node(row)
+
                 table.insert(rows, row)
 
             end
 
             end
 +
        end
 +
 +
        for _, row in ipairs(rows) do
 +
            tableHtml:node(row)
 
         end
 
         end
 
     end
 
     end
Line 226: Line 366:
 
         return "No id found for item '" .. name .. "'."
 
         return "No id found for item '" .. name .. "'."
 
     end
 
     end
    -- { locationId = { sourceId = { drop1, drop2, ... }, ... }, ... }
 
 
     local sources = getSources(itemId)
 
     local sources = getSources(itemId)
 
     if not sources then
 
     if not sources then
Line 234: Line 373:
 
     return html
 
     return html
 
end
 
end
p.fid = findId._findId
 
  
  
 
return p
 
return p

Revision as of 18:01, 22 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")
local locationData = mw.loadData("Module:Location/data")
local monstersData = mw.loadData("Module:Monsters/data")
local monstersStatsData = mw.loadData("Module:Monsters stats/data")


---@class LootData
---@field id number The ID of the item.
---@field chance number The chance of the item dropping.
---@field minAmount number The minimum amount of the item that can drop.
---@field maxAmount number The maximum amount of the item that can drop.
---@field allowedLeagues number[]|nil The IDs of the leagues that allow the item to drop, or nil if allowed everywhere.


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

---Formats the given number range.
---@param min number The minimum of the range.
---@param max number The maximum of the range.
---@return string # The formatted range as string.
local function toNumberRange(min, max)
    local lang = mw.language.getContentLanguage()
    if min == max then
        return lang:formatNum(min)
    end
    return lang:formatNum(min) .. "–" .. lang:formatNum(max)
end

---Calculates the length of a table.
---@param t table
---@return number
local function getTableLength(t)
    local count = 0
    for _ in pairs(t) do
        count = count + 1
    end
    return count
end

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

---Finds locations, sources and `LootData` for a given item ID.
---{ locationId = { sourceId = { LootData1, LootData2, ... }, ... }, ... }
---@param itemId string|number The ID of the item to search for.
---@return table<string, table<string, LootData[]>>|nil # A table containing locations, sources and `LootData` for the item, or nil if not found.
local function getSources(itemId)
    itemId = tostring(itemId)
    local sources = {}
    for locationId, location in pairs(lootData) do
        for sourceId, source in pairs(location) do
            for _, drop in ipairs(source) do
                if tostring(drop.id) == itemId then
                    sources[locationId] = sources[locationId] or {}
                    sources[locationId][sourceId] = sources[locationId][sourceId] or {}
                    table.insert(sources[locationId][sourceId], drop)
                end
            end
        end
    end
    return sources
end

---Gets monster or location object based on the source ID and optionally location ID for locations and override monster.
---@param sourceId string|number The ID of the source, -1 for locations.
---@param locationId string|number|nil The ID of the location (optional), required for locations.
---@return table|nil # The stats of the monster or nil if not found.
local function getSource(sourceId, locationId)
    sourceId = tostring(sourceId)
    if locationId then
        locationId = tostring(locationId)
        --If sourceId is "-1", it means we are looking for a location
        if sourceId == "-1" then
            local location = locationData[locationId]
            if location then
                return copyTable(location)
            end
        end
        local baseMonster = monstersStatsData[sourceId]
        for _, monster in pairs(baseMonster.overrides or {}) do
            for _, location in ipairs(monster.locations or {}) do
                if location and tostring(location.id) == locationId then
                    return copyTable(monster)
                end
            end
        end
    end
    --Tries to get the stats of base monster if not a location or no override is found
    --  e.g. no locationId or was given but no ovverides for the monster
    return copyTable(monstersStatsData[sourceId])
end

---Converts location-source-loot table to name-sourceStats-loot table.
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
---@return table<string, table> # A table containing the sources and their loot data and and optional Invalid IDs table.
--[[

    return {
        ["Source Name"] = {
            _loot = { LootData1, LootData2, ... },
            _id = sourceId,
            `unpack key-value pairs from Module monstersStatsData or locationData for the source`
        },
        ...,
        ["Invalid ID"] = { invalidSourceId1, invalidSourceId2, ... }
    }
]]
local function getSourceList(sources)
    local sourceList = {}
    for locationId, sources_ in pairs(sources) do
        for sourceId, loot in pairs(sources_) do
            local source = getSource(sourceId, locationId)
            if source then
                local name = source.name
                sourceList[name] = sourceList[name] or source
                sourceList[name]._loot = sourceList[name]._loot or loot
                sourceList[name]._id = sourceList[name]._id or sourceId
            else
                sourceList["Invalid ID"] = sourceList["Invalid ID"] or {}
                table.insert(sourceList["Invalid ID"], sourceId)
            end
        end
    end
    return sourceList
end

---Creates a mw.html TR element containing monster and loot data.
---@param name string The name of the monster or location.
---@param src string The source of the image.
---@param loot LootData The loot data for the item.
---@param imageWidth string|number The width of the image.
---@param imageHeight string|number The height of the image.
---@return mw.html|table # mw.html TR element containing monster and loot data.
local function createMonsterLootRow(name, src, loot, imageWidth, imageHeight)
    local imageHtml = mw.html.create("td")
        :wikitext(createWikitextImage(
            name,
            {
                src = "https://www.play.idlescape.com" .. src,
                alt = name,
                width = imageWidth,
                height = imageHeight
            }
        ))
    local itemHtml = mw.html.create("td")
        :wikitext('[[' .. name .. ']]')
    local quantityHtml = mw.html.create("td")
        :css("text-align", "center")
        :wikitext(toNumberRange(loot.minAmount, loot.maxAmount))
    local chanceHtml = mw.html.create("td")
        :css("text-align", "center")
        :wikitext(toFixed(loot.chance * 100, 3) .. "%")
    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 loot.allowedLeagues then
        for _, leagueId in ipairs(loot.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 = 30,
                        height = 30
                    }
                ))
            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(leagueHtml)

    return rowHtml
end

---Creates a table of drop sources for a given item.
---@param sources table<string, table<string, LootData[]>> # The drop sources for the item.
---@param imageWidth string|number # The width of the images in the table.
---@param imageHeight string|number # The height of the images in the table.
local function createDropSourceTable(sources, imageWidth, imageHeight)
    ---@param names string[] Array of wikitext.
    ---@return mw.html|table # 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(mw.html.create("th")
                :addClass("headerSort")
                :attr("tabindex", 0)
                :attr("title", "Sort ascending")
                :wikitext(name)
        )
        end
        return tr
    end

    ---Creates a mw.html SPAN element with optional text, attributes, CSS and class.
    ---@param text string|nil The text to be added to the span.
    ---@param attr table|nil The attributes to be added to the span.
    ---@param css table|nil The CSS styles to be added to the span.
    ---@param class string|nil The classes to be added to the span.
    ---@return table|mw.html # mw.html SPAN element.
    local function createSpan(text, attr, css, class)
        local span = mw.html.create("span")
        if attr then span:attr(attr) end
        if css then span:css(css) end
        if class then span:addClass(class) end
        if text then span:wikitext(text) end
        return span
    end

    local tableHtml = mw.html.create("table")
        :addClass("wikitable sortable mw-collapsible")
        --[[ Adding caption and collapsing the table did sift the content after the table under infobox
               (normally it would occupy the space next to it), so captions are disabled for now
        :tag("caption")
        :wikitext("Monsters and locations")
        :done()
        ]]
        :node(createThs({ " ", "Source", "Quantity", "Rarity", "Leagues" }))

    for name, source in pairs(getSourceList(sources)) do
        local rows = {}

        if name == "Invalid ID" then
            table.insert(
                rows,
                mw.html.create("tr")
                    :tag("td")
                    :attr("colspan", 5)
                    :wikitext("No sources with IDs: " .. table.concat(source, ", "))
                    :done()
            )
        else
            local _loot = source._loot
            local multiDrop = getTableLength(_loot) > 1
            local relatedKey = itemsData[tostring(source.relatedKey)]
            local relatedKeyImage = relatedKey and relatedKey.itemImage
            local src = source.imageOverride or relatedKeyImage or (monstersData[tostring(source._id)] or {}).image
            src = src and src:sub(1, 1) ~= "/" and "/" .. src or src
            local className = name:gsub(" ", "_"):gsub("[^%w_]", "")

            if multiDrop then
                table.insert(
                    rows,
                    mw.html.create("tr")
                        :tag("td")
                        :wikitext(createWikitextImage(
                            name,
                            {
                                src = "https://www.play.idlescape.com" .. src,
                                alt = name,
                                width = imageWidth,
                                height = imageHeight
                            }
                        ))
                        :done()
                        :tag("td")
                        :wikitext("[[" .. name .. "]]")
                        :done()
                        :tag("td")
                        :attr("colspan", 3)
                        :css("text-align", "center")
                        :node(createSpan(
                            "[Toggle all drops]",
                            { ["role"] = "button" },
                            {
                                ["cursor"] = "pointer",
                                ["font-weight"] = "700",
                                ["color"] = "var(--color-progressive,#36c)"
                            },
                            "mw-customtoggle-" .. className
                        ))
                        :node(createSpan(
                            "▼",
                            { ["id"] = "mw-customcollapsible-" .. className },
                            { ["float"] = "right" },
                            "mw-collapsible"
                        ))
                        :node(createSpan(
                            "▲",
                            { ["id"] = "mw-customcollapsible-" .. className },
                            { ["float"] = "right" },
                            "mw-collapsible mw-collapsed"

                        ))
                        :done()
                )
            end

            for _, loot in ipairs(_loot) do
                local row = createMonsterLootRow(name, src, loot, imageWidth, imageHeight)
                if multiDrop then
                    row
                        :addClass("mw-collapsible mw-collapsed")
                        :attr("id", "mw-customcollapsible-" .. className)
                end
                table.insert(rows, row)
            end
        end

        for _, row in ipairs(rows) do
            tableHtml:node(row)
        end
    end

    return tableHtml
end


function p.dropSources(frame)
    local args = frame:getParent().args or {}
    return p._dropSources(args)
end

function p._dropSources(args)
    local name = args["name"] or args["title"] or mw.title.getCurrentTitle().text
    local itemId = findId._findId({ name, "item" })
    if itemId == "id not found" then
        return "No id found for item '" .. name .. "'."
    end
    local sources = getSources(itemId)
    if not sources then
        return "No sources found for '" .. name .. "', Modules: Loot/data or Monsters_stats/data may be outdated."
    end
    local html = createDropSourceTable(sources, args["width"] or 30, args["height"] or 30)
    return html
end


return p