Module:Modifiers

From Melvor Idle
Revision as of 08:35, 15 July 2024 by Auron956 (talk | contribs) (getTemplateData: Fix actionID handling for Firemaking)

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 then
						-- May use the product name instead
						local productKey = (
							(scopeSourceID == 'melvorD:Firemaking' and 'logID')
							or 'productID'
							)
						local productID = action[productKey]
						if productID ~= nil then
							local productItem = GameData.getEntityByID('items', productID)
							actionName = productItem.name
						end
					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