Module:Modifiers
From Melvor Idle
Documentation for this module may be created at Module:Modifiers/doc
-- Module responsible for handling modifiers
local p = {}
local GameData = require('Module:GameData')
local Shared = require('Module:Shared')
local Common = require('Module:Common')
-- Initialisation
-- Index modifier definitions by their aliases & local IDs
p.ModifierIndex = {
["alias"] = {},
["localID"] = {}
}
for _, modDefn in ipairs(GameData.rawData.modifiers) do
-- Index by local ID
local ns, modLocalID = Shared.getLocalID(modDefn.id)
p.ModifierIndex.localID[modLocalID] = modDefn
-- Index by alias
for _, allowedScope in ipairs(modDefn.allowedScopes) do
local aliasKeys = {
{ ["key"] = 'posAliases', ["type"] = 'pos' },
{ ["key"] = 'negAliases', ["type"] = 'neg' }
}
for _, keyDefn in ipairs(aliasKeys) do
local modAliases = allowedScope[keyDefn.key]
if modAliases ~= nil then
for _, modAlias in ipairs(modAliases) do
if modAlias.key ~= nil then
p.ModifierIndex.alias[modAlias.key] = {
['scope'] = allowedScope,
['mod'] = modDefn,
['alias'] = modAlias,
['type'] = keyDefn.type
}
end
end
end
end
end
end
-- Maps object modifier keys to modifier definition scope keys
-- See in game code: class ModifierScope, static getScopeFromData
local ScopeKeyMap = {
['skillID'] = {
['key'] = 'skill',
['templateKey'] = 'skillName'
},
['damageTypeID'] = {
['key'] = 'damageType',
['templateKey'] = 'damageType',
['templateKey2'] = 'resistanceName'
},
['realmID'] = {
['key'] = 'realm',
['templateKey'] = 'realmName'
},
['currencyID'] = {
['key'] = 'currency',
['templateKey'] = 'currencyName'
},
['categoryID'] = {
['key'] = 'category',
['templateKey'] = 'categoryName'
},
['actionID'] = {
['key'] = 'action',
['templateKey'] = 'actionName'
},
['subcategoryID'] = {
['key'] = 'subcategory',
['templateKey'] = 'subcategoryName'
},
['itemID'] = {
['key'] = 'item',
['templateKey'] = 'itemName'
},
['effectGroupID'] = {
['key'] = 'effectGroup',
['templateKey'] = 'effectGroupName'
},
}
-- Retrieves a modifier definition by ID
function p.getModifierByID(modID)
local modNS, modLocalID = Shared.getLocalID(modID)
return p.ModifierIndex.localID[modLocalID]
end
-- Given a modifier alias, returns the relevant modifier and scope definitions
function p.getModifierByAlias(modAlias)
return p.ModifierIndex.alias[modAlias]
end
-- Given a modifier's scope data, converts it to a scope definition that can be used
-- to match against another scope definition
function p.convertScopeDataToDefinition(scopeData)
local scopeDefn = {}
if scopeData ~= nil then
for k, v in pairs(scopeData) do
if k ~= 'value' then
local scopeKey = ScopeKeyMap[k]
if scopeKey == nil then
error('Failed to map scope data with key "' .. k .. '" to a scope key', 2)
end
scopeDefn[scopeKey.key] = true
end
end
end
return scopeDefn
end
-- Compares two scope definitions to check if they are equal
function p.doScopeDefinitionsMatch(scopeDefn1, scopeDefn2)
if Shared.tableCount(scopeDefn1) ~= Shared.tableCount(scopeDefn2) then
return false
end
-- If sizes match, compare elements of each definition
for k, v in pairs(scopeDefn1) do
if not scopeDefn2[k] then
-- Definition 1 has a key that definition 2 does not
return false
end
end
-- At this point, both definitions have the same elements
return true
end
-- Checks that the given scope data matches the restrictions imposed by dataCriteria.
-- This is used to check that a modifier provides its benefit to a given skill, or currency,
-- or so on.
-- dataCriteria accepts one additional property that isn't usually found in scope data, being
-- "valueType". This indicates whether the value of the modifier should be positive ('pos') or
-- negative ('neg') in order for the criteria to be met
function p.checkScopeDataMeetsCriteria(scopeData, scopeDefn, dataCriteria)
for criteriaKey, criteriaValue in pairs(dataCriteria) do
if criteriaKey == 'valueType' then
-- Special case, check sign of modifier value
if (
(criteriaValue ~= nil and scopeData.value ~= nil)
and not (
(criteriaValue == 'pos' and scopeData.value > 0)
or (criteriaValue == 'neg' and scopeData.value < 0)
)
) then
-- Value criteria not met
return false
end
elseif criteriaValue ~= nil and criteriaKey ~= 'key' then
local mapData = ScopeKeyMap[criteriaKey]
if mapData == nil then
error('Invalid modifier scope data criteria: ' .. criteriaKey, 2)
end
local defnKey = mapData.key
if (
-- Scope definition indicates the criteria is used for this modifier
scopeDefn[defnKey]
and (
scopeData[criteriaKey] == nil
or scopeData[criteriaKey] ~= criteriaValue
)
) then
-- Modifier value doesn't match criteria for this key
return false
end
end
end
-- All criteria checked successfully
return true
end
-- Given a list of modifier IDs and aliases, returns all match criteria for these.
-- This matching criteria can then be pased to p.getMatchingModifiers()
function p.getMatchCriteriaFromIDs(modifierIDs, modifierAliases)
local matchCriteria = {}
-- For modifier IDs, find the relevant mod definition and add all allowed scopes
if modifierIDs ~= nil then
for _, modifierID in ipairs(modifierIDs) do
local modDefn = p.getModifierByID(modifierID)
if modDefn == nil then
error('No such modifier ID: ' .. modifierID)
end
for _, allowedScope in ipairs(modDefn.allowedScopes) do
if Shared.tableIsEmpty(allowedScope.scopes) then
-- No scope definitions, so add modifier once with an empty scope
table.insert(matchCriteria, {
["mod"] = modDefn,
["scope"] = {}
})
else
-- Add all available scope definitions
for _, scopeDefn in ipairs(allowedScope.scopes) do
table.insert(matchCriteria, {
["mod"] = modDefn,
["scope"] = scopeDefn
})
end
end
end
end
end
-- For alias IDs, simply add these one at a time to the table
if modifierAliases ~= nil then
for _, modifierAlias in ipairs(modifierAliases) do
table.insert(matchCriteria, {
["alias"] = modifierAlias
})
end
end
return matchCriteria
end
-- Checks if any elements of modifiers match the provided criteria, and
-- returns any elements which do match.
-- skillID when specified ensures that any skill specific modifiers are only matched
-- when they relate to that given skill ID
-- matchCriteria is a table of elements structured as follows:
-- { ["mod"] = modDefn, ["scope"] = scopeDefn, ["alias"] = modAlias }
-- Examples of valid mod and scope definitions can be obtained from
-- p.getModifierByID() and p.getScope()
-- alias is an optional property, if specified mod and scope are derived
-- from the modAlias.
function p.getMatchingModifiers(modifiers, matchCriteria, skillID)
local resultMods = {
["matched"] = {},
["unmatched"] = {}
}
if type(matchCriteria) == 'table' and not Shared.tableIsEmpty(matchCriteria) then
-- Transform matchCriteria
local matchCriteriaMap = {}
for _, matchDefn in ipairs(matchCriteria) do
local modDefn, scopeDefn, modAlias, valueType = matchDefn.mod, matchDefn.scope, {}, matchDefn.type
if matchDefn.alias ~= nil then
local aliasData = p.getModifierByAlias(matchDefn.alias)
if aliasData == nil then
error('No such modifier alias: ' .. matchDefn.alias, 2)
else
modDefn, scopeDefn, modAlias, valueType = aliasData.mod, aliasData.scope.scopes, aliasData.alias, aliasData.type
end
end
local modNS, modLocalID = Shared.getLocalID(modDefn.id)
if matchCriteriaMap[modLocalID] == nil then
matchCriteriaMap[modLocalID] = {}
end
table.insert(matchCriteriaMap[modLocalID], { ["scope"] = scopeDefn, ["alias"] = modAlias, ["valueType"] = valueType })
end
local function addToResult(category, modID, scopeData)
if resultMods[category][modID] == nil then
resultMods[category][modID] = {}
end
table.insert(resultMods[category][modID], scopeData)
end
for modID, scopeDataRaw in pairs(modifiers) do
-- modID can sometimes be namespaced
local modNS, modLocalID = Shared.getLocalID(modID)
local scopeDataArray = p.getIteratableScopeData(scopeDataRaw)
-- Find any match criteria relevant to this modifier
local modMatchCriteria = matchCriteriaMap[modLocalID]
if modMatchCriteria ~= nil then
-- Check scope data matches
for _, scopeData in ipairs(scopeDataArray) do
local isProcessed = false
local modScopeDefn = p.convertScopeDataToDefinition(scopeData)
for _, matchDefn in ipairs(modMatchCriteria) do
local scopeDefn, modAlias = matchDefn.scope, matchDefn.alias
local dataCriteria = {
["skillID"] = skillID,
["valueType"] = matchDefn.valueType
}
local matchKey = 'unmatched'
-- Check that:
-- - scope definitions match
-- - Data criteria from the alias (within modAlias) are met
-- - Data criteria from elsewhere (within dataCriteria) are met
if (
p.doScopeDefinitionsMatch(modScopeDefn, scopeDefn)
and p.checkScopeDataMeetsCriteria(scopeData, scopeDefn, modAlias)
and p.checkScopeDataMeetsCriteria(scopeData, scopeDefn, dataCriteria)
) then
-- Add to matched table
addToResult('matched', modID, scopeData)
isProcessed = true
break
end
end
if not isProcessed then
addToResult('unmatched', modID, scopeData)
end
end
else
for _, scopeData in ipairs(scopeDataArray) do
addToResult('unmatched', modID, scopeData)
end
end
end
end
return resultMods
end
-- Given a table of modifiers, returns the cumulated magnitudes of those modifiers
function p.getModifierValue(modifiers)
local modValue = 0
for modID, scopeDataRaw in pairs(modifiers) do
local scopeDataArray = p.getIteratableScopeData(scopeDataRaw)
for _, scopeData in ipairs(scopeDataArray) do
if type(scopeData.value) == 'number' then
modValue = modValue + scopeData.value
end
end
end
return modValue
end
-- Given a modifier definition & scope data, returns the appropriate scope definition
function p.getScope(modDefn, scopeData)
-- First derive the desired scope definition that matches the provided data
local wantedScopeDefn = p.convertScopeDataToDefinition(scopeData)
-- Attempt to find an allowed scope with a matching definition
for _, allowedScope in ipairs(modDefn.allowedScopes) do
if p.doScopeDefinitionsMatch(wantedScopeDefn, allowedScope.scopes) then
return allowedScope
end
end
-- No scope matches at this point
return nil
end
-- Given a scope definition & scope data, returns the appropriate description template
function p.getDescriptionTemplate(scopeDefn, scopeData)
local dataValue = scopeData.value
for _, descTemplate in ipairs(scopeDefn.descriptions) do
local condAbove, condBelow = descTemplate.above, descTemplate.below
if (
condAbove == nil
or (condAbove ~= nil and dataValue ~= nil and dataValue > condAbove)
) and (
condBelow == nil
or (condBelow ~= nil and dataValue ~= nil and dataValue < condBelow)
) then
return descTemplate
end
end
end
-- Given a value and a modification rule, returns a modified value
function p.modifyValue(value, modifyRule)
if modifyRule == 'value*hpMultiplier' then
return value * 10
elseif modifyRule == 'value/hpMultiplier' then
return value / 10
elseif modifyRule == 'value/1000' then
return value / 1000
elseif modifyRule == '2^value' then
return 2^value
elseif modifyRule == '100 + value' then
return 100 + value
elseif modifyRule == 'value*100' then
return value * 100
elseif modifyRule == 'floor(value)' then
return math.floor(value)
else
error('Unknown value modification rule: ' .. (modifyRule or 'nil'), 2)
end
end
-- Given scope data and optionally a mod definition & description template, returns template data
-- that can be used with Shared.applyTemplateData() to retrieve a modifier description.
-- descriptionTemplate is used to determine if the value should be signed or not (default
-- is unsigned)
-- modDefn is used to determine if the value should be modified or not (default is unmodified)
function p.getTemplateData(scopeData, modDefn, scopeDefn, descriptionTemplate)
local templateData = {}
local signValue = false
local modifyRule = nil
if descriptionTemplate ~= nil then
signValue = (descriptionTemplate.includeSign == nil or descriptionTemplate.includeSign)
end
if modDefn ~= nil and modDefn.modifyValue ~= nil then
modifyRule = modDefn.modifyValue
end
-- Some scopes have source data, being many skill related modifiers and some modifiers which
-- affect certain categories of spells
local scopeSourceID, scopeSourceData = scopeDefn.scopeSource or scopeData.skillID, nil
if scopeSourceID == 'AttackSpell' then
-- Uses spell categories, contained within magic skill data
scopeSourceData = GameData.skillData.Magic
elseif scopeSourceID == 'melvorD:CombatArea' then
scopeSourceData = GameData.rawData
elseif scopeSourceID ~= nil then
-- Assumed to be a skill ID
local skillObj = GameData.getEntityByProperty('skillData', 'skillID', scopeSourceID)
if skillObj == nil then
error('Unhandled scope source ID: ' .. scopeSourceID)
else
scopeSourceData = skillObj.data
end
end
for k, v in pairs(scopeData) do
local tKey, tVal = k, v
if tKey == 'value' then
if modifyRule ~= nil then
tVal = p.modifyValue(tVal, modifyRule)
end
templateData.valueUnsigned = tVal
if signValue then
tVal = (tVal >= 0 and '+' or '-') .. math.abs(tVal)
else
tVal = math.abs(tVal)
end
else
local keyMap = ScopeKeyMap[k]
if keyMap == nil then
error('Failed to map scope data with key "' .. tKey .. '" to a scope key', 2)
end
tKey = keyMap.templateKey
if tKey == 'skillName' and scopeSourceData ~= nil then
tVal = scopeSourceData.name
elseif tKey == 'damageType' then
local damageType = GameData.getEntityByID('damageTypes', v)
tVal = (damageType ~= nil and damageType.name) or Shared.printError('Unknown damage type: ' .. v)
local tKey2 = keyMap.templateKey2
templateData[tKey2] = (damageType ~= nil and damageType.resistanceName) or tVal
elseif tKey == 'realmName' then
local realm = GameData.getEntityByID('realms', v)
tVal = (realm ~= nil and realm.name) or Shared.printError('Unknown realm: ' .. v)
elseif tKey == 'currencyName' then
local currency = GameData.getEntityByID('currencies', v)
tVal = (currency ~= nil and currency.name) or Shared.printError('Unknown currency: ' .. v)
elseif tKey == 'categoryName' then
if scopeSourceData == nil then
error('Scope data is required for scope type ' .. tKey, 2)
end
local catKey = (
(scopeSourceID == 'AttackSpell' and 'spellCategories')
or (scopeSourceID == 'melvorD:CombatArea' and 'combatAreaCategories')
or (scopeSourceID == 'melvorD:Thieving' and 'areas')
or (scopeSourceID == 'melvorD:Township' and 'biomes')
or 'categories'
)
if scopeSourceData[catKey] == nil then
error('No categories property for scope source with ID ' .. scopeSourceID .. ', ' .. v, 2)
end
local category = GameData.getEntityByID(scopeSourceData[catKey], v)
tVal = (category ~= nil and (category.name or v)) or Shared.printError('Unknown category: ' .. v)
elseif tKey == 'actionName' then
if scopeSourceData == nil then
error('Skill data is required for scope type ' .. tKey, 2)
end
local recipeKey = (
(scopeSourceID == 'melvorD:Township' and 'resources')
or Common.getSkillRecipeKey(scopeSourceID)
)
local action = GameData.getEntityByID(scopeSourceData[recipeKey], v)
if action == nil then
tVal = Shared.printError('Unknown action: ' .. v, ' for scope source ID ' .. scopeSourceID)
else
local actionName = action.name
if actionName == nil and action.productID ~= nil then
local productItem = GameData.getEntityByID('items', action.productID)
actionName = productItem.name
end
tVal = (action ~= nil and (actionName or v)) or Shared.printError('Unknown action: ' .. v, ' for scope source ID ' .. scopeSourceID)
end
elseif tKey == 'subcategoryName' then
local subcategory = nil
if scopeSourceData == nil then
error('Scope source data is required for scope type ' .. tKey, 2)
elseif scopeSourceID == 'AttackSpell' then
subcategory = GameData.getEntityByID(scopeSourceData.spellCategories, v)
else
subcategory = GameData.getEntityByID(scopeSourceData.subcategories, v)
end
tVal = (subcategory ~= nil and subcategory.name) or Shared.printError('Unknown subcategory: ' .. v)
elseif tKey == 'itemName' then
local item = GameData.getEntityByID('items', v)
tVal = (item ~= nil and item.name) or Shared.printError('Unknown item: ' .. v)
elseif tKey == 'effectGroupName' then
local effectGroup = GameData.getEntityByID('combatEffectGroups', v)
tVal = (effectGroup ~= nil and effectGroup.name) or Shared.printError('Unknown effect group: ' .. v)
else
error('Failed to convert scope type ' .. tKey .. ' to description', 2)
end
end
templateData[tKey] = tVal
end
return templateData
end
-- Given the scope data assigned to a modifier, transforms it into a format that can be iterated
function p.getIteratableScopeData(scopeData)
-- scopeData may be a table of multiple scope data sets, which will appear as
-- multiple rows. Split these so that the number of visible lines is always accurate
if type(scopeData) == 'table' and scopeData[1] ~= nil then
-- Elements of the table will have numeric indexes if a table of multiple
-- scope data sets
return scopeData
elseif type(scopeData) ~= 'table' then
-- Single (likely numeric) value
return { { ["value"] = scopeData } }
else
-- Data is a table, but appears to be a single scope data set
return {scopeData}
end
end
-- Given a modifier local ID and scope data, returns a modifier description.
-- applyColour is optional, colouring the modifier green or red to indicate a
-- positive or negative impact upon the player respectively
function p.getModifierText(modID, scopeData, applyColour)
if applyColour == nil then
applyColour = true
end
-- Sometimes ID can be namespaced
local modNS, modLocalID = Shared.getLocalID(modID)
local modDefn = p.getModifierByID(modLocalID)
if modDefn == nil then
return Shared.printError('No modifier found with ID: ' .. (modLocalID or 'nil'))
end
local scopeDefn = p.getScope(modDefn, scopeData)
local descTemplate = p.getDescriptionTemplate(scopeDefn, scopeData)
local templateData = p.getTemplateData(scopeData, modDefn, scopeDefn, descTemplate)
local modTextPlain = Shared.applyTemplateData(descTemplate.text, templateData)
local valU = templateData.valueUnsigned
if applyColour and valU ~= nil then
local isInverted = modDefn.inverted
if isInverted == nil then
isInverted = false
end
local isPositive = ((isInverted and valU < 0) or (not isInverted and valU > 0))
local colourClass = isPositive and 'text-positive' or 'text-negative'
return '<span class="' .. colourClass .. '">' .. modTextPlain .. '</span>'
end
return modTextPlain
end
-- Given a table of modifiers (kv pairs, k = mod local ID, v = scope data),
-- produces text including all modifier descriptions.
-- applyColour is optional, colouring the modifier green or red to indicate a
-- positive or negative impact upon the player respectively.
-- inline is optional, if specified as true, the returned modifiers are presented
-- upon a single line, as opposed to one modifier per line.
-- maxVisible is optional, if specified as an integer, any modifiers beyond the
-- limit this parameter imposes are hidden by default
-- entryDecorator is optional, if specified this is a function which will accept
-- text for each individual modifier entry, and will return the desired format of
-- that entry.
-- entrySeparator is optional, if specified as a string, this defines the text separator
-- for each modifier entry
function p.getModifiersText(modifiers, applyColour, inline, maxVisible, entryDecorator, entrySeparator)
if modifiers == nil or Shared.tableIsEmpty(modifiers) then
return ''
end
if inline == nil then
inline = false
end
if type(maxVisible) ~= 'number' then
maxVisible = nil
end
local entrySep = nil
if type(entrySeparator) == 'string' then
entrySep = entrySeparator
else
entrySep = (inline and ' and ' or '<br>')
end
local function formatLine(text)
if entryDecorator == nil then
return text
else
return entryDecorator(text)
end
end
local modArray = { ["visible"] = {}, ["overflow"] = {} }
local modCount = { ["visible"] = 0, ["overflow"] = 0 }
local insertKey = 'visible'
for modLocalID, scopeDataRaw in pairs(modifiers) do
-- scopeDataRaw may be a table of multiple scope data sets, which will appear as
-- multiple rows. Split these so that the number of visible lines is always accurate
local scopeDataArray = p.getIteratableScopeData(scopeDataRaw)
for _, scopeData in ipairs(scopeDataArray) do
if (maxVisible ~= nil and not inline and insertKey == 'visible'
and modCount[insertKey] >= maxVisible) then
insertKey = 'overflow'
end
table.insert(modArray[insertKey], formatLine(p.getModifierText(modLocalID, scopeData, applyColour)))
modCount[insertKey] = modCount[insertKey] + 1
end
end
if modCount.overflow == 1 then
-- Having a single toggle-able line occupies the same height as showing all modifiers
table.insert(modArray.visible, modArray.overflow[1])
end
local overflowText = ''
if modCount.overflow > 1 then
-- Number of other modifiers has exceeded the specified maximum
overflowText = table.concat({
'<br><span class="mw-collapsible mw-collapsed" data-expandtext=',
'"Show ' .. Shared.formatnum(modCount.overflow) .. ' more modifiers" ',
'data-collapsetext="Hide">',
table.concat(modArray.overflow, entrySep),
'</span>'
})
end
return table.concat(modArray.visible, entrySep) .. overflowText
end
-- Given a table of modifiers (kv pairs, k = mod local ID, v = scope data),
-- returns a table of skill names involved for any modifiers affecting specific skills
function p.getModifierSkills(modifiers)
local skillArray = {}
for localModID, scopeDataRaw in pairs(modifiers) do
local scopeDataArray = p.getIteratableScopeData(scopeDataRaw)
for _, scopeData in ipairs(scopeDataArray) do
local skillID = scopeData.skillID
if skillID ~= nil and not Shared.contains(skillArray, skillID) then
table.insert(skillArray, skillID)
end
end
end
local skillNameArray = {}
for _, skillID in ipairs(skillArray) do
local skillName = GameData.getEntityByProperty('skillData', 'skillID', skillID).data.name
table.insert(skillNameArray, skillName)
end
return skillNameArray
end
-- Leaving for now as examples of usage
function p.test()
local modDefn = p.getModifierByID('skillCostReduction')
local scopeData = {
["realmID"] = 'melvorItA:Abyssal',
["value"] = 5
}
local scopeDefn = p.getScope(modDefn, scopeData)
local descTemplate = p.getDescriptionTemplate(scopeDefn, scopeData)
local templateData = p.getTemplateData(scopeData, modDefn, scopeDefn, descTemplate)
return p.getModifierText('skillCostReduction', scopeData, true)
end
function p.test2()
local obj = GameData.getEntityByID('pets', 'melvorF:Harley') --'melvorD:Aorpheats_Signet_Ring')
return p.getModifiersText(obj.modifiers, true, false, 5)
--return p.getModifierSkills(item.modifiers)
end
function p.test3()
local item = GameData.getEntityByID('items', 'melvorD:Aorpheats_Signet_Ring')
local matchCriteria = {
{
--["mod"] = p.getModifierByID('skillItemDoublingChance'),
--["scope"] = {
-- ["skill"] = true
--}
-- Should no longer match if changed to increased
["alias"] = 'decreasedChanceToDoubleItemsSkill',
},
{
["mod"] = p.getModifierByID('globalItemDoublingChance'),
["scope"] = {}
},
{
-- Should only match the bonus for thieving
["mod"] = p.getModifierByID('currencyGain'),
["scope"] = {
["currency"] = true,
["skill"] = true
}
},
{
["mod"] = p.getModifierByID('combatLootDoublingChance'),
["scope"] = {
-- Shouldn't match because of mis-matching scope definition
["damageType"] = true
}
}
}
return p.getMatchingModifiers(item.modifiers, matchCriteria, 'melvorD:Thieving')
end
function p.test4()
local purch = GameData.getEntityByID('shopPurchases', 'melvorD:Rune_Axe')
local matchCriteria = {
{
["alias"] = 'decreasedSkillIntervalPercent'
}
}
local matchedMods = p.getMatchingModifiers(purch.contains.modifiers, matchCriteria)
mw.logObject(matchedMods)
return p.getModifierValue(matchedMods.matched)
--return p.getMatchingModifiers(purch.contains.modifiers, matchCriteria)
end
return p