Skip to main content

Automation State Machine

AutomationStateMachine is the SDK's driver for branching, looping work with real terminal states - anything that is not a fixed linear sequence (use TaskPipeline for that) or a single domain loop (use a workflow builder). You declare named states with guarded transitions, then advance the machine with pulse() once per game tick.

Pieces:

  • AutomationStateMachine - the engine; holds the current state and context.
  • AutomationStateMachineBuilder - fluent construction.
  • AutomationContext - shared key/value store plus tick-based cooldowns.

Concepts

  • A state has a status string, an optional execute action, and an ordered list of transitions. It may be marked terminal via complete() or error().
  • Each tick, pulse() runs the current state's execute, then evaluates its transitions in declaration order - first matching guard wins - and moves to that target. pulse() returns false once a terminal state is reached.
  • The context (AutomationContext) is how states share data and pace themselves without owning counter fields.

Building a machine

AutomationStateMachineBuilder.create() → declare states → build(initialState).

AutomationStateMachine machine = AutomationStateMachineBuilder.create()
.state("validate")
.status("Validating")
.transition("error", ctx -> !requirementsMet()) // guards first
.transition("work", ctx -> true) // fallthrough
.state("work")
.status("Working")
.execute(ctx -> doOneUnitOfWork())
.transition("done", ctx -> finished())
.transition("validate", ctx -> true) // loop back
.state("error").status("Requirements missing").error()
.state("done").status("Finished").complete()
.build("validate");

StateBuilder verbs: status(String), execute(Consumer<AutomationContext>), transition(String target, Predicate<AutomationContext> guard), complete(), error(), state(String) (start the next state), build(String initialState).

Driving it

private AutomationStateMachine machine;

@Subscribe
public void onGameTick(GameTick event) {
if (machine == null) {
machine = buildMachine();
}
boolean running = machine.pulse();
status = machine.getStatusText();
if (!running) {
if (machine.isErrored()) {
log.warn("halted: {}", machine.getErrorMessage());
}
machine = null; // rebuild for the next run
}
}

Query surface: pulse(), getCurrentState(), getStatusText(), getErrorMessage(), isStarted(), isCompleted(), isErrored(), reset(), getContext().

Pacing without counters: AutomationContext

AutomationContext carries shared values and tick-based deadlines, so a state can wait out a cooldown without storing a tick field:

.state("cast")
.status("Casting")
.transition("cooldown", ctx -> ctx.isCooldownActive("cast"))
.execute(ctx -> {
if (cast()) {
ctx.startCooldown("cast", 3); // 3-tick gap before the next cast
}
})
.state("cooldown")
.status("Waiting for cooldown")
.transition("cast", ctx -> !ctx.isCooldownActive("cast"))

Context API: put/get/contains/remove/clearValues, setStatusText/getStatusText, setErrorMessage/getErrorMessage, getCurrentTick, startCooldown(key, ticks), isCooldownActive(key), hasTimedOut(key, ticks), remainingTicks(key), clearDeadline(key), clearDeadlines().

Worked flow - jewelry enchanting

EnchantingWorkflowBuilder.create(EnchantingPlan) is the suite's reference state machine. It returns a fully wired AutomationStateMachine whose state graph is:

VALIDATE ─┬─ magic too low ───────────────► ERROR_MAGIC (terminal)
├─ has all requirements ─────────► CAST
├─ bank open, no secondary left ─► COMPLETE (terminal)
├─ bank open, runes unsatisfiable► ERROR_RUNES (terminal)
├─ bank open ────────────────────► PREPARE_BANK
└─ otherwise ────────────────────► OPEN_BANK

OPEN_BANK ─┬─ open failed ─► ERROR_BANK (terminal)
└─ bank open ───► PREPARE_BANK [execute: attemptOpenBank]

PREPARE_BANK ─┬─ requirements met ─► CAST
├─ no secondary left ─► COMPLETE
└─ runes unsatisfiable► ERROR_RUNES [execute: prepareBankInventory]

CAST ─ cooldown active ─► COOLDOWN [execute: cast, then startCooldown]
COOLDOWN ─ cooldown clear ─► VALIDATE

Define the plan with EnchantingPlan.builder(Mode):

EnchantingPlan plan = EnchantingPlan.builder(EnchantingPlan.Mode.JEWELRY)
.spellWidgetId(ENCHANT_SAPPHIRE_WIDGET)
.secondaryItemId(ItemID.SAPPHIRE_RING)
.bankWithdrawAmount(13)
.minimumMagicLevel(7)
.castCooldownTicks(3)
.requiredRuneIds(Set.of(ItemID.COSMIC_RUNE, ItemID.WATER_RUNE))
.build();

AutomationStateMachine enchant = EnchantingWorkflowBuilder.create(plan);
while (enchant.pulse()) { Delays.tick(); } // or pulse() once per onGameTick

Mode.BOLTS additionally uses resumeWidgetId/resumeChildId for the make-X resume dialog. The cast cooldown is enforced through AutomationContext.startCooldown(...) keyed on an internal cast-cooldown constant, using plan.getCastCooldownTicks().

Testing

EnchantingWorkflowBuilder.setServicesForTesting(...) swaps the live services (magic level, bank, withdraw, cast) for fakes; reset in @After. Because the machine is pure aside from those services, you can pulse it deterministically and assert getCurrentState() after each tick. Add ActionPacer.reset() in @Before if any fake routes through a paced action.