Support team
Two characters in one fight: a support that lays sharpened blades and potent traps, and a
hitter that spends them. The combat half is life_fire.yml, one of the example strategies
included with your installation; the movement half is a small plugin that keeps the follower
with the leader.
Most of the work is in the strategy. Coordination out of combat is deliberately minimal — the less the plugin does while a duel is live, the fewer ways it can desynchronise the two clients.
Two-character setups depend on two things the console has and the desktop app does not:
stable p1 / p2 aliases, and a single combat runner that a multi-member strategy can route
between clients.
In the console, plugins see hooked clients under their console aliases, so
clients:Get('p1') resolves. Under the desktop app a client's ID is its process ID, so
clients:Get('p1') returns nil.
A strategy with a members: block is also registered differently by each host. The console
registers it once under its name: — life_fire — and routes members internally. The desktop
app registers one entry per member, life_fire/life and life_fire/fire, with no plain
life_fire to ask for, and its combat automation holds one strategy for all clients at once.
So EnsureCombat({ strategy = 'life_fire' }) fails there with strategy not found.
Single-character strategies like storm_cp are unaffected and work under both hosts.
Before you start
- Two clients running and hooked, with
statusshowing them asp1andp2. - Both characters in the same zone, in the same fight. This recipe does not pull one into the other's duel; it keeps them together so that one aggro pulls both.
life_fire.ymlin your strategies folder, validating cleanly.- Decks that actually contain the cards named in the strategy. A strategy naming a card the character does not own simply never resolves that action.
The strategy
One file with a members: block, so both characters stay in sync when you edit it.
# yaml-language-server: $schema=https://scm.kebab.sh/strat/config.json
name: life_fire
members:
life:
clients: [p1]
enchant:
support: { prefer: sharpen }
trap: { prefer: potent }
targets:
default_ally: p2
default_enemy: boss
fallback: pass
fire:
clients: [p2]
enchant:
damage: { prefer: epic }
targets:
default_enemy: boss
fallback: pass
phases:
life-main:
members: [life]
priority: 100
actions:
- cast:
name: 'Tri Blade'
enchant: required
enchants:
- prefer: sharpen
when:
no_blade: { target: p2, school: fire, enchanted: true }
- cast:
name: 'Tri Blade'
enchant: none
when:
all:
- has_blade: { target: p2, school: fire, enchanted: true }
- no_blade: { target: p2, school: fire, enchanted: false }
- cast:
name: 'Feint'
enchant: required
enchants:
- prefer: potent
when:
no_trap: { target: boss, enchanted: true }
- cast:
name: 'Feint'
enchant: none
when:
all:
- has_trap: { target: boss, enchanted: true }
- no_trap: { target: boss, enchanted: false }
- pass
fire-main:
members: [fire]
priority: 100
actions:
- cast: { name: 'Frenzy', enchant: none }
- cast: { name: 'Fire Dragon', enchant: required }
- cast: { name: 'Phantasmania', enchant: required }
- pass
Each member owns its own clients, enchant, targets, and fallback. That is why those
keys do not also appear at the top level: a file that uses members: must not set them
outside a member, and libstrat rejects a file that mixes the two forms.
How the support half works
Four actions, each guarded, forming a checklist:
- Sharpened Tri Blade on p2 — only if p2 has no enchanted fire blade.
- Plain Tri Blade on p2 — only if the sharpened one is up and the plain one is not.
- Potent Feint on the boss — only if the boss has no enchanted trap.
- Plain Feint — same layering.
Then pass, so once the board is set the support stops spending pips on chip damage.
The enchanted: true / enchanted: false distinction is what makes stacking work. An
enchanted blade and a plain blade of the same school are separate effects on the board, so
"does p2 already have a sharpened fire blade" is a different question from "does p2 have any
fire blade" — and only the first one lets you stack both.
Because each guard reads the board rather than counting rounds, a blade that gets shattered is reapplied automatically on the next round.
How the hitter half works
Ordered by value, nothing more:
- cast: { name: 'Frenzy', enchant: none }
- cast: { name: 'Fire Dragon', enchant: required }
- cast: { name: 'Phantasmania', enchant: required }
- pass
Frenzy first as a cheap self-buff, then the big hits when they can be enchanted. enchant: required means an unenchanted Fire Dragon is not thrown away — the action does not resolve,
and evaluation falls through to Phantasmania, then to pass.
Both phases sit at priority 100 and never compete: a phase is only evaluated for the members
listed in its members:, and priority orders phases within a member. A phase with no
members: key runs for every member, which is where a shared emergency action belongs.
The follower plugin
Out of combat, keep p2 near p1.
---@type KebabPlugin
plugin = {
name = 'follow',
version = '1.0.0',
description = 'Keeps p2 with p1 out of combat',
}
local ticks = utils.Ticks
local FOLLOW_DISTANCE = 400
local CHECK_INTERVAL = ticks(1)
local COMBAT_STRATEGY = 'life_fire'
local state = {}
local function log(msg) utils.Log('[follow] ' .. msg) end
local function log_throttled(k, msg)
utils.LogThrottled('follow_' .. k, '[follow] ' .. msg, 15000)
end
local function reset()
state = { enabled = true, wait = 0 }
end
local function ensure_combat()
if automation == nil then return true end
local ready, status, err = automation:EnsureCombat({
strategy = COMBAT_STRATEGY,
owner = plugin.name,
})
if not ready then
log_throttled('combat', 'combat automation failed: ' .. tostring(err))
return false
end
if status and status.strategy ~= COMBAT_STRATEGY then
utils.LogOnce('follow_strategy',
'[follow] fighting with ' .. tostring(status.strategy) ..
' held by ' .. tostring(status.owner))
end
return true
end
local function tick()
if not state.enabled then return end
if state.wait > 0 then
state.wait = state.wait - 1
return
end
state.wait = CHECK_INTERVAL
local leader = clients:Get('p1')
local follower = clients:Get('p2')
if not leader or not follower then
log_throttled('missing', 'need both p1 and p2 hooked')
return
end
if not leader:IsConnected() or not follower:IsConnected() then return end
if not ensure_combat() then return end
-- combat belongs to libstrat
if leader:IsInCombat() or follower:IsInCombat() then return end
if leader:IsLoading() or follower:IsLoading() then return end
-- a positional teleport cannot cross zones
if leader:GetZone() ~= follower:GetZone() then
log_throttled('zone', 'p2 is in ' .. tostring(follower:GetZone()) ..
', p1 is in ' .. tostring(leader:GetZone()))
return
end
if not follower:InRange(leader:GetPosition(), FOLLOW_DISTANCE) then
local ok, err = follower:TeleportWithRecovery(leader:GetPosition())
if not ok then
log_throttled('tp', 'follow teleport failed: ' .. tostring(err))
end
end
end
plugin.on_load = function()
reset()
log('following p1 with p2, fighting with ' .. COMBAT_STRATEGY)
end
plugin.on_stop = function()
state.enabled = false
if automation ~= nil then
automation:DisableCombat({ owner = plugin.name })
end
log('stopped')
end
plugin.on_tick = tick
plugin.hotkeys = {
f8 = function()
state.enabled = not state.enabled
log('enabled=' .. tostring(state.enabled))
end,
}
EnsureCombat is called with no client_id, which is deliberate: one call enables the
runner for every hooked client, and the strategy's members: and clients: blocks decide
which character plays which half. Always pass strategy explicitly — the default when you
omit it is not the same on both hosts.
Four guards run before any movement, in order of consequence:
Either client in combat → do nothing. The strategy runner owns both clients during a duel. Teleporting the follower mid-fight moves it out of the duel circle while the fight continues.
Either client loading → do nothing. Memory reads during a zone transition return garbage.
Zones differ → do nothing. A positional teleport does not cross zones. Writing p1's coordinates while p2 is in a different world appears to succeed — the write lands and the position reads back — and leaves p2 standing at those coordinates in the wrong place. Log it and wait for the operator.
Already in range → do nothing. InRange is a 2D check, ignoring Z, which is what you want
here — a leader on a bridge overhead is still "here".
Cross-zone
The plugin above deliberately stops at a zone boundary. Following through a transition needs the follower to go through the same door or sigil the leader used, not a coordinate write.
The reliable shape is: the leader takes the transition, then the follower walks to the same
door and interacts with it. Doing this well is genuinely hard, and the failure mode is nasty —
a memory teleport into an instance can report success without actually crossing, leaving the
follower somewhere it has never been. If you build it, verify GetZone() matches afterwards
before believing it worked, on every attempt, not once.
Nothing shipped does this reliably today, which is why no recipe here claims to.
Running it
hook
status
strategies validate
scripts run follow
status should list p1 and p2 with the aliases the strategy expects. Aliases are assigned
by window layout, not PID order, so if they come out the other way round either re-hook after
moving the windows, or swap the aliases in the strategy — clients: [p1] under the life
member is the line that binds it.
Check that both characters cast in the first round. If only one does, the usual cause is a
member bound to an alias that is not hooked: strategy explain life_fire prints the resolved
tree, and the console's strategy listing shows each member with the aliases it targets.
Adapting
Different roles: rename the members and swap the card names. The structure — guarded checklist for support, ordered value list for the hitter — carries over to any pairing.
Three characters: add a third member with its own clients: and phases. Remember that a
phase with no members: runs for everyone, so a shared emergency heal only needs writing
once.
Different follow distance: FOLLOW_DISTANCE = 400 is close enough to be pulled into the
same fight. Increase it if the follower teleporting constantly is more annoying than it being
slightly behind.
Single character instead: drop the members: block and the follower plugin, and you have
a plain one-character strategy that runs under either host. storm.yml is the shipped example
of that shape.