Expressions
expr: is a small comparison language for conditions the fixed fields cannot express —
comparing participants, aggregating over groups, reading boolean flags, and combining with
|| and !.
when:
expr: "self.health < 50% && avg(enemies).health > 30%"
Everything is parsed and validated when the file loads. A malformed expression, an unknown
attribute, or a misplaced % fails strategies validate with a byte offset into the string —
you never discover an expression typo mid-duel.
Grammar
expr := or
or := and ( "||" and )*
and := unary ( "&&" unary )*
unary := "!" unary | primary
primary := "(" expr ")" | clause
clause := target "." attr op value
target := "self" | "boss"
| "enemy" [ "(" index ")" ]
| "ally" [ "(" index ")" ]
| ("any"|"all"|"avg") "(" ("enemies"|"allies") ")"
| "p1" | "p2" | ...
attr := identifier
op := "<=" | ">=" | "!=" | "==" | "<" | ">"
index := digit+
value := digit+ [ "." digit+ ] [ "%" ]
Precedence, loosest to tightest: ||, then &&, then !. Both binary operators are
left-associative, so a && b && c groups as (a && b) && c. Parentheses override.
expr: "!(boss.is_dead == 1) && (boss.health < 20% || any(enemies).health < 10%)"
A clause is the smallest unit. There is nothing else in the language: no arithmetic, no string
literals, no comparing one participant against another, no naked booleans. self.is_dead on its
own is a parse error — write self.is_dead == 0.
Things the lexer will reject outright, each with the byte offset:
| Written | Error |
|---|---|
self.health > -1 | unexpected character "-" — there is no unary minus, and no negative literals |
self.health = 50 | single = is not an operator, use == |
a & b | single & is not an operator, use && |
a | b | single | is not an operator, use || |
self.health < 1.2.3 | number has more than one decimal point |
self.health < 50 followed by anything | unexpected <token> after a complete expression |
Whitespace is free. Attribute and target names are ASCII letters, digits, and underscores, and
may not start with a digit. Comparisons are numeric — booleans are compared as 1 and 0.
Targets
| Target | Resolves to |
|---|---|
self | The client's own participant |
boss | The first live boss, or nothing if there is none |
enemy, enemy(N) | The Nth enemy, 0-based. enemy is enemy(0) |
ally, ally(N) | The Nth entry of the ally list, 0-based |
any/all/avg(enemies) | Every enemy |
any/all/avg(allies) | Self, followed by the whole ally list |
p1, p2, … | The participant owned by that client alias |
"Allies" is every participant on your team, and you are on your own team. So:
ally(0)is the first participant on your team in duel order, which on a solo character is you, and in a group is whoever the game happens to list first — not reliably a teammate.any(allies),all(allies), andavg(allies)prepend self to a list that already contains self, so you are counted twice.avg(allies).healthis biased toward your own health, and in a solo fight it is simply your health.
Do not build logic on ally indices. Use self for yourself, a p1/p2 alias for a specific
teammate (console only), or the fixed-field any_ally_health_below when "somebody on my team"
is genuinely what you mean.
p1, p2, … only resolve on the consoleAlias targets come from a live client table that the desktop app's trainer never fills in.
There, p1.health < 50% resolves to no participant and the clause is always false. See
which host is running it.
Group targets require an aggregation and singular targets reject one. Both of these are load errors:
expr: "enemies.health < 10" # group without aggregation
expr: "any(self).health < 10" # aggregation over a singular target
boss and self take no index either — boss(0).health < 50 is a load error.
An index out of range makes the clause false, not an error. enemy(5).health < 50 in a
two-enemy fight is simply false. So is any clause whose target resolves to nobody: no boss in
the fight makes every boss. clause false, including boss.health > 0.
Aggregation
| Form | Meaning |
|---|---|
any(...) | True when some readable member satisfies the comparison |
all(...) | True when every member is readable and satisfies it |
avg(...) | Mean of the readable members, compared once |
avg skips unreadable members rather than letting them poison the mean. all treats an
unreadable member as a failure.
allall(enemies).health < 50% with no enemies is false, which is the opposite of what a bare
Go loop or a mathematician would give you. This matches Deimos.
If you want "no enemies", say enemies_at_most: 0 with the fixed fields.
Dead participants are not filtered out of any group. Filter explicitly:
expr: "all(enemies).is_dead == 0"
This is the biggest behavioural gap between expr: and the fixed-field conditions —
enemies_at_least and friends filter by liveness, expr: does not. Both forms can appear in
one file, so be deliberate about which you use.
Attributes
Hanging-effect counts
Checked first, so they shadow any same-named stat. Each category has two spellings and three dispositions — 24 names total:
| Category | Spellings | Prefixes |
|---|---|---|
| Charms | charms, charm | none, beneficial_, harmful_ |
| Wards | wards, ward | none, beneficial_, harmful_ |
| Over time | over_time, ot | none, beneficial_, harmful_ |
| Auras | auras, aura | none, beneficial_, harmful_ |
So beneficial_charms, harmful_ot, wards, aura are all valid.
expr: "self.beneficial_charms >= 2"
expr: "boss.harmful_wards == 0"
The engine caps auras at one per side, while a multi-effect aura exposes one list entry per
sub-effect. Counts are therefore clamped, which makes avg(allies).auras > 1 unsatisfiable by
construction.
Participant stats
| Attribute | Reads |
|---|---|
health, max_health | Current and maximum health |
mana, max_mana | Current and maximum mana |
level | Reference level, from the wizard's stat block |
normal_pips, power_pips, shadow_pips | Pip counts by kind |
total_pips | All pips together |
team_id | Which side of the duel the participant is on |
owner_id, template_id | Identity numbers, useful only for == checks |
is_dead, is_player, is_minion, is_stunned | Booleans, as 1 or 0 |
is_boss | The participant's own boss flag, as 1 or 0 |
is_monster | A raw number, not a clean 1/0 |
Booleans read as 1 and 0:
expr: "boss.is_dead == 0"
expr: "ally(0).is_minion == 1"
Three of these need care:
is_boss is the unreliable one. It reads the participant's own boss flag, which plenty of
real bosses do not set. The boss target and the fixed-field boss_present do something
better — they read the NPC's behaviour template and only fall back to this flag when the
template is unreadable. Prefer boss_present: true or a boss. clause over
any(enemies).is_boss == 1.
is_monster is not a boolean. It is read as a raw unsigned number, so == 1 is a guess.
Use is_player == 0 if you mean "not a wizard".
level comes from the wizard stat block. Monsters may not expose one; when the read fails
the clause is false, not zero.
Any attribute that cannot be read makes its clause false. There is no error and no default —
an expression that never fires may be reading something that is not there, and
enable dbg is the way to tell.
Percentages
A trailing % rescales the left side to 0–100 by dividing by that attribute's max_
counterpart. The literal on the right is never scaled.
expr: "self.health < 50%" # health / max_health, compared against 50
expr: "self.health < 500" # raw health, compared against 500
Only health and mana have a max_ counterpart, so only they accept %. Anything else is
a load error — including hanging counts:
expr: "self.level < 50%" # load error
expr: "self.charms > 2%" # load error
Divergences from Deimos
| Behaviour | Deimos | libstrat |
|---|---|---|
Unknown attribute (self.helth) | Parses, evaluates false forever | Load error |
% without a max_ counterpart | Silently false | Load error |
% on a hanging count | Silently ignored | Load error |
|| and ! | Absent, && only | Supported, with precedence |
| Nested parentheses | Absent | Supported |
p1..pN targets | Absent | Supported |
all() over an empty group | False | False — matching |
| Dead participants in groups | Not filtered | Not filtered — matching |
The last two rows are listed because they are surprising, not because they differ.
The pattern in the first three: where Deimos silently evaluates false forever, libstrat fails at load. A typo that makes a phase never fire is much harder to notice than a file that refuses to load.
Worked examples
Focus fire when the boss is nearly dead:
when:
expr: "boss.health < 15% && self.total_pips >= 4"
AOE only when it is worth it:
when:
all:
- enemies_at_least: 3
- expr: "avg(enemies).health > 25%"
Heal when anyone on the team — including you — is hurting:
when:
expr: "any(allies).health < 40%"
Wait for a full board before committing a big hit:
when:
expr: "self.beneficial_charms >= 2 && boss.harmful_wards >= 1"
Do not blow the nuke on a nearly-dead group:
when:
expr: "!(all(enemies).health < 20%)"