Difference between revisions of "Module:Drop Sources"
Jump to navigation
Jump to search
Demcookies (talk | contribs) m (Add error if no id found for given item name) |
Demcookies (talk | contribs) (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) .. "%") | ||
| − | |||
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 | + | ---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 | + | 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") | :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() | :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 | ||
| − | + | 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 | ||
| − | |||
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 | ||
| − | |||
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