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
executeaction, and an ordered list of transitions. It may be marked terminal viacomplete()orerror(). - Each tick,
pulse()runs the current state'sexecute, then evaluates its transitions in declaration order - first matching guard wins - and moves to that target.pulse()returnsfalseonce 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.