Variables
ennvina opened this issue ยท 0 comments
Introduction
#300 introduces many new concepts. One is missing among them: variables. Because the PR is already massive, this new concept has been set aside to limit potential issues.
Concept
There are currently 4 types of trigger:
- Aura
- Action Usable
- Talent
- Holy Power
There is a Trigger
class, which is no more than a bit field telling which of these types of trigger are required for each effect.
Currently, each of these types is spread out in many locations. The goal of the variables concept is to make possible to support a new type of trigger easily, without having to write code in so many places.
Textbook case
In order to illustrate how spread the code is, let's take a look at an example: Holy Power.
It will help imagining the kind of tasks necessary to achieve the ultimate variable implementation.
Trigger type
Near the beginning of trigger.lua
all types of triggers are defined. Currently, the list is:
SAO.TRIGGER_AURA = 0x1
SAO.TRIGGER_ACTION_USABLE = 0x2
SAO.TRIGGER_TALENT = 0x4
SAO.TRIGGER_HOLY_POWER = 0x8
Adding a new variable should start by adding a new value in this list.
Trigger name
In trigger.lua
, close to the above list, is the list of trigger names:
SAO.TriggerNames = {
[SAO.TRIGGER_AURA ] = "aura",
[SAO.TRIGGER_ACTION_USABLE] = "action",
[SAO.TRIGGER_TALENT ] = "talent",
[SAO.TRIGGER_HOLY_POWER ] = "holyPower",
}
Manual Check
Variables are updated by two means: getting the current state, and updating the state through events. Manual checks cover the former.
Currently, manual checks are written in trigger.lua
in the TriggerManualChecks
map:
local TriggerManualChecks = {
[SAO.TRIGGER_HOLY_POWER] = function(bucket)
if Enum and Enum.PowerType and Enum.PowerType.HolyPower then
local holyPower = UnitPower("player", Enum.PowerType.HolyPower);
bucket:setHolyPower(holyPower);
else
SAO:Debug(Module, "Cannot fetch Holy Power because this resource is unknown from Enum.PowerType");
end
end,
}
As seen in this example, it calls the bucket:setHolyPower
method. It will be discussed later on.
Hash type
Each trigger end up in the hash value used to index displays uniquely in buckets.
Hash types are defined in hash.lua
. They are computed using bit fields, different from trigger bit fields. Hash bit fields carry data, while trigger bit fields only carry flags of whether or not we need this data to begin with.
Hash type bit fields are defined as the list of possible values, their mask, and are prefixed by a comment explaining the formula to go from the actual value to the bit field, or vice versa:
-- Holy Power
-- hash = HASH_HOLY_POWER_0 * (1 + holy_power)
local HASH_HOLY_POWER_0 = 0x0800
local HASH_HOLY_POWER_1 = 0x1000
local HASH_HOLY_POWER_2 = 0x1800
local HASH_HOLY_POWER_3 = 0x2000
local HASH_HOLY_POWER_MASK = 0x3800
Hash conflict check
To make sure there aren't any conflict with bit fields in hashes, hash.lua
defines a list checked at start:
local masks = {
HASH_AURA_MASK,
HASH_ACTION_USABLE_MASK,
HASH_TALENT_MASK,
HASH_HOLY_POWER_MASK
}
In case you're wondering if there is such check with trigger bit fields, the answer is yes. It is done automatically, based on the content of TriggerNames
.
Hash stringifier
In order to help how to read and write hash values, hash.lua
defines an internal concept: hash stringifiers.
HashStringifier:register(
HASH_HOLY_POWER_MASK,
"holy_power", -- key
function(hash) -- toValue()
local holyPower = hash:getHolyPower();
return tostring(holyPower);
end,
function(hash, value) -- fromValue()
if tostring(tonumber(value)) == value then
hash:setHolyPower(tonumber(value));
return true;
else
return nil; -- Not good
end
end,
function(hash) -- getHumanReadableKeyValue
local holyPower = hash:getHolyPower();
return string.format(HOLY_POWER_COST, holyPower);
end,
function(hash) -- optionIndexer
return hash:getHolyPower();
end
);
Stringifiers are interesting, because they are an attempt at simplifying how to add new trigger types. It can definitely serve as an inspiration for the variable concept.
Hash methods
The Hash
class defined in hash.lua
has methods for getting and setting each hash type.
-- Holy Power
hasHolyPower = function(self)
return bit.band(self.hash, HASH_HOLY_POWER_MASK) ~= 0;
end,
setHolyPower = function(self, holyPower)
if type(holyPower) ~= 'number' or holyPower < 0 then
SAO:Warn(Module, "Invalid Holy Power "..tostring(holyPower));
elseif holyPower > 3 then
SAO:Debug(Module, "Holy Power overflow ("..holyPower..") truncated to 3");
setMaskedHash(self, HASH_HOLY_POWER_3, HASH_HOLY_POWER_MASK);
else
setMaskedHash(self, HASH_HOLY_POWER_0 * (1 + holyPower), HASH_HOLY_POWER_MASK);
end
end,
getHolyPower = function(self)
local maskedHash = getMaskedHash(self, HASH_HOLY_POWER_MASK);
if maskedHash == nil then return nil; end
return (maskedHash / HASH_HOLY_POWER_0) - 1;
end,
It should be noted that there is no "holy power member". In hashes, everything is baked in the unique hash number, which is a 32-bit integer (maybe more than 32 bits, but we at least 32 for sure).
Another important point, is that there is no method in the Hash
class for reading or writing strings. These are computed automagically thanks to hash stringifiers.
Member in bucket
The Bucket
class defined in bucket.lua
holds a 'current state' of each variable.
The member is set in the class constructor and reset method:
SAO.Bucket = {
create = function(self, name, spellID)
local bucket = {
...
-- Initialize current state with unattainable values
currentStacks = -1,
currentActionUsable = nil,
currentTalented = nil,
currentHolyPower = nil,
...
};
...
return bucket;
end,
reset = function(self)
...
self.currentStacks = -1;
self.currentActionUsable = nil;
self.currentTalented = nil;
self.currentHolyPower = nil;
...
end,
And there is a method for setting them and apply these changes internally for e.g. showing or hiding displays:
setHolyPower = function(self, holyPower)
if self.currentHolyPower == holyPower then
return;
end
self.currentHolyPower = holyPower;
self.trigger:inform(SAO.TRIGGER_HOLY_POWER);
self.hashCalculator:setHolyPower(holyPower);
self:applyHash();
end,
Talking about displays, there is no need to define explicitly what holy power is, how it changes, or even why it exists in the first place. Displays only care about hash numbers as unique indexes. They don't care about about how the hash was computed.
Effect doc
At the beginning of effect.lua
, a big comment showcases what a Native Optimized Effect (NOE) looks like. Among this comment, is this:
--[[
...
triggers = { -- Default is false for every trigger; at least one trigger must be set
aura = true, -- The aura with spell ID 'spellID' is gained or lost by the player
action = false, -- The action with spell ID 'spellID' is usable or not
talent = false, -- The player spent at least one point in effect's talent, or spent none
holyPower = false, -- Number of charges of Holy Power (Paladin only, Cataclysm)
},
overlays = {{
project = SAO.WRATH, -- Default is project from effect
condition = { -- Default is the default value for each trigger defined in 'triggers'
aura = 0, -- Default is 0. -1 for 'aura missing', 0 for 'any stacks', 1 or more tells the number of stacks
action = nil, -- Default is true. true for action usable, false for action not usable
talent = nil, -- Default is true. true for talent picked (at least one point), false for talent not picked
holyPower = nil, -- Default is 3
},
...
]]
There is no equivalent documentation for showcasing a Human Readable Effect (HRE). If we ever write one, variables should be mentioned here as well.
Condition Builder
Similar to hash stringifiers, effect.lua
defines an internal concept of condition builders. This helps building the condition
, mentioned in effect documentation:
ConditionBuilder:register(
"holyPower", -- Name used by NOE
"holyPower", -- Name used by HRE
0, -- Default (NOE only)
"setHolyPower", -- Setter method for Hash
"Holy Power value",
function(value) return type(value) == 'number' and value >= 0 and value <= 3 end,
function(value) return value end
);
Import trigger
Each HRE ends up converted into a NOE. This conversion process performs operations called 'imports', which fetches content from the HRE to put it into the corresponding NOE.
Among those imports is the step of "import trigger". But not all triggers are imported. Each HRE may only import triggers useful to them, and either discard or force imports to the NOE, whether or not they are part of the HRE. For example, the "aura"
HRE forces the usage of the 'aura trigger', while the "counter"
HRE forces the usage of the 'action usable' trigger.
The Holy Power uses the (poorly worded) importResource
function:
local function importResource(effect, props)
importTrigger(effect, props, "holyPower", "useHolyPower");
end
This function is then used in appropriate HRE creators, for example when creating a counter:
local function createCounter(effect, props)
-- Import triggers
effect.triggers.action = true; -- Forced
importTalent(effect, props); -- Optional
importResource(effect, props); -- Optional
importCounterButton(effect, props);
return effect;
end
When variables will be centralized, the importX
functions can be generalized, but extra caution should be taken to not call all trigger imports for all types of HREs.
Events
Variables are updated by two means: getting the current state, and updating the state through events. Events cover the latter.
In its most basic form, events just grab a "the state I'm looking at has changed" event. This is what Holy Power does.
function SAO.UNIT_POWER_FREQUENT(self, unitTarget, powerType)
if unitTarget == "player" and powerType == "HOLY_POWER" then
self:CheckManuallyAllBuckets(SAO.TRIGGER_HOLY_POWER);
end
end
Calling CheckManuallyAllBuckets(SAO.TRIGGER_HOLY_POWER)
is straightforward, and could be used as a lazy way to start testing new variables. But when performance comes into play, more refined calls should be made.
And of course, events must be caught by a frame, using the game's Frame:RegisterEvent
method. For example in SpellActivationOverlay.lua
:
if ( SAO.IsCata() and classFile == "PALADIN" ) then
self:RegisterEvent("UNIT_POWER_FREQUENT"); -- For Holy Power, introduced in Cataclysm
end
The "if paladin" test is a bit overkill. But it shows that events should not be registered bluntly. There are cases (here, based on class) where we do not need to register to every event out there. Event parsing has a cost and should be handled with caution, especially if we want the addon to avoid wasting precious resources, which has been a core objective since its inception.
Suggestion
Based on the above analysis, here are a few key points that come to mind:
- each variable could be indexed from the Trigger type e.g., from
SAO.TRIGGER_HOLY_POWER
for Holy Power - most code can be derived from a single 'variable' containing members or methods that perform necessary operations described above
variable.lua
Here is a quick start for implementing variables in what could be the future variable.lua
file.
local AddonName, SAO = ...
local Module
-- Variable definition map
-- key = trigger flag, value = variable object
SAO.Variables = {}
local function check(var, member, expectedType)
if type(var) ~= 'table' then
return;
elseif not var[member] then
SAO:Warn(Module, "Variable does not define a "..tostring(member));
elseif type(var[member]) ~= expectedType then
SAO:Warn(Module, "Variable defines member "..tostring(member).." of type '"..type(var[member]).."' instead of '"..expectedType.."'");
end
end
SAO.Variable = {
register = function(self, var)
check(var, trigger, 'table');
check(var.trigger, flag, 'number'); -- TRIGGER_HOLY_POWER
check(var.trigger, name, 'string'); -- "holyPower"
check(var, hash, 'table');
check(var.hash, mask, 'number'); -- HASH_HOLY_POWER_MASK
check(var.hash, key, 'number'); -- "holy_power"
check(var.hash, setter, 'string'); -- "setHolyPower"
--[[ function(self, holyPower, bucket)
if type(holyPower) ~= 'number' or holyPower < 0 then
SAO:Warn(Module, "Invalid Holy Power "..tostring(holyPower));
elseif holyPower > 3 then
SAO:Debug(Module, "Holy Power overflow ("..holyPower..") truncated to 3");
setMaskedHash(self, HASH_HOLY_POWER_3, HASH_HOLY_POWER_MASK);
else
setMaskedHash(self, HASH_HOLY_POWER_0 * (1 + holyPower), HASH_HOLY_POWER_MASK);
end
end]]
check(var.hash, setterFunc, 'function');
check(var.hash, getter, 'string'); -- "getHolyPower"
--[[ function(self)
local maskedHash = getMaskedHash(self, HASH_HOLY_POWER_MASK);
if maskedHash == nil then return nil; end
return (maskedHash / HASH_HOLY_POWER_0) - 1;
end]]
check(var.hash, getterFunc, 'function');
-- function(hash) return tostring(hash:getHolyPower()) end
check(var.hash, toValue, 'function');
--[[ function(hash, value)
if tostring(tonumber(value)) == value then
hash:setHolyPower(tonumber(value));
return true;
else
return nil; -- Not good
end
end]]
-- function(hash) return string.format(HOLY_POWER_COST, hash:getHolyPower()) end
check(var.hash, getHumanReadableKeyValue, 'function');
-- function(hash) return hash:getHolyPower() end
check(var.hash, optionIndexer, 'function');
check(var, bucket, 'table');
check(var.bucket, member, 'string'); -- "currentHolyPower"
-- check(var.bucket, impossibleValue, 'any'); -- can be anything, usually nil or -1
check(var.bucket, setter, 'string'); -- "setHolyPower"
check(var, event, 'table');
check(var.event, names, 'table'); -- { "UNIT_POWER_FREQUENT" }
-- function() return SAO.IsCata() and select(2, UnitClass("player")) == "PALADIN" end
check(var.event, isRequired, 'function');
--[[ function(bucket)
if Enum and Enum.PowerType and Enum.PowerType.HolyPower then
local holyPower = UnitPower("player", Enum.PowerType.HolyPower);
bucket:setHolyPower(holyPower);
else
SAO:Debug(Module, "Cannot fetch Holy Power because this resource is unknown from Enum.PowerType");
end
end]]
check(var, fetchAndSet, 'function'); -- Formerly in TriggerManualChecks
check(var, condition, 'table');
check(var.condition, noeVar, 'string'); -- "holyPower"
check(var.condition, hreVar, 'string'); -- "holyPower"
-- check(var.condition, noeDefault, 'any'); -- can be anything, usually 0
check(var.condition, description, 'string'); -- "Holy Power value"
-- function(value) return type(value) == 'number' and value >= 0 and value <= 3 end
check(var.condition, checker, 'function');
-- function(value) return value end
check(var.condition, noeToHash, 'function');
check(var, import, 'table');
check(var.import, noeTrigger, 'string'); -- "holyPower"
check(var.import, hreTrigger, 'string'); -- "useHolyPower"
-- Add the hash setter and getter directly to the Hash class definition
Hash[var.hash.setter] = var.hash.setterFunc;
Hash[var.hash.getter] = var.hash.getterFunc;
-- Add the bucket setter directly to the bucket class declaration
Bucket[var.bucket.setter] = function(self, value)
if self[var.bucket.member] == value then
return;
end
self[var.bucket.member] = value;
self.trigger:inform(var.trigger.flag);
self.hashCalculator[var.hash.setter](value, bucket);
self:applyHash();
end,
self.__index = nil;
setmetatable(var, self);
self.__index = nil;
Variables[var.trigger.flag] = var;
end
}
And then, we would need to update the code to:
- replace existing code for variables in buckets, hashes, etc. and even tables such as
TriggerNames
- for example, bucket's
create
andreset
methods would parseVariables
- initializing/resetting variables would use and use
var.bucket.member
andvar.bucket.impossibleValue
- for example, bucket's
- bit field checks should parse the list of
Variables
instead of e.g.TriggerNames
- port code for hash stringifiers and condition builders to base their list on the Variables table
Events
Event are declared in the Variable class, but not actually plugged in.
Code has to be added to SpellActivationOverlay_OnLoad
to parse the list of variable events, and call RegisterEvent
to register those which return true
to their isRequired()
function.
And event handling code has to be added to events.lua
as well.
Homemade Development
Some code still needs to be done manually by developers:
- the new trigger type must be added to
trigger.lua
- the trigger must be mentioned in effect documentation
- and as mentioned previously, event code must be written to
events.lua
File structure
Because each variable may have a significant amount of lines of code, and because variables never really interact with each other, they could end up within a new folder structure of variables
. For example:
- the above source code would be in
components/variable.lua
- the Holy Power code would be in
variables/holypower.lua
- and this folder would have others e.g.
variables/talent.lua
These files should be added to SpellActivationOverlay.toc
and the new folder variables
folder should be included when packaging flavors. Some flavors could also exclude unused variables. For example, holypower.lua
would be included in the Cataclysm flavor only.