Event Listener 1.1.0.0


Event Listener

v1.1.0.0

by MyPad


Description

How to use

Requirements

Package Snippet

API

Credits

Changelog


A lightweight Lua package to serve your event dispatching and handling needs.

Offers the following features:
  1. Robust infinite-loop protection (theoretically speaking; practical limit in wc3 is around 250+).
  2. Parametrized callback execution.
  3. Active and inactive behavior implemented in Event Subscribers (similar to JASS triggers in general).
  4. Unsubscribe features (for Event Subscribers) that automatically clean up said Event Subscribers.

This package is ideal for generic events which require workarounds, like unit within range events,
generic individual damage events (before 1.31 introduced EVENT_PLAYER_UNIT_DAMAGED),
transformation events, unit creation and removal tracking and more.

Event Listeners are objects created from EventListener.create() which contains
a list of executable callback functions (wrapped in objects known as Event Subscribers) that get executed
whenever a signal is given. To trigger an Event Listener object, one must call
<event_listener_object>:run(...). Upon triggering an Event Listener object,
the passed arguments will be supplied to each Event Subscriber that is subscribed / registered to
that Event Listener.

While originally designed with object permanence in mind (e.g. while the game is running), Event Listeners
can be destroyed and created dynamically, but I do not recommend that. Still, for those users who would
appreciate this feature, the choice is theirs to make.

For this system, the scripting language in your map must be Lua and your version must be 1.31 or higher.

For additional debugging, this package can take advantage of DebugUtils to show a detailed breakdown
on what exactly goes wrong when using this package. Still, it isn't required.

To open the test map, your wc3 version must be the latest version (sorry about that).

To save on bandwidth, you can copy and paste the package below:

Lua:
--[[
    --------------------------------------------------
    --- EventListener
    ---     - 1.1.0.0
    ---         - MyPad
    ---
    --------------------------------------------------
    ---
    --- Changelog:
    ---     - Updated current implementation.
    ---
    --------------------------------------------------
    ---
    --- A
]]
if Debug and Debug.beginFile then Debug.beginFile("EventListener") end
do
    --------------------------------------------------
    ---     Configuration section
    --------------------------------------------------
    local config                = {
        -- Determines the default amount of times a function can recursively be called
        -- before the system treats it as a runaway infinite loop.
        MAX_RECURSION           = 8,

        -- The maximum number of times a function can repeatedly be called
        -- within a stack before it is considered non-halting (infinite loop).
        SANITY_MAX_RECURSION    = 32,

        -- Appends the current index of the executing function as the first parameter
        -- among the list of parameters to be used.
        APPEND_INDEX            = false,

        -- Default function
        DEF_FUN                 = DoNothing,
        -- Blacklisted functions.
        BLACKLIST               = {
            [_G['DoNothing']]   = true,
        }
    }
    --------------------------------------------------
    ---     End Configuration section
    --------------------------------------------------

    ---@alias callable fun() | table

    ---@class EventListener
    local eventListener         = {}
    EventListener               = eventListener

    ---@class Subscriber
    local subscriber            = {}

    ---@class Subscriber
    local nullSubscriber        = setmetatable({}, subscriber)

    -----------------------------------------
    --- Define class members
    -----------------------------------------
    do
        ---@class EventListener
        ---@field private _subList Subscriber[]
        ---@field private _curDepth integer The current call stack depth of the object.
        ---@field private _curIndex integer The current sub index of the running object.
        ---@field private _curFun Subscriber
        ---@field private _size integer The number of subscribers to an EventListener.
        ---@field _maxDepth integer The number of subscribers to an EventListener.

        ---@class Subscriber
        ---@field _callable callable
        ---@field _ableCounter integer The counter value which determines if a callable action should be executed or not.
        ---@field _depthCounter integer The current depth of the callable action in the call stack.
        ---@field _event EventListener A pointer to the holding EventListener object.
    end

    -----------------------------------------
    --- Define read and write access actions.
    -----------------------------------------
    do
        local rawset                = rawset
        eventListener.__index       = eventListener
        eventListener.__metatable   = eventListener
        eventListener.__newindex    =
        function (t, k, v)
            if eventListener[k] then return eventListener end
            return rawset(t, k, v)
        end

        function eventListener:__len()
            return self._size or 0
        end

        subscriber.__index          = subscriber
        subscriber.__metatable      = subscriber
        subscriber.__newindex       =
        function (t, k, v)
            if eventListener[k] then return eventListener end
            return rawset(t, k, v)
        end
    end

    local isCallable    = IsCallable
    if not isCallable then
        isCallable      =
        function (obj)
            return (type(obj) == 'function') or
                   (type(obj) == 'table') and
                   (isCallable(getmetatable(obj)))
        end
    end

    local try           = try or
    function (fun, ...)
        return select(2, xpcall(fun, print, ...))
    end

    ---Creates a new instance.
    ---@param o? table | nil
    ---@return EventListener
    function eventListener.create(o)
        o                   = o or {
            _subList        = {},
            _curDepth       = 0,
            _curIndex       = 0,
            _maxDepth       = config.MAX_RECURSION,
            _size           = 0,
            _curFun         = nullSubscriber,
        }
        setmetatable(o, eventListener)
        return o
    end

    --- This will try to remove sparseness in the list assuming
    --- the full size of the list is known.
    ---@param list table
    ---@param trueSize integer
    ---@param startIndex? integer=1
    local function densifyList(list, trueSize, startIndex)
        local i     = startIndex or 1
        local j, n  = 0, 0
        while (i <= trueSize) do
            if (list[i] ~= nil) then
                i   = i + 1
                goto continue
            end
            j   = j + 1
            n   = i + j
            if (n > #list) then
                break
            end
            if list[n] then
                list[i] = list[n]
                list[n] = nil
            end
            ::continue::
        end
    end

    do
        local insert, remove    = table.insert, table.remove

        ---@param obj callable
        ---@param initDisabled? boolean
        ---@return Subscriber
        function eventListener:register(obj, initDisabled)
            if (not isCallable(obj)) or
               (config.BLACKLIST[obj]) then
                return nullSubscriber
            end
            local newSub        = setmetatable({
                _callable       = obj,
                _ableCounter    = (initDisabled and 0) or 1,
                _depthCounter   = 0,
                _event          = self
            }, subscriber)
            insert(self._subList, newSub)
            self._size          = self._size + 1
            return newSub
        end

        ---@param subscribed Subscriber
        ---@param allowSparse? boolean
        function eventListener:unregister(subscribed, allowSparse)
            if ((not subscribed) or (subscribed == nullSubscriber)) or
               (subscribed._event ~= self) then
                return
            end

            --- Locate the subscribed object's index and remove
            --- it from the subscriber list.
            for i = 1, self._size do
                if (self._subList[i] == subscribed) then
                    remove(self._subList, i)
                    self._size              = self._size - 1
                    if allowSparse then
                        self._subList[i]    = nil
                        goto terminate
                    end
                    densifyList(self._subList, self._size, i)
                    ::terminate::
                    break
                end
            end

            -- Not necessary, but I like to clear all relevant data.
            do
                subscribed._callable     = nil
                subscribed._ableCounter  = nil
                subscribed._depthCounter = nil
                subscribed._event        = nil
            end
            do
                local mt                = subscriber.__metatable
                subscriber.__metatable  = nil
                setmetatable(subscribed, nil)
                subscriber.__metatable  = mt
            end
        end

        function subscriber:unsub()
            if (not self) or (self == nullSubscriber) then
                return
            end
            self._event:unregister(self)
        end

        function subscriber:isNonHalting()
            if (not self) or (self == nullSubscriber) then
                return true
            end
            local maxDepth  = config.SANITY_MAX_RECURSION
            if (self._event._maxDepth > 0) and
               (self._event._maxDepth <= maxDepth) then
                maxDepth    = self._event._maxDepth
            end
            return self._depthCounter >= maxDepth
        end

        function subscriber:isEnabled()
            if (not self) or (self == nullSubscriber) then
                return false
            end
            return self._ableCounter > 0
        end

        function subscriber:run(...)
            if (not self:isEnabled()) or (self:isNonHalting()) then
                return
            end
            ---@diagnostic disable-next-line: param-type-mismatch
            try(self._callable, ...)
        end

        function subscriber:enable(flag)
            if (not self) or (self == nullSubscriber)then
                return self
            end
            local inc           = ((flag) and 1) or -1
            self._ableCounter   = self._ableCounter + inc
            return self
        end

        function subscriber:forceEnable(flag)
            if (not self) or (self == nullSubscriber)then
                return self
            end
            local inc           = ((flag) and 1) or 0
            self._ableCounter   = inc
            return self
        end
    end

    function eventListener:clear()
        while (#self > 0) do
            self:unregister(self._subList[#self], true)
        end
        return self
    end

    -- Destroys the instance.
    function eventListener:destroy()
        self:clear()
        do
            self._subList   = nil
            self._curDepth  = nil
            self._curIndex  = nil
            self._curFun    = nil
            self._maxDepth  = nil
            self._size      = nil
        end
        do
            local mt                    = eventListener.__metatable
            eventListener.__metatable   = nil
            setmetatable(self, nil)
            eventListener.__metatable   = mt
        end
    end

    -- Invokes all Subscribers using the supplied arguments as the input.
    -- Input arguments do not change.
    ---@vararg any
    ---@return EventListener
    function eventListener:run(...)
        if (not self._curDepth) then
            return self
        end
        self._curDepth                  = self._curDepth + 1
        local prevIndex, prevFun        = self._curIndex, self._curFun
        if (#self < 1) then
            goto endpoint
        end

        for i = 1, #self do
            self._curIndex              = i
            self._curFun                = self._subList[self._curIndex]
            self._curFun._depthCounter  = self._curFun._depthCounter + 1

            if config.APPEND_INDEX then
                self._curFun:run(i, ...)
            else
                self._curFun:run(...)
            end

            if (not self._subList) then
                -- In case the object was actually destroyed mid-run.
                break
            end
            if (self._curFun == nullSubscriber) then
                -- In case the object might be set to nullSubscriber.
                goto continue
            end
            self._curFun._depthCounter  = self._curFun._depthCounter - 1
            ::continue::
        end

        ::endpoint::
        if (not self._curDepth) then
            goto final_outcome
        end
        self._curDepth                  = self._curDepth - 1
        self._curIndex, self._curFun    = prevIndex, prevFun
        ::final_outcome::
        return self
    end

    function eventListener:getMaxDepth()
        return self._maxDepth or config.MAX_RECURSION
    end

    function eventListener:setMaxDepth(new)
        self._maxDepth  = new
        return self
    end

    function eventListener:getCurrentSubscriber()
        return self._curFun
    end

    function eventListener:getCurrentIndex()
        return self._curIndex
    end

    function eventListener:getCurrentDepth()
        return self._curDepth
    end

    function eventListener:peekSubscriber(index)
        index   = index or 1
        index   = (((index > #self) or (index < 1)) and #self) or index
        return self._subList[index]
    end

    --- Swaps the order of two Subscribers.
    ---@param subOne Subscriber
    ---@param subTwo Subscriber
    ---@return EventListener
    function eventListener:swap(subOne, subTwo)
        if (subOne._event ~= self) or (subTwo._event ~= self) then
            return self
        end
        local i, j = 1, 1
        while (i <= #self) and (self._subList[i] ~= subOne) do
            i   = i + 1
        end
        while (j <= #self) and (self._subList[j] ~= subTwo) do
            j   = j + 1
        end
        self._subList[i]    = subTwo
        self._subList[j]    = subOne
        return self
    end

    --- Swaps the order of two Subscribers based on the indices.
    ---@param i integer
    ---@param j integer
    ---@return EventListener
    function eventListener:swapByIndex(i, j)
        if (i == j) or (not self._subList) then
            return self
        end
        local temp          = self._subList[i]
        self._subList[i]    = self._subList[j]
        self._subList[j]    = temp
        return self
    end
end
if Debug and Debug.endFile then Debug.endFile() end

A more detailed breakdown on what each method does is provided below.

Event Listener

Event Subscriber


  • static method EventListener.create()
    • Returns a new Event Listener object.
  • method EventListener:register(function callback, boolean? initiallyDisabled)
    • Creates and returns a new Event Subscriber object.
    • Depending on the value of the second parameter, the Event Subscriber object may be disabled from the start.
      By default, the Event Subscriber object is enabled.
    • The second parameter is optional.
  • method EventListener:unregister(EventSubscriber object)
    • Unsubscribes the queried object from the Event Listener and automatically destroys it.
    • Note: This does not work when the queried object does not belong to the querying
      Event Listener object.
    • Warning: It isn't recommended to unregister an Event Subscriber while the Event Listener object
      is running.
  • method EventListener:run(...)
    • Notifies all registered Event Subscriber objects of the incoming callback request with the
      arguments passed to the method being provided to the subscribers.
    • Will not run certain Event Subscriberobjects under the following conditions:
      • The Event Subscriber has reached / exceeded the depth threshold of the Event Listener object.
        This implies that the Event Subscriber may be trapped in an infinite loop, or that the depth
        was not deep enough to lead to such an outcome (which should almost never be the case).
      • The Event Subscriber is disabled.
  • method EventListener:destroy()
    • Destroys the Event Listener object.
      • Warning: DO NOT call this method while the Event Listener object is still running.
        While an Event Listener object is running, it is to be treated as an immutable object
        and should not be modified by any means, unless the user knows what they are
        doing.
  • method EventListener:getMaxDepth()
    • Returns the max depth an Event Listener object can reach while recursively triggering itself.
  • method EventListener:setMaxDepth(integer new)
    • Sets the max depth of an Event Listener object.
  • method EventListener:getCurrentSubscriber()
    • When invoked within a callback request, this returns the current Event Subscriber object.
  • method EventListener:getCurrentIndex()
    • When invoked within a callback request, this returns the index of the running Event Subscriber object.
  • method EventListener:getCurrentDepth()
    • When invoked within a callback request, this returns the current depth of the running Event Subscriber object.
    • Usually, this value would be 1 or below. However, if you see it go beyond a reasonable depth (3 for example), you
      can guess that something must have caused it to recursively trigger the Event Subscriber object, if not do so
      directly.
  • EventListener:peekSubscriber(integer index)
    • Returns an Event Subscriber object mapped at that index.
    • Warning: This can return nil for index values greater than the size of the list.

  • method EventSubscriber:unsub()
    • Internally calls EventListener:unregister() by providing the Event Listener object it is mapped to as the
      first argument to the method above.
  • method EventSubscriber:isNonHalting()
    • Returns true if the Event Subscriber meets the criteria for triggering an infinite loop.
  • method EventSubscriber:isEnabled()
    • Should be self-explanatory.
  • method EventSubscriber:enable(boolean flag)
    • Enables or disables an Event Subscriber. If called multiple times in order to enable/disable it,
      the Event Subscriber must be called the same amount of times in order to disable/enable it.

    • Runs on counter-based logic. If the enabled counter value is greater than 0, it is enabled.
      Otherwise, it is disabled.

    • Can be chain-called.
  • method EventSubscriber:forceEnable(boolean flag)
    • Enables or disables an Event Subscriber. Use this if you have called EventSubscriber:enable
      too many times (e.g. 200 times).

    • Directly sets the counter value to 1 (if flag is true) or 0.

    • Can be chain-called.
  • method EventSubscriber:run(...)
    • Runs the registered callback function of the Event Subscriber object.
    • If the Event Subscriber object is non-halting or is not enabled, this
      does nothing.


  • AGD for providing suggestions for additional features in the package.
  • Wrda for providing insights on the presentability of the package.

  • v.1.0.0.0
    • Release
  • v.1.0.1.0
    • Made the system extensible.
      • Now features an addon script that allows one to get a function based on index, as well as swap the positions of certain functions.
  • v.1.0.2.0
    • Added a flag (IGNORE_INDEX) that tells the system to introduce the index of the currently executing function as the first argument.
      • Note: This flag cannot change the system's behavior at runtime. It must be declared during compile-time.
        If true, the system will append the index of the currently executing function as the first argument.
        If false, the system will behave just like the previous version.
        Credits to AGD for the index suggestion.
      • Fixed a potential bug with is_function where a table "function" might crash the thread because its metatable is inaccessible or does not exist.
        If a metatable exists, the function is called recursively to the table's __call metamethod, returning the flag status of the metamethod.
        If a metatable does not exist, the system returns false.
  • v.1.0.3.0
    • Rewrote the system while keeping backwards-compatibility as much as possible. There are some quirks, though:
      • EventListener:register no longer accepts tables whose metatables have a __call metamethod.
      • EventListener:deregister no longer accepts tables for the same reason.
    • Added Emmy Notation to further express the intent of public functions, as well as their implementations.
    • A link to the the repository can be found here: wc3/Lua/Events/Base at master · gitMyPad/wc3
  • v.1.0.3.1
    • Added a legacy flag to the system
    • Documented the previously undocumented functions / methods.
    • Added annotations to these functions / methods.
  • v.1.1.0.0
    • Release (Spells and Systems).
      • Major rewrite to the original system (which can be found here.)
      • EventListener:deregister renamed to EventListener:unregister.
      • EventListener:getCurDepth renamed to EventListener:getCurrentDepth.
      • EventListener:getCurCallback renamed to EventListener:getCurrentSubscriber().
        • Return value type changed from function to Event Subscriber.
      • EventListener:getCallbackDepth renamed to EventListener:getCurrentDepth.
      • EventListener:cond_run removed.
      • Legacy flag removed from the system.
        • Similarly, all legacy methods have been removed as well.


Author's Notes:


I didn't really feel like porting this over to the Spells and Systems section from the Code section (Lua Resources) at the time
the Spells and Systems section "merged" with the Code section, given that there are other excellent systems similar to this one
and my growing disinterest in modding Warcraft 3.

Then, I felt like posting this, so I crafted a test map that will showcase the capabilities of this package.
Unfortunately, this test map will only run on Reforged, but the script should work for versions as early as 1.31.
Previews
Contents

EventListener Test (Map)

Reviews
Antares
Moved resource. Approved
Moved resource.

Approved as one of multiple options of processing events in Lua. Having GUI variable event support would be nice, so I might add 3 separate flavors to my own event library (strictly my version, then a flavor that adds GUI support to yours, then another that adds GUI support to @ScorpioT1000 's eventDispatcher).

Approved
 
Top