events
Two ways to subscribe: declare an on_* field on the plugin table, or register imperatively
through the events global. Both feed the same dispatcher.
events.On(event, callback) --> nothing
events.OnHotkey(key, callback) --> nothing
Neither returns anything — there is no handle and no way to unsubscribe. Both are literally
assignments: events.On('zone_change', fn) sets plugin.on_zone_change = fn, and
events.OnHotkey('f8', fn) sets plugin.on_hotkey_f8 = fn (the key is lowercased).
Because they are assignments, a second events.On('zone_change', ...) replaces the first,
and either one replaces a plugin.on_zone_change you declared at the top of the file. Nothing
warns you; the earlier callback simply stops running.
If two parts of your plugin care about the same event, register one handler that calls both.
This is also why drops:Listen and a hand-written
on_drop cannot coexist.
events.On accepts any string and creates plugin.on_<whatever>. A typo — zone_changed,
combat_start — is not an error, it is a handler that never fires. Copy names from the table
below.
Prefer the plugin table form for anything static — the loader discovers those automatically
and they show up in scripts. Use events.On when the subscription is conditional:
plugin.on_load = function()
reset()
if TRACK_ZONES then
events.On('zone_change', function(data)
log('moved from ' .. tostring(data.old_zone) .. ' to ' .. tostring(data.new_zone))
end)
end
end
Available events
Every payload carries client_id.
| Event | plugin field | Extra payload |
|---|---|---|
combat_enter | on_combat_enter | — |
combat_exit | on_combat_exit | — |
zone_change | on_zone_change | old_zone, new_zone |
dialog_open | on_dialog_open | dialog_type, mobile_id, quest_id, total_pages |
dialog_close | on_dialog_close | — |
loading_start | on_loading_start | — |
loading_end | on_loading_end | — |
dialog_page_changed | on_dialog_page_changed | current_page, total_pages |
died | on_died | — |
level_up | on_level_up | level, old_level |
health_changed | on_health_changed | current_health, max_health, old_health |
mana_changed | on_mana_changed | current_mana, max_mana, old_mana |
npc_range_entered | on_npc_range_entered | — |
npc_range_exited | on_npc_range_exited | — |
drop | on_drop | name, kind, quantity, time, seq, and when present raw_kind, zone, character, run_id, source — see Drops |
client_connected | on_client_connected | pid, window_title, connected, hooks_active |
client_disconnected | on_client_disconnected | pid |
Payload keys are exactly as listed. zone_change carries old_zone and new_zone — not
zone. dialog_open carries no dialog text; it identifies the dialog, and you read the text
from the UI with GetWindowText.
Optional keys are genuinely optional: a payload only carries a field the source could fill in.
data.old_zone can be nil on the first zone change after a client attaches, and every
drop field except the first five is conditional. Use tostring(data.x) in log lines and
data.x or default in logic.
The loader scans the plugin table for on_* functions twice: when the file loads, and again
immediately after on_load returns. So declaring plugin.on_zone_change at file scope or
inside on_load both work.
An assignment made any later than that — from a tick, from another event handler, from
execute — is not picked up, and the handler never fires. events.On registers the
subscription as well as the function, so it works at any point; that is why the conditional
form above uses it rather than a plain assignment.
Stat events carry the previous value alongside the current one, so you can see the delta without tracking it yourself:
plugin.on_health_changed = function(data)
local lost = data.old_health - data.current_health
if lost > 0 then
utils.Log(string.format('took %d damage (%d/%d)',
lost, data.current_health, data.max_health))
end
end
plugin.on_level_up = function(data)
utils.Log('level ' .. data.old_level .. ' -> ' .. data.level)
end
health_changed and mana_changed fire on every observed change, at the state watcher's
poll rate. During a fight that is a steady stream. Do the cheap check first and keep the
handler short — and never log unthrottled from one. If you only care about crossing a
threshold, compare against a stored flag rather than reacting to every tick:
plugin.on_health_changed = function(data)
local pct = (data.current_health / data.max_health) * 100
local low = pct < 35
if low ~= state.was_low then
state.was_low = low
if low then utils.Log('health critical') end
end
end
on_tick, on_load, on_stop, on_unload, execute, and on_combat_round are lifecycle
callbacks rather than events — they are not subscribable through events.On. See
Plugin lifecycle. on_tick, on_load, on_stop, and
on_unload are called with no arguments; only the event handlers receive a payload.
There is also a hooks list on the plugin table for subscribing to an event you have no
function for yet:
plugin.hooks = { 'zone_change', 'combat' }
Names are lowercased and prefixed with on_ if you leave the prefix off, so 'zone_change'
and 'on_zone_change' are the same subscription. 'combat' is the one alias that expands,
covering on_combat, on_combat_enter, and on_combat_exit. Declaring the on_* function
is enough on its own — the list is only needed when the handler is installed later.
Filtering by client
Events reach every plugin regardless of which client produced them.
local function belongs_to_us(data)
if data == nil or data.client_id == nil then return true end
local c = clients:First()
if not c then return false end
return tostring(data.client_id) == tostring(c:GetID())
end
plugin.on_combat_enter = function(data)
if not state.enabled or not belongs_to_us(data) then return end
state.phase = 'fight'
end
That version treats a payload with no client_id as "ours", which suits a single-client
plugin. If you actually drive several clients, invert the default and require an explicit
match — otherwise an unattributed event gets processed once per plugin instance.
Compare tostring on both sides. Ids arrive as strings but may be built from numeric sources
elsewhere, and "12" ~= 12 in Lua.
Events plus polling
Events can be missed — a hook rebind during a game patch, a client that reattaches. Polling never misses but is up to 250ms late. Non-trivial plugins do both:
-- event: prompt
plugin.on_combat_exit = function(data)
if not state.enabled or not belongs_to_us(data) then return end
if state.phase == 'fight' then
state.phase = 'settle'
state.waited = 0
end
end
-- poll: reliable
local function tick()
-- ...
if state.phase == 'fight' and not c:IsInCombat() then
state.phase = 'settle'
state.waited = 0
end
end
Both paths guard on state.phase, so whichever fires first wins and the other is a no-op.
That guard is what makes having both safe rather than double-counting.
Hotkeys
plugin.hotkeys = {
f8 = function() state.enabled = not state.enabled end,
f9 = function() reset() end,
}
events.OnHotkey('f10', function()
utils.Log('phase=' .. tostring(state.phase))
end)
Same mechanism — both end up as plugin.on_hotkey_<key>. Callbacks take no arguments.
On the console, a plugin hotkey must be a–z, 0–9, or f1–f12. Modifiers,
arrows, and combinations are not names it knows. An unrecognised name, or one the console has
already claimed for itself, is skipped silently — no error, the key simply does nothing.
Bindings are also claimed when the plugin file is loaded, so register hotkeys at file
scope (either form works there). An events.OnHotkey first reached inside on_load sets up
the handler but has no OS binding until the next scripts reload.
The desktop app dispatches hotkeys from its own UI instead, so that key list is a console constraint rather than a rule about the Lua API.
A pause toggle is worth adding to anything long-running. A status dump on another key is worth almost as much — when a bot is stuck you want to know which phase it is in without adding log lines and reloading.
Global hotkeys read /dev/input/event*. If they silently do nothing on the console, that is
usually a permissions problem rather than a code problem — run it with --sudo or add
yourself to the input group. This page has not verified whether the desktop app needs the
same on Linux; if its hotkeys are similarly silent, the same input group membership is worth
trying first.