Handing combat to libstrat
Your plugin decides what to fight. libstrat decides how to fight it. The automation
global is the seam between them.
The division is not a style preference. A duel is a turn-based negotiation with the server; issuing movement into one leaves the client physically somewhere the duel is not, and there is no clean recovery. Once combat starts, your plugin's job is to do nothing until it ends.
The contract
local ready, status, err = automation:EnsureCombat({
client_id = c:GetID(),
strategy = 'storm_cp',
owner = plugin.name,
})
EnsureCombat is idempotent. It reads the current status and only enables combat if it is not
already running. Call it every tick; it costs a status read when things are already fine.
Three return values, in that order:
| Return | Meaning |
|---|---|
ready | Whether combat automation is now enabled |
status | The full status table (see below), or nil if the status read failed |
err | An error string, or nil |
Read the owner off it:
if status and status.owner ~= plugin.name then
-- someone else is driving combat
end
strategyThe two hosts default to different strategies when you omit it: the console falls back to
aoe, the desktop app to priority. A plugin that relies on the default fights differently
depending on where it runs.
Ownership
owner is how two plugins avoid fighting over the combat runner. Set it to plugin.name and
you can tell whether the running automation is yours:
local function ensure_combat(c)
if automation == nil then return true end
local ready, status, err = automation:EnsureCombat({
client_id = c:GetID(),
strategy = COMBAT_STRATEGY,
owner = plugin.name,
})
if not ready then
log_throttled('combat_enable', 'combat automation failed: ' .. tostring(err))
return false
end
if status and status.strategy ~= COMBAT_STRATEGY then
utils.LogOnce('combat_strategy',
'fighting with ' .. tostring(status.strategy) ..
' held by ' .. tostring(status.owner) .. ', not ' .. COMBAT_STRATEGY ..
'; disable combat automation to switch')
end
return true
end
Note what this does when someone else owns combat with a different strategy: it logs once and
carries on. It does not seize the runner. Stomping on another owner's automation mid-duel is
worse than fighting with the wrong strategy, and the operator can resolve it directly — from
the console, disable combat; from the desktop app, stopping whichever plugin or action
started it.
The hosts enforce that for you, in slightly different ways:
| Console | Desktop app | |
|---|---|---|
| Enabling while already enabled | Always a no-op, whoever asks | A no-op unless the owner matches, or you pass replace = true |
DisableCombat with a foreign owner | No-op (except owner = 'console', which always disables) | No-op |
DisableCombat with no owner | Disables whatever is running | Disables whatever is running |
| Default owner | console | lua |
| Default strategy | aoe | priority |
| Scope | Session-wide; client_id is ignored when enabling | Per-client; client_id / client_ids are honoured |
Two consequences for portable plugins:
- Never call
DisableCombatwith no owner. It stops combat the operator set up by hand. replace = trueonly does anything in the desktop app. Do not build a design around being able to take the runner from another owner.
The automation == nil guard covers hosts that do not provide the module. EnsureCombat
returning true there means "nothing is stopping us", which lets a plugin run under a bare
scripting host without combat support.
Releasing it
Whatever you enabled, disable it on the way out:
local function release()
state.enabled = false
if automation ~= nil then
automation:DisableCombat({ owner = plugin.name })
end
end
plugin.on_stop = release
plugin.on_unload = release
Passing owner scopes the disable to automation you own, so stopping your plugin does not
switch off combat that the operator configured by hand.
Registering it on both on_stop and on_unload matters: reloading a plugin, and shutting the
host down, both call on_unload and not on_stop. A plugin that only cleans up in
on_stop leaves combat automation running every time it is reloaded.
Staying out of the way during a fight
Once combat starts, the strategy runner owns the client. Your tick loop should notice and do nothing:
local function tick()
if not state.enabled then return end
local c = ready_client()
if not c then return end
if not ensure_combat(c) then return end
if c:IsInCombat() then
if state.phase ~= 'fight' then
state.fights = state.fights + 1
state.phase = 'fight'
log('fight ' .. state.fights .. ' started')
end
return -- nothing else happens this tick
end
if state.phase == 'fight' then
state.phase = 'settle'
state.waited = 0
return
end
-- ... out-of-combat phases
end
Note that the combat check comes before every out-of-combat phase and returns unconditionally.
It is not enough to skip movement; skip everything. A SendKey or a ClickWindow aimed at the
overworld UI during a duel will land on whatever the duel UI has put in that spot.
Issuing a teleport during a duel does not cancel the duel — it moves your character while the fight continues, and the client ends up somewhere the duel circle is not. Recovering from that is much harder than not doing it.
Settling after a fight
Combat ending is not the same as being ready to act. The fight-to-settle transition exists to give the game time to close the duel UI, return control, and put the character somewhere stable:
local SETTLE_TICKS = utils.Ticks(2)
local function settle()
state.waited = state.waited + 1
if state.waited < SETTLE_TICKS then return end
state.waited = 0
state.phase = 'nudge'
end
Two seconds, then a nudge, then decide whether to recover or hunt. Skipping the settle produces teleports that get rubber-banded because the client had not finished the transition.
Dying does not put you back where you were. The client is relocated — often to a hub several
seconds later, sometimes a different world entirely — and a plugin gated on a fixed
ZONE constant will then refuse to do anything, including recover.
A fixed settle timer can also fire before the relocation starts, so the plugin decides it has
settled and then finds itself somewhere else on the next tick. If your bot farms anywhere it
can lose, re-read c:GetZone() after settling rather than assuming it is unchanged, and give
on_died (or a zone check) a path back.
Combat events versus polling
Both work, and the shipped plugins use both:
plugin.on_combat_enter = function(data)
if not state.enabled or not belongs_to_us(data) then return end
if state.phase ~= 'fight' then
state.fights = state.fights + 1
state.phase = 'fight'
end
end
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
combat_enter and combat_exit are among the events both hosts deliver, so this is portable —
unlike health_changed, which the console does not forward. See the
per-host event table.
Events are prompt. The IsInCombat() check in the tick loop is the backstop for a missed
event. Having both is not redundancy for its own sake — a plugin that only listens for events
gets permanently stuck if one is dropped during a hook rebind, and a plugin that only polls
reacts up to one tick late.
Both paths guard on state.phase so that whichever arrives first wins and the second is a
no-op. That guard is load-bearing: without it, a plugin that gets both the event and the poll
counts every fight twice.
Which combat state is "in combat"?
IsInCombat() is true when the client has a live duel pointer and the duel's phase is
not an ended phase. Both halves matter. There is a window at the end of a fight where the
pointer is still valid but the duel has ended; a check that only looked at the pointer would
call that "in combat", and a check that only looked at the phase would flap.
Getting this wrong once produced a loop that engaged and exited combat 27,000 times, at about
25,000 log lines a second. If you ever find yourself writing your own in-combat test out of
several sub-conditions, that is the failure mode to design against — and the reason to use
IsInCombat() rather than inspecting c.combat:GetSnapshot() yourself.
Picking the strategy
The strategy name is the file's name: field, not its filename — a storm_cp.yml whose
name: says storm is registered as storm. Two files with the same name: silently
overwrite each other, so keep them distinct.
The console lowercases the name you pass, so treat strategy names as case-insensitive there and match the file's casing anyway for portability.
Verify it loads before wiring it into a plugin. In the desktop app, open the Trainer's Build tab and start editing a combat action — your strategy should appear in that action's strategy list if the file loaded cleanly. From the console:
strategies
strategies validate
enable combat storm_cp
strategies validate reports per-file pass/fail. A strategy that loads but does nothing is
a different problem — see libstrat troubleshooting.
A plugin can also be a strategy: a plugin that declares hooks = { 'combat' } and an
on_combat_round function is registered under its own name and can be selected the same way.
See combat.
Checking status without changing it
local status, err = automation:CombatStatus({ client_id = c:GetID() })
if status and status.enabled then
-- ...
end
Useful for a plugin that wants to cooperate with whatever is already running rather than
configure it. Note the return order here is (status, err) — two values, status first — which
is the opposite shape to EnsureCombat's (ready, status, err).
The status table carries enabled, owner, strategy, status, client_ids, a per-client
clients array, updated_at, and generation. The per-client entries include in_combat
and last_engaged_at, which is how you would build a "have we actually fought in the last
five minutes" watchdog:
local STALE_MS = 5 * 60 * 1000
local function combat_is_stale(c)
local status = automation:CombatStatus({ client_id = c:GetID() })
if not status or not status.enabled then return false end
for _, entry in ipairs(status.clients or {}) do
if entry.client_id == c:GetID() then
local last = tonumber(entry.last_engaged_at) or 0
local now = tonumber(status.updated_at) or 0
return last > 0 and (now - last) > STALE_MS
end
end
return false
end
updated_at and last_engaged_at are Unix milliseconds. The console does not populate
last_engaged_at, so this particular watchdog is a desktop-app tool; under the console it
reads as 0 and the check stays false.