Difference between revisions of "Module:Infobox Monster"
Jump to navigation
Jump to search
m (bug) |
Demcookies (talk | contribs) m (fix list multiple species) |
||
| (28 intermediate revisions by 2 users not shown) | |||
| Line 1: | Line 1: | ||
| − | local p ={} | + | local p = {} |
local findId = require("Module:FindId") | local findId = require("Module:FindId") | ||
local infoboxModule = require('Module:Infobox') | local infoboxModule = require('Module:Infobox') | ||
| + | local abilitiesData = mw.loadData("Module:Abilities/data") | ||
local monstersData = mw.loadData("Module:Monsters/data") | local monstersData = mw.loadData("Module:Monsters/data") | ||
| − | local | + | local monstersStats = mw.loadData("Module:Monsters stats/data") |
| − | |||
local headerCount = 1 | local headerCount = 1 | ||
local labelCount = 1 | local labelCount = 1 | ||
local dataCount = 1 | local dataCount = 1 | ||
| + | |||
local defaultAffinities = { | local defaultAffinities = { | ||
| − | { Melee = 1 }, | + | {Melee = 1}, |
| − | { Magic = 1 }, | + | {Magic = 1}, |
| − | { Range = 1 }, | + | {Range = 1}, |
| − | { Piercing = 1 }, | + | {Piercing = 1}, |
| − | { Blunt = 1 }, | + | {Blunt = 1}, |
| − | { Slashing = 1 }, | + | {Slashing = 1}, |
| − | { Fire = 1 }, | + | {Fire = 1}, |
| − | { Ice = 1 }, | + | {Ice = 1}, |
| − | { Nature = 1 }, | + | {Nature = 1}, |
| − | { Chaos = 1 }, | + | {Chaos = 1}, |
| − | { Posion = 1 }, | + | {Posion = 1}, |
| − | { Lightning = 1 } | + | {Lightning = 1} |
} | } | ||
local affinitiesIcon = { | 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) | -- Convert from CSV string to table (converts a single line of a CSV file) | ||
local function fromCSV(s) | 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 t = {} -- table to collect fields | ||
local fieldstart = 1 | local fieldstart = 1 | ||
| Line 56: | Line 57: | ||
local a = {} | local a = {} | ||
local orgi_key_type | local orgi_key_type | ||
| − | |||
for n in pairs(t) do | for n in pairs(t) do | ||
if tonumber(n) == nil then | if tonumber(n) == nil then | ||
| Line 72: | Line 72: | ||
local key | local key | ||
local value | local value | ||
| − | local i = 0 | + | local i = 0 -- iterator variable |
local iter = function() -- iterator function | local iter = function() -- iterator function | ||
i = i + 1 | i = i + 1 | ||
| Line 90: | Line 90: | ||
local function tchelper(first, rest) | local function tchelper(first, rest) | ||
| − | return first:upper()..rest:lower() | + | return first:upper() .. rest:lower() |
end | end | ||
local function capitalize(s) | local function capitalize(s) | ||
| − | + | s = s | |
| − | + | :gsub("^(%a)([%w_']*)", tchelper) | |
| + | :gsub("( %a)([%w_']*)", tchelper) | ||
| + | :gsub(" Of ", " of ") | ||
| + | :gsub(" The ", " the ") | ||
| + | :gsub(" A(n?) ", " a%1 ") | ||
| + | :gsub("Ii+$", string.upper) | ||
| + | :gsub("Ii+[ <]+", string.upper) | ||
| + | return s | ||
end | end | ||
local function tablelength(T) | local function tablelength(T) | ||
local count = 0 | local count = 0 | ||
| − | for _ in pairs(T) do count = count + 1 end | + | for _ in pairs(T) do |
| + | count = count + 1 | ||
| + | end | ||
return count | return count | ||
end | end | ||
local function h() | local function h() | ||
| − | + | local s = "header" .. headerCount | |
| − | + | headerCount = headerCount + 1 | |
| − | + | labelCount = headerCount | |
| − | + | dataCount = headerCount | |
| − | + | return s | |
end | end | ||
local function sbreak() | local function sbreak() | ||
| − | + | local s = "sbreak" .. headerCount | |
| − | + | headerCount = headerCount + 1 | |
| − | + | labelCount = headerCount | |
| − | + | dataCount = headerCount | |
| − | + | return s | |
end | end | ||
local function l() | local function l() | ||
| − | + | local s = "label" .. labelCount | |
| − | + | dataCount = labelCount | |
| − | + | labelCount = labelCount + 1 | |
| − | + | headerCount = labelCount | |
| − | + | return s | |
end | end | ||
local function d() | local function d() | ||
| − | + | local s = "data" .. dataCount | |
| − | + | dataCount = dataCount + 1 | |
| − | + | headerCount = dataCount | |
| − | + | labelCount = dataCount | |
| − | + | return s | |
end | end | ||
local function sl() | local function sl() | ||
| − | + | local s = "s" .. l() | |
| − | + | return s | |
end | end | ||
local function sd() | local function sd() | ||
| − | + | local s = "s" .. d() | |
| − | + | return s | |
end | end | ||
local function rc() | local function rc() | ||
| − | + | local s = "rowclass" .. labelCount | |
| − | + | return s | |
end | end | ||
| − | --- | + | ---Fetches a monster object from Module:Monsters/data. |
| − | ---@param id number | + | ---@param id string|number # The monster's ID. |
| − | ---@return table | + | ---@return table? # The monster object, or nil if not found. |
local function getMonster(id) | local function getMonster(id) | ||
| − | local | + | local monster = monstersData[tostring(id)] |
| − | if | + | if monster then |
| − | return | + | return monster |
end | end | ||
| − | |||
end | end | ||
local function getMonsterStats(id) | 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 | end | ||
| Line 180: | Line 191: | ||
local attackSpeedThreatLevel = 2.4 / monster.attackSpeed | local attackSpeedThreatLevel = 2.4 / monster.attackSpeed | ||
local attackSpeedThreatFinal = attackSpeedThreatLevel * attackSpeedThreat | local attackSpeedThreatFinal = attackSpeedThreatLevel * attackSpeedThreat | ||
| − | local potentialDamageThreatFinal = | + | 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 weaponThreatFinal = | ||
| − | |||
| − | |||
| − | |||
| − | |||
| − | local targetArmorRating = | ||
| − | |||
| − | |||
| − | |||
local armorThreatFinal = (targetArmorRating + monster.defense * 10) * armorThreat | local armorThreatFinal = (targetArmorRating + monster.defense * 10) * armorThreat | ||
local baseThreat = (potentialDamageThreatFinal + weaponThreatFinal) * attackSpeedThreatFinal + armorThreatFinal | local baseThreat = (potentialDamageThreatFinal + weaponThreatFinal) * attackSpeedThreatFinal + armorThreatFinal | ||
| Line 201: | Line 200: | ||
local function fullUrl(url) | 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 | end | ||
local function createImgTag(monster) | local function createImgTag(monster) | ||
| − | |||
local attrs = { | local attrs = { | ||
src = fullUrl(monster.image), | src = fullUrl(monster.image), | ||
alt = monster.name, | alt = monster.name, | ||
| − | width = | + | width = 150 |
} | } | ||
local img = mw.html.create('img'):attr(attrs) | local img = mw.html.create('img'):attr(attrs) | ||
| − | + | img = tostring(img):gsub("<img(.-) */>", "<img%1>") -- Fixes the self-closing tag | |
| + | 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 | end | ||
local function createOffAffinity(args, monsterStats) | 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\">" .. (affinityValue - 1) * 100 .. "%<span | + | args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>" |
elseif affinityValue < 1 then | elseif affinityValue < 1 then | ||
| − | args[sd()] = "<span style=\"color:#f44336\">" .. (affinityValue - 1) * 100 .. "%<span | + | args[sd()] = "<span style=\"color:#f44336\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>" |
| − | + | else | |
| − | + | args[sd()] = toFixed((affinityValue - 1) * 100, 2) .. "%" | |
end | 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 | end | ||
local function createDeffAffinity(args, monsterStats) | 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\">" .. (affinityValue - 1) * 100 .. "%<span | + | args[sd()] = "<span style=\"color:#4caf50\">" .. toFixed((affinityValue - 1) * 100, 2) .. "%</span>" |
elseif affinityValue < 1 then | elseif affinityValue < 1 then | ||
| − | args[sd()] = "<span style=\"color:#f44336\">" .. (affinityValue - 1) * 100 .. "%<span | + | 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[]|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_) | ||
| + | if titles == "" then return "" end | ||
| + | if type(titles) == "string" then | ||
| + | titles = {titles} | ||
| + | end | ||
| + | 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 | ||
| − | + | 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 | end | ||
| − | local function | + | ---Replaces all instances of values in an array with other values. |
| − | + | ---@param array any[] # The array to search. | |
| − | + | ---@param tbl { from: any, to: any }[] # The table of values to replace. | |
| − | + | ---@return any[] # The modified array. | |
| − | + | local function replaceArrayElements(array, tbl) | |
| − | + | local newArray = {} | |
| − | + | for i, v in ipairs(array or {}) do | |
| − | + | for _, t in ipairs(tbl or {}) do | |
| − | + | if v == t.from then | |
| − | + | newArray[i] = t.to | |
| − | + | else | |
| + | newArray[i] = v | ||
| + | end | ||
| + | end | ||
| + | end | ||
| + | return newArray | ||
end | end | ||
| − | local function createInfobox(monster, monsterStats, zones, dps) | + | ---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 args = {} | ||
| + | local replace = { | ||
| + | {from = "shrimp", to = "shrimp (species)|Shrimp"}, | ||
| + | {from = "goblin", to = "goblin (species)|Goblin"}, | ||
| + | } | ||
| + | local species = replaceArrayElements(monsterStats.species, replace) | ||
| + | for i, v in ipairs(species) do | ||
| + | species[i] = v:gsub("^%l", string.upper) | ||
| + | end | ||
args.autoheaders = "y" | args.autoheaders = "y" | ||
| − | + | args.subbox = "no" | |
| − | + | args.bodystyle = " " | |
| − | + | args.title = monster.name | |
args.image = createImgTag(monster) | args.image = createImgTag(monster) | ||
| − | + | args[l()] = "Zones" | |
| − | + | args[d()] = createWikitextLinks(zones) | |
| + | args[l()] = "Species" | ||
| + | args[d()] = createWikitextLinks(species) | ||
| + | if monsterStats.abilities then | ||
| + | args[h()] = unique and "Ability List" or "Ability Rotation" | ||
| + | args[sd()] = createAbilityRotation(monsterStats.abilities, unique) | ||
| + | end | ||
args[h()] = "Offensive Stats" | 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" | 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 | end | ||
| − | function | + | |
| + | function p.infoboxMonster(frame) | ||
local args = frame:getParent().args | local args = frame:getParent().args | ||
| − | + | return p._infoboxMonster(args) | |
end | end | ||
function p._infoboxMonster(_args) | 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 | + | 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 | end | ||
| − | + | local monster = overrideStats and {name=name, image=overrideStats.imageOverride} or getMonster(id) | |
| − | + | if not monster then | |
| − | return | + | return monsterError |
end | end | ||
| − | + | local stats = overrideStats or getMonsterStats(id) | |
| − | + | if not stats then | |
| − | return "<div style=\"color:red\"> | + | 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 | end | ||
| − | infobox = createInfobox(monster, | + | |
| − | + | 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 | end | ||
return p | return p | ||
Latest revision as of 21:10, 1 June 2025
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("( %a)([%w_']*)", tchelper)
:gsub(" Of ", " of ")
:gsub(" The ", " the ")
:gsub(" A(n?) ", " a%1 ")
:gsub("Ii+$", string.upper)
:gsub("Ii+[ <]+", string.upper)
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>") -- Fixes the self-closing tag
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[]|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_)
if titles == "" then return "" end
if type(titles) == "string" then
titles = {titles}
end
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 values in an array with other values.
---@param array any[] # The array to search.
---@param tbl { from: any, to: any }[] # The table of values to replace.
---@return any[] # The modified array.
local function replaceArrayElements(array, tbl)
local newArray = {}
for i, v in ipairs(array or {}) do
for _, t in ipairs(tbl or {}) do
if v == t.from then
newArray[i] = t.to
else
newArray[i] = v
end
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 replace = {
{from = "shrimp", to = "shrimp (species)|Shrimp"},
{from = "goblin", to = "goblin (species)|Goblin"},
}
local species = replaceArrayElements(monsterStats.species, replace)
for i, v in ipairs(species) do
species[i] = v:gsub("^%l", string.upper)
end
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)
if monsterStats.abilities then
args[h()] = unique and "Ability List" or "Ability Rotation"
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