Farm loop
Find a named mob, teleport onto it to start a fight, let libstrat win, recover, repeat. This
is farm_tw.lua plus storm_cp.yml, an example plugin and strategy included with your
installation.
The step-by-step build is in Writing a farm bot. This page is the finished configuration, the knobs worth turning, and the ways it fails.
Before you start
- One account, hooked.
farm_twis single-client throughout. - A mob you can beat repeatedly without gear or deck changes.
- A stack of potions. The loop prefers potions and falls back to wisps.
- The two folders in place, and
strategies validateclean — see the cookbook overview.
The strategy
~/.kebab/strategies/storm_cp.yml
name: storm_cp
phases:
main:
priority: 100
actions:
- cast:
name: 'Tempest'
enchant: none
Farm strategies should be boring. Trash mobs die to one unenchanted AOE, and spending blades
or enchants on them wastes cards you want for real fights. enchant: none is what says "do
not spend an enchant on this", and it is the whole point of the file.
When no phase produces an action — no Tempest in hand this round — the runner falls back, and
the default fallback is pass. You can write it out:
fallback: pass
That changes nothing; it only makes the behaviour visible to the next person reading the file.
Set fallback: flee instead if you would rather bail out of a fight the strategy cannot
progress than sit passing forever.
The plugin
Configuration block, from the top of farm_tw.lua:
local TARGET_NAME = 'Troubled Warrior'
local COMBAT_STRATEGY = 'storm_cp'
local ZONE = 'Grizzleheim/GH_Hero'
local ticks = utils.Ticks
local SETTLE_TICKS = ticks(2)
local PULL_INTERVAL_TICKS = ticks(3)
local NUDGE_MS = 300
local NUDGE_EVERY_PULLS = 3
local HEALTH_LOW_PCT = 55
local HEALTH_OK_PCT = 85
local MANA_LOW_PCT = 30
local MANA_OK_PCT = 65
local POTION_MANA_RESERVE = 0.23
local POTION_MANA_FLOOR = 16
local WISP_MAX_PULLS = 10
local WISP_MOB_CLEARANCE = 700
local SWEEP_ENABLED = true
local SWEEP_SETTLE_TICKS = ticks(1.5)
local SWEEP_RETRY_TICKS = ticks(15)
Adapting it to a different mob is three lines:
local TARGET_NAME = 'Whatever You Are Farming'
local COMBAT_STRATEGY = 'your_strategy'
local ZONE = 'World/ZoneName'
COMBAT_STRATEGY is a strategy's name: field, not a filename.
TARGET_NAME is matched case- and separator-insensitively against a mob's display name, its
template name, and its internal name, so 'Troubled Warrior' finds an entity whose internal
name is GH-Bear-Scout-1-R3. Get the name by logging what is actually nearby — see the
entity guide — or, from the console, dump entities
lists everything in range with a DISPLAY column.
Get ZONE the same way: log clients:First():GetZone() while standing there, or from the
console:
scripts eval utils.Log(clients:First():GetZone())
Setting ZONE = '' runs the loop anywhere. Not recommended: the zone check is what stops the
bot teleporting around a zone it does not understand after an accidental transition. It is
also what strands it after a death, though — see Known limitations, below, before you decide
where to put that check.
The phases
| Phase | Does |
|---|---|
hunt | Find the target, teleport onto it |
sweep | Step through zone chunks looking for a target outside draw distance |
fight | Nothing. libstrat owns the client. |
settle | Wait 2s for the duel to close cleanly |
nudge | Forward/back tap, then decide: recover or hunt |
recover | Drink a potion, or collect wisps |
The fight phase doing nothing is the important one. The plugin notices combat, records it,
and returns — no movement, no casting, no teleports — until on_combat_exit or an
IsInCombat() that comes back false. See
Combat handoff for what goes wrong if it does not.
Combat is handed over once per tick, before anything else:
local ready, status, err = automation:EnsureCombat({
client_id = c:GetID(),
strategy = COMBAT_STRATEGY,
owner = plugin.name
})
EnsureCombat is idempotent — calling it every tick is the intended usage, not a leak. If it
returns a status.strategy that is not yours, something else already owns the combat runner,
and the plugin logs that once rather than fighting over it. on_stop releases it with
automation:DisableCombat({ owner = plugin.name }).
Recovery, in order
The loop prefers potions, not wisps. A potion restores health and mana together and takes one click; wisps need a teleport each.
local function mana_floor(c)
local level = c:GetLevel()
if level > 0 and level < POTION_MANA_FLOOR then return level end
return POTION_MANA_FLOOR
end
local function potion_needed(c)
local reserve = (POTION_MANA_RESERVE * c:GetMaxMana()) + mana_floor(c)
if c:GetMana() < reserve then return true end
return health_pct(c) < HEALTH_LOW_PCT
end
So it drinks when mana drops below 0.23 × max mana + min(level, 16), or when health drops
below 55%. The reserve scales with both max mana and level rather than being a flat
percentage, so a low-level character with a small mana pool is not left with a "healthy"
percentage that is still too little to cast anything.
Wisps are the fallback, and they come up in two situations: the potion rule did not fire (you are under the "ok" thresholds but still over the "low" ones), or it fired and there were no charges left. Either way the loop picks a wisp kind from whatever is actually low:
local function wanted_kinds(c)
local kinds = {}
if health_pct(c) < HEALTH_OK_PCT then kinds[#kinds + 1] = 'health' end
if mana_pct(c) < MANA_OK_PCT then kinds[#kinds + 1] = 'mana' end
return kinds
end
local wisp = c.entity:Nearest({
tag = 'wisp_' .. kind,
min_mob_distance = WISP_MOB_CLEARANCE,
exclude = state.collected
})
exclude is a set keyed by Entity.key. Never rebuild that key yourself — store
wisp.key and pass the table straight back, which is what state.collected[wisp.key] = true
is doing.
Tuning
PULL_INTERVAL_TICKS — how long between pull attempts. Three seconds gives the game time
to register the teleport and start the fight. Shortening it makes the bot spam teleports at a
mob that is already aggroing.
HEALTH_LOW_PCT / HEALTH_OK_PCT — recovery hysteresis. Two thresholds, not one. A
single threshold gives you a bot that recovers to 56%, fights, drops to 54%, recovers again.
Keep at least 20 points between them.
POTION_MANA_RESERVE — raise it if the bot keeps entering fights it cannot finish
casting in; lower it if it drinks potions it did not need.
WISP_MOB_CLEARANCE — how far a wisp must be from the nearest mob before the bot will
teleport to it. 700 is roughly "outside the aggro radius". Lower it in a zone where wisps
always spawn near mobs, and accept some unplanned fights.
WISP_MAX_PULLS — the escape hatch. Without a cap, a bot in a zone with no mana wisps
sits in recovery forever. Ten pulls, then resume even if still not recovered.
NUDGE_EVERY_PULLS — repeated teleports onto the same coordinates eventually leave the
character in a state where the aggro trigger stops firing. A physical forward/back step
resets it. Every third pull is empirically about right.
SWEEP_ENABLED — the sweep walks c:ZoneChunks(), a list of points spaced so that
standing on one loads every entity assigned to it. That is how the bot finds mobs beyond the
entity draw distance. Turn it off in small zones where everything is always in range; it
costs a few seconds each time it runs.
Running it
In the desktop app: put farm_tw.lua in ~/.kebab/plugins and storm_cp.yml in
~/.kebab/strategies, open Forge → Trainer → Plugins, refresh, and click Run on
farm_tw.
From the console:
hook
strategies validate
scripts run farm_tw
Either way, watch the first two cycles and check three things:
- Does it find a target by name? If not, the
no target foundline includes a sample of the mob names it can see. Compare those againstTARGET_NAME. - Does combat engage with your strategy? A
fighting with X held by Yline means something else owns the runner — disable combat and start again. - Does it recover, rather than walking into a fight at 20% health?
F8 pauses and resumes. scripts run farm_tw again restarts the state machine, because
farm_tw implements execute as a reset. To stop it properly use scripts stop farm_tw, or
Stop on the plugin's row in the desktop app — both run on_stop, which is what releases
combat automation.
Known limitations
Death moves you, and the zone gate does not know
The ZONE check lives in the readiness gate:
local function ready_client()
...
if ZONE ~= '' then
local zone = c:GetZone()
if zone ~= ZONE then
log_throttled('zone', 'wrong zone ...')
return nil
end
end
return c
end
Every phase is downstream of that gate. In some zones — dungeon interiors especially — dying
does not respawn you where you fell; it moves you to a different zone entirely, several
seconds later. When that happens farm_tw stops doing anything at all, forever, logging a
throttled wrong zone line. It cannot recover, because recovery would have to run in a zone
the gate refuses to run in.
The shipped farm_sm.lua is the same loop rebuilt around that problem, and it is the pattern
to copy if you are farming anywhere you might die:
-
The zone check is not in the readiness gate.
ready_client()only checks connected and loading; a separatein_farm_zone(c)is consulted by the phases that actually care. -
The spot is marked once, on arrival, by sending the mark key:
c:SendKey('pagedown'). Recall isc:SendKey('pageup'). That round trip needs no coordinates and crosses zones, which a positional teleport cannot do. -
Death is detected two ways, because neither alone is reliable:
plugin.on_died, and ac:GetHealth() <= 0check in the tick. -
After a death it waits for the zone to stop changing before acting — a fixed timer fires while the relocation is still in flight:
if zone ~= '' and zone ~= state.death_zone thenif zone == state.settled_zone thenstate.zone_stable = state.zone_stable + 1elsestate.settled_zone = zonestate.zone_stable = 1endif state.zone_stable >= RESPAWN_SETTLE_TICKS thenenter_respawn()endreturnend -
It tops up mana at the respawn point, recalls, and then verifies it arrived by checking the zone again, retrying up to three times before giving up and saying so in the log.
Everything else
No restocking. When potions run out the loop falls back to wisps and keeps going at lower efficiency. It never visits a vendor.
PotionCount cannot tell empty from unreadable. It returns 0, 0 for a transient read
failure and for genuinely having none, so do not build restocking on that signal alone.
No backpack management. Add an inventory check if drops matter:
require('inventory')
-- in tick, before hunting
if c.inventory:IsFull() then
log('backpack full, stopping')
state.enabled = false
return
end
Single client. farm_tw uses clients:First() throughout. For two accounts farming
independently from the console, run two console processes rather than adapting the plugin —
the event filtering and per-client state are more work than they look. For a leader/follower
pair that fights together, see Support team.
Next
Add drop tracking to find out what the loop is actually yielding per hour. It is four lines on top of what you already have.