Module:Infobox Monster

From Idlescape Wiki
Revision as of 02:38, 10 April 2025 by Demcookies (talk | contribs) (Fix correct handling of monsters that share an id with other monsters; needs to be included in the table "overrides" key points in the monster's Module:Monsters_stats/data table.)
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["location"] and findSourceId(args["location"])
  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."
      )
  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()

  ---@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 ' heigth="' .. 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))
  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 ".\n" .. item2.extraTooltip or ""),
          width=args.width or 30,
          heigth=args.height or nil
        }
      ))
    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.tradeable and "Yes" or "No")
    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=args.width or 30,
              heigth=args.height or nil
            }
          ))
        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