Skip to main content

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:

WrittenError
self.health > -1unexpected character "-" — there is no unary minus, and no negative literals
self.health = 50single = is not an operator, use ==
a & bsingle & is not an operator, use &&
a | bsingle | is not an operator, use ||
self.health < 1.2.3number has more than one decimal point
self.health < 50 followed by anythingunexpected <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

TargetResolves to
selfThe client's own participant
bossThe 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
The ally list contains you, and the group form adds you again

"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), and avg(allies) prepend self to a list that already contains self, so you are counted twice. avg(allies).health is 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 console

Alias 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

FormMeaning
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.

All three are false over an empty group — including all

all(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:

CategorySpellingsPrefixes
Charmscharms, charmnone, beneficial_, harmful_
Wardswards, wardnone, beneficial_, harmful_
Over timeover_time, otnone, beneficial_, harmful_
Aurasauras, auranone, beneficial_, harmful_

So beneficial_charms, harmful_ot, wards, aura are all valid.

expr: "self.beneficial_charms >= 2"
expr: "boss.harmful_wards == 0"
Aura counts are clamped to 0 or 1

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

AttributeReads
health, max_healthCurrent and maximum health
mana, max_manaCurrent and maximum mana
levelReference level, from the wizard's stat block
normal_pips, power_pips, shadow_pipsPip counts by kind
total_pipsAll pips together
team_idWhich side of the duel the participant is on
owner_id, template_idIdentity numbers, useful only for == checks
is_dead, is_player, is_minion, is_stunnedBooleans, as 1 or 0
is_bossThe participant's own boss flag, as 1 or 0
is_monsterA 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

BehaviourDeimoslibstrat
Unknown attribute (self.helth)Parses, evaluates false foreverLoad error
% without a max_ counterpartSilently falseLoad error
% on a hanging countSilently ignoredLoad error
|| and !Absent, && onlySupported, with precedence
Nested parenthesesAbsentSupported
p1..pN targetsAbsentSupported
all() over an empty groupFalseFalse — matching
Dead participants in groupsNot filteredNot 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%)"