Difference between revisions of "Module:Infobox Monster"
Jump to navigation
Jump to search
Demcookies (talk | contribs) (Fix shrimp link to shrimp and not shrimp (species)) |
Demcookies (talk | contribs) m (Change Species link name to exclude (species)) |
||
(5 intermediate revisions by the same user not shown) | |||
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 values in an array with other values. | |
− | ---Replaces all instances of | ||
---@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 455: | 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)|Shrimp"}, | ||
+ | {from = "goblin", to = "goblin (species)|Goblin"}, | ||
+ | } | ||
+ | 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 506: | 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 |
Latest revision as of 18:52, 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)|Shrimp"}, {from = "goblin", to = "goblin (species)|Goblin"}, } 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