Skip to main content

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:

ReturnMeaning
readyWhether combat automation is now enabled
statusThe full status table (see below), or nil if the status read failed
errAn error string, or nil
The second return is the status table, not an owner string

Read the owner off it:

if status and status.owner ~= plugin.name then
-- someone else is driving combat
end
Always pass an explicit strategy

The 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:

ConsoleDesktop app
Enabling while already enabledAlways a no-op, whoever asksA no-op unless the owner matches, or you pass replace = true
DisableCombat with a foreign ownerNo-op (except owner = 'console', which always disables)No-op
DisableCombat with no ownerDisables whatever is runningDisables whatever is running
Default ownerconsolelua
Default strategyaoepriority
ScopeSession-wide; client_id is ignored when enablingPer-client; client_id / client_ids are honoured

Two consequences for portable plugins:

  • Never call DisableCombat with no owner. It stops combat the operator set up by hand.
  • replace = true only 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.

Losing a fight moves you

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.