Automations
An automation is a loop that plays the game for a long time. Declare it in automations and Phantom starts it, stops it and restarts it for you.
This matters because the game's page reloads when the game switches server and when a dropped connection reconnects. Every request in flight then fails with NOT_READY, so a loop you start yourself in setup usually catches the error, marks itself failed and never runs again.
import { defineAutomation, definePlugin } from '@phantomclient/plugin';
export default definePlugin({
automations: {
adventure: defineAutomation({
title: 'Play the desert',
requires: ['game', 'room'],
notes: () => ({ runs: 0, gems: 0 }),
async run(task, phantom, ctx) {
while (true) {
task.check();
task.step(`Run ${task.notes.runs + 1}`);
await phantom.aj.adventures.lobby.join(23, { autoStart: true });
await task.sleep(4_000);
task.save({ runs: task.notes.runs + 1 });
ctx.log(`${task.notes.runs} runs`);
}
},
}),
},
});What Phantom does with it
Phantom:
- waits until the game is
playingand has what yourequire, then starts the run - cancels the run when the game goes
- starts it again when the game comes back, waiting 2s, then 4s, then 8s, up to a minute
- gives your notes back on every start, so a restart carries on
- never starts two runs of one automation on one game
- shows the run's status beside the plugin and in your own panels
A run is per game. An each-game plugin gets one, and a single plugin gets one for each game it's allowed on.
Declaring one
title | The name the player sees. Defaults to the id. |
requires | What the game must have reached first (see Waiting for the game). Waited for before every start, including restarts. |
autoStart | Start as soon as the game is ready, without the player asking. Defaults to false, since the player should choose to turn on something that plays Animal Jam for them. |
notes | What the run saves, at its starting value. Use a function if it holds anything mutable, so two runs never share one object. |
forgets | When Phantom resets the notes: ended (a run finished, or the player stopped it) by default, finished (only when a run finished), or never. |
run | The loop. Gets the task, the game's phantom and your ctx, in the same order a command does. |
Wrap each one in defineAutomation so task.notes has your type.
Cancellation
Every wait on the task throws AutomationCancelled as soon as the run should stop, so your loop unwinds through its own finally blocks.
import { defineAutomation, definePlugin, isCancelled } from '@phantomclient/plugin';
export default definePlugin({
automations: {
adventure: defineAutomation({
async run(task, phantom, ctx) {
await phantom.aj.adventures.dialogue.autoAccept();
try {
while (true) await task.sleep(1_000);
} catch (error) {
if (isCancelled(error)) throw error;
ctx.log('that did not work:', error);
} finally {
await phantom.aj.adventures.dialogue.autoAccept();
}
},
}),
},
});A call already sent to Phantom isn't taken back. The run unwinds at the next check, sleep, until or first.
The task
task.check() | Throws if the run should stop. Call it between steps. |
task.sleep(ms) | Waits, and throws instead of returning if the run should stop. |
task.until(fn, options) | Waits until a condition holds, checking every everyMs (250). Throws on timeoutMs (a minute); what names the wait in the error. |
task.first(listen, opts) | Waits for the first time a listener fires. It subscribes, waits and always unsubscribes. |
task.step(what) | One line for the player on what it's doing now. |
task.save(changes) | Saves the notes. Returns at once; Phantom batches the writes. |
task.flush() | Waits until everything saved has reached the plugin's storage. |
task.notes | What this run has saved. See Keeping state. |
task.attempt | Which run this is: 1 the first time, then 2, 3 and so on. |
task.because | player, ready, or restart. restart means Phantom started it again after the game went away. |
task.signal | An AbortSignal, for anything that takes one, or a wait of your own. |
Carrying on
Check task.because to carry on after a restart instead of starting over:
import { defineAutomation, definePlugin } from '@phantomclient/plugin';
export default definePlugin({
automations: {
adventure: defineAutomation({
notes: () => ({ runs: 0, gems: 0 }),
async run(task, phantom, ctx) {
if (task.because === 'restart') {
ctx.log(`carrying on: ${task.notes.runs} runs, ${task.notes.gems} gems`);
} else {
task.save({ runs: 0, gems: 0 });
}
while (task.notes.runs < 10) {
await phantom.aj.adventures.lobby.join(23, { autoStart: true });
task.save({ runs: task.notes.runs + 1 });
await task.sleep(4_000);
}
},
}),
},
});Waiting for something
import { defineAutomation, definePlugin } from '@phantomclient/plugin';
export default definePlugin({
automations: {
adventure: defineAutomation({
async run(task, phantom) {
await task.first<void>(
(heard) => phantom.aj.adventures.onStart(() => heard()),
{
timeoutMs: 60_000,
what: 'the adventure to start',
}
);
await task.until(async () => (await phantom.aj.adventures.current()) !== null, {
timeoutMs: 30_000,
what: 'to be inside the adventure',
});
},
}),
},
});task.first subscribes and waits in one go, so it doesn't fit when you need to start listening before you act, as with an adventure that can start in the same bundle as the join's answer. Use task.signal for that; plugins/examples/auto-tfd shows how.
How a run ends
Your run | Phantom says | And |
|---|---|---|
| returns | finished | doesn't start it again until it's asked |
throws AutomationCancelled | stopped | or waiting, if the game went and will come back |
throws NOT_READY | waiting | starts it again, since that's a reload |
| throws anything else | failed | doesn't start it again, and tells the player why |
A failure isn't retried, as with setup. To recover from one bad round, catch the error in your own loop, log it and carry on.
Starting and stopping
From your own panel, a command, or anywhere in your plugin:
await ctx.automations.start('adventure', { game: ctx.games()[0]!.id });
await ctx.automations.stop('adventure', { game: ctx.games()[0]!.id });
const run = ctx.automations.status<{ runs: number }>('adventure', {
game: ctx.games()[0]!.id,
});
ctx.log(run?.state ?? 'not supervised', run?.notes.runs ?? 0);Input
You can start a run with a plain JSON input. The run reads it as task.input, and its status shows it as input. It's null when started without one, as autoStart and the plugin's page do. A restart after the game comes back keeps it. Starting a run that's already running changes nothing, including its input, so stop it first.
const play = defineAutomation<{ loops: number }, { routeId: string }>({
notes: { loops: 0 },
async run(task) {
ctx.log(`playing ${task.input?.routeId ?? 'nothing'}`);
},
});
const input: AutomationInputOf<typeof play> = { routeId: 'r1' };
await ctx.automations.start('play', { input });To type-check every start of an id, declare its input once: declare module '@phantomclient/plugin' { interface PluginAutomationInputs { play: { routeId: string } } }.
start resolves once Phantom has the request, not when the run finishes. Starting one that's already running does nothing, so a double click doesn't start two runs.
In a panel:
<script setup lang="ts">
import { useAutomation, usePhantom, usePlugin } from '@phantomclient/plugin/vue';
import { computed } from 'vue';
const plugin = usePlugin();
const phantom = usePhantom();
const run = useAutomation<{ runs: number }>('adventure');
const going = computed(
() => run.value?.state === 'running' || run.value?.state === 'waiting'
);
function toggle(): void {
const action = going.value ? plugin.automations.stop : plugin.automations.start;
void action('adventure', { game: phantom.id });
}
</script>
<template>
<button type="button" @click="toggle">{{ going ? 'Stop' : 'Start' }}</button>
<p v-if="run?.state === 'waiting'">{{ run.step }}</p>
<p v-else>{{ run?.notes.runs ?? 0 }} runs</p>
</template>What the player sees
The plugin's page in Plugins gets an Automations tab: one row per game, with what the run is doing, how many times the game has gone away during it, and Start and Stop. The line at the top of the page says Playing while a run is going and Waiting for the game while one is held back.
Every change of state is written to the plugin's log, so you can check afterwards what happened during a reconnect.
A dropped connection that doesn't reload
Most drops end with Phantom reloading the page. The game says reconnecting meanwhile, and Phantom cancels the run.
Sometimes it doesn't reload, because the setting is off or the retries ran out, and the game still says playing with no connection behind it. Only the client knows, so Phantom listens for it (phantom.aj.session.onLost) and cancels the run with game-offline. That needs "game": "read" in your permissions, which anything with requires: ['game'] already has.
The status
ctx.automations.status() and useAutomation() give you:
| Field | What it is |
|---|---|
state | idle, waiting, running, finished, failed, stopped |
step | What the run last said it was doing, or what it's waiting for |
message | Why it stopped or failed |
attempt | Which run this is |
restarts | How many times Phantom has started it again after the game went away |
nextInMs | How long until Phantom looks again |
notes | What the run has written down |
title, game, accountId | Which automation, on which game, for which player |
Converting an existing plugin
plugins/bitter-sweets and plugins/examples/auto-tfd were both converted from a loop in setup. The steps are:
- Move the loop into
automations.<id>.run, and deletestart,stop, therunningflag and the stopped promise. - Turn the status you wrote into
ctx.storageinto the automation'snotes, minus anything about whether the run is going, which Phantom's status now covers. - Replace your
check(),wait()and event helper withtask.check,task.sleepandtask.first, and yourStoppedclass withisCancelled. - Replace the control key your panel polled with
ctx.automations.startand.stop, and the status your panel read on a timer withuseAutomation. - Read
task.becauseso a restart carries on.