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
| Field | Type |
|---|---|
duel_id | number |
round | number |
phase | planning | execution | idle | string |
planning_timer | number |
is_pvp | boolean |
is_battleground | boolean |
is_raid | boolean |
participants | Participant[] |
Participant
| Field | Type |
|---|---|
owner_id, template_id | number |
name | string |
is_player | boolean |
team_id | number |
health, max_health | number |
pips, power_pips, shadow_pips | number |
school | string |
mob_level | number |
is_boss, is_stunned, is_dead, is_minion | boolean |
subcircle | number |
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
| Field | Type |
|---|---|
index | number — position in hand, 0-based |
name, display_name | string |
school | string |
pip_cost, shadow_pip_cost | number |
accuracy, damage | number |
is_castable | boolean |
is_enchanted, is_treasure, is_aoe | boolean |
spell_type | damage | heal | buff | debuff | charm | ward | aura | enchant | other | unknown |
target_type | self | 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.
card.index are not the same numberGetHand() 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:
| Purpose | Accepted keys |
|---|---|
| Card index | index, card_index, card |
| Name | name, spell_name |
| Display name | display_name, spell_display_name |
| Template id | template_id, spell_template_id |
| Name match mode | match, spell_match — exact (default) or contains |
| Target | target, target_index, enemy, enemy_index |
| Castable filter | require_castable, castable — defaults to true |
| Enchant | enchant, enchant_match |
| Filters | school, type/spell_type, enchanted, treasure/treasure_card/is_treasure |
Where the defaults bite:
matchisexact.CastSpell('Tempest')will not find "Tempest" if the card's real name isTempest_Rank5. Pass{ name = 'Tempest', match = 'contains' }when you are matching on a fragment.require_castableis on. A card you cannot afford is skipped rather than attempted. Setcastable = falseonly 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_matchdefaults to whatevermatchis, so settingmatch = 'contains'loosens the enchant lookup too unless you setenchant_matchexplicitly.
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.
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.