Skip to main content

Troubleshooting

About the commands on this page

Commands shown in text blocks (status, strategies validate, combat dry-run, enable dbg, and similar) belong to the console, used here because they are the fastest way to poke at a strategy directly from a terminal. The desktop app has no command line — look to the trainer's own controls and its log output instead.

Evaluation inside a round — conditions, priorities, action ordering, expressions, enchant policy — is identical on both hosts. Everything around it is not: client scoping, alias targeting, default strategy names, and the debug trace all differ. Where that matters below, it is called out; the full comparison is in Getting started.

Strategy does not appear at all

Loading a strategy directory is all-or-nothing: if any file in it fails to parse or validate, none of the directory's strategies load, and the whole batch is skipped in favor of whatever was loaded before (or nothing, if this is the first load).

The two hosts react differently to that failure, and this is deliberate. At console startup it is fatal — one bad file in the strategy folder stops the console from starting at all, with the parse error printed to the terminal. That applies whether the folder came from --strategy or was picked up automatically from ~/.kebab/strategies, so a broken file there will stop a plain console from starting. strategies reload surfaces the same error in the output pane instead of updating anything. The desktop app instead logs a warning and keeps running with no strategies available until the bad file is fixed: an operator at a terminal is better served by failing loudly, while the app has plenty to do that does not involve combat.

From the console:

strategies validate

That re-parses every file in the strategy directory independently and prints per-file pass/fail with the reason, so one broken file does not hide the rest. Run it after every edit, and before pointing --strategy at a folder for the first time.

If the file still does not appear, check:

  1. It is in ~/.kebab/strategies — or, if the console was started with --strategy pointing somewhere else on purpose, that it is in that folder instead.
  2. The extension is .yaml or .yml. Anything else is skipped silently.
  3. The file is directly in that directory. Subdirectories are not searched.
  4. It has a phases: section with at least one phase, and every phase has actions:.
  5. No other file claims the same name:.

Another file stole the name

This is the one that looks like the file vanished. Strategies are stored in a table keyed by name:, not by filename, so two files with the same name: are one strategy and the second to load replaces the first. Nothing warns you.

strategies validate prints each file's path alongside the name it resolved to. Two rows with the same name means one of those files is dead:

strategies validate

Directory order is alphabetical by filename, so the later filename wins. The console also matches names case-insensitively, which means Storm and storm collide there too.

The name is reserved

pass and flee are built-in strategies on both hosts, aoe is one on the console, and priority and configurable are built-ins in the desktop app. On the console the built-in wins and your file is unreachable; in the desktop app your file replaces the built-in. Rename it.

It appeared, but under a different name

A file with a members: block registers in the desktop app as <strategy>/<member> — one entry per member — not under its plain name:. On the console it keeps its plain name and starts all members together. See Teams.

Strategy loads but does nothing

The most common report, and it has several distinct causes.

The client alias does not match

clients: [p2]

If only p1 is hooked, this strategy applies to nothing. Clients not listed are left completely alone — not passed, not engaged. Check status for the aliases actually assigned, or combat dry-run <name>, which prints the target preview without starting anything.

Remove clients: entirely to apply to everything. Note that this only bites on the console — the desktop app ignores clients:.

You are on the desktop app and the strategy uses p1/p2

The single most common "it worked on the other machine". Client aliases resolve through a table that only the console populates, so in the desktop app:

  • every action with target: p1 is skipped, every round;
  • a targets: { default_ally: p2 } block skips every ally-targeting action;
  • has_blade: { target: p1 } is always false, so no_blade: { target: p1 } is always true and its guarded action fires every round instead of once;
  • expr: "p1.health < 50%" is always false.

Nothing is logged. Rewrite with self, lowest_health_ally, or per-member targets defaults, or run the strategy on the console. Full comparison: which host is running it.

You are stunned

The runner passes the round before evaluating anything when you are stunned. That also means a phase or guard written as is_stunned: true can never fire, and expr: "self.is_stunned == 1" is always false.

No phase condition matches

Add a catch-all at the lowest priority and see whether it fires:

phases:
# ... your phases ...

debug-catchall:
priority: -100
actions:
- wand_hit: { target: first }

If the wand hit happens every round, your other phases' conditions are never satisfied. Watch for the combinator shadowing rule — a leaf field beside an all: or any: on the same node is silently ignored.

No action in the matched phase resolves

A phase can match and still produce nothing when no action finds a card. Common reasons:

  • enchant: required with no enchant policy that applies to the matched card. The most common one by far, and it is not the same as "no enchant in hand" — the action is skipped before it ever looks at your cards. A cast: that matched a blade uses enchant.support, not enchant.blade. See Enchants → which policy applies.
  • enchant: required and no matching enchant in hand
  • enchant: auto with no damage: policy, which quietly enchants nothing
  • min_pips higher than your current pips
  • A name: that does not match any card you actually have
  • A school: filter with no matching cards — including school: any on blade/trap/ shield/heal, where any is not a wildcard and matches nothing
  • An alias target that is not in this duel, or any alias target at all on the desktop app
  • An alias target on a damage spell, which never resolves

Relax one constraint at a time. Start by dropping enchant: required.

A typo'd scalar action

actions:
- pss # silently accepted, does nothing

Only pass, flee, and draw are recognised bare scalars. Anything else parses into an empty action with no error — the file loads, strategies validate passes, and the phase reports one action that can never resolve.

The mapping form does not have this problem: - cst: {} is a hard error. If a phase looks correct and refuses to do anything, check the scalar actions letter by letter.

No fallback

fallback: pass

Without it, a round where nothing resolves does nothing at all — the runner does not automatically pass.

Wrong spell being cast

Actions are tried in order and the first that resolves wins. A general action above a specific one swallows every round:

# the named card never gets a chance
actions:
- cast: { type: damage }
- cast: { name: 'Glowbug Squall' }

Reorder most-specific-first. See Actions → ordering.

Wrong target

Check targets.default_enemy and default_ally — an action with no target: uses those.

lowest_health targets an enemy; lowest_health_ally targets an ally. Confusing them gives you a strategy that heals the monster.

An alias target that is not in the duel causes the action to be skipped, not mistargeted — so a support strategy that suddenly does nothing may just mean its hitter is not in this fight.

A misspelt target does not fail. Every selector the runner does not recognise falls through to the first live enemy, silently — so lowest_helth or an invented weakest produces working but wrong behaviour. The loader never checks target strings; only the schema does, which is the best argument for attaching it in your editor.

target: boss in a fight with no boss also falls through to the first live enemy. If a phase is meant for bosses only, gate it with when: { boss_present: true } — the target alone does not.

Everything on your own team counts as an ally, including you. lowest_health_ally will pick you when you are the most hurt, and any_ally_health_below fires on your own health.

Phase priority ties

Two phases with the same priority are ordered alphabetically by phase name as a tiebreak — deterministic, but not something you should design around. A phase named attack will always be tried before one named blade at the same priority, which is easy to mistake for random chance the first time it bites you.

Give every phase a distinct explicit priority instead of relying on the tiebreak. If behaviour looks inconsistent between edits, check for a priority collision before assuming nondeterminism.

"Property '$schema' is not allowed"

Your editor is using an old copy of the schema. libstrat explicitly permits top-level $schema. Point your editor at the published schema instead: https://scm.kebab.sh/strat/config.json.

Expression errors

Expressions are validated at load time, with a byte offset into the expression string:

expr: unknown attribute "helth" at offset 5
MessageCause
unknown attribute "x"Typo, or an attribute that does not exist
"x" has no max_x counterpart so % is not supported% on anything but health or mana
"enemies" needs an aggregation, write any(enemies), all(enemies) or avg(enemies)A group target used bare
any() requires enemies or allies, found "self"Aggregation over a singular target
unknown target "boss2"Not self, boss, enemy, ally, or a p<number> alias
"boss" does not take an indexOnly enemy and ally are indexable
single = is not an operator, use === instead of ==
single & is not an operator, use &&& instead of &&
unexpected character "-"Negative numbers do not exist in this language
unexpected <token> after a complete expressionTrailing junk, or a missing &&/||
expected number but found ...A clause with no right-hand value

Unlike Deimos, these are load errors rather than expressions that quietly evaluate false forever. If a strategy that worked in Deimos now refuses to load, that is usually a real bug the old engine was hiding.

Expression never true

Three semantics catch people out:

all() over an empty group is false. all(enemies).health < 50% with no enemies is false, not true. Use enemies_at_most: 0 if you mean "no enemies".

Dead participants are not filtered. expr: includes corpses in groups, while the fixed-field conditions filter by liveness. Add is_dead == 0 explicitly.

You are your own ally. The ally list includes you, and the group forms prepend you to it a second time. ally(0) is not reliably a teammate, and avg(allies) double-counts your own numbers. See Expressions → targets.

self.is_stunned == 1 is never true. The runner passes the round before evaluating anything when you are stunned.

An unreadable attribute is false, not an error. If a clause never fires, check enable dbg before rewriting it.

See Expressions.

Casts appear to fire but nothing happens

Turn on cast retry:

retry_failed_casts: true
cast_retry_limit: 2

The click is verified by watching hand size shrink, so a cast that did not land surfaces as a timeout and gets replanned in the same round. Only casts retry, and only on timeouts.

If it happens constantly rather than occasionally, the problem is upstream — a stale UI hook after a game patch rather than anything in the strategy.

Combat never engages

The strategy is only half of it. Check that the runner is actually enabled:

status

From a plugin, check that automation:EnsureCombat returned ready and that the owner and strategy are what you expect. A different plugin may already hold the runner with a different strategy — EnsureCombat will not seize it. See Handing combat to libstrat.

Getting more detail

enable dbg

turns on verbose logging, including a full per-round strategy trace:

strategy[storm]: evaluating round=3 planning=true
strategy[storm]: hand=[Epic{type=Enchant castable=true}, Tempest{type=Damage castable=true}]
strategy[storm]: phase=boss condition_match=false
strategy[storm]: phase=main condition_match=true
strategy[storm]: phase=main action[1]=blade(school="storm" target="self")+guard guard failed
strategy[storm]: phase=main action[2]=cast(name="Tempest" ...) matched cast(Tempest{...} -> owner=123)

Read from the top: what the hand was, which phases matched, which actions were skipped and why ("guard failed" versus "no match" versus "skipped because required enchant was unavailable"), and what was finally cast. That is usually enough to see exactly where a round is going wrong, and it is the only way to distinguish a guard that evaluated false from an action that found no card.

This trace is console-only

The desktop app's trainer does not wire up the strategy debug logger, so none of the lines above appear in its log no matter what logging level you set. To debug a strategy's decisions, run it under the console.

Before a fight, strategy explain <name> prints the phase and action tree the loader actually built — which is how you confirm a phase was assigned to the member you meant, and that an action you thought you wrote is really there. combat dry-run <name> adds the client and member mapping on top.