local p = {}
local findId = require("Module:FindId")
local infoboxModule = require('Module:Infobox')
local abilitiesData = mw.loadData("Module:Abilities/data")
local monstersData = mw.loadData("Module:Monsters/data")
local monstersStats = mw.loadData("Module:Monsters stats/data")
local headerCount = 1
local labelCount = 1
local dataCount = 1
local defaultAffinities = {
{Melee = 1},
{Magic = 1},
{Range = 1},
{Piercing = 1},
{Blunt = 1},
{Slashing = 1},
{Fire = 1},
{Ice = 1},
{Nature = 1},
{Chaos = 1},
{Posion = 1},
{Lightning = 1}
}
local affinitiesIcon = {
[1] = "[[File:Melee splash.png|20px|link=Combat#Affinities]]",
[2] = "[[File:Magic splash.png|20px|link=Combat#Affinities]]",
[3] = "[[File:Range splash.png|20px|link=Combat#Affinities]]",
[4] = "[[File:Stab splash.png|20px|link=Combat#Affinities]]",
[5] = "[[File:Crush splash.png|20px|link=Combat#Affinities]]",
[6] = "[[File:Slash splash.png|20px|link=Combat#Affinities]]",
[7] = "[[File:Fire_splash.png|20px|link=Combat#Affinities]]",
[8] = "[[File:Ice_splash.png|20px|link=Combat#Affinities]]",
[9] = "[[File:Nature_splash.png|20px|link=Combat#Affinities]]",
[10] = "[[File:Chaos_splash.png|20px|link=Combat#Affinities]]",
[11] = "[[File:Poison_splash.png|20px|link=Combat#Affinities]]",
[12] = "[[File:Lightning_splash.png|20px|link=Combat#Affinities]]"
}
-- Convert from CSV string to table (converts a single line of a CSV file)
local function fromCSV(s)
if string.sub(s, -1) ~= ',' then
s = s .. ',' -- ending comma
end
s = string.gsub(s, "[%[%]]", ""):gsub(",%s", ",")
local t = {} -- table to collect fields
local fieldstart = 1
repeat
local nexti = string.find(s, ',', fieldstart)
table.insert(t, string.sub(s, fieldstart, nexti - 1))
fieldstart = nexti + 1
until fieldstart > string.len(s)
return t
end
local function pairsByKeys(t, f)
local a = {}
local orgi_key_type
for n in pairs(t) do
if tonumber(n) == nil then
table.insert(a, n)
orgi_key_type = "word"
elseif type(n) == "number" then
table.insert(a, n)
orgi_key_type = "int"
elseif type(n) == "string" and type(tonumber(n) == "number") then
orgi_key_type = "number"
table.insert(a, tonumber(n))
end
end
table.sort(a, f)
local key
local value
local i = 0 -- iterator variable
local iter = function() -- iterator function
i = i + 1
if a[i] == nil then
return nil
elseif orgi_key_type == "word" or orgi_key_type == "int" then
key = a[i]
value = t[a[i]]
elseif orgi_key_type == "number" then
key = tostring(a[i])
value = t[tostring(a[i])]
end
return key, value
end
return iter
end
local function tchelper(first, rest)
return first:upper() .. rest:lower()
end
local function capitalize(s)
s = s:gsub("(%a)([%w_']*)", tchelper):gsub(" Of ", " of "):gsub(" The ", " the "):gsub("Ii", "II")
return s
end
local function tablelength(T)
local count = 0
for _ in pairs(T) do
count = count + 1
end
return count
end
local function h()
local s = "header" .. headerCount
headerCount = headerCount + 1
labelCount = headerCount
dataCount = headerCount
return s
end
local function sbreak()
local s = "sbreak" .. headerCount
headerCount = headerCount + 1
labelCount = headerCount
dataCount = headerCount
return s
end
local function l()
local s = "label" .. labelCount
dataCount = labelCount
labelCount = labelCount + 1
headerCount = labelCount
return s
end
local function d()
local s = "data" .. dataCount
dataCount = dataCount + 1
headerCount = dataCount
labelCount = dataCount
return s
end
local function sl()
local s = "s" .. l {}
return s
end
local function sd()
local s = "s" .. d {}
return s
end
local function rc()
local s = "rowclass" .. labelCount
return s
end
---Fetches a monster object from Module:Monsters/data.
---@param id string|number # The monster's ID.
---@return table? # The monster object, or nil if not found.
local function getMonster(id)
local monster = monstersData[tostring(id)]
if monster then
return monster
end
end
local function getMonsterStats(id)
local monsterStats = monstersStats[tostring(id)]
if not monsterStats then
monsterStats = mw.loadData("Module:Monsters stats dungeon/data")[tostring(id)]
if not monsterStats then
monsterStats = mw.loadData("Module:Monsters stats normal/data")[tostring(id)]
if not monsterStats then
return nil
end
end
end
return monsterStats
end
local function calcThreat(monster)
local damageThreat = 2
local weaponThreat = 3
local armorThreat = 5
local attackSpeedThreat = 3
local attackSpeedThreatLevel = 2.4 / monster.attackSpeed
local attackSpeedThreatFinal = attackSpeedThreatLevel * attackSpeedThreat
local potentialDamageThreatFinal = (monster.attack + monster.strength + monster.magic + monster.range) * damageThreat
local weaponThreatFinal = (monster.weapon.dexterity + monster.weapon.intellect + monster.weapon.strength) * weaponThreat
local targetArmorRating = monster.armor.protection + monster.armor.resistance + monster.armor.agility * 1.5
local armorThreatFinal = (targetArmorRating + monster.defense * 10) * armorThreat
local baseThreat = (potentialDamageThreatFinal + weaponThreatFinal) * attackSpeedThreatFinal + armorThreatFinal
return math.floor(baseThreat)
end
local function fullUrl(url)
local newUrl = url
if url:sub(1, 5) == "https" then
return newUrl
end
if url:sub(1, 1) ~= "/" then
newUrl = "/" .. newUrl
end
newUrl = "https://www.play.idlescape.com" .. newUrl
return newUrl
end
local function createImgTag(monster)
local attrs = {
src = fullUrl(monster.image),
alt = monster.name,
width = 150
}
local img = mw.html.create('img'):attr(attrs)
img = tostring(img):gsub("<img(.-) */>", "<img%1>")
return img
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
local function createOffAffinity(args, monsterStats)
for index, value in pairsByKeys(defaultAffinities) do
for affinity, affinityValue in pairs(value) do
if monsterStats.offensiveDamageAffinity[affinity] then
affinityValue = monsterStats.offensiveDamageAffinity[affinity]
args[sl()] = affinitiesIcon[index]
if affinityValue > 1 then
args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>"
elseif affinityValue < 1 then
args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>"
else
args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
end
else
args[sl()] = affinitiesIcon[index]
args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
end
if index % 3 == 0 then
args["bodyclass"] = "equal-space"
args[sbreak()] = "yes"
end
end
end
return args
end
local function createDeffAffinity(args, monsterStats)
for index, value in pairsByKeys(defaultAffinities) do
for affinity, affinityValue in pairs(value) do
if monsterStats.defensiveDamageAffinity[affinity] then
affinityValue = monsterStats.defensiveDamageAffinity[affinity]
args[sl()] = affinitiesIcon[index]
if affinityValue > 1 then
args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>"
elseif affinityValue < 1 then
args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>"
else
args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
end
else
args[sl()] = affinitiesIcon[index]
args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%"
end
if index % 3 == 0 then
args["bodyclass"] = "equal-space"
args[sbreak()] = "yes"
end
end
end
return args
end
---Creates a Wikitext link for a list of titles.
---@param titles string[] # The list of titles to link to.
---@param capitalize_ boolean|nil # If true, capitalizes the first letter of each word.
---@return string # The Wikitext links.
local function createWikitextLinks(titles, capitalize_)
local s = ""
if titles then
for index, value in ipairs(titles) do
local title = capitalize_ and capitalize(value) or value
s = s .. "[[" .. title .. "]]"
if tablelength(titles) > index then
s = s .. ", "
end
end
end
return s
end
---Creates an Wikitext link with an image inside a div.
---@param title string # The title of the link.
---@param imageAttributes table # The html attributes for the image.
---@param divCSS table # The CSS styles for the div.
---@return string # The Wikitext link with the image.
local function createWikitextImage(title, imageAttributes, divCSS)
divCSS = divCSS or {}
divCSS["display"] = divCSS["display"] or "inline-block" --Makes the div same size as the image
local e = mw.html.create("div")
:css(divCSS)
:tag("img")
:attr(imageAttributes)
:done()
e = tostring(e):gsub("<img(.-) */>", "<img%1>")
return string.format('[[%s|%s]]', title, e)
end
---Creates a Wikitext of links and images for the abilities.
---@param abilities table[] # Array of abilities, from monsters stats.
---@param unique? boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
---@return string # The Wikitext of links and images for the abilities.
local function createAbilityRotation(abilities, unique)
local colors = {
Melee = "red",
Range = "green",
Magic = "blue",
}
local seenIds = {}
local images = {}
for _, ability in ipairs(abilities) do
if not seenIds[ability.id] then
seenIds[ability.id] = unique and true or false
local a = abilitiesData[tostring(ability.id)]
local attributes = {
src="https://www.play.idlescape.com" .. a.abilityImage,
alt=a.abilityName .. ". " .. a.description,
width=30
}
local css = {
["box-shadow"]=string.format("0 0 2px 1px %s", colors[a.damageType] or "white"),
background=colors[a.damageType] or "white",
margin="5px"
}
table.insert(images, createWikitextImage(a.abilityName, attributes, css))
end
end
return table.concat(images, "")
end
---Gets the monster's DPS and damage type. Converted to Lua from CombatStats.tsx getAbilityInfo()
---@param stats table # ICombatStatsData
---@return {dps: number, primaryDamageType: string}
local function getDamageInfo(stats)
local dps = 0
local timeLeft = 1
local attackSpeed = stats.attackSpeed
local str = stats.strength * 2 + (stats.masteryStrength or 0) + stats.weapon.strength * 2
local mgc = stats.magic * 2 + (stats.masteryMagic or 0) + stats.weapon.intellect * 2
local rng = stats.range * 2 + (stats.masteryRange or 0) + stats.weapon.dexterity * 2
local minHitMult = stats.hitMults.minimum
local maxHitMult = stats.hitMults.maximum
local meleeAbilityCount = 0
local magicAbilityCount = 0
local rangeAbilityCount = 0
local bestOffensiveScaling = {[1]="none", [2]=0}
local critChance = stats.offensiveCritical.chance
local critDamage = stats.offensiveCritical.damageMultiplier
for _, value in pairs(stats.abilities or {}) do
local ability = abilitiesData[tostring(value.id)]
if ability then
local min = minHitMult * ability.baseMinimumDamageCoeff
local max = maxHitMult * ability.baseMaximumDamageCoeff
local damage = 0
if ability.damageType == "Magic" then
damage = (min * mgc + max * mgc) / 2
magicAbilityCount = magicAbilityCount + 1
elseif ability.damageType == "Melee" then
damage = (min * str + max * str) / 2
meleeAbilityCount = meleeAbilityCount + 1
elseif ability.damageType == "Range" then
damage = (min * rng + max * rng) / 2
rangeAbilityCount = rangeAbilityCount + 1
end
local damageBonus = 0
for _, damageScale in ipairs(ability.damageScaling) do
local scalingHere = (stats.offensiveDamageAffinity[damageScale.affinity] or 1) * damageScale.scaling
if scalingHere > 1 then
damageBonus = damageBonus + scalingHere
end
if scalingHere > bestOffensiveScaling[2] then
bestOffensiveScaling = {damageScale.affinity, scalingHere}
end
end
damageBonus = damageBonus / (#ability.damageScaling > 0 and #ability.damageScaling or 1)
damage = damage * damageBonus
damage = damage * (critChance * critDamage + (1 - critChance))
local dot = ability.healthChangeEvent
if dot and not dot.targetsSelf and not ability.targetFriendly then
damage = damage + dot.dotCount * -dot.healthChange
end
local abilitySpeed = attackSpeed * ability.baseSpeedCoeff
local cooldown = (ability.cooldown or 0) / 1000
if cooldown == 0 or abilitySpeed / cooldown > timeLeft then
dps = dps + (damage / abilitySpeed) * timeLeft
timeLeft = 0
break
end
dps = dps + damage / cooldown
timeLeft = timeLeft - abilitySpeed / cooldown
end
end
local rotationFocus
if meleeAbilityCount > magicAbilityCount + rangeAbilityCount then
rotationFocus = "Melee"
elseif magicAbilityCount > meleeAbilityCount + rangeAbilityCount then
rotationFocus = "Magic"
elseif rangeAbilityCount > meleeAbilityCount + magicAbilityCount then
rotationFocus = "Range"
else
rotationFocus = "Hybrid"
end
local dataToReturn = {dps = dps, primaryDamageType = rotationFocus}
return dataToReturn
end
---Replaces all instances of a value in an array with another value.
---@param array any[] # The array to search.
---@param to any # The value to replace.
---@param from any # The value to replace with.
---@return any[] # The modified array.
local function replaceArrayElement(array, to, from)
local newArray = {}
for i, v in ipairs(array) do
if v == from then
newArray[i] = to
else
newArray[i] = v
end
end
return newArray
end
---Creates an infobox for the monster.
---@param monster table # The monster object from Module:Monsters/data.
---@param monsterStats table # The monster stats object from Module:Monsters stats/data.
---@param zones string[] # The zones where the monster can be found.
---@param dps string # The monster's DPS and damage type.
---@param unique boolean # If falsy, creates an element for each ability in order, otherwie skips duplicate abilities.
---@return table # mw.html element; the infobox table.
local function createInfobox(monster, monsterStats, zones, dps, unique)
local args = {}
local species = replaceArrayElement(monsterStats.species, "shrimp", "shrimp (species)")
args.autoheaders = "y"
args.subbox = "no"
args.bodystyle = " "
args.title = monster.name
args.image = createImgTag(monster)
args[l()] = "Zones"
args[d()] = createWikitextLinks(zones)
args[l()] = "Species"
args[d()] = createWikitextLinks(species, monsterStats.species ~= "shrimp")
if monsterStats.abilities then
args[h()] = unique and "Ability list" or "Abilities"
args[sd()] = createAbilityRotation(monsterStats.abilities, unique)
end
args[h()] = "Offensive Stats"
args[sl()] = "Attack Speed"
args[sd()] = tostring(monsterStats.attackSpeed)
args[sl()] = "DPS"
args[sd()] = tostring(dps)
args[sl()] = "Crit Chance"
args[sd()] = tostring(monsterStats.offensiveCritical.chance * 100) .. "%"
args[sl()] = "Crit Multiplier"
args[sd()] = tostring(monsterStats.offensiveCritical.damageMultiplier) .. "x"
args[h()] = "Offensive Affinities"
createOffAffinity(args, monsterStats)
args[h()] = "Defensive Stats"
args[sl()] = "Threat"
args[sd()] = calcThreat(monsterStats)
args[sl()] = "Crit Avoidance"
args[sd()] = tostring(monsterStats.defensiveCritical.chance * 100) .. "%"
args[sl()] = "Crit Reduction"
args[sd()] = tostring(monsterStats.defensiveCritical.damageMultiplier * 100) .. "%"
args[h()] = "Defensive Affinities"
createDeffAffinity(args, monsterStats)
for key, data in pairs(args) do
if string.find(key, "data") then
args[key] = tostring(data)
end
end
return infoboxModule.infobox(args)
end
---Finds the overriding monster with the given name.
---@param name string The name of the monster that overrides another monster.
---@return string|nil The ID of the monsters, or nil if not found.
---@return table|nil The stats for monster that overrides, or nil if not found.
local function findMonsterByOverrideName(name)
for id, monster in pairs(monstersStats) do
for overrideName, overrideMonster in pairs(monster.overrides or {}) do
if overrideName == name then
return id, overrideMonster
end
end
end
end
function p.infoboxMonster(frame)
local args = frame:getParent().args
return p._infoboxMonster(args)
end
function p._infoboxMonster(_args)
local name = _args[1] or _args["name"] or mw.title.getCurrentTitle().text
local zones = _args["zones"] and fromCSV(_args["zones"]) or nil
local unique = (_args["unique"] or _args["abilityList"]) and true or false
local monsterError = "<div style=\"color:red\"> No monster named '" .. name ..
"'. Some of the Modules: Monsters/data, Monsters_Ids/data or Monsters_stats/data might be outdated.</div>"
local overrideStats
local id = findId._findId({name, "monster"})
if id == "id not found" then
--Some monsters use same id as other monsters so we need to fetch their info differently
id, overrideStats = findMonsterByOverrideName(name)
if not id then
return monsterError
end
end
local monster = overrideStats and {name=name, image=overrideStats.imageOverride} or getMonster(id)
if not monster then
return monsterError
end
local stats = overrideStats or getMonsterStats(id)
if not stats then
return monsterError .. "<div style=\"color:red\">Please add monster stats to Module:Monsters_stats/data " ..
"if possible or try manually making <code><nowiki>{{Infobox}}</nowiki></code></div>"
end
local dps
if _args["DPS"] then
dps = _args["DPS"]
else
local damageInfo = getDamageInfo(stats)
dps = string.format("%d (%s)", damageInfo.dps, damageInfo.primaryDamageType)
end
local infobox = createInfobox(monster, stats, zones, dps, unique)
return infobox
end
return p