Skip to main content

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_tw is 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 validate clean — 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

PhaseDoes
huntFind the target, teleport onto it
sweepStep through zone chunks looking for a target outside draw distance
fightNothing. libstrat owns the client.
settleWait 2s for the duel to close cleanly
nudgeForward/back tap, then decide: recover or hunt
recoverDrink 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:

  1. Does it find a target by name? If not, the no target found line includes a sample of the mob names it can see. Compare those against TARGET_NAME.
  2. Does combat engage with your strategy? A fighting with X held by Y line means something else owns the runner — disable combat and start again.
  3. 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 separate in_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 is c: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 a c:GetHealth() <= 0 check 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 then
    if zone == state.settled_zone then
    state.zone_stable = state.zone_stable + 1
    else
    state.settled_zone = zone
    state.zone_stable = 1
    end
    if state.zone_stable >= RESPAWN_SETTLE_TICKS then
    enter_respawn()
    end
    return
    end
  • 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.