Module:Sandbox/Modifiers

Documentation for this module may be created at Module:Sandbox/Modifiers/doc

-- Module responsible for handling modifiers

local p = {}

local GameData = require('Module:Sandbox/GameData')
local Shared = require('Module:Shared')

-- 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 = { 'posAliases', 'negAliases' }
		for _, key in ipairs(aliasKeys) do
			local modAliases = allowedScope[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
						}
					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 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 = {}
	local wantedScopeSize = 0
	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
				wantedScopeDefn[scopeKey.key] = true
				wantedScopeSize = wantedScopeSize + 1
			end
		end
	end

	-- Attempt to find an allowed scope with a matching definition
	for _, allowedScope in ipairs(modDefn.allowedScopes) do
		local scopeMatched = true
		local scopeSize = 0
		for k, v in pairs(allowedScope.scopes) do
			if not wantedScopeDefn[k] then
				-- The scope definition has a key that isn't wanted, don't match
				scopeMatched = false
				break
			end
			scopeSize = scopeSize + 1
		end
		if scopeMatched and scopeSize == wantedScopeSize then
			-- Scope matches
			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

-- TODO Lazy copy from Module:Skills to avoid dependency, must resolve later
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'
	}
	return recipeIDs[localSkillID]
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, descriptionTemplate)
	local templateData = {}
	local signValue = false
	local modifyRule = nil
	if descriptionTemplate ~= nil and descriptionTemplate.includeSign ~= nil then
		signValue = (descriptionTemplate.includeSign == "true")
	end
	if modDefn ~= nil and modDefn.modifyValue ~= nil then
		modifyRule = modDefn.modifyValue
	end
	-- If scope data has a skill ID, retrieve the skill data
	local skillData = nil
	if scopeData.skillID ~= nil then
		skillData = GameData.getEntityByProperty('skillData', 'skillID', scopeData.skillID).data
	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 skillData ~= nil then
				tVal = skillData.name
			elseif tKey == 'damageType' then
				local damageType = GameData.getEntityByID('damageTypes', v)
				tVal = damageType.name
				local tKey2 = keyMap.templateKey2
				templateData[tKey2] = damageType.resistanceName
			elseif tKey == 'realmName' then
				tVal = GameData.getEntityByID('realms', v).name
			elseif tKey == 'currencyName' then
				tVal = GameData.getEntityByID('currencies', v).name
			elseif tKey == 'actionName' then
				if skillData == nil then
					error('Skill data is required for scope type ' .. tKey, 2)
				end
				local recipeKey = p.getSkillRecipeKey(skillData.id)
				tVal = GameData.getEntityByID(skillData[recipeKey], v).name
			elseif tKey == 'subcategoryName' then
				if skillData == nil then
					error('Skill data is required for scope type ' .. tKey, 2)
				end
				tVal = GameData.getEntityByID(skillData.categories, v).name
			elseif tKey == 'itemName' then
				tVal = GameData.getEntityByID('items', v).name
			elseif tKey == 'effectGroupName' then
				tVal = GameData.getEntityByID('combatEffectGroups', v).name
			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)
	local scopeDefn = p.getScope(modDefn, scopeData)
	local descTemplate = p.getDescriptionTemplate(scopeDefn, scopeData)
	local templateData = p.getTemplateData(scopeData, modDefn, 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
function p.getModifiersText(modifiers, applyColour, inline, maxVisible)
	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 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], p.getModifierText(modLocalID, scopeData, applyColour))
			modCount[insertKey] = modCount[insertKey] + 1
		end
	end
	if inline then
		return table.concat(modArray.visible, ' and ')
	else
		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, '<br>'),
				'</span>'
			})
		end
		return table.concat(modArray.visible, '<br>') .. overflowText
	end
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, descTemplate)
	return p.getModifierText('skillCostReduction', scopeData, true)
end

function p.test2()
	local item = GameData.getEntityByID('items', 'melvorD:Aorpheats_Signet_Ring')
	return p.getModifiersText(item.modifiers, true, false, 5)
end

return p