Skip to main content

CombatController

c.combat reads and drives the current duel. Most plugins should not use the casting methods — combat decisions belong in a libstrat strategy, which is validated at load time and easier to reason about than imperative Lua.

Use this controller for reading combat state, and for the rare case where a fight needs scripted behaviour a strategy cannot express.

Reading

c.combat:GetSnapshot() --> CombatSnapshot|nil
c.combat:GetHand() --> Card[]
c.combat:GetAllies() --> Participant[]
c.combat:GetEnemies() --> Participant[]
c.combat:IsPlanning() --> boolean
c.combat:GetRound() --> number

GetSnapshot() returns nil when not in combat, which is the cheapest way to ask "is there a duel and what does it look like". It also returns nil when the read fails, and the other readers degrade the same way: GetHand, GetAllies, and GetEnemies return an empty table, GetRound returns 0, IsPlanning returns false. None of them has an error return, so "not in a duel" and "could not read the duel" are indistinguishable here.

CombatSnapshot

FieldType
duel_idnumber
roundnumber
phaseplanning | execution | idle | string
planning_timernumber
is_pvpboolean
is_battlegroundboolean
is_raidboolean
participantsParticipant[]

Participant

FieldType
owner_id, template_idnumber
namestring
is_playerboolean
team_idnumber
health, max_healthnumber
pips, power_pips, shadow_pipsnumber
schoolstring
mob_levelnumber
is_boss, is_stunned, is_dead, is_minionboolean
subcirclenumber

Methods: p:IsAlive() (just not is_dead), p:HealthPct() (0–100, and 0 when max_health is 0 rather than a division error).

GetAllies() and GetEnemies() split participants by team relative to your client. Neither filters the dead — check is_dead or IsAlive() yourself.

"Your team" is the team of the first participant that is a player and not a minion. If no such participant can be found — which happens on a partial read — team 0 is assumed, and the two lists can come back the wrong way round. Sanity-check with is_player before acting on a split you did not expect.

is_boss comes from the duel participant and is not reliable on its own; the dependable boss marker lives on the NPC template, which this snapshot does not expose.

Card

FieldType
indexnumber — position in hand, 0-based
name, display_namestring
schoolstring
pip_cost, shadow_pip_costnumber
accuracy, damagenumber
is_castableboolean
is_enchanted, is_treasure, is_aoeboolean
spell_typedamage | heal | buff | debuff | charm | ward | aura | enchant | other | unknown
target_typeself | aoe | enemy | ally | unknown

Methods: card:CanCast(), card:IsDamage(), card:IsHeal(), card:IsAOE(). They are thin readers of is_castable, spell_type == 'damage', spell_type == 'heal', and is_aoe — use whichever form reads better, they cannot disagree.

The list position and card.index are not the same number

GetHand() is a Lua array, so hand[1] is the first card. That card's own index field is 0, because it is the game's index into the hand.

CastSpell(n) takes the game's index, so CastSpell(hand[1].index) is right and CastSpell(1) casts the second card. Pass card.index — never a loop counter.

is_castable accounts for pips available right now. A card you cannot afford this round is still in hand with is_castable == false.

A card whose target_type could not be read is reported as unknown, and unknown is treated as a non-enemy spell when casting: the target index is ignored.

Acting

c.combat:CastSpell(selector) --> ok, err
c.combat:Pass() --> ok, err
c.combat:Flee() --> ok, err

CastSpell

Three call shapes:

c.combat:CastSpell(0) -- card index (0-based), no target
c.combat:CastSpell(0, 1) -- card index, target index
c.combat:CastSpell('Tempest') -- by name
c.combat:CastSpell({ name = 'Tempest', target = 1, require_castable = true })

The selector table accepts a lot of aliases so that snippets copied from different places keep working:

PurposeAccepted keys
Card indexindex, card_index, card
Namename, spell_name
Display namedisplay_name, spell_display_name
Template idtemplate_id, spell_template_id
Name match modematch, spell_matchexact (default) or contains
Targettarget, target_index, enemy, enemy_index
Castable filterrequire_castable, castable — defaults to true
Enchantenchant, enchant_match
Filtersschool, type/spell_type, enchanted, treasure/treasure_card/is_treasure

Where the defaults bite:

  • match is exact. CastSpell('Tempest') will not find "Tempest" if the card's real name is Tempest_Rank5. Pass { name = 'Tempest', match = 'contains' } when you are matching on a fragment.
  • require_castable is on. A card you cannot afford is skipped rather than attempted. Set castable = false only if you want the selection to ignore pip cost.
  • No target means no target. Without target, an enemy spell is cast on the game's own default rather than on a chosen enemy.
  • enchant_match defaults to whatever match is, so setting match = 'contains' loosens the enchant lookup too unless you set enchant_match explicitly.
An index selector overrides everything else

When the selector carries an index, it is used alone: the name, school, type, and require_castable filters are all skipped and the card at that index is chosen. { index = 2, name = 'Tempest' } does not mean "Tempest, expected at 2"; it means "card 2", whatever is sitting there. Pick one or the other.

The cast itself still checks the card — an unaffordable one fails with card at index 2 is not castable — so skipping the filter moves the failure from selection time to cast time rather than removing it.

Target indexes are 0-based positions in GetEnemies(). An index past the end silently falls back to the first enemy, and self, AOE, and ally spells ignore the target entirely.

enchant takes a card index, a name, a boolean, or a selector table of its own:

c.combat:CastSpell({
name = 'Glowbug Squall',
enchant = { name = 'Epic' },
target = 1,
})

enchant = true takes the first enchant-type card in hand without saying which; enchant = false is the default "do not enchant". A named or indexed enchant implies true. If the chosen spell cannot be enchanted, or no matching enchant is in hand, the whole cast fails — select enchant: enchant card not found — rather than casting the spell bare.

Pass and Flee

c.combat:Pass() --> ok, err
c.combat:Flee() --> ok, err
ok can mean "a key was sent"

When the combat bridge is unavailable, both fall back to sending a keystroke instead — p for pass, escape for flee — and report true as soon as the key goes out, whether or not the game acted on it.

That fallback is what keeps a plugin working on a host with no bridge, but it means ok is not confirmation. Verify with GetRound() or IsPlanning() on a later tick if the outcome matters.

Prefer a strategy

An imperative cast is a decision made with the information available at that instant, with no validation and no fallback. A libstrat phase is a prioritised, load-time-validated rule that picks a card each round with the whole hand in view.

If you find yourself writing an if-chain over GetHand(), you are writing a strategy in the wrong language. See libstrat actions.

Reacting to rounds

plugin.on_combat_round = function(args)
-- fires each round while a duel is active
end

Useful for logging or for a plugin that needs to do something out-of-band each round — watching a timer, tracking a counter. It is not a place to make casting decisions; by the time your Lua runs the strategy runner has usually already planned.

Combat lifecycle events

plugin.on_combat_enter = function(data) end
plugin.on_combat_exit = function(data) end

Both receive { client_id = ... }. Guard on it if you run more than one client.

Pair the events with a poll of c:IsInCombat() in your tick loop. Events are prompt but can be missed across a hook rebind; the poll is up to 250ms late but never misses. Having both, with a state.phase guard so the second is a no-op, is the pattern the shipped plugins use.