SpellActivationOverlay

SpellActivationOverlay

3M Downloads

Variables

ennvina opened this issue ยท 0 comments

commented

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:

  1. each variable could be indexed from the Trigger type e.g., from SAO.TRIGGER_HOLY_POWER for Holy Power
  2. 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 and reset methods would parse Variables
    • initializing/resetting variables would use and use var.bucket.member and var.bucket.impossibleValue
  • 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.