Difference between revisions of "Module:Infobox Monster"
Jump to navigation
Jump to search
Demcookies (talk | contribs) m (Rename header Abilities to Ability Rotation) |
Demcookies (talk | contribs) (Fix Updated links for species that share names with monsters (Goblin, Shrimp) to point to 'Name (species)' instead. Update how capitalization works.) |
||
| Line 10: | Line 10: | ||
local defaultAffinities = { | 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 = { | 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 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 | end | ||
local function pairsByKeys(t, f) | 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 | 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 | end | ||
| − | + | return iter | |
| − | |||
| − | |||
end | end | ||
local function tchelper(first, rest) | local function tchelper(first, rest) | ||
| − | + | 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 | |
| − | + | for _ in pairs(T) do | |
| − | + | count = count + 1 | |
| − | + | end | |
| − | + | 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 | ||
| Line 157: | Line 164: | ||
---@return table? # The monster object, or nil if not found. | ---@return table? # The monster object, or nil if not found. | ||
local function getMonster(id) | local function getMonster(id) | ||
| − | + | local monster = monstersData[tostring(id)] | |
| − | + | if monster then | |
| − | + | return monster | |
| − | + | end | |
end | end | ||
local function getMonsterStats(id) | local function getMonsterStats(id) | ||
| − | + | local monsterStats = monstersStats[tostring(id)] | |
| − | |||
| − | |||
if not monsterStats then | 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 | end | ||
| − | + | return monsterStats | |
| − | |||
end | end | ||
local function calcThreat(monster) | 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 | end | ||
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 | return newUrl | ||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
end | end | ||
local function createImgTag(monster) | 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 | end | ||
| Line 220: | Line 227: | ||
--- @return string The formatted number as a string. | --- @return string The formatted number as a string. | ||
local function toFixed(num, digits) | 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\">" .. 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 | ||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
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\">" .. 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 | ||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
end | end | ||
| − | + | return args | |
| − | |||
end | end | ||
---Creates a Wikitext link for a list of titles. | ---Creates a Wikitext link for a list of titles. | ||
| − | ---@param titles string[] # The list of titles to link to. | + | ---@param titles string[]|string # The list of titles to link to. |
---@param capitalize_ boolean|nil # If true, capitalizes the first letter of each word. | ---@param capitalize_ boolean|nil # If true, capitalizes the first letter of each word. | ||
---@return string # The Wikitext links. | ---@return string # The Wikitext links. | ||
local function createWikitextLinks(titles, capitalize_) | local function createWikitextLinks(titles, capitalize_) | ||
| − | + | 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 | end | ||
| − | + | return s | |
| − | |||
end | end | ||
| Line 302: | Line 312: | ||
---@return string # The Wikitext link with the image. | ---@return string # The Wikitext link with the image. | ||
local function createWikitextImage(title, imageAttributes, divCSS) | 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 | end | ||
| Line 318: | Line 328: | ||
---@return string # The Wikitext of links and images for the abilities. | ---@return string # The Wikitext of links and images for the abilities. | ||
local function createAbilityRotation(abilities, unique) | 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 | end | ||
| − | |||
| − | + | return table.concat(images, "") | |
end | end | ||
| Line 351: | Line 361: | ||
---@return {dps: number, primaryDamageType: string} | ---@return {dps: number, primaryDamageType: string} | ||
local function getDamageInfo(stats) | 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 | 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 | ||
| − | ---Replaces all instances of | + | ---Replaces all instances of values in an array with other values. |
---@param array any[] # The array to search. | ---@param array any[] # The array to search. | ||
| − | ---@param | + | ---@param tbl { from: any, to: any }[] # The table of values to replace. |
| − | |||
---@return any[] # The modified array. | ---@return any[] # The modified array. | ||
| − | local function | + | 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 | end | ||
| − | + | return newArray | |
| − | |||
end | end | ||
| Line 457: | Line 468: | ||
---@return table # mw.html element; the infobox table. | ---@return table # mw.html element; the infobox table. | ||
local function createInfobox(monster, monsterStats, zones, dps, unique) | local function createInfobox(monster, monsterStats, zones, dps, unique) | ||
| − | + | local args = {} | |
| − | + | local replace = { | |
| + | {from = "shrimp", to = "shrimp (species)"}, | ||
| + | {from = "goblin", to = "goblin (species)"}, | ||
| + | } | ||
| + | local species = replaceArrayElements(monsterStats.species, replace) | ||
| + | species = (species[1] or ""):gsub("^%l", string.upper) | ||
| − | + | 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 | end | ||
| − | |||
| − | + | return infoboxModule.infobox(args) | |
end | end | ||
| Line 508: | Line 524: | ||
---@return table|nil The stats for monster that overrides, or nil if not found. | ---@return table|nil The stats for monster that overrides, or nil if not found. | ||
local function findMonsterByOverrideName(name) | 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 | end | ||
function p.infoboxMonster(frame) | function p.infoboxMonster(frame) | ||
| − | + | 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 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 | + | 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 | 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 | end | ||
return p | return p | ||
Revision as of 18:48, 30 April 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>")
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 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)"},
{from = "goblin", to = "goblin (species)"},
}
local species = replaceArrayElements(monsterStats.species, replace)
species = (species[1] or ""):gsub("^%l", string.upper)
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