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.
page-fingerprint-probesBroadcast
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.
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:
| Band | Promise |
|---|---|
observe | Takes no captures, taps nothing. Always runs. |
record | Measurements that need an untouched screen — captures included. |
guard | Handles what is not a game screen at all: system dialogs, the root warning. |
dismiss | Closes interruptions standing between the script and where it is going. |
navigate | Moves toward the goal. Silent when the look had no goal, and never on the destination page. |
notify | Logging 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:
navigateis 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.navigatenever fires on the destination. Arriving isnavigate()'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:
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.transientsits 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.