Module:Skills: Difference between revisions
From Melvor Idle
(Amend incomplete category & indentation) |
(Add Archaeology recipe key) |
||
(53 intermediate revisions by 5 users not shown) | |||
Line 1: | Line 1: | ||
--This module should avoid including skill specific functions which generate | |||
--output for wiki pages, especially those which require() other modules. For | |||
--these functions, consider using the appropriate module from the below list. | |||
--Some skills have their own modules: | --Some skills have their own modules: | ||
--Module:Magic for Magic | --Module:Magic for Magic | ||
Line 11: | Line 15: | ||
local p = {} | local p = {} | ||
local Shared = require('Module:Shared') | local Shared = require('Module:Shared') | ||
local Constants = require('Module:Constants') | local Constants = require('Module:Constants') | ||
local Common = require('Module:Common') | |||
local GameData = require('Module:GameData') | |||
local SkillData = GameData.skillData | |||
local Modifiers = require('Module:Modifiers') | |||
local Items = require('Module:Items') | local Items = require('Module:Items') | ||
local Icons = require('Module:Icons') | local Icons = require('Module:Icons') | ||
local Num = require('Module:Number') | |||
local | -- Given a skill ID, returns the key for that skill's recipe data. | ||
-- If the skill has no recipes (e.g. is a combat skill) then the | |||
-- return value is nil | |||
function p.getSkillRecipeKey(skillID) | |||
-- Convert skillID to local ID if not already | |||
local ns, localSkillID = GameData.getLocalID(skillID) | |||
local recipeIDs = { | |||
["Woodcutting"] = 'trees', | |||
["Fishing"] = 'fish', | |||
["Firemaking"] = 'logs', | |||
["Mining"] = 'rockData', | |||
["Thieving"] = 'npcs', | |||
["Agility"] = 'obstacles', | |||
["Cooking"] = 'recipes', | |||
["Smithing"] = 'recipes', | |||
["Farming"] = 'recipes', | |||
["Summoning"] = 'recipes', | |||
["Fletching"] = 'recipes', | |||
["Crafting"] = 'recipes', | |||
["Runecrafting"] = 'recipes', | |||
["Herblore"] = 'recipes', | |||
["Astrology"] = 'recipes', | |||
["Archaeology"] = 'digSites', | |||
["Harvesting"] = 'veinData' | |||
} | |||
return recipeIDs[localSkillID] | |||
end | |||
function p. | -- Given a skill ID & recipe, returns the skill level requirement for | ||
local | -- that recipe and a boolean value indicating whether the level if abyssal or not. | ||
-- If the level could not be determined, then the return value is nil, nil | |||
function p.getRecipeLevelRealm(skillID, recipe) | |||
local level, isAbyssal = nil, nil | |||
-- Convert skillID to local ID if not already | |||
local ns, localSkillID = GameData.getLocalID(skillID) | |||
local realmID = p.getRecipeRealm(recipe) | |||
if localSkillID == 'Agility' then | |||
local course = GameData.getEntityByProperty(SkillData.Agility.courses, 'realm', realmID) | |||
isAbyssal = (realmID == 'melvorItA:Abyssal') | |||
-- For Agility, level is derived from obstacle category | |||
if recipe.category ~= nil then | |||
-- Obstacle | |||
local slot = course.obstacleSlots[recipe.category + 1] | |||
if isAbyssal then | |||
level = slot.abyssalLevel | |||
else | |||
level = slot.level | |||
end | |||
elseif recipe.slot ~= nil then | |||
-- Pillar | |||
level = course.pillarSlots[recipe.slot + 1].level | |||
end | |||
elseif recipe.abyssalLevel ~= nil then | |||
level, isAbyssal = recipe.abyssalLevel, true | |||
else | |||
-- For all other skills, the recipe should have a level property | |||
level, isAbyssal = recipe.level, false | |||
end | end | ||
return level, isAbyssal | |||
end | |||
local | function p.getRecipeLevel(skillID, recipe) | ||
if | local level, isAbyssal = p.getRecipeLevelRealm(skillID, recipe) | ||
return level | |||
end | |||
function p.getRecipeRequirementText(skillName, recipe) | |||
local reqText = {} | |||
if recipe.abyssalLevel ~= nil and recipe.abyssalLevel > 0 then | |||
table.insert(reqText, Icons._SkillReq(skillName, recipe.abyssalLevel, false, 'melvorItA:Abyssal')) | |||
elseif recipe.level ~= nil and recipe.level > 0 then | |||
table.insert(reqText, Icons._SkillReq(skillName, recipe.level, false)) | |||
end | |||
if recipe.totalMasteryRequired ~= nil then | |||
table.insert(reqText, Num.formatnum(recipe.totalMasteryRequired) .. ' ' .. Icons.Icon({skillName, type='skill', notext=true}) .. ' ' .. Icons.Icon({'Mastery'})) | |||
end | |||
local reqsData = {} | |||
if type(recipe.requirements) == 'table' then | |||
reqsData = Shared.shallowClone(recipe.requirements) | |||
end | |||
if recipe.shopItemPurchased ~= nil then | |||
-- Mining requirements are stored differently than other skills like | |||
-- Woodcutting, standardize here | |||
table.insert(reqsData, { | |||
["type"] = 'ShopPurchase', | |||
["purchaseID"] = recipe.shopItemPurchased, | |||
["count"] = 1 | |||
}) | |||
end | end | ||
if not Shared.tableIsEmpty(reqsData) then | |||
local reqs = Common.getRequirementString(reqsData) | |||
if reqs ~= nil then | |||
table.insert(reqText, reqs) | |||
end | |||
end | end | ||
return table.concat(reqText, '<br/>') | |||
end | end | ||
function p. | function p.standardRecipeSort(skillID, recipeA, recipeB) | ||
local | local levelA, isAbyssalA = p.getRecipeLevelRealm(skillID, recipeA) | ||
local | local levelB, isAbyssalB = p.getRecipeLevelRealm(skillID, recipeB) | ||
local isAbyssalNumA, isAbyssalNumB = (isAbyssalA and 1) or 0, (isAbyssalB and 1) or 0 | |||
return (isAbyssalA == isAbyssalB and levelA < levelB) or isAbyssalNumA < isAbyssalNumB | |||
end | |||
function p.getRecipeRealm(recipe) | |||
return recipe.realm or 'melvorD:Melvor' | |||
end | end | ||
function p. | function p.getRealmFromName(realmName) | ||
local | local realm = nil | ||
if | if realmName == nil or realmName == '' then | ||
-- Default realm | |||
realm = GameData.getEntityByID('realms', 'melvorD:Melvor') | |||
else | else | ||
realm = GameData.getEntityByName('realms', realmName) | |||
end | end | ||
return realm | |||
end | |||
local | function p.getMasteryActionCount(skillID, realmID, levelLimit) | ||
local actCount = 0 | |||
local skillNS, skillLocalID = Shared.getLocalID(skillID) | |||
local skillData = SkillData[skillLocalID] | |||
local recipeKey = p.getSkillRecipeKey(skillLocalID) | |||
if recipeKey ~= nil then | |||
local recipeData = skillData[recipeKey] | |||
for i, recipe in ipairs(recipeData) do | |||
if ( | |||
p.getRecipeRealm(recipe) == realmID | |||
and (recipe.noMastery == nil or not recipe.noMastery) | |||
and (levelLimit == nil or p.getRecipeLevel(skillLocalID, recipe) <= levelLimit) | |||
) then | |||
actCount = actCount + 1 | |||
end | |||
end | end | ||
end | end | ||
return actCount | |||
end | |||
-- Thieving | |||
return | function p.getThievingNPCByID(npcID) | ||
return GameData.getEntityByID(SkillData.Thieving.npcs, npcID) | |||
end | end | ||
function p. | function p.getThievingNPC(npcName) | ||
return GameData.getEntityByName(SkillData.Thieving.npcs, npcName) | |||
end | |||
function p.getThievingNPCArea(npc) | |||
for i, area in ipairs(SkillData.Thieving.areas) do | |||
for j, npcID in ipairs(area.npcIDs) do | |||
if npcID == npc.id then | |||
return area | |||
end | |||
end | |||
end | |||
end | end | ||
function p. | function p._getThievingNPCStat(npc, statName) | ||
local result | local result = nil | ||
if statName == 'level' then | |||
result = Icons._SkillReq('Thieving', npc.level) | |||
elseif statName == 'maxHit' then | |||
result = npc.maxHit * 10 | |||
elseif statName == 'area' then | |||
local area = p.getThievingNPCArea(npc) | |||
result = area.name | |||
else | |||
result = npc[statName] | |||
end | |||
if result == nil then | |||
result = '' | |||
end | end | ||
return result | return result | ||
end | end | ||
function p. | function p.getThievingNPCStat(frame) | ||
local | local npcName = frame.args ~= nil and frame.args[1] or frame[1] | ||
local | local statName = frame.args ~= nil and frame.args[2] or frame[2] | ||
local npc = p.getThievingNPC(npcName) | |||
if npc == nil then | |||
return Shared.printError('Invalid Thieving NPC ' .. npcName) | |||
if | |||
return | |||
end | end | ||
local | return p._getThievingNPCStat(npc, statName) | ||
end | |||
function p.getThievingSourcesForItem(itemID) | |||
local resultArray = {} | |||
local areaNPCs = {} | |||
for i, | --First check area unique drops | ||
--If an area drops the item, add all the NPC ids to the list so we can add them later | |||
for i, area in pairs(SkillData.Thieving.areas) do | |||
for j, drop in pairs(area.uniqueDrops) do | |||
if drop.id == itemID then | |||
for k, npcID in ipairs(area.npcIDs) do | |||
areaNPCs[npcID] = { qty = drop.quantity, area = area } | |||
end | |||
break | |||
end | |||
end | end | ||
end | end | ||
--Now go through and get drop chances on each NPC if needed | |||
for i, npc in pairs(SkillData.Thieving.npcs) do | |||
end | local totalWt = 0 | ||
local dropWt = 0 | |||
local dropQty = { min = 0, max = 0 } | |||
for j, drop in ipairs(npc.lootTable) do | |||
totalWt = totalWt + drop.weight | |||
if drop.itemID == itemID then | |||
dropWt = drop.weight | |||
dropQty = { min = drop.minQuantity, max = drop.maxQuantity } | |||
end | |||
end | |||
if dropWt > 0 then | |||
table.insert(resultArray, {npc = npc.name, minQty = dropQty.min, maxQty = dropQty.max, wt = dropWt * SkillData.Thieving.itemChance, totalWt = totalWt * 100, level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, type = 'npc'}) | |||
end | |||
--Chance of -1 on unique drops is to indicate variable chance | |||
if npc.uniqueDrop ~= nil and npc.uniqueDrop.id == itemID then | |||
table.insert(resultArray, {npc = npc.name, minQty = npc.uniqueDrop.quantity, maxQty = npc.uniqueDrop.quantity, wt = -1, totalWt = -1, level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, type = 'npcUnique'}) | |||
end | |||
local areaNPC = areaNPCs[npc.id] | |||
if areaNPC ~= nil then | |||
table.insert(resultArray, {npc = npc.name, minQty = areaNPC.qty, maxQty = areaNPC.qty, wt = SkillData.Thieving.baseAreaUniqueChance, totalWt = 100, level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, area = areaNPC.area, type = 'areaUnique'}) | |||
end | end | ||
end | end | ||
for i, drop in ipairs(SkillData.Thieving.generalRareItems) do | |||
for i, | if drop.itemID == itemID then | ||
if | if drop.npcs == nil then | ||
if | table.insert(resultArray, {npc = 'all', minQty = 1, maxQty = 1, wt = 1, totalWt = Num.round2(1/(drop.chance/100), 0), level = 1, npcID = itemID, type = 'generalRare'}) | ||
else | else | ||
for j, | for j, npcID in ipairs(drop.npcs) do | ||
if | local npc = p.getThievingNPCByID(npcID) | ||
table.insert( | if npc ~= nil then | ||
table.insert(resultArray, {npc = npc.name, minQty = 1, maxQty = 1, wt = 1, totalWt = Num.round2(1/(drop.chance/100), 0), level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, type = 'generalRare'}) | |||
end | end | ||
end | end | ||
Line 204: | Line 278: | ||
end | end | ||
return resultArray | |||
end | |||
-- Astrology | |||
function p.getConstellationByID(constID) | |||
return GameData.getEntityByID(SkillData.Astrology.recipes, constID) | |||
end | |||
function p.getConstellation(constName) | |||
return GameData.getEntityByName(SkillData.Astrology.recipes, constName) | |||
end | |||
function p.getConstellations(checkFunc) | |||
return GameData.getEntities(SkillData.Astrology.recipes, checkFunc) | |||
end | |||
-- Combines Astrology constellation modifiers into an object similar to other entities, | |||
-- and multiplies the values up to their maximum possible amount | |||
function p._getConstellationModifiers(cons) | |||
local result = {} | |||
local modKeys = { 'standardModifiers', 'uniqueModifiers', 'abyssalModifiers' } | |||
for _, keyID in ipairs(modKeys) do | |||
result[keyID] = {} | |||
local mods = cons[keyID] | |||
if mods ~= nil then | |||
for _, mod in ipairs(mods) do | |||
local newModObj = {} | |||
for | local multValue = mod.maxCount | ||
result = | local subKeys = { 'modifiers', 'enemyModifiers' } | ||
for _, subKey in ipairs(subKeys) do | |||
local | local modAdj = Shared.clone(mod[subKey]) | ||
if type(modAdj) == 'table' then | |||
for modName, modValueDef in pairs(modAdj) do | |||
if type(modValueDef) == 'table' then | |||
if modValueDef[1] ~= nil then | |||
-- Table of multiple values | |||
for i, subValue in ipairs(modValueDef) do | |||
if type(subValue) == 'table' and subValue.value ~= nil then | |||
subValue.value = subValue.value * multValue | |||
elseif type(subValue) == 'number' then | |||
modValueDef[i] = subValue * multValue | |||
end | |||
end | |||
elseif modValueDef.value ~= nil then | |||
-- Table but with a single value | |||
modValueDef.value = modValueDef.value * multValue | |||
end | |||
elseif type(modValueDef) == 'number' then | |||
-- Single value | |||
modAdj[modName] = modValueDef * multValue | |||
end | |||
end | |||
newModObj[subKey] = modAdj | |||
end | |||
end | |||
table.insert(result[keyID], newModObj) | |||
end | end | ||
end | end | ||
end | end | ||
return result | |||
end | |||
-- Mastery | |||
function p.getMasteryUnlockTable(frame) | |||
local skillName = frame.args ~= nil and frame.args[1] or frame | |||
local skillID = Constants.getSkillID(skillName) | |||
if skillID == nil then | |||
return Shared.printError('Failed to find a skill ID for ' .. skillName) | |||
end | |||
local _, localSkillID = GameData.getLocalID(skillID) | |||
-- Clone so that we can sort by level | |||
local unlockTable = Shared.shallowClone(SkillData[localSkillID].masteryLevelUnlocks) | |||
if unlockTable == nil then | |||
return Shared.printError('Failed to find Mastery Unlock data for ' .. skillName) | |||
end | |||
table.sort(unlockTable, function(a, b) return (a.level == b.level and a.descriptionID < b.descriptionID) or a.level < b.level end) | |||
local result = '{|class="wikitable"\r\n!Level!!Unlock' | |||
for i, unlock in ipairs(unlockTable) do | |||
result = result..'\r\n|-' | |||
result = result..'\r\n|'..unlock.level..'||'..unlock.description | |||
end | |||
result = result..'\r\n|}' | result = result..'\r\n|}' | ||
return result | return result | ||
end | end | ||
function p. | function p.getMasteryCheckpointTable(frame) | ||
local | local args = frame.args ~= nil and frame.args or frame | ||
local skillName = args[1] | |||
local realmName = args.realm | |||
local realm = p.getRealmFromName(realmName) | |||
if realm == nil then | |||
return Shared.printError('Failed to find a realm with name ' .. (realmName or 'nil')) | |||
end | |||
local skillID = Constants.getSkillID(skillName) | |||
if skillID == nil then | |||
return Shared.printError('Failed to find a skill ID for ' .. skillName) | |||
end | |||
local _, localSkillID = GameData.getLocalID(skillID) | |||
local checkpoints = SkillData[localSkillID].masteryPoolBonuses | |||
if checkpoints == nil then | |||
return Shared.printError('Failed to find Mastery checkpoint data for ' .. skillName) | |||
end | end | ||
local totalPoolXP = p.getMasteryActionCount(localSkillID, realm.id) * 500000 | |||
local result = '{|class="wikitable"\r\n!Pool %!!style="width:100px"|Pool XP!!Bonus' | |||
for i, checkpointData in ipairs(checkpoints) do | |||
if checkpointData.realm == realm.id then | |||
local chkDesc = Modifiers.getModifiersText(checkpointData.modifiers, false, false) | |||
local chkPercent = checkpointData.percent | |||
result = result..'\r\n|-' | |||
result = result..'\r\n|'..chkPercent..'%||' | |||
result = result..Num.formatnum(math.floor(totalPoolXP * chkPercent / 100))..' xp||'..chkDesc | |||
end | |||
end | |||
result = result..'\r\n|-\r\n!colspan="2"|Total Mastery Pool XP' | |||
result = result..'\r\n|'..Num.formatnum(totalPoolXP) | |||
result = result..'\r\n|}' | |||
return result | |||
end | end | ||
function p.getMasteryTokenTable() | function p.getMasteryTokenTable() | ||
-- Defines which skill levels should be included within the output | |||
local skillLevels = { | |||
{ | |||
["id"] = 'Base', | |||
["level"] = 99, | |||
["description"] = '[[Full Version|Base Game]] (Level 99)' | |||
}, { | |||
["id"] = 'TotH', | |||
["level"] = 120, | |||
["description"] = Icons.TotH() .. ' [[Throne of the Herald Expansion|Throne of the Herald]] (Level 120)' | |||
} | |||
} | |||
local baseTokenChance = 18500 | local baseTokenChance = 18500 | ||
local | local masteryActionCount = {} | ||
local CCI_ID = 'melvorD:Clue_Chasers_Insignia' | |||
local CCI = Items.getItemByID(CCI_ID) | |||
local | if CCI == nil then | ||
for | return Shared.printError('Failed to find item with ID ' .. CCI_ID) | ||
local | end | ||
if | |||
local tokens = Items.getItems(function(item) return item.itemType == 'MasteryToken' end) | |||
local tokenItems = {} | |||
for _, item in ipairs(tokens) do | |||
if item.realm == 'melvorD:Melvor' and item.skill ~= nil then | |||
local skillNS, skillLocalID = Shared.getLocalID(item.skill) | |||
tokenItems[skillLocalID] = item | |||
end | |||
end | |||
-- Iterate over each skill with mastery, determining the number of | |||
-- mastery actions for each | |||
for skillLocalID, skill in pairs(SkillData) do | |||
if skill.masteryPoolBonuses ~= nil then | |||
local actCount = { ["skill"] = skill, ["token"] = tokenItems[skillLocalID] } | |||
for i, levelDef in ipairs(skillLevels) do | |||
actCount[levelDef.id] = p.getMasteryActionCount(skillLocalID, 'melvorD:Melvor', levelDef.level) | |||
end | |||
table.insert(masteryActionCount, actCount) | |||
end | end | ||
end | end | ||
table.sort( | |||
local firstID = skillLevels[1].id | |||
table.sort(masteryActionCount, | |||
function(a, b) | |||
if a[firstID] == b[firstID] then | |||
return a.skill.name < b.skill.name | |||
else | |||
return a[firstID] > b[firstID] | |||
end | |||
end) | |||
-- Generate output table | -- Generate output table | ||
local resultPart = {} | local resultPart = {} | ||
local CCIIcon = Icons.Icon({CCI.name, type='item', notext=true}) | |||
local CCIIcon = Icons.Icon({ | local columnPairs = Shared.tableCount(skillLevels) | ||
-- Generate header | |||
table.insert(resultPart, '{| class="wikitable sortable"') | table.insert(resultPart, '{| class="wikitable sortable"') | ||
table.insert(resultPart, ' | table.insert(resultPart, '\n!rowspan="3"|Token!!rowspan="3"|Skill!!colspan="' .. columnPairs * 2 .. '"|Approximate Mastery Token Chance') | ||
table.insert(resultPart, '\ | table.insert(resultPart, '\n|-') | ||
for i, levelDef in ipairs(skillLevels) do | |||
table.insert(resultPart, '\n!colspan="2"| ' .. levelDef.description) | |||
end | |||
table.insert(resultPart, '\n|-' .. string.rep('\n!Without ' .. CCIIcon .. '\n!With ' .. CCIIcon, columnPairs)) | |||
for i, | for i, rowData in ipairs(masteryActionCount) do | ||
local token = | local token = rowData.token | ||
local | table.insert(resultPart, '\n|-') | ||
local tokenImg = (token == nil and '?') or Icons.Icon({token.name, type='item', notext=true}) | |||
table.insert(resultPart, '\n|style="text-align:center"|' .. tokenImg) | |||
table.insert(resultPart, '\n|' .. Icons.Icon({rowData.skill.name, type='skill'})) | |||
for j, levelDef in ipairs(skillLevels) do | |||
local actCount = rowData[levelDef.id] | |||
local denom, denomCCI = 0, 0 | |||
if actCount > 0 then | |||
denom = math.floor(baseTokenChance / actCount) | |||
denomCCI = Num.round(baseTokenChance / (actCount * (1 + CCI.modifiers.offItemChance / 100)), 0, 0) | |||
end | |||
table.insert(resultPart, '\n|style="text-align:right" data-sort-value="' .. denom .. '"|1/' .. Num.formatnum(denom)) | |||
table.insert(resultPart, '\n|style="text-align:right" data-sort-value="' .. denomCCI .. '"|1/' .. Num.formatnum(denomCCI)) | |||
end | |||
end | end | ||
table.insert(resultPart, ' | table.insert(resultPart, '\n|}') | ||
return table.concat(resultPart) | return table.concat(resultPart) | ||
end | end | ||
function p. | function p.getFiremakingTable(frame) | ||
local args = frame.args ~= nil and frame.args or frame | |||
local realmName = args.realm | |||
local realm = p.getRealmFromName(realmName) | |||
if realm == nil then | |||
return Shared.printError('Failed to find a realm with name ' .. (realmName or 'nil')) | |||
end | |||
local skillID = 'Firemaking' | |||
local tableHtml = mw.html.create('table') | |||
:addClass('wikitable sortable stickyHeader') | |||
local headerRow0 = tableHtml:tag('tr'):addClass('headerRow-0') | |||
headerRow0:tag('th'):attr('colspan', '2') | |||
:attr('rowspan', '2') | |||
:wikitext('Logs') | |||
headerRow0:tag('th'):attr('rowspan', '2') | |||
:wikitext(Icons._SkillRealmIcon('Firemaking', realm.id) .. '<br>Level') | |||
headerRow0:tag('th'):attr('rowspan', '2') | |||
:wikitext('[[DLC]]') | |||
headerRow0:tag('th'):attr('rowspan', '2') | |||
:wikitext('Burn<br>Time') | |||
headerRow0:tag('th'):attr('colspan', '2') | |||
:wikitext('Without Bonfire') | |||
headerRow0:tag('th'):attr('colspan', '2') | |||
:wikitext('With Bonfire') | |||
headerRow0:tag('th'):attr('rowspan', '2') | |||
:wikitext('Bonfire<br>Bonus') | |||
headerRow0:tag('th'):attr('rowspan', '2') | |||
:wikitext('Bonfire<br>Time') | |||
local headerRow1 = tableHtml:tag('tr'):addClass('headerRow-1') | |||
headerRow1:tag('th'):wikitext('XP') | |||
headerRow1:tag('th'):wikitext('XP/s') | |||
headerRow1:tag('th'):wikitext('XP') | |||
headerRow1:tag('th'):wikitext('XP/s') | |||
local logsData = GameData.getEntities(SkillData.Firemaking.logs, function(obj) | |||
return p.getRecipeRealm(obj) == realm.id | |||
end) | |||
table.sort(logsData, function(a, b) return p.standardRecipeSort(skillID, a, b) end) | |||
for i, logData in ipairs(logsData) do | |||
local logs = Items.getItemByID(logData.logID) | |||
local name = logs.name | |||
local level = p.getRecipeLevel(skillID, logData) | |||
local baseXP = logData.baseAbyssalExperience or logData.baseExperience | |||
local reqText = p.getRecipeRequirementText(SkillData.Firemaking.name, logData) | |||
local bonfireBonus = logData.bonfireAXPBonus or logData.bonfireXPBonus | |||
local burnTime = logData.baseInterval / 1000 | |||
local bonfireTime = logData.baseBonfireInterval / 1000 | |||
local XPS = baseXP / burnTime | |||
local XP_BF = baseXP * (1 + bonfireBonus / 100) | |||
local XPS_BF = Num.round(XP_BF / burnTime, 2, 2) | |||
XP_BF = Num.round(XP_BF, 2, 0) | |||
local row = tableHtml:tag('tr') | |||
row:tag('td'):attr('data-sort-value', name) | |||
:wikitext(Icons.Icon({name, type='item', notext=true})) | |||
row:tag('td'):wikitext('[[' .. name .. ']]') | |||
row:tag('td'):css('text-align', 'center') | |||
:wikitext(level) | |||
row:tag('td'):css('text-align', 'center') | |||
:attr('data-sort-value', Icons.getExpansionID(logData.logID)) | |||
:wikitext(Icons.getDLCColumnIcon(logData.logID)) | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', burnTime) | |||
:wikitext(Shared.timeString(burnTime, true)) | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', baseXP) | |||
:wikitext(Num.formatnum(baseXP)) | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', XPS) | |||
:wikitext(Num.formatnum(Num.round(XPS, 2, 2))) | |||
if bonfireBonus == 0 then | |||
row:tag('td'):attr('colspan', '4') | |||
:addClass('table-na') | |||
:wikitext('N/A') | |||
else | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', XP_BF) | |||
:wikitext(Num.formatnum(XP_BF)) | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', XPS_BF) | |||
:wikitext(Num.formatnum(XPS_BF, 2, 2)) | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', bonfireBonus) | |||
:wikitext(bonfireBonus .. '%') | |||
row:tag('td'):css('text-align', 'right') | |||
:attr('data-sort-value', bonfireTime) | |||
:wikitext(Shared.timeString(bonfireTime, true)) | |||
end | |||
end | |||
return tostring(tableHtml) | |||
end | end | ||
return p | return p |
Latest revision as of 22:57, 15 October 2024
Data pulled from Module:GameData
Some skills have their own modules:
- Module:Magic for Magic
- Module:Prayer for Prayer
- Module:Skills/Agility for Agility
- Module:Skills/Summoning for Summoning
- Module:Skills/Gathering for Mining, Fishing, Woodcutting
- Module:Skills/Artisan for Smithing, Cooking, Herblore, etc.
Also be aware of:
- Module:Navboxes for navigation boxes appearing near the bottom of pages
--This module should avoid including skill specific functions which generate
--output for wiki pages, especially those which require() other modules. For
--these functions, consider using the appropriate module from the below list.
--Some skills have their own modules:
--Module:Magic for Magic
--Module:Prayer for Prayer
--Module:Skills/Agility for Agility
--Module:Skills/Summoning for Summoning
--Module:Skills/Gathering for Mining, Fishing, Woodcutting
--Module:Skills/Artisan for Smithing, Cooking, Herblore, etc.
--Also be aware of:
--Module:Navboxes for navigation boxes appearing near the bottom of pages
local p = {}
local Shared = require('Module:Shared')
local Constants = require('Module:Constants')
local Common = require('Module:Common')
local GameData = require('Module:GameData')
local SkillData = GameData.skillData
local Modifiers = require('Module:Modifiers')
local Items = require('Module:Items')
local Icons = require('Module:Icons')
local Num = require('Module:Number')
-- Given a skill ID, returns the key for that skill's recipe data.
-- If the skill has no recipes (e.g. is a combat skill) then the
-- return value is nil
function p.getSkillRecipeKey(skillID)
-- Convert skillID to local ID if not already
local ns, localSkillID = GameData.getLocalID(skillID)
local recipeIDs = {
["Woodcutting"] = 'trees',
["Fishing"] = 'fish',
["Firemaking"] = 'logs',
["Mining"] = 'rockData',
["Thieving"] = 'npcs',
["Agility"] = 'obstacles',
["Cooking"] = 'recipes',
["Smithing"] = 'recipes',
["Farming"] = 'recipes',
["Summoning"] = 'recipes',
["Fletching"] = 'recipes',
["Crafting"] = 'recipes',
["Runecrafting"] = 'recipes',
["Herblore"] = 'recipes',
["Astrology"] = 'recipes',
["Archaeology"] = 'digSites',
["Harvesting"] = 'veinData'
}
return recipeIDs[localSkillID]
end
-- Given a skill ID & recipe, returns the skill level requirement for
-- that recipe and a boolean value indicating whether the level if abyssal or not.
-- If the level could not be determined, then the return value is nil, nil
function p.getRecipeLevelRealm(skillID, recipe)
local level, isAbyssal = nil, nil
-- Convert skillID to local ID if not already
local ns, localSkillID = GameData.getLocalID(skillID)
local realmID = p.getRecipeRealm(recipe)
if localSkillID == 'Agility' then
local course = GameData.getEntityByProperty(SkillData.Agility.courses, 'realm', realmID)
isAbyssal = (realmID == 'melvorItA:Abyssal')
-- For Agility, level is derived from obstacle category
if recipe.category ~= nil then
-- Obstacle
local slot = course.obstacleSlots[recipe.category + 1]
if isAbyssal then
level = slot.abyssalLevel
else
level = slot.level
end
elseif recipe.slot ~= nil then
-- Pillar
level = course.pillarSlots[recipe.slot + 1].level
end
elseif recipe.abyssalLevel ~= nil then
level, isAbyssal = recipe.abyssalLevel, true
else
-- For all other skills, the recipe should have a level property
level, isAbyssal = recipe.level, false
end
return level, isAbyssal
end
function p.getRecipeLevel(skillID, recipe)
local level, isAbyssal = p.getRecipeLevelRealm(skillID, recipe)
return level
end
function p.getRecipeRequirementText(skillName, recipe)
local reqText = {}
if recipe.abyssalLevel ~= nil and recipe.abyssalLevel > 0 then
table.insert(reqText, Icons._SkillReq(skillName, recipe.abyssalLevel, false, 'melvorItA:Abyssal'))
elseif recipe.level ~= nil and recipe.level > 0 then
table.insert(reqText, Icons._SkillReq(skillName, recipe.level, false))
end
if recipe.totalMasteryRequired ~= nil then
table.insert(reqText, Num.formatnum(recipe.totalMasteryRequired) .. ' ' .. Icons.Icon({skillName, type='skill', notext=true}) .. ' ' .. Icons.Icon({'Mastery'}))
end
local reqsData = {}
if type(recipe.requirements) == 'table' then
reqsData = Shared.shallowClone(recipe.requirements)
end
if recipe.shopItemPurchased ~= nil then
-- Mining requirements are stored differently than other skills like
-- Woodcutting, standardize here
table.insert(reqsData, {
["type"] = 'ShopPurchase',
["purchaseID"] = recipe.shopItemPurchased,
["count"] = 1
})
end
if not Shared.tableIsEmpty(reqsData) then
local reqs = Common.getRequirementString(reqsData)
if reqs ~= nil then
table.insert(reqText, reqs)
end
end
return table.concat(reqText, '<br/>')
end
function p.standardRecipeSort(skillID, recipeA, recipeB)
local levelA, isAbyssalA = p.getRecipeLevelRealm(skillID, recipeA)
local levelB, isAbyssalB = p.getRecipeLevelRealm(skillID, recipeB)
local isAbyssalNumA, isAbyssalNumB = (isAbyssalA and 1) or 0, (isAbyssalB and 1) or 0
return (isAbyssalA == isAbyssalB and levelA < levelB) or isAbyssalNumA < isAbyssalNumB
end
function p.getRecipeRealm(recipe)
return recipe.realm or 'melvorD:Melvor'
end
function p.getRealmFromName(realmName)
local realm = nil
if realmName == nil or realmName == '' then
-- Default realm
realm = GameData.getEntityByID('realms', 'melvorD:Melvor')
else
realm = GameData.getEntityByName('realms', realmName)
end
return realm
end
function p.getMasteryActionCount(skillID, realmID, levelLimit)
local actCount = 0
local skillNS, skillLocalID = Shared.getLocalID(skillID)
local skillData = SkillData[skillLocalID]
local recipeKey = p.getSkillRecipeKey(skillLocalID)
if recipeKey ~= nil then
local recipeData = skillData[recipeKey]
for i, recipe in ipairs(recipeData) do
if (
p.getRecipeRealm(recipe) == realmID
and (recipe.noMastery == nil or not recipe.noMastery)
and (levelLimit == nil or p.getRecipeLevel(skillLocalID, recipe) <= levelLimit)
) then
actCount = actCount + 1
end
end
end
return actCount
end
-- Thieving
function p.getThievingNPCByID(npcID)
return GameData.getEntityByID(SkillData.Thieving.npcs, npcID)
end
function p.getThievingNPC(npcName)
return GameData.getEntityByName(SkillData.Thieving.npcs, npcName)
end
function p.getThievingNPCArea(npc)
for i, area in ipairs(SkillData.Thieving.areas) do
for j, npcID in ipairs(area.npcIDs) do
if npcID == npc.id then
return area
end
end
end
end
function p._getThievingNPCStat(npc, statName)
local result = nil
if statName == 'level' then
result = Icons._SkillReq('Thieving', npc.level)
elseif statName == 'maxHit' then
result = npc.maxHit * 10
elseif statName == 'area' then
local area = p.getThievingNPCArea(npc)
result = area.name
else
result = npc[statName]
end
if result == nil then
result = ''
end
return result
end
function p.getThievingNPCStat(frame)
local npcName = frame.args ~= nil and frame.args[1] or frame[1]
local statName = frame.args ~= nil and frame.args[2] or frame[2]
local npc = p.getThievingNPC(npcName)
if npc == nil then
return Shared.printError('Invalid Thieving NPC ' .. npcName)
end
return p._getThievingNPCStat(npc, statName)
end
function p.getThievingSourcesForItem(itemID)
local resultArray = {}
local areaNPCs = {}
--First check area unique drops
--If an area drops the item, add all the NPC ids to the list so we can add them later
for i, area in pairs(SkillData.Thieving.areas) do
for j, drop in pairs(area.uniqueDrops) do
if drop.id == itemID then
for k, npcID in ipairs(area.npcIDs) do
areaNPCs[npcID] = { qty = drop.quantity, area = area }
end
break
end
end
end
--Now go through and get drop chances on each NPC if needed
for i, npc in pairs(SkillData.Thieving.npcs) do
local totalWt = 0
local dropWt = 0
local dropQty = { min = 0, max = 0 }
for j, drop in ipairs(npc.lootTable) do
totalWt = totalWt + drop.weight
if drop.itemID == itemID then
dropWt = drop.weight
dropQty = { min = drop.minQuantity, max = drop.maxQuantity }
end
end
if dropWt > 0 then
table.insert(resultArray, {npc = npc.name, minQty = dropQty.min, maxQty = dropQty.max, wt = dropWt * SkillData.Thieving.itemChance, totalWt = totalWt * 100, level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, type = 'npc'})
end
--Chance of -1 on unique drops is to indicate variable chance
if npc.uniqueDrop ~= nil and npc.uniqueDrop.id == itemID then
table.insert(resultArray, {npc = npc.name, minQty = npc.uniqueDrop.quantity, maxQty = npc.uniqueDrop.quantity, wt = -1, totalWt = -1, level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, type = 'npcUnique'})
end
local areaNPC = areaNPCs[npc.id]
if areaNPC ~= nil then
table.insert(resultArray, {npc = npc.name, minQty = areaNPC.qty, maxQty = areaNPC.qty, wt = SkillData.Thieving.baseAreaUniqueChance, totalWt = 100, level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, area = areaNPC.area, type = 'areaUnique'})
end
end
for i, drop in ipairs(SkillData.Thieving.generalRareItems) do
if drop.itemID == itemID then
if drop.npcs == nil then
table.insert(resultArray, {npc = 'all', minQty = 1, maxQty = 1, wt = 1, totalWt = Num.round2(1/(drop.chance/100), 0), level = 1, npcID = itemID, type = 'generalRare'})
else
for j, npcID in ipairs(drop.npcs) do
local npc = p.getThievingNPCByID(npcID)
if npc ~= nil then
table.insert(resultArray, {npc = npc.name, minQty = 1, maxQty = 1, wt = 1, totalWt = Num.round2(1/(drop.chance/100), 0), level = npc.level, abyssalLevel = npc.abyssalLevel, npcID = npc.id, type = 'generalRare'})
end
end
end
end
end
return resultArray
end
-- Astrology
function p.getConstellationByID(constID)
return GameData.getEntityByID(SkillData.Astrology.recipes, constID)
end
function p.getConstellation(constName)
return GameData.getEntityByName(SkillData.Astrology.recipes, constName)
end
function p.getConstellations(checkFunc)
return GameData.getEntities(SkillData.Astrology.recipes, checkFunc)
end
-- Combines Astrology constellation modifiers into an object similar to other entities,
-- and multiplies the values up to their maximum possible amount
function p._getConstellationModifiers(cons)
local result = {}
local modKeys = { 'standardModifiers', 'uniqueModifiers', 'abyssalModifiers' }
for _, keyID in ipairs(modKeys) do
result[keyID] = {}
local mods = cons[keyID]
if mods ~= nil then
for _, mod in ipairs(mods) do
local newModObj = {}
local multValue = mod.maxCount
local subKeys = { 'modifiers', 'enemyModifiers' }
for _, subKey in ipairs(subKeys) do
local modAdj = Shared.clone(mod[subKey])
if type(modAdj) == 'table' then
for modName, modValueDef in pairs(modAdj) do
if type(modValueDef) == 'table' then
if modValueDef[1] ~= nil then
-- Table of multiple values
for i, subValue in ipairs(modValueDef) do
if type(subValue) == 'table' and subValue.value ~= nil then
subValue.value = subValue.value * multValue
elseif type(subValue) == 'number' then
modValueDef[i] = subValue * multValue
end
end
elseif modValueDef.value ~= nil then
-- Table but with a single value
modValueDef.value = modValueDef.value * multValue
end
elseif type(modValueDef) == 'number' then
-- Single value
modAdj[modName] = modValueDef * multValue
end
end
newModObj[subKey] = modAdj
end
end
table.insert(result[keyID], newModObj)
end
end
end
return result
end
-- Mastery
function p.getMasteryUnlockTable(frame)
local skillName = frame.args ~= nil and frame.args[1] or frame
local skillID = Constants.getSkillID(skillName)
if skillID == nil then
return Shared.printError('Failed to find a skill ID for ' .. skillName)
end
local _, localSkillID = GameData.getLocalID(skillID)
-- Clone so that we can sort by level
local unlockTable = Shared.shallowClone(SkillData[localSkillID].masteryLevelUnlocks)
if unlockTable == nil then
return Shared.printError('Failed to find Mastery Unlock data for ' .. skillName)
end
table.sort(unlockTable, function(a, b) return (a.level == b.level and a.descriptionID < b.descriptionID) or a.level < b.level end)
local result = '{|class="wikitable"\r\n!Level!!Unlock'
for i, unlock in ipairs(unlockTable) do
result = result..'\r\n|-'
result = result..'\r\n|'..unlock.level..'||'..unlock.description
end
result = result..'\r\n|}'
return result
end
function p.getMasteryCheckpointTable(frame)
local args = frame.args ~= nil and frame.args or frame
local skillName = args[1]
local realmName = args.realm
local realm = p.getRealmFromName(realmName)
if realm == nil then
return Shared.printError('Failed to find a realm with name ' .. (realmName or 'nil'))
end
local skillID = Constants.getSkillID(skillName)
if skillID == nil then
return Shared.printError('Failed to find a skill ID for ' .. skillName)
end
local _, localSkillID = GameData.getLocalID(skillID)
local checkpoints = SkillData[localSkillID].masteryPoolBonuses
if checkpoints == nil then
return Shared.printError('Failed to find Mastery checkpoint data for ' .. skillName)
end
local totalPoolXP = p.getMasteryActionCount(localSkillID, realm.id) * 500000
local result = '{|class="wikitable"\r\n!Pool %!!style="width:100px"|Pool XP!!Bonus'
for i, checkpointData in ipairs(checkpoints) do
if checkpointData.realm == realm.id then
local chkDesc = Modifiers.getModifiersText(checkpointData.modifiers, false, false)
local chkPercent = checkpointData.percent
result = result..'\r\n|-'
result = result..'\r\n|'..chkPercent..'%||'
result = result..Num.formatnum(math.floor(totalPoolXP * chkPercent / 100))..' xp||'..chkDesc
end
end
result = result..'\r\n|-\r\n!colspan="2"|Total Mastery Pool XP'
result = result..'\r\n|'..Num.formatnum(totalPoolXP)
result = result..'\r\n|}'
return result
end
function p.getMasteryTokenTable()
-- Defines which skill levels should be included within the output
local skillLevels = {
{
["id"] = 'Base',
["level"] = 99,
["description"] = '[[Full Version|Base Game]] (Level 99)'
}, {
["id"] = 'TotH',
["level"] = 120,
["description"] = Icons.TotH() .. ' [[Throne of the Herald Expansion|Throne of the Herald]] (Level 120)'
}
}
local baseTokenChance = 18500
local masteryActionCount = {}
local CCI_ID = 'melvorD:Clue_Chasers_Insignia'
local CCI = Items.getItemByID(CCI_ID)
if CCI == nil then
return Shared.printError('Failed to find item with ID ' .. CCI_ID)
end
local tokens = Items.getItems(function(item) return item.itemType == 'MasteryToken' end)
local tokenItems = {}
for _, item in ipairs(tokens) do
if item.realm == 'melvorD:Melvor' and item.skill ~= nil then
local skillNS, skillLocalID = Shared.getLocalID(item.skill)
tokenItems[skillLocalID] = item
end
end
-- Iterate over each skill with mastery, determining the number of
-- mastery actions for each
for skillLocalID, skill in pairs(SkillData) do
if skill.masteryPoolBonuses ~= nil then
local actCount = { ["skill"] = skill, ["token"] = tokenItems[skillLocalID] }
for i, levelDef in ipairs(skillLevels) do
actCount[levelDef.id] = p.getMasteryActionCount(skillLocalID, 'melvorD:Melvor', levelDef.level)
end
table.insert(masteryActionCount, actCount)
end
end
local firstID = skillLevels[1].id
table.sort(masteryActionCount,
function(a, b)
if a[firstID] == b[firstID] then
return a.skill.name < b.skill.name
else
return a[firstID] > b[firstID]
end
end)
-- Generate output table
local resultPart = {}
local CCIIcon = Icons.Icon({CCI.name, type='item', notext=true})
local columnPairs = Shared.tableCount(skillLevels)
-- Generate header
table.insert(resultPart, '{| class="wikitable sortable"')
table.insert(resultPart, '\n!rowspan="3"|Token!!rowspan="3"|Skill!!colspan="' .. columnPairs * 2 .. '"|Approximate Mastery Token Chance')
table.insert(resultPart, '\n|-')
for i, levelDef in ipairs(skillLevels) do
table.insert(resultPart, '\n!colspan="2"| ' .. levelDef.description)
end
table.insert(resultPart, '\n|-' .. string.rep('\n!Without ' .. CCIIcon .. '\n!With ' .. CCIIcon, columnPairs))
for i, rowData in ipairs(masteryActionCount) do
local token = rowData.token
table.insert(resultPart, '\n|-')
local tokenImg = (token == nil and '?') or Icons.Icon({token.name, type='item', notext=true})
table.insert(resultPart, '\n|style="text-align:center"|' .. tokenImg)
table.insert(resultPart, '\n|' .. Icons.Icon({rowData.skill.name, type='skill'}))
for j, levelDef in ipairs(skillLevels) do
local actCount = rowData[levelDef.id]
local denom, denomCCI = 0, 0
if actCount > 0 then
denom = math.floor(baseTokenChance / actCount)
denomCCI = Num.round(baseTokenChance / (actCount * (1 + CCI.modifiers.offItemChance / 100)), 0, 0)
end
table.insert(resultPart, '\n|style="text-align:right" data-sort-value="' .. denom .. '"|1/' .. Num.formatnum(denom))
table.insert(resultPart, '\n|style="text-align:right" data-sort-value="' .. denomCCI .. '"|1/' .. Num.formatnum(denomCCI))
end
end
table.insert(resultPart, '\n|}')
return table.concat(resultPart)
end
function p.getFiremakingTable(frame)
local args = frame.args ~= nil and frame.args or frame
local realmName = args.realm
local realm = p.getRealmFromName(realmName)
if realm == nil then
return Shared.printError('Failed to find a realm with name ' .. (realmName or 'nil'))
end
local skillID = 'Firemaking'
local tableHtml = mw.html.create('table')
:addClass('wikitable sortable stickyHeader')
local headerRow0 = tableHtml:tag('tr'):addClass('headerRow-0')
headerRow0:tag('th'):attr('colspan', '2')
:attr('rowspan', '2')
:wikitext('Logs')
headerRow0:tag('th'):attr('rowspan', '2')
:wikitext(Icons._SkillRealmIcon('Firemaking', realm.id) .. '<br>Level')
headerRow0:tag('th'):attr('rowspan', '2')
:wikitext('[[DLC]]')
headerRow0:tag('th'):attr('rowspan', '2')
:wikitext('Burn<br>Time')
headerRow0:tag('th'):attr('colspan', '2')
:wikitext('Without Bonfire')
headerRow0:tag('th'):attr('colspan', '2')
:wikitext('With Bonfire')
headerRow0:tag('th'):attr('rowspan', '2')
:wikitext('Bonfire<br>Bonus')
headerRow0:tag('th'):attr('rowspan', '2')
:wikitext('Bonfire<br>Time')
local headerRow1 = tableHtml:tag('tr'):addClass('headerRow-1')
headerRow1:tag('th'):wikitext('XP')
headerRow1:tag('th'):wikitext('XP/s')
headerRow1:tag('th'):wikitext('XP')
headerRow1:tag('th'):wikitext('XP/s')
local logsData = GameData.getEntities(SkillData.Firemaking.logs, function(obj)
return p.getRecipeRealm(obj) == realm.id
end)
table.sort(logsData, function(a, b) return p.standardRecipeSort(skillID, a, b) end)
for i, logData in ipairs(logsData) do
local logs = Items.getItemByID(logData.logID)
local name = logs.name
local level = p.getRecipeLevel(skillID, logData)
local baseXP = logData.baseAbyssalExperience or logData.baseExperience
local reqText = p.getRecipeRequirementText(SkillData.Firemaking.name, logData)
local bonfireBonus = logData.bonfireAXPBonus or logData.bonfireXPBonus
local burnTime = logData.baseInterval / 1000
local bonfireTime = logData.baseBonfireInterval / 1000
local XPS = baseXP / burnTime
local XP_BF = baseXP * (1 + bonfireBonus / 100)
local XPS_BF = Num.round(XP_BF / burnTime, 2, 2)
XP_BF = Num.round(XP_BF, 2, 0)
local row = tableHtml:tag('tr')
row:tag('td'):attr('data-sort-value', name)
:wikitext(Icons.Icon({name, type='item', notext=true}))
row:tag('td'):wikitext('[[' .. name .. ']]')
row:tag('td'):css('text-align', 'center')
:wikitext(level)
row:tag('td'):css('text-align', 'center')
:attr('data-sort-value', Icons.getExpansionID(logData.logID))
:wikitext(Icons.getDLCColumnIcon(logData.logID))
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', burnTime)
:wikitext(Shared.timeString(burnTime, true))
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', baseXP)
:wikitext(Num.formatnum(baseXP))
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', XPS)
:wikitext(Num.formatnum(Num.round(XPS, 2, 2)))
if bonfireBonus == 0 then
row:tag('td'):attr('colspan', '4')
:addClass('table-na')
:wikitext('N/A')
else
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', XP_BF)
:wikitext(Num.formatnum(XP_BF))
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', XPS_BF)
:wikitext(Num.formatnum(XPS_BF, 2, 2))
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', bonfireBonus)
:wikitext(bonfireBonus .. '%')
row:tag('td'):css('text-align', 'right')
:attr('data-sort-value', bonfireTime)
:wikitext(Shared.timeString(bonfireTime, true))
end
end
return tostring(tableHtml)
end
return p