Module:Modifiers: Difference between revisions

From Melvor Idle
(Support modifier matching by properties such as skill ID, item ID, etc.)
(Implement modType criteria for checking if a modifier is deemed to have a positive or negative effect upon the target)
Line 160: Line 160:
error('Value for criteria ' .. criteriaKey .. ' cannot be nil', 2)
error('Value for criteria ' .. criteriaKey .. ' cannot be nil', 2)
end
end
if criteriaKey == 'valueType' then
if criteriaKey == 'valueType' or criteriaKey == 'modType' then
-- Special case
-- Special cases:
-- valueType restricts by sign of modifier value
-- modType restricts by whether the modifier is deemed to be a positive or
-- negative to the target
dataCriteria[criteriaKey] = string.lower(criteriaName)
dataCriteria[criteriaKey] = string.lower(criteriaName)
elseif ScopeKeyMap[criteriaKey] ~= nil then
elseif ScopeKeyMap[criteriaKey] ~= nil then
Line 200: Line 203:
-- "valueType". This indicates whether the value of the modifier should be positive ('pos') or
-- "valueType". This indicates whether the value of the modifier should be positive ('pos') or
-- negative ('neg') in order for the criteria to be met
-- negative ('neg') in order for the criteria to be met
function p.checkScopeDataMeetsCriteria(scopeData, scopeDefn, dataCriteria)
function p.checkScopeDataMeetsCriteria(modDefn, scopeData, scopeDefn, dataCriteria)
for criteriaKey, criteriaValue in pairs(dataCriteria) do
for criteriaKey, criteriaValue in pairs(dataCriteria) do
if criteriaKey == 'valueType' then
if criteriaKey == 'valueType' then
Line 213: Line 216:
-- Value criteria not met
-- Value criteria not met
return false
return false
end
elseif criteriaKey == 'modType' then
if criteriaValue ~= nil and scopeData.value ~= nil then
local isInverted = modDefn.inverted
if isInverted == nil then
isInverted = false
end
local isPositive = ((isInverted and scopeData.value < 0) or (not isInverted and scopeData.value > 0))
if not (
(criteriaValue == 'pos' and isPositive)
or (criteriaValue == 'neg' and not isPositive)
) then
-- Mod type criteria not met
return false
end
end
end
elseif criteriaValue ~= nil and criteriaKey ~= 'key' then
elseif criteriaValue ~= nil and criteriaKey ~= 'key' then
Line 317: Line 335:
matchCriteriaMap[modLocalID] = {}
matchCriteriaMap[modLocalID] = {}
end
end
table.insert(matchCriteriaMap[modLocalID], { ["scope"] = scopeDefn, ["alias"] = modAlias, ["props"] = propCriteria })
table.insert(matchCriteriaMap[modLocalID], {
["mod"] = modDefn,
["scope"] = scopeDefn,
["alias"] = modAlias,
["props"] = propCriteria
})
end
end


Line 339: Line 362:
local modScopeDefn = p.convertScopeDataToDefinition(scopeData)
local modScopeDefn = p.convertScopeDataToDefinition(scopeData)
for _, matchDefn in ipairs(modMatchCriteria) do
for _, matchDefn in ipairs(modMatchCriteria) do
local scopeDefn, modAlias, propCriteria = matchDefn.scope, matchDefn.alias, matchDefn.props
local modDefn, scopeDefn, modAlias, propCriteria = matchDefn.mod, matchDefn.scope, matchDefn.alias, matchDefn.props
local matchKey = 'unmatched'
local matchKey = 'unmatched'
-- Check that:
-- Check that:
Line 347: Line 370:
if (
if (
p.doScopeDefinitionsMatch(modScopeDefn, scopeDefn)
p.doScopeDefinitionsMatch(modScopeDefn, scopeDefn)
and p.checkScopeDataMeetsCriteria(scopeData, scopeDefn, modAlias)
and p.checkScopeDataMeetsCriteria(modDefn, scopeData, scopeDefn, modAlias)
and p.checkScopeDataMeetsCriteria(scopeData, scopeDefn, propCriteria)
and p.checkScopeDataMeetsCriteria(modDefn, scopeData, scopeDefn, propCriteria)
) then
) then
-- Add to matched table
-- Add to matched table

Revision as of 17:52, 6 July 2024

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')
local Num = require('Module:Number')

-- 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,
							['props'] = { ['valueType'] = 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',
		['gameDataKey'] = 'damageTypes'
	},
	['realmID'] = {
		['key'] = 'realm',
		['templateKey'] = 'realmName',
		['gameDataKey'] = 'realms'
	},
	['currencyID'] = {
		['key'] = 'currency',
		['templateKey'] = 'currencyName',
		['gameDataKey'] = 'currencies'
	},
	['categoryID'] = {
		['key'] = 'category',
		['templateKey'] = 'categoryName'
	},
	['actionID'] = {
		['key'] = 'action',
		['templateKey'] = 'actionName'
	},
	['subcategoryID'] = {
		['key'] = 'subcategory',
		['templateKey'] = 'subcategoryName'
	},
	['itemID'] = {
		['key'] = 'item',
		['templateKey'] = 'itemName',
		['gameDataKey'] = 'items'
	},
	['effectGroupID'] = {
		['key'] = 'effectGroup',
		['templateKey'] = 'effectGroupName',
		['gameDataKey'] = 'combatEffectGroups'
	},
}

local ScopeKeyToIDMap = {}
for idKey, defn in pairs(ScopeKeyMap) do
	ScopeKeyToIDMap[defn.key] = idKey
end

-- 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

-- Given a table containing one or more sets of data criteria, combines those criteria into a
-- single set of criteria. Should the same criteria key be specified more than once, the value from
-- the last set containing that key will be retained
function p.combineDataCriteria(dataCriteriaList)
	local rv = {}
	for _, dataCriteria in ipairs(dataCriteriaList) do
		for dataKey, dataValue in pairs(dataCriteria) do
			rv[dataKey] = dataValue
		end
	end
	return rv
end

-- Given data criteria where the values are entity names, converts those names to IDs.
function p.convertCriteriaNamesToIDs(dataCriteriaByName)
	local dataCriteria = {}

	for criteriaKey, criteriaName in pairs(dataCriteriaByName) do
		if criteriaName == nil or criteriaName == '' then
			error('Value for criteria ' .. criteriaKey .. ' cannot be nil', 2)
		end
		if criteriaKey == 'valueType' or criteriaKey == 'modType' then
			-- Special cases:
			-- valueType restricts by sign of modifier value
			-- modType restricts by whether the modifier is deemed to be a positive or
			-- 		negative to the target
			dataCriteria[criteriaKey] = string.lower(criteriaName)
		elseif ScopeKeyMap[criteriaKey] ~= nil then
			-- ID specified directly
			dataCriteria[criteriaKey] = criteriaName
		else
			local criteriaIDKey = ScopeKeyToIDMap[criteriaKey]
			if criteriaIDKey == nil then
				error('Invalid criteria specified: ' .. criteriaKey, 2)
			end
			local keyMap = ScopeKeyMap[criteriaIDKey]

			local criteriaID = nil
			if keyMap.gameDataKey ~= nil then
				local criteriaData = GameData.getEntityByName(keyMap.gameDataKey, criteriaName)
				if criteriaData ~= nil then
					criteriaID = criteriaData.id
				end
			elseif criteriaKey == 'skill' then
				criteriaID = Common.getSkillID(criteriaName)
			else
				error('Criteria ' .. criteriaKey .. ' is currently unsupported', 2)
			end

			if criteriaID == nil then
				error('Unknown ' .. criteriaKey .. ': ' .. criteriaName, 2)
			end
			dataCriteria[criteriaIDKey] = criteriaID
		end
	end
	return dataCriteria
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(modDefn, 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 criteriaKey == 'modType' then
			if criteriaValue ~= nil and scopeData.value ~= nil then
				local isInverted = modDefn.inverted
				if isInverted == nil then
					isInverted = false
				end
				local isPositive = ((isInverted and scopeData.value < 0) or (not isInverted and scopeData.value > 0))
				if not (
					(criteriaValue == 'pos' and isPositive)
					or (criteriaValue == 'neg' and not isPositive)
				) then
					-- Mod type criteria not met
					return false
				end
			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)
	local matchCriteria = {}
	-- For modifier IDs, find the relevant mod definition and add all allowed scopes
	if modifierIDs ~= nil then
		for _, idObject in ipairs(modifierIDs) do
			local modifierID = idObject.id
			local modType = idObject.type
			local modProps = idObject.props or {}

			if modType == 'id' then
				local modDefn = p.getModifierByID(modifierID)
				if modDefn == nil then
					error('No such modifier ID: ' .. modifierID, 2)
				end

				-- Add all scopes
				for _, allowedScope in ipairs(modDefn.allowedScopes) do
					table.insert(matchCriteria, {
						["mod"] = modDefn,
						["scope"] = allowedScope.scopes or {},
						["props"] = modProps
					})
				end
			elseif modType == 'alias' then
				-- For alias IDs, simply add these one at a time to the table
				table.insert(matchCriteria, {
					["alias"] = modifierID,
					["props"] = modProps
				})
			else
				error('Unknown modifier ID type: ' .. (modType or 'nil'), 2)
			end
		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,
--		["props"] = modProps
-- }
-- 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.
-- props is an optional property, if specified then a modifier is only matched
-- if the properties (skillID, itemID, etc.) also match.
function p.getMatchingModifiers(modifiers, matchCriteria)
	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, propCriteria = matchDefn.mod, matchDefn.scope, {}, (matchDefn.props or {})
			if matchDefn.alias ~= nil then
				local aliasData = p.getModifierByAlias(matchDefn.alias)
				if aliasData == nil then
					error('No such modifier alias: ' .. matchDefn.alias, 2)
				else
					local aliasProps = p.combineDataCriteria({propCriteria, aliasData.props})
					modDefn, scopeDefn, modAlias, propCriteria = aliasData.mod, aliasData.scope.scopes, aliasData.alias, aliasProps
					
				end
			end
			local modNS, modLocalID = Shared.getLocalID(modDefn.id)
			if matchCriteriaMap[modLocalID] == nil then
				matchCriteriaMap[modLocalID] = {}
			end
			table.insert(matchCriteriaMap[modLocalID], {
				["mod"] = modDefn,
				["scope"] = scopeDefn,
				["alias"] = modAlias,
				["props"] = propCriteria
			})
		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 modDefn, scopeDefn, modAlias, propCriteria = matchDefn.mod, matchDefn.scope, matchDefn.alias, matchDefn.props
						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(modDefn, scopeData, scopeDefn, modAlias)
							and p.checkScopeDataMeetsCriteria(modDefn, scopeData, scopeDefn, propCriteria)
					 	) 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 == 'melvorD: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 == 'melvorD: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 == 'melvorD: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 ' .. Num.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', 'melvorItA:Corrupted_Light_Consumable_I')
	local matchCriteria = {
		{
			--["mod"] = p.getModifierByID('skillItemDoublingChance'),
			--["scope"] = {
			--	["skill"] = true
			--}
			-- Should no longer match if changed to increased
			["alias"] = 'increasedChanceNoDamageMining',
		},
		{
			["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
			}
		},
		{
			["mod"] = p.getModifierByID('flatBaseRandomProductQuantity'),
			["scope"] = {
				["item"] = true,
				["skill"] = true
			},
			["props"] = p.convertCriteriaNamesToIDs({ ["item"] = 'Abyssal Stardust' })
		}
	}
	return p.getMatchingModifiers(item.modifiers, matchCriteria)
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

-- Checks p.getMatchCriteriaFromIDs() and p.getMatchingModifiers()
-- Any entity processed by this should return a table with all modifiers
-- matched and none unmatched. Unmatched modifiers implies an issue with one
-- of the aforementioned functions
function p.test5()
	local ent = GameData.getEntityByID('items', 'melvorF:Miners_Helmet')
	local entMods = ent.modifiers
	if entMods ~= nil then
		local modIDs = {}
		for modID, modDet in pairs(entMods) do
			table.insert(modIDs, {
				["id"] = modID,
				["type"] = 'id',
				["props"] = {}
			})
		end
	local matchCriteria = p.getMatchCriteriaFromIDs(modIDs)
	mw.logObject(matchCriteria)
	mw.log('=======================================================')
	local matchingMods = p.getMatchingModifiers(entMods, matchCriteria)
	return matchingMods
	end
	return nil
end

return p