Skip to main content

Run lifecycle

A run is one start()…stop(). It owns a world — the Tsum object, the page router's binding, a scheduler with a table of jobs — and it dismantles that world itself when it ends.

start()​

  1. Pick the log catalogue for the run's language and open a new runId, so every line from here on carries it.
  2. If a run is already active, stop() it first. If it will not go, log run.startBusy and refuse rather than build a second world over the first.
  3. Broadcast run.started to outside tooling.
  4. buildRun(settings): construct ts = new Tsum(...), copy every setting onto it, attach the page router, reset the fever and Lorcana watchers, build a TsumTaskController and register one job per row of runTaskTable(settings).
  5. Yield once so a stop() that arrived mid-build can set its flag, then controller.start() — which does not return until the loop ends.
  6. In a finally, endRun(): clear gRunActive, stop the controller, detach the router, drop ts, broadcast run.stopped, close the runId.
app.gap.Tsum/src/index.ts
loading...

The scheduler​

TsumTaskController is a cooperative scheduler: on a short fixed tick it takes the first job that is due and runs it to completion. A long job like a round blocks the loop for the whole round; nothing else runs meanwhile. That is the model — no timers, no workers, every native call synchronous — and it is why async/await buys nothing here.

Which jobs there are is runTaskTable in runPlan.ts, a table compiled into both the game bundle and the settings page, so the Run order card shows exactly what a run will register:

app.gap.Tsum/src/runPlan.ts
loading...

When more than one job is due, the order is JobPriority (lowest first), and every job's priority is distinct, so the order is the table's and nothing else's. One-shot sweeps queued by a Now button go first; the app restart next, so the chores after it run on a fresh app; then the coin-spending sweeps, the mailbox, the hearts, and the round last, because it is the job that never finishes early.

Repeated throws from one job restart the game app — counted per job, so a healthy round cannot mask a chore that throws every time.

stop(), on a second thread​

start() is still on the stack while the run goes, so every stop() arrives on another thread. The host dispatches each runScript on its own pool thread, and the engine hands the interpreter lock over at every sleep().

stop() therefore tears nothing down. It calls requestStop() — set gStopRequested, clear ts.isRunning, remove every task and stop the controller — and then waits, up to StopWaitMs, for gRunActive to clear. The running task notices isRunning at its next loop boundary, the controller's loop returns, and start()'s finally does the dismantling on the thread that owns the world.

The rule that holds this together, and the one to keep in mind before moving any of it: a run dismantles its own world. Clearing ts or detaching gPages from inside stop() pulls the world out from under a task that is still using it. index.ts's header comment says why at length.

requestStop() is also what a task body may call when it decides the run should end — the Max Round Duration cap's "stop the script" action does — so a task never has to know about threads.

Pause is not stop​

Opening the settings panel or the Quick Bar pauses the run: the host sets a flag that parks sleep() and every touch injector, so the script freezes where it is. onPause() (quickbar.ts) is evaluated once after that flag is set, and presses the game's own Pause button if a round is running, so the round's clock stops too. Closing the panel resumes: onResume() runs before the flag lifts, presses Continue and waits for the board. It has to come first because the script is parked inside its next touch, often mid-chain, and that touch would otherwise land on the pause menu.

Play with the panel open is the one thing that ends a run: it sends a fresh start() with the settings on screen.

Rounds inside a run​

The play job (taskPlayGameQuick) opens a round, plays it, and returns; the scheduler calls it again shortly after. Each round gets a roundId that every round.* event and log record carries. Play loop is the round.