Skip to main content

Page router

Recognising a screen is half the job; the other half is what happens next. Both belong to one place. gPages (a PageRouter, in pages.ts) is the only thing that looks, and it broadcasts what it found. Anything that wants to react registers a subscription in pageHandlers.ts rather than writing another branch of its own.

Detection​

Every screen the script knows is a fingerprint in the Page table in data.ts: a handful of probe pixels, each with the colour expected there and a tolerance. sweep scores every entry against a captured frame and takes the best, weighing evidence before comfort — an entry confirming more probes beats one that matched more loosely on fewer.

Several PageNames have more than one Page entry — regional, emulator and resolution variants of the same screen. That is why the enum is written by hand rather than derived from the table: the keys are fingerprints, the names are screens.

A caller that knows what it is watching can pass an expect list, which narrows which entries are scored at all.

Broadcast​

Every detection produces a PageEvent: the page, the previous page, whether the screen actually turned over since the last look (changed), the goal the look was made with, the page's kind and, for a transient page, how long it has left. The router files it in a bounded history (gPages.history, rendered by gPages.trail()) and runs the queue over it.

The queue​

Subscriptions are grouped into bands with fixed priorities, ordered within a band, and topologically sorted by their declared after dependencies. They run one at a time, and the first one to touch the screen ends the queue — everything below would be acting on a frame that no longer exists.

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

Whether a subscription touched the screen is its band's answer, not a value it returns: guard, dismiss and navigate act unless their acts predicate declined. Which band a subscription joins is therefore a contract:

BandPromise
observeTakes no captures, taps nothing. Always runs.
recordMeasurements that need an untouched screen — captures included.
guardHandles what is not a game screen at all: system dialogs, the root warning.
dismissCloses interruptions standing between the script and where it is going.
navigateMoves toward the goal. Silent when the look had no goal, and never on the destination page.
notifyLogging and bookkeeping. Decides nothing, and runs even after the queue has stopped.

Nothing checks this, and PAGE_DISPATCH.md will happily document a wrong choice. A handler that captures belongs in record; one that taps belongs in guard, dismiss or navigate.

Two rules carry most of the safety:

  • navigate is silent with no goal. A goal is an argument to the look, not router state, which is what lets other callers detect every cycle without anything tapping the game away.
  • navigate never fires on the destination. Arriving is navigate()'s job.

There is no blind fallback: navigation presses a page's back only where that page's PageRoutes row says back is a way out. A page with no such row is not tapped — navigate() logs nav.noRoute once and the stall guard takes over. So a new page that navigation has to leave needs its exit declared, or navigation will sit on it and say so.

Subscriptions are data​

A subscription is a list of steps, not a body: tap (an anchor of the entry that matched — never a coordinate, because variants of a page put the same button in different places), tapAt, settle, sleep, waitOut, log and call for what is neither a tap nor a wait. Because the taps and the budgets are data, PAGE_DISPATCH.md can print every one, and tools/dispatchEval can pin them. Reading pageHandlers.ts top to bottom is reading the dispatch order:

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

Handle a page walks through adding one.

Permanent and transient​

Every PageName declares in PageProfiles (data.ts) how it leaves the screen:

  • Permanent — it waits for input, and the only way past it is a tap. The navigate band taps these.
  • Transient — it dismisses itself after durationMs. Tapping it is worse than doing nothing: by the time the tap lands, the page is gone and the tap hits whatever replaced it. nav.wait.transient sits these out.

PageProfiles is a mapped type over PageName, so a name with no profile is a build error rather than a page whose behaviour nobody decided. Durations are quoted in frames rather than milliseconds, because that is how the game counts these windows; the Device frame rate setting scales them.

The five looks​

gPages.detect() // → PageName, after the subscriptions have run
gPages.observe() // → the PageEvent, filed in the history, nothing run over it
gPages.react(event) // run the queue over an event `observe` returned
gPages.peek() // → detection with no broadcast and no history
gPages.matches(name) // → is *this* page up? A cheaper, per-page check
gPages.navigate(PageName.FriendPage) // look and act until we are there

detect is observe then react. peek is for "where am I?" from code that must not set anything in motion. Writing the caller — a task that walks a flow rather than reacting to one screen — is Driving screens.

Modes are not pages​

A fever is not a screen: it is the same board with the lights down and the gauge turned into a timer. State like that is read from its own probe table (ts.isFeverTime(), fever.ts) and never from the matched key — a Page entry's variant is documentation and tooling by contract; the script only ever learns the page name. Formal Beast's twin gauge and the Lorcana transformation are the other two modes, and each has a watcher modelled on fever.ts.