From c0038749e4969c5a1613c30416d477f48c050452 Mon Sep 17 00:00:00 2001 From: Daniel Hobi Date: Mon, 5 Oct 2026 21:15:39 +0000 Subject: [PATCH 1/6] Speedrun: play a sheet headless, the same to the millisecond as the browser (#509) `npm run speedrun -w taoot -- --headless` plays a sheet in node with the workbench's own driver over a stand-in window. From a cold boot, seed 20, all 361 lines of the sheet came out the same in-game time in node and in the browser (2:45 in 17 s against 3:36). What it took, beyond the runner: - a standing watch is asked by the session on every pass and halts the game on the pass it rises; only the watch's action moves it until done - the watchdog's quarter-second tick no longer holds up the next line - the runner does not look at the game while it waits on a file - headless frames give each callback its own task, as a browser does - budgets count the headless run's own frames, not the wall Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/tests.md | 35 + engine/src/runtime/session.ts | 74 ++- engine/src/web/speedrun/action.ts | 10 +- engine/src/web/speedrun/actions-core.ts | 16 +- engine/src/web/speedrun/driver.ts | 19 + engine/src/web/speedrun/page-driver.ts | 841 ++++++++++++------------ engine/src/web/speedrun/runner.ts | 117 +++- engine/tests/speedrun-clock.ts | 57 ++ taoot/tests/auto/speedrun-headless.ts | 47 ++ taoot/tests/harness.ts | 8 +- taoot/tests/speedrun/driver.ts | 771 +++++++++++----------- taoot/tests/speedrun/headless.ts | 264 ++++++++ taoot/tests/speedrun/speedrun.ts | 29 + 13 files changed, 1455 insertions(+), 833 deletions(-) create mode 100644 taoot/tests/auto/speedrun-headless.ts create mode 100644 taoot/tests/speedrun/headless.ts diff --git a/docs/reference/tests.md b/docs/reference/tests.md index 5660b8d9..04cf4f74 100644 --- a/docs/reference/tests.md +++ b/docs/reference/tests.md @@ -462,6 +462,41 @@ in-game time with the wall time small beside it, and says `LOADING` while the link is busy. On a slow machine `--patience=N` gives every line N times its budget; a budget only decides when a run is called stuck. +**A standing watch** (`watchFor`) is asked by the session too, on every pass: on +the pass its condition rises the game stops, the interrupted line stands still, +and only the watch's own action moves the game until it is done. Polled on the +runner's beat instead, the ESC for a sinking film landed some passes in, and how +many was the machine's. + +### Without a browser + +``` +npm run speedrun -w taoot -- --headless --from="m3p0 smokestack" --seed=20 +``` + +`--headless` plays the sheet in node against the same host the playthrough +suites drive, with no drawing, as fast as the CPU goes +([#509](https://github.com/dhobi/dreamrefactory/issues/509)). It drives the game +with the workbench's own driver over a stand-in window, so every gesture is the +same code, and it gives the browser's numbers: the sheet from a cold boot, seed +20, came out the same to the millisecond of in-game time on every one of 361 +lines in both runners (2:45 of it, in 17 s headless against 3:36 in the +browser). Load points are shared with the browser runner (`out/speedrun/`). +What it cannot run is what the workbench cannot: `travel`, `hunt` and `stand`. +Its budgets count its own frames, not the wall. + +Two rules make the runners agree, and both are the sheet clock's, not the +headless runner's: + +- **The runner looks at the game only once the frame's work has settled.** A + browser settles every promise between one animation-frame callback and the + next; the headless runner gives each callback a task of its own for the same + reason. +- **The runner does not look at the game while it waits on a file** + (`GameSession.loadingFiles`): a pause is not over and a hold does not close + until the load has landed. A runner that saw a film still downloading as no + film let a pass run the moment it arrived, before pressing ESC at it. + ### A coin on arrival Some of the ship is decided by a die. The Gorse-Joneses, the only people who diff --git a/engine/src/runtime/session.ts b/engine/src/runtime/session.ts index 898ad30e..3ef33e4b 100644 --- a/engine/src/runtime/session.ts +++ b/engine/src/runtime/session.ts @@ -1070,7 +1070,7 @@ export class GameSession { // own intro and its question run on the page, not on passes (#508) const held = this.blockedOnFiles > 0 || - (this.sheetClock && (!this.coreLoaded || (this.sheetHolds.size === 0 && this.sheetPasses === 0))); + (this.sheetClock && (!this.coreLoaded || (this.runningHolds() === 0 && this.pausePasses === 0))); if (held && this.lastRaw !== null) this.blockedTotal += raw - this.lastRaw; this.lastRaw = raw; this.rawNow = raw; @@ -1091,7 +1091,10 @@ export class GameSession { if (pass.due) { this.stepAnchor = pass.next; this.gameNow += ENGINE_STEP_MS; - if (this.sheetHolds.size === 0 && this.sheetPasses > 0) this.sheetPasses--; + if (this.runningHolds() === 0 && this.pausePasses > 0) { + if (this.sheetHalted) this.sheetWatchPasses--; + else this.sheetPasses--; + } } } return this.gameNow; @@ -1136,17 +1139,60 @@ export class GameSession { readonly sheetHolds = new Map boolean>(); /** passes still to run with no hold open */ sheetPasses = 0; + /** + * A sheet's standing watches (`watchFor`), by id, asked beside the holds + * (#509). A watch never runs the game; RISING, it stops it — on the very pass + * its condition came true — until the runner has acted on it. Polled from the + * runner's own beat instead, a watch pressed its key some passes after the + * film it watches for began, and how many was the machine's. + */ + readonly sheetWatches = new Map boolean; was: boolean; rose: boolean }>(); + /** + * A watch has risen and the runner has not acted on it yet: the interrupted + * line stands still — its holds neither run the game nor close, its pauses + * do not count down — and only the watch's own action moves the game, through + * {@link sheetWatchHolds} and {@link sheetWatchPasses}. The runner clears it + * when the action is done. + */ + sheetHalted = false; + /** the holds a watch's action opened — the only ones that count while halted */ + readonly sheetWatchHolds = new Set(); + /** a watch's action's pause, in passes — the only one that counts while halted */ + sheetWatchPasses = 0; - /** close every hold whose condition now holds (a throwing one counts as not yet) */ + /** the open holds that may run the game: the watch's own while halted, else all */ + private runningHolds(): number { + if (!this.sheetHalted) return this.sheetHolds.size; + let n = 0; + for (const id of this.sheetHolds.keys()) if (this.sheetWatchHolds.has(id)) n++; + return n; + } + /** the pause in passes that counts right now */ + private get pausePasses(): number { + return this.sheetHalted ? this.sheetWatchPasses : this.sheetPasses; + } + + /** close every hold whose condition now holds, and note every watch that rose */ closeSheetHolds(): void { - for (const [id, met] of this.sheetHolds) { - let ok = false; + const asked = (met: () => boolean): boolean => { try { - ok = met(); + return met(); } catch { - ok = false; + return false; // a throwing condition counts as not yet } - if (ok) this.sheetHolds.delete(id); + }; + if (this.blockedOnFiles > 0) return; // not while a file is coming — see loadingFiles + for (const [id, met] of this.sheetHolds) { + if (this.sheetHalted && !this.sheetWatchHolds.has(id)) continue; + if (asked(met)) this.sheetHolds.delete(id); + } + for (const w of this.sheetWatches.values()) { + const now = asked(w.met); + if (now && !w.was) { + w.rose = true; + this.sheetHalted = true; + } + w.was = now; } } /** the game time {@link gameTime} last returned */ @@ -1211,6 +1257,18 @@ export class GameSession { return this.frozenSince !== null; } + /** + * Is the engine waiting on a file right now? A sheet's runner does not look + * at the game while it is (#509): a pause is not over and a hold does not + * close until the load has landed. Else a runner on a slow link saw a film + * that was still downloading as no film at all, paused a pass, and the pass + * ran the moment the file came — with the film on screen and nobody pressing + * ESC at it — where a runner with the file to hand pressed first. + */ + get loadingFiles(): boolean { + return this.blockedOnFiles > 0; + } + /** * Stop the world: hold the clock and suspend the sound, until {@link thawTime}. * diff --git a/engine/src/web/speedrun/action.ts b/engine/src/web/speedrun/action.ts index 7e906836..ee1ce8ca 100644 --- a/engine/src/web/speedrun/action.ts +++ b/engine/src/web/speedrun/action.ts @@ -36,7 +36,7 @@ */ import { SheetError, type Step, type VerbSpec } from "./sheet"; import type { SpeedrunDriver, WaitMode } from "./driver"; -import { SCREEN, SHOWING } from "./driver"; +import { SCREEN, SHOWING, budgetNow } from "./driver"; export interface ActionContext { d: SpeedrunDriver; @@ -765,12 +765,12 @@ export async function converse( /** how many plaques were answered -1 on the way out — `then: leave` */ let bailed = 0; const maxTurns = Number(c.step.opts.maxturns ?? 60); - const deadline = Date.now() + c.budget; + const deadline = budgetNow(c.d) + c.budget; - const left = () => Math.max(1000, deadline - Date.now()); + const left = () => Math.max(1000, deadline - budgetNow(c.d)); for (let turn = 0; turn < maxTurns; ) { - if (Date.now() > deadline) { + if (budgetNow(c.d) > deadline) { throw new Error( `conversation ran past its ${c.budget} ms budget (picked ${picked.join(",") || "nothing"})`, ); @@ -941,7 +941,7 @@ export async function converse( await d.hold( `!(window.dbg.viewer && window.dbg.viewer.awaitingChoice)`, `bevel ${idx} to be taken`, - Math.max(1000, deadline - Date.now()), + Math.max(1000, deadline - budgetNow(c.d)), ); } throw new Error(`conversation did not close in ${maxTurns} turns (picked ${picked.join(",") || "nothing"})`); diff --git a/engine/src/web/speedrun/actions-core.ts b/engine/src/web/speedrun/actions-core.ts index 4aa2fd12..ed2dc05c 100644 --- a/engine/src/web/speedrun/actions-core.ts +++ b/engine/src/web/speedrun/actions-core.ts @@ -18,7 +18,7 @@ * {@link ActionContext.verbs}, which is what lets `watchFor` parse a line * against the table the run is actually using. */ -import { SHOWING } from "./driver"; +import { SHOWING, budgetNow } from "./driver"; import { IDLE, aimAtSettled, @@ -245,8 +245,8 @@ export const CORE_ACTIONS: ActionTable = { */ const until = condition(c.step.opts.until); const max = Number(c.step.opts.max ?? 40); - const deadline = Date.now() + c.budget; - const left = () => Math.max(250, deadline - Date.now()); + const deadline = budgetNow(c.d) + c.budget; + const left = () => Math.max(250, deadline - budgetNow(c.d)); if (await c.d.evaluate(`!!(${until})`)) { c.say(`${c.step.opts.until} already — no click needed`); return; @@ -259,7 +259,7 @@ export const CORE_ACTIONS: ActionTable = { let taps = 0; for (;;) { if (await c.d.evaluate(`!!(${until})`)) break; - if (Date.now() > deadline) { + if (budgetNow(c.d) > deadline) { throw new Error( `clicked ${x},${y} ${taps} time(s) in ${c.budget} ms and ${c.step.opts.until} never came true`, ); @@ -789,11 +789,11 @@ export const CORE_ACTIONS: ActionTable = { return { x: Number(m[1]), y: Number(m[2]) }; }); const max = Number(c.step.opts.max ?? 400); - const deadline = Date.now() + c.budget; + const deadline = budgetNow(c.d) + c.budget; let thrown = 0; for (; thrown < max; thrown++) { if (await c.d.evaluate(`(() => !!(${until}))()`)) break; - if (Date.now() > deadline) throw new Error(`combo ran past its ${c.budget} ms budget after ${thrown}`); + if (budgetNow(c.d) > deadline) throw new Error(`combo ran past its ${c.budget} ms budget after ${thrown}`); /** * ONE BLOW PER IDLE ENGINE, and both halves of that are load-bearing. * @@ -868,11 +868,11 @@ export const CORE_ACTIONS: ActionTable = { if (!c.step.opts.until) throw new Error(`hammer needs an until: condition, or it would never stop`); const until = condition(c.step.opts.until); const max = Number(c.step.opts.max ?? 400); - const deadline = Date.now() + c.budget; + const deadline = budgetNow(c.d) + c.budget; let taps = 0; for (;;) { if (await c.d.evaluate(`!!(${until})`)) break; - if (Date.now() > deadline) { + if (budgetNow(c.d) > deadline) { throw new Error( `hammered ${name} ${taps} time(s) in ${c.budget} ms and ${c.step.opts.until} never came true`, ); diff --git a/engine/src/web/speedrun/driver.ts b/engine/src/web/speedrun/driver.ts index 5932eef4..b0419213 100644 --- a/engine/src/web/speedrun/driver.ts +++ b/engine/src/web/speedrun/driver.ts @@ -400,6 +400,22 @@ export interface SpeedrunDriver { */ pause?(): never; + /** + * The clock a step's `budget:` counts on, in ms — the wall clock when left + * out. A runner that plays the game faster than real time (the headless one, + * #509) counts its own frames instead, or a stuck line would run hours of + * the game inside ten seconds of the wall. + */ + budgetNow?(): number; + + /** + * The same driver for a standing watch's action (`watchFor`, #509): its holds + * and pauses are the watch's, the only ones that move the game while a watch + * that rose has halted it under a sheet's clock (GameSession.sheetHalted). A + * driver without it has its watches polled on the runner's beat instead. + */ + forWatch?(): SpeedrunDriver; + log(message: string): void; /** * The Playwright page, when there is one. @@ -514,3 +530,6 @@ export function waitExpr(mode: WaitMode): string | null { if (mode === "ready") return KEY_SAFE; return QUIET; } + +/** now, on the clock `d`'s budgets count on — see {@link SpeedrunDriver.budgetNow} */ +export const budgetNow = (d: SpeedrunDriver): number => d.budgetNow?.() ?? Date.now(); diff --git a/engine/src/web/speedrun/page-driver.ts b/engine/src/web/speedrun/page-driver.ts index 1e25ac85..491ee798 100644 --- a/engine/src/web/speedrun/page-driver.ts +++ b/engine/src/web/speedrun/page-driver.ts @@ -81,6 +81,8 @@ export interface PageDriverOptions { log?(message: string): void; /** raised to abort a run mid-flight; every wait checks it */ signal?: AbortSignal; + /** the clock budgets count on — {@link SpeedrunDriver.budgetNow}; the wall when left out */ + budgetNow?(): number; /** * Called just before a `reset()` reloads the document. * @@ -214,439 +216,454 @@ export function pageDriver(opts: PageDriverOptions): SpeedrunDriver { const frame = (): Promise => new Promise((r) => win.requestAnimationFrame(() => r())); + let holdIds = 0; + const budgetClock = opts.budgetNow ?? (() => performance.now()); /** - * A pause the game runs through: under a sheet's clock, `ms / 50` passes of it - * (at least one) rather than `ms` of the wall, so a gap between presses is - * the same length on any machine (#508); otherwise wall time, as before. - */ - const sleep = async (ms: number): Promise => { - const passes = Math.max(1, Math.round(ms / 50)); - const stepped = run(`(() => { - const s = window.dbg && window.dbg.session; - if (!s || !s.sheetClock) return false; - s.sheetPasses = ${passes}; - return true; - })()`); - if (!stepped) return wallWait(ms); - while (!run(`!window.dbg || !window.dbg.session.sheetClock || window.dbg.session.sheetPasses === 0`)) { - check(); - await frame(); - } - }; - - /** wall time that does not move the game — what polls beside a step use */ - const wallWait = (ms: number): Promise => - new Promise((resolve, reject) => { - const t = win.setTimeout(() => resolve(), ms); - opts.signal?.addEventListener( - "abort", - () => { - win.clearTimeout(t); - reject(new Aborted()); - }, - { once: true }, - ); - }); - - /** - * Poll a predicate on the animation frame. - * - * rAF and not a timer, because the engine advances on rAF: waiting on the same - * beat means a condition is seen the moment the frame that caused it has been - * drawn, never a timer-slice later. It also stops the loop dead when the tab is - * hidden, which is right — the game stops too. + * The driver, twice over (#509): the run's own, and {@link SpeedrunDriver.forWatch}'s + * for a standing watch's action, whose holds and pauses are marked as the + * watch's — the only ones that move the game while a risen watch has halted + * it (GameSession.sheetHalted). */ - const until = async (expr: string, budget: number): Promise => { - const deadline = performance.now() + budget; - // Under a sheet's clock the session asks the condition itself and stops the - // game on the pass it comes true (GameSession.sheetHolds, #508); this loop - // only watches for that. Without one it asks, as it always did. - const id = ++holdIds; - const held = run(`(() => { - const s = window.dbg && window.dbg.session; - if (!s || !s.sheetClock) return false; - s.sheetHolds.set(${id}, () => !!(${expr})); - return true; - })()`); - const met = held ? `!window.dbg || !window.dbg.session.sheetHolds.has(${id})` : expr; - try { - for (;;) { + const make = (watch: boolean): SpeedrunDriver => { + const passesField = watch ? "sheetWatchPasses" : "sheetPasses"; + /** the line that marks a hold as the watch's, or none */ + const watchMark = (id: number): string => (watch ? "s.sheetWatchHolds.add(" + id + ");" : ""); + /** + * A pause the game runs through: under a sheet's clock, `ms / 50` passes of it + * (at least one) rather than `ms` of the wall, so a gap between presses is + * the same length on any machine (#508); otherwise wall time, as before. + */ + const sleep = async (ms: number): Promise => { + const passes = Math.max(1, Math.round(ms / 50)); + const stepped = run(`(() => { + const s = window.dbg && window.dbg.session; + if (!s || !s.sheetClock) return false; + s[${JSON.stringify(passesField)}] = ${passes}; + return true; + })()`); + if (!stepped) return wallWait(ms); + while (!run(`!window.dbg || !window.dbg.session.sheetClock || (!window.dbg.session[${JSON.stringify(passesField)}] && !window.dbg.session.loadingFiles)`)) { check(); - if (run(met)) return true; - if (performance.now() > deadline) return false; await frame(); } - } finally { - if (held) run(`void (window.dbg && window.dbg.session.sheetHolds.delete(${id}))`); - } - }; - let holdIds = 0; - - const hold = async (expr: string, what: string, budget = timeout): Promise => { - if (!(await until(expr, budget))) throw new Error(`stuck waiting for ${what}`); - }; - - const tryHold = (expr: string, budget: number): Promise => until(expr, budget); - - const settle = async (mode: WaitMode, what: string, budget = timeout): Promise => { - const expr = waitExpr(mode); - if (!expr) return; - await hold(expr, `${what} to settle`, budget); - }; - - /** canvas pixel (512x384) -> a client point, through main.ts's own mapping */ - const clientPoint = (x: number, y: number): Point => - clientPointFor(x, y, canvas.getBoundingClientRect(), canvas); - - const pointer = (type: string, at: Point, target: EventTarget = canvas): void => { - target.dispatchEvent( - new win.PointerEvent(type, { - clientX: at.x, - clientY: at.y, - pointerId: pointerId++, - // explicitly a mouse: the pointerdown handler sends a TOUCH down - // `beginTouch`, which is a different gesture entirely - pointerType: "mouse", - bubbles: true, - cancelable: true, - button: 0, - buttons: type === "pointerup" ? 0 : 1, - }), - ); - }; - - /** - * Move the cursor — which takes TWO events, and a `pointermove` is not one of - * them. - * - * main.ts listens for a moving mouse in `mousemove` (the one that reads - * `session.pointerDown` and republishes the cursor mid-drag), and its - * `pointermove` listener is the TOUCH gesture recogniser, which drops anything - * whose pointerId is not the finger it is following. A real browser hides the - * difference: a physical move fires `pointermove` and then a compatibility - * `mousemove`, so a page that listens to either one sees the move. A - * SYNTHESIZED PointerEvent generates no compatibility event at all — nothing - * else does, since the compatibility event comes from the input pipeline rather - * than from dispatch. - * - * So a drag built out of `pointermove` alone pressed in the right place and - * then never moved: `mouse()` kept answering the grab point for every turn of - * the held script's `while stilldown()` loop. Silent, because every gesture - * still went in and the loop still ran — the coal lever simply stayed on - * whatever deg the cursor was pressed at (deg 0, its travel starting below the - * point `aimAtThing` grabs it by), and the five turbine dials saw `delt = 0` - * every frame and never turned at all. - * - * Both events, in the order a browser sends them, at the canvas. - */ - const movePointer = (at: Point): void => { - pointer("pointermove", at); - canvas.dispatchEvent( - new win.MouseEvent("mousemove", { - clientX: at.x, - clientY: at.y, - bubbles: true, - cancelable: true, - button: 0, - buttons: 1, - }), - ); - }; - - /** - * How long a click holds the button down — because a click is not an instant. - * - * A hand takes 50–150 ms between pressing and letting go, and the game reads - * that gap. Whole gestures are decided inside it: `while stilldown()` loops - * carry a held item and read `mouse()` every turn, `trackbut` lights a stage - * button only while it is held, and INVEN.SHP's `stdmouse` decides where a - * carried object LANDS from what `hittest` finds when the button comes up. - * - * Dispatching down and up in one task gives the engine no gap at all. The press - * is handled asynchronously (`session.track(viewer.press(...))`), so by the time - * the script chain runs its first line the button is already up and every one of - * those loops falls straight through. Measured on the coal lever, whose - * mousedown IS such a loop: - * - * instant deg 9 -> 9 coal 50 -> 50 stilldown turns 1 - * held 3 frames deg 9 -> 11 coal 50 -> 47 stilldown turns 3 - * - * One turn means the loop was entered and `stilldown()` was already false. The - * symptom higher up is a click that "does nothing" — putting the Rubaiyat down - * in the coal bunker, where the drop is the release and the release never - * happened while anything was listening. - * - * Three frames is what that measurement needed; it is deliberately a count of - * frames rather than milliseconds, because what has to fit in the gap is a turn - * of an engine loop and the engine runs on frames. - */ - const CLICK_FRAMES = 3; - const heldFrames = async (): Promise => { - for (let i = 0; i < CLICK_FRAMES; i++) await frame(); - }; - - const pressKey = (name: string): void => { - // `key` is what main.ts reads; the rest are filled in because a listener - // further up the page may look at them, and a half-built event is a bug - // waiting for the first person who adds one - const key = name === "Space" ? " " : name; - win.dispatchEvent( - new win.KeyboardEvent("keydown", { key, code: keyCode(key), bubbles: true, cancelable: true }), - ); - win.dispatchEvent( - new win.KeyboardEvent("keyup", { key, code: keyCode(key), bubbles: true, cancelable: true }), - ); - }; - - /** a best-effort `code` for the few keys a sheet actually sends */ - const keyCode = (key: string): string => { - if (key === " ") return "Space"; - if (key === "Escape") return "Escape"; - if (key.startsWith("Arrow")) return key; - if (key.length === 1) return /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : ""; - return key; - }; + }; + + /** wall time that does not move the game — what polls beside a step use */ + const wallWait = (ms: number): Promise => + new Promise((resolve, reject) => { + const t = win.setTimeout(() => resolve(), ms); + opts.signal?.addEventListener( + "abort", + () => { + win.clearTimeout(t); + reject(new Aborted()); + }, + { once: true }, + ); + }); - return { /** - * Wall clock, engine frames and the load remover's total (#251), sampled - * together — the two page-side numbers in ONE compiled expression, so they - * are a reading of one instant rather than of two. + * Poll a predicate on the animation frame. * - * The wall clock is this window's `performance.now` and the loading total is - * measured on the GAME window's, which are two time origins if the game is - * ever in a frame. That is harmless and stays harmless: nothing subtracts - * one from the other, only a difference of one from a difference of the - * other, and both count real milliseconds at the same rate. + * rAF and not a timer, because the engine advances on rAF: waiting on the same + * beat means a condition is seen the moment the frame that caused it has been + * drawn, never a timer-slice later. It also stops the loop dead when the tab is + * hidden, which is right — the game stops too. */ - clock: (): Promise => - new Promise((resolve) => { - const [frames, loading, game] = run<[number, number, number]>( - "[window.dbg.session.frameCounter, window.dbg.loading().ms, window.dbg.session.gameNow]", - ); - resolve({ ms: performance.now(), frames, loading, game }); - }), - evaluate, - hold, - tryHold, - settle, - sleep, - wallWait, - pad: async (ms) => { - if (!ms) return; - paddedMs += ms; - await sleep(ms); - }, - padded: () => paddedMs, - - key: async (name, wait = "ready", budget = timeout) => { - await hold(KEY_SAFE, `the engine to accept ${name}`, budget); - pressKey(name); - await settle(wait, `key ${name}`, budget); - }, - rawKey: async (name) => { - pressKey(name); - await frame(); - }, - - clickAt: async (x, y, wait = "taken", budget = timeout) => { - const at = clientPoint(x, y); - pointer("pointerdown", at); - await heldFrames(); - pointer("pointerup", at, win); - await settle(wait, `click ${x},${y}`, budget); - }, - - holdAt: async (x, y, opts, budget = timeout) => { - // ARM FIRST, then press — see HoldOptions.arm - const armed = opts.arm ? await until(opts.arm, opts.armBudget ?? Math.min(budget, 10_000)) : true; - if (!armed) return { armed, held: false }; - const at = clientPoint(x, y); - movePointer(at); - pointer("pointerdown", at); - let held = false; + const until = async (expr: string, budget: number): Promise => { + const deadline = budgetClock() + budget; + // Under a sheet's clock the session asks the condition itself and stops the + // game on the pass it comes true (GameSession.sheetHolds, #508); this loop + // only watches for that. Without one it asks, as it always did. + const id = ++holdIds; + const held = run(`(() => { + const s = window.dbg && window.dbg.session; + if (!s || !s.sheetClock) return false; + s.sheetHolds.set(${id}, () => !!(${expr})); + ${watchMark(id)} + return true; + })()`); + const met = held ? `!window.dbg || !window.dbg.session.sheetHolds.has(${id})` : expr; try { - held = await until(opts.until, budget); + for (;;) { + check(); + if (run(met)) return true; + if (budgetClock() > deadline) return false; + await frame(); + } } finally { - // released whatever happened: leaving the button down would make every - // later gesture a drag - pointer("pointerup", at, win); + if (held) run(`void (window.dbg && window.dbg.session.sheetHolds.delete(${id}))`); } - await heldFrames(); - return { armed, held }; - }, + }; + + const hold = async (expr: string, what: string, budget = timeout): Promise => { + if (!(await until(expr, budget))) throw new Error(`stuck waiting for ${what}`); + }; + + const tryHold = (expr: string, budget: number): Promise => until(expr, budget); + + const settle = async (mode: WaitMode, what: string, budget = timeout): Promise => { + const expr = waitExpr(mode); + if (!expr) return; + await hold(expr, `${what} to settle`, budget); + }; + + /** canvas pixel (512x384) -> a client point, through main.ts's own mapping */ + const clientPoint = (x: number, y: number): Point => + clientPointFor(x, y, canvas.getBoundingClientRect(), canvas); + + const pointer = (type: string, at: Point, target: EventTarget = canvas): void => { + target.dispatchEvent( + new win.PointerEvent(type, { + clientX: at.x, + clientY: at.y, + pointerId: pointerId++, + // explicitly a mouse: the pointerdown handler sends a TOUCH down + // `beginTouch`, which is a different gesture entirely + pointerType: "mouse", + bubbles: true, + cancelable: true, + button: 0, + buttons: type === "pointerup" ? 0 : 1, + }), + ); + }; - hammer: async (name, { until: goal, arm, gap = defaultGap, budget = timeout, what }: HammerOptions) => { - const deadline = performance.now() + budget; - let pressed = 0; - for (;;) { - check(); - if (run(goal)) return pressed; - if (performance.now() > deadline) { - throw new Error(`stuck waiting for ${what}: ${pressed} presses of ${name} in ${budget} ms`); - } - // only press when the key means what we think it means, and only when it - // will not be dropped - if ((!arm || run(arm)) && run(KEY_SAFE)) { - pressKey(name); - pressed++; - } - await (gap ? sleep(gap) : frame()); - } - }, + /** + * Move the cursor — which takes TWO events, and a `pointermove` is not one of + * them. + * + * main.ts listens for a moving mouse in `mousemove` (the one that reads + * `session.pointerDown` and republishes the cursor mid-drag), and its + * `pointermove` listener is the TOUCH gesture recogniser, which drops anything + * whose pointerId is not the finger it is following. A real browser hides the + * difference: a physical move fires `pointermove` and then a compatibility + * `mousemove`, so a page that listens to either one sees the move. A + * SYNTHESIZED PointerEvent generates no compatibility event at all — nothing + * else does, since the compatibility event comes from the input pipeline rather + * than from dispatch. + * + * So a drag built out of `pointermove` alone pressed in the right place and + * then never moved: `mouse()` kept answering the grab point for every turn of + * the held script's `while stilldown()` loop. Silent, because every gesture + * still went in and the loop still ran — the coal lever simply stayed on + * whatever deg the cursor was pressed at (deg 0, its travel starting below the + * point `aimAtThing` grabs it by), and the five turbine dials saw `delt = 0` + * every frame and never turned at all. + * + * Both events, in the order a browser sends them, at the canvas. + */ + const movePointer = (at: Point): void => { + pointer("pointermove", at); + canvas.dispatchEvent( + new win.MouseEvent("mousemove", { + clientX: at.x, + clientY: at.y, + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + }), + ); + }; - aim: async (kind, name) => { - // the engine's own hit test, called directly — this is the sweep the - // Playwright driver has to inject as source, and here it is simply local - const { aimAtThing, aimAtHotspot } = await import("./aim"); - const dbg = (win as unknown as { dbg: any }).dbg; - const s = dbg.session; - const v = dbg.viewer; - const adapter = { - // the framebuffer's size, not this canvas's: Dust draws 512x384 through - // a 1024x768 canvas, and a hit test is asked in framebuffer pixels - width: dbg.host.screen.width, - height: dbg.host.screen.height, - hitTest: (x: number, y: number) => s.hitTestAt(x, y), - propUnder: (x: number, y: number) => { - const p = v.propUnder(x, y); - return p ? p.group.name : null; - }, - inFlat: !s.viewShowing && !!s.stageScript, - hotspot: (n: string) => { - const obj = v.scene.views[v.viewIdx].objects.find( - (o: { identifier?: string }) => (o.identifier || "").toLowerCase() === n.toLowerCase(), + /** + * How long a click holds the button down — because a click is not an instant. + * + * A hand takes 50–150 ms between pressing and letting go, and the game reads + * that gap. Whole gestures are decided inside it: `while stilldown()` loops + * carry a held item and read `mouse()` every turn, `trackbut` lights a stage + * button only while it is held, and INVEN.SHP's `stdmouse` decides where a + * carried object LANDS from what `hittest` finds when the button comes up. + * + * Dispatching down and up in one task gives the engine no gap at all. The press + * is handled asynchronously (`session.track(viewer.press(...))`), so by the time + * the script chain runs its first line the button is already up and every one of + * those loops falls straight through. Measured on the coal lever, whose + * mousedown IS such a loop: + * + * instant deg 9 -> 9 coal 50 -> 50 stilldown turns 1 + * held 3 frames deg 9 -> 11 coal 50 -> 47 stilldown turns 3 + * + * One turn means the loop was entered and `stilldown()` was already false. The + * symptom higher up is a click that "does nothing" — putting the Rubaiyat down + * in the coal bunker, where the drop is the release and the release never + * happened while anything was listening. + * + * Three frames is what that measurement needed; it is deliberately a count of + * frames rather than milliseconds, because what has to fit in the gap is a turn + * of an engine loop and the engine runs on frames. + */ + const CLICK_FRAMES = 3; + const heldFrames = async (): Promise => { + for (let i = 0; i < CLICK_FRAMES; i++) await frame(); + }; + + const pressKey = (name: string): void => { + // `key` is what main.ts reads; the rest are filled in because a listener + // further up the page may look at them, and a half-built event is a bug + // waiting for the first person who adds one + const key = name === "Space" ? " " : name; + win.dispatchEvent( + new win.KeyboardEvent("keydown", { key, code: keyCode(key), bubbles: true, cancelable: true }), + ); + win.dispatchEvent( + new win.KeyboardEvent("keyup", { key, code: keyCode(key), bubbles: true, cancelable: true }), + ); + }; + + /** a best-effort `code` for the few keys a sheet actually sends */ + const keyCode = (key: string): string => { + if (key === " ") return "Space"; + if (key === "Escape") return "Escape"; + if (key.startsWith("Arrow")) return key; + if (key.length === 1) return /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : ""; + return key; + }; + + return { + /** + * Wall clock, engine frames and the load remover's total (#251), sampled + * together — the two page-side numbers in ONE compiled expression, so they + * are a reading of one instant rather than of two. + * + * The wall clock is this window's `performance.now` and the loading total is + * measured on the GAME window's, which are two time origins if the game is + * ever in a frame. That is harmless and stays harmless: nothing subtracts + * one from the other, only a difference of one from a difference of the + * other, and both count real milliseconds at the same rate. + */ + clock: (): Promise => + new Promise((resolve) => { + const [frames, loading, game] = run<[number, number, number]>( + "[window.dbg.session.frameCounter, window.dbg.loading().ms, window.dbg.session.gameNow]", ); - return obj - ? { x0: obj.startRegionX, y0: obj.startRegionY, x1: obj.endRegionX, y1: obj.endRegionY } - : null; - }, - }; - return kind === "thing" ? aimAtThing(adapter, name) : aimAtHotspot(adapter, name); - }, - - drag: async (from, to, steps = 8) => { - const a = clientPoint(from.x, from.y); - const b = clientPoint(to.x, to.y); - // the steps matter: main.ts publishes the pointer as the mouse moves and - // the held script's `while stilldown()` loop reads it every frame, so a - // jump from press to release drops the item where it was picked up - movePointer(a); - pointer("pointerdown", a); - for (let i = 1; i <= steps; i++) { - movePointer({ x: a.x + ((b.x - a.x) * i) / steps, y: a.y + ((b.y - a.y) * i) / steps }); + resolve({ ms: performance.now(), frames, loading, game }); + }), + evaluate, + hold, + tryHold, + settle, + sleep, + wallWait, + pad: async (ms) => { + if (!ms) return; + paddedMs += ms; + await sleep(ms); + }, + padded: () => paddedMs, + + key: async (name, wait = "ready", budget = timeout) => { + await hold(KEY_SAFE, `the engine to accept ${name}`, budget); + pressKey(name); + await settle(wait, `key ${name}`, budget); + }, + rawKey: async (name) => { + pressKey(name); await frame(); - } - pointer("pointerup", b, win); - }, + }, - dragOnto: async (from, to, o = {}) => { - const budget = o.budget ?? timeout; - const a = clientPoint(from.x, from.y); - const b = clientPoint(to.x, to.y); - movePointer(a); - pointer("pointerdown", a); - let armed = true; - let landed = true; - try { - // ARM, then move: see the interface note. Capped rather than given the - // whole budget, because a loop that has not taken the press in ten - // seconds is not going to. - if (o.armed) armed = await until(o.armed, Math.min(budget, 10_000)); - movePointer(b); - if (o.landed) landed = await until(o.landed, Math.min(budget, 10_000)); - } finally { - // Released WHEREVER the drag got to, and released even when the waits - // above came back false: leaving the button down makes every later - // gesture a drag (the same rule holdAt follows). + clickAt: async (x, y, wait = "taken", budget = timeout) => { + const at = clientPoint(x, y); + pointer("pointerdown", at); + await heldFrames(); + pointer("pointerup", at, win); + await settle(wait, `click ${x},${y}`, budget); + }, + + holdAt: async (x, y, opts, budget = timeout) => { + // ARM FIRST, then press — see HoldOptions.arm + const armed = opts.arm ? await until(opts.arm, opts.armBudget ?? Math.min(budget, 10_000)) : true; + if (!armed) return { armed, held: false }; + const at = clientPoint(x, y); + movePointer(at); + pointer("pointerdown", at); + let held = false; + try { + held = await until(opts.until, budget); + } finally { + // released whatever happened: leaving the button down would make every + // later gesture a drag + pointer("pointerup", at, win); + } + await heldFrames(); + return { armed, held }; + }, + + hammer: async (name, { until: goal, arm, gap = defaultGap, budget = timeout, what }: HammerOptions) => { + const deadline = budgetClock() + budget; + let pressed = 0; + for (;;) { + check(); + if (run(goal)) return pressed; + if (budgetClock() > deadline) { + throw new Error(`stuck waiting for ${what}: ${pressed} presses of ${name} in ${budget} ms`); + } + // only press when the key means what we think it means, and only when it + // will not be dropped + if ((!arm || run(arm)) && run(KEY_SAFE)) { + pressKey(name); + pressed++; + } + await (gap ? sleep(gap) : frame()); + } + }, + + aim: async (kind, name) => { + // the engine's own hit test, called directly — this is the sweep the + // Playwright driver has to inject as source, and here it is simply local + const { aimAtThing, aimAtHotspot } = await import("./aim"); + const dbg = (win as unknown as { dbg: any }).dbg; + const s = dbg.session; + const v = dbg.viewer; + const adapter = { + // the framebuffer's size, not this canvas's: Dust draws 512x384 through + // a 1024x768 canvas, and a hit test is asked in framebuffer pixels + width: dbg.host.screen.width, + height: dbg.host.screen.height, + hitTest: (x: number, y: number) => s.hitTestAt(x, y), + propUnder: (x: number, y: number) => { + const p = v.propUnder(x, y); + return p ? p.group.name : null; + }, + inFlat: !s.viewShowing && !!s.stageScript, + hotspot: (n: string) => { + const obj = v.scene.views[v.viewIdx].objects.find( + (o: { identifier?: string }) => (o.identifier || "").toLowerCase() === n.toLowerCase(), + ); + return obj + ? { x0: obj.startRegionX, y0: obj.startRegionY, x1: obj.endRegionX, y1: obj.endRegionY } + : null; + }, + }; + return kind === "thing" ? aimAtThing(adapter, name) : aimAtHotspot(adapter, name); + }, + + drag: async (from, to, steps = 8) => { + const a = clientPoint(from.x, from.y); + const b = clientPoint(to.x, to.y); + // the steps matter: main.ts publishes the pointer as the mouse moves and + // the held script's `while stilldown()` loop reads it every frame, so a + // jump from press to release drops the item where it was picked up + movePointer(a); + pointer("pointerdown", a); + for (let i = 1; i <= steps; i++) { + movePointer({ x: a.x + ((b.x - a.x) * i) / steps, y: a.y + ((b.y - a.y) * i) / steps }); + await frame(); + } pointer("pointerup", b, win); - } - // and NOTHING after it — the release is the end of this gesture, and what - // it set off belongs to the next line of the sheet - return { armed, landed }; - }, - - dragProp: async (at, next, budget = timeout) => { - const from = clientPoint(at.x, at.y); - // `realYieldSeq` counts the frames a script has given up, bumped twice per - // turn of exactly the `while stilldown()` loop holding the drag — the - // `stilldown()` opening the turn and the `forceupdate()` closing it. So - // this waits one turn, which is the rate the dial itself steps at; see - // HELD_YIELDS for why one turn rather than the two this used to take. - const held = async (): Promise => { - const was = run("window.dbg.session.realYieldSeq"); - await until(`window.dbg.session.realYieldSeq >= ${was + HELD_YIELDS}`, Math.min(budget, 20_000)); - }; - movePointer(from); - pointer("pointerdown", from); - // where the cursor actually is, so the release happens there rather than - // back at the grab point — which is where a real hand lets go - let last = from; - try { - await held(); - for (let to = await next(at); to; to = await next(at)) { - last = clientPoint(to.x, to.y); - movePointer(last); - await held(); + }, + + dragOnto: async (from, to, o = {}) => { + const budget = o.budget ?? timeout; + const a = clientPoint(from.x, from.y); + const b = clientPoint(to.x, to.y); + movePointer(a); + pointer("pointerdown", a); + let armed = true; + let landed = true; + try { + // ARM, then move: see the interface note. Capped rather than given the + // whole budget, because a loop that has not taken the press in ten + // seconds is not going to. + if (o.armed) armed = await until(o.armed, Math.min(budget, 10_000)); + movePointer(b); + if (o.landed) landed = await until(o.landed, Math.min(budget, 10_000)); + } finally { + // Released WHEREVER the drag got to, and released even when the waits + // above came back false: leaving the button down makes every later + // gesture a drag (the same rule holdAt follows). + pointer("pointerup", b, win); } - } finally { - pointer("pointerup", last, win); - // The release is a gesture too, and a control that snaps on the button - // coming up has not snapped yet — see the long note on the Playwright - // twin of this method (taoot/tests/speedrun/driver.ts). - await until( - `!window.dbg.session.pollingInput() && !window.dbg.session.scriptBusy`, - Math.min(budget, 5_000), - ); - } - }, - - // localStorage, because the page has no disk. A `.ti` is a few kilobytes and - // base64 costs a third on top, which is nothing against the 5 MB a browser - // gives an origin — and it survives the reload that rebooting the game takes. - putSave: (name: string, bytes: Uint8Array) => - new Promise((resolve) => { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - localStorage.setItem(opts.keys.key(opts.sheet(), name), btoa(bin)); - resolve(); - }), - getSave: (name: string) => - new Promise((resolve) => { - const raw = localStorage.getItem(opts.keys.key(opts.sheet(), name)); - if (!raw) { - resolve(null); - return; + // and NOTHING after it — the release is the end of this gesture, and what + // it set off belongs to the next line of the sheet + return { armed, landed }; + }, + + dragProp: async (at, next, budget = timeout) => { + const from = clientPoint(at.x, at.y); + // `realYieldSeq` counts the frames a script has given up, bumped twice per + // turn of exactly the `while stilldown()` loop holding the drag — the + // `stilldown()` opening the turn and the `forceupdate()` closing it. So + // this waits one turn, which is the rate the dial itself steps at; see + // HELD_YIELDS for why one turn rather than the two this used to take. + const held = async (): Promise => { + const was = run("window.dbg.session.realYieldSeq"); + await until(`window.dbg.session.realYieldSeq >= ${was + HELD_YIELDS}`, Math.min(budget, 20_000)); + }; + movePointer(from); + pointer("pointerdown", from); + // where the cursor actually is, so the release happens there rather than + // back at the grab point — which is where a real hand lets go + let last = from; + try { + await held(); + for (let to = await next(at); to; to = await next(at)) { + last = clientPoint(to.x, to.y); + movePointer(last); + await held(); + } + } finally { + pointer("pointerup", last, win); + // The release is a gesture too, and a control that snaps on the button + // coming up has not snapped yet — see the long note on the Playwright + // twin of this method (taoot/tests/speedrun/driver.ts). + await until( + `!window.dbg.session.pollingInput() && !window.dbg.session.scriptBusy`, + Math.min(budget, 5_000), + ); } - const bin = atob(raw); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - resolve(out); - }), - - /** - * Reload the document, and never come back. - * - * The promise deliberately never settles. The reload tears down this - * JavaScript context, so an action that awaited it and carried on would be - * running against a dying page — a torn-off canvas, a `window.dbg` that is - * about to stop existing — and would report whatever nonsense it read there - * as the state of the game. Hanging is the honest shape: the run ends here, - * and it is the page that comes back, not this call. - */ - // the sheet's own breakpoint; the page catches this and keeps the pointer - pause: () => { - throw new Paused(); - }, - - restart: () => { - opts.beforeRestart?.(); - win.location.reload(); - return new Promise(() => {}); - }, - - log, + }, + + // localStorage, because the page has no disk. A `.ti` is a few kilobytes and + // base64 costs a third on top, which is nothing against the 5 MB a browser + // gives an origin — and it survives the reload that rebooting the game takes. + putSave: (name: string, bytes: Uint8Array) => + new Promise((resolve) => { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + localStorage.setItem(opts.keys.key(opts.sheet(), name), btoa(bin)); + resolve(); + }), + getSave: (name: string) => + new Promise((resolve) => { + const raw = localStorage.getItem(opts.keys.key(opts.sheet(), name)); + if (!raw) { + resolve(null); + return; + } + const bin = atob(raw); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + resolve(out); + }), + + /** + * Reload the document, and never come back. + * + * The promise deliberately never settles. The reload tears down this + * JavaScript context, so an action that awaited it and carried on would be + * running against a dying page — a torn-off canvas, a `window.dbg` that is + * about to stop existing — and would report whatever nonsense it read there + * as the state of the game. Hanging is the honest shape: the run ends here, + * and it is the page that comes back, not this call. + */ + // the sheet's own breakpoint; the page catches this and keeps the pointer + pause: () => { + throw new Paused(); + }, + + restart: () => { + opts.beforeRestart?.(); + win.location.reload(); + return new Promise(() => {}); + }, + + log, + budgetNow: opts.budgetNow, + }; }; + return { ...make(false), forWatch: () => make(true) }; } export { SHOWING } from "./driver"; diff --git a/engine/src/web/speedrun/runner.ts b/engine/src/web/speedrun/runner.ts index d9d6c20d..f39e2bfd 100644 --- a/engine/src/web/speedrun/runner.ts +++ b/engine/src/web/speedrun/runner.ts @@ -323,31 +323,27 @@ export async function runSheet( * as a single array. Nothing is polled at all when no watch is registered, so * a sheet that does not use them pays nothing. */ - const runWatches = async (): Promise => { + const runWatches = async (sheet: boolean): Promise => { const live = watches(); if (!live.length) return; - const probe = "[" + live.map((w) => `!!(${w.expr})`).join(",") + "]"; - let now: boolean[]; + let fire: boolean[]; try { - now = await d.evaluate(probe); + fire = sheet + ? await d.evaluate(takeRises(live)) + : edges(live, await d.evaluate("[" + live.map((w) => `!!(${w.expr})`).join(",") + "]")); } catch { return; // a page mid-navigation is not a watch failing } for (let i = 0; i < live.length; i++) { + if (!fire[i]) continue; const w = live[i]; - if (!now[i]) { - w.armed = false; // the edge is re-armed by the condition going false - continue; - } - if (w.armed) continue; - w.armed = true; w.fired++; const act = resolve(w.action.verb); if (!act) continue; const said: string[] = []; try { await act.run({ - d, + d: sheet ? watchDriver : d, step: w.action, wait: waitOf(actions, w.action), budget: Number(w.action.opts.budget ?? 10_000) * (hooks.patience ?? 1), @@ -361,18 +357,98 @@ export async function runSheet( hooks.onWatch?.(w, [`failed: ${(e as Error).message}`]); } } + // acted on: the interrupted line may go on + if (sheet && fire.some(Boolean)) { + await d.evaluate(`void (window.dbg && ${UNHALT})`).catch(() => {}); + } }; - /** poll the watches until the step it is running beside is done */ - const watchdog = (done: () => boolean): Promise => + /** which watches rose since the last poll on the runner's beat; going false re-arms the edge */ + const edges = (live: Watch[], now: boolean[]): boolean[] => + live.map((w, i) => { + const rose = now[i] && !w.armed; + w.armed = now[i]; + return rose; + }); + + /* + * Under a sheet's clock the SESSION asks the watches, on every pass, and + * halts the game on the pass one rises (GameSession.sheetWatches) — so a + * watch fires at the same point of the game on any machine (#509). These + * open them for the length of a step, collect what rose, and hand the edge + * back at the end. + */ + const watchDriver = d.forWatch?.() ?? d; + const UNHALT = `((s) => { s.sheetHalted = false; s.sheetWatchHolds.clear(); s.sheetWatchPasses = 0; })(window.dbg.session)`; + const watchIds = new Map(); + const idOf = (w: Watch): number => { + if (!watchIds.has(w)) watchIds.set(w, watchIds.size + 1); + return watchIds.get(w)!; + }; + const openWatches = (live: Watch[]): Promise => { + if (!d.forWatch) return Promise.resolve(false); + const open = live + .map((w) => "s.sheetWatches.set(" + idOf(w) + ", { met: () => !!(" + w.expr + "), was: " + w.armed + ", rose: false });") + .join("\n"); + return d + .evaluate(`(() => { + const s = window.dbg && window.dbg.session; + if (!s || !s.sheetClock || !s.sheetWatches) return false; + ${open} + return true; + })()`) + .catch(() => false); + }; + const takeRises = (live: Watch[]): string => `(() => { + const s = window.dbg.session; + return [${live.map(idOf).join(",")}].map((id) => { + const w = s.sheetWatches.get(id); + const rose = !!(w && w.rose); + if (w) w.rose = false; + return rose; + }); + })()`; + /** close them, keeping the edge: a rise nobody acted on yet fires at the next step */ + const closeWatches = async (live: Watch[]): Promise => { + const was = await d + .evaluate<(boolean | null)[]>(`(() => { + const s = window.dbg && window.dbg.session; + if (!s || !s.sheetWatches) return []; + const out = [${live.map(idOf).join(",")}].map((id) => { + const w = s.sheetWatches.get(id); + s.sheetWatches.delete(id); + return w ? w.was && !w.rose : null; + }); + ${UNHALT}; + return out; + })()`) + .catch((): (boolean | null)[] => []); + live.forEach((w, i) => { + const v = was[i]; + if (typeof v === "boolean") w.armed = v; + }); + }; + + /** + * Poll the watches until the step it is running beside is done — and stop + * the moment it is: the tick's wait is cut short by `finished`, or every step + * under a watch would wait out the rest of a quarter second before the next + * one began (#509, where that was the whole of a headless run's wall time). + */ + const watchdog = (done: () => boolean, finished: Promise): Promise => (async () => { - while (!done()) { - await runWatches(); - if (done()) return; - await (d.wallWait ?? d.sleep).call(d, WATCH_TICK_MS); + const live = watches(); + const sheet = await openWatches(live); + try { + while (!done()) { + await runWatches(sheet); + if (done()) return; + await Promise.race([(d.wallWait ?? d.sleep).call(d, WATCH_TICK_MS), finished]); + } + } finally { + if (sheet) await closeWatches(live); } })(); - for (const step of steps) { if (step.verb === "split") { const now = await d.clock(); @@ -431,8 +507,10 @@ export async function runSheet( verbs, }; let over = false; + let finish = (): void => {}; + const finished = new Promise((r) => (finish = r)); const dog = watches().length && step.verb !== "watchfor" - ? watchdog(() => over) + ? watchdog(() => over, finished) : null; try { for (let i = 0; i < step.repeat; i++) { @@ -441,6 +519,7 @@ export async function runSheet( } } finally { over = true; + finish(); if (dog) await dog; } } catch (e) { diff --git a/engine/tests/speedrun-clock.ts b/engine/tests/speedrun-clock.ts index c492e5cc..178d4503 100644 --- a/engine/tests/speedrun-clock.ts +++ b/engine/tests/speedrun-clock.ts @@ -113,3 +113,60 @@ test("a person's waiting counts in real time on any machine, the game's busy tim } expect(sheet.runMs).toBe(0); }); + +test("a standing watch stops the game on the pass it rises, and only its own action runs it until answered", () => { + const s = session(); + s.sheetClock = true; + (s as unknown as { coreLoaded: boolean }).coreLoaded = true; + s.gameTime(0); + // a line waiting on something far off, and a watch for a film at 300 ms + s.sheetHolds.set(1, () => s.gameNow >= 5000); + s.sheetWatches.set(1, { met: () => s.gameNow >= 300, was: false, rose: false }); + for (let ms = 50; ms <= 1000; ms += 50) s.gameTime(ms); + expect(s.gameNow).toBe(300); // halted on the very pass, however long the runner takes + expect(s.sheetWatches.get(1)!.rose).toBe(true); + + // the interrupted line's holds neither run the game nor close, even when met + s.sheetHolds.set(2, () => true); + s.sheetPasses = 5; + for (let ms = 1050; ms <= 1500; ms += 50) s.gameTime(ms); + expect(s.gameNow).toBe(300); + expect(s.sheetHolds.has(2)).toBe(true); + + // the watch's action does: its own hold, then its own pause + s.sheetHolds.set(3, () => s.gameNow >= 400); + s.sheetWatchHolds.add(3); + for (let ms = 1550; ms <= 2000; ms += 50) s.gameTime(ms); + expect(s.gameNow).toBe(400); + s.sheetWatchPasses = 2; + for (let ms = 2050; ms <= 2500; ms += 50) s.gameTime(ms); + expect(s.gameNow).toBe(500); + + // answered: the line goes on from where it stood + s.sheetHalted = false; + s.sheetWatchHolds.clear(); + s.gameTime(2550); + expect(s.sheetHolds.has(2)).toBe(false); + for (let ms = 2600; ms <= 3000; ms += 50) s.gameTime(ms); + expect(s.gameNow).toBeGreaterThan(500); +}); + +test("under a sheet's clock a hold does not close while a file is coming, and the runner can tell", async () => { + const s = session(); + s.sheetClock = true; + (s as unknown as { coreLoaded: boolean }).coreLoaded = true; + s.gameTime(0); + let land!: () => void; + const loading = s.whileLoading(new Promise((r) => (land = r))); + s.sheetHolds.set(1, () => true); // met already, but the screen is half-loaded + for (let ms = 50; ms <= 500; ms += 50) s.gameTime(ms); + expect(s.loadingFiles).toBe(true); + expect(s.sheetHolds.has(1)).toBe(true); + expect(s.gameNow).toBe(0); + land(); + await loading; + expect(s.loadingFiles).toBe(false); + s.gameTime(550); + expect(s.sheetHolds.has(1)).toBe(false); // closed before any pass ran + expect(s.gameNow).toBe(0); +}); diff --git a/taoot/tests/auto/speedrun-headless.ts b/taoot/tests/auto/speedrun-headless.ts new file mode 100644 index 00000000..dbda926f --- /dev/null +++ b/taoot/tests/auto/speedrun-headless.ts @@ -0,0 +1,47 @@ +/** + * A sheet played headless (#509): the same in-game time and frames every time. + * + * npx vitest run taoot/tests/auto/speedrun-headless.ts + * + * The opening of the real sheet — cold boot, the title menu, the London flat's + * card trick — played twice in node under the sheet clock. The browser gives + * the same numbers for it (measured when this landed: 3.6 s in + * game to the millisecond on every line), but a browser is not something a node + * suite can ask; what it can ask is that the run is a function of the sheet. + */ +import { expect, test } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseSheet } from "@dreamfactory/engine/web/speedrun/sheet"; +import { runSheet } from "@dreamfactory/engine/web/speedrun/runner"; +import { ACTIONS, VERBS } from "../../src/speedrun/actions"; +import { headlessRun } from "../speedrun/headless"; + +/** the sheet up to its second split, `flat scored` */ +function opening(): string { + const text = readFileSync(join(import.meta.dirname, "..", "speedrun", "run.sheet.txt"), "utf8"); + const end = text.indexOf("split(flat scored)"); + return text.slice(0, end + "split(flat scored)".length); +} + +async function play(): Promise<{ game: number; frames: number; splits: string[] }> { + const { driver } = await headlessRun({ + prepare: (s) => { + s.nominalTime = true; + s.sheetClock = true; + s.seedRandom(20); + }, + seed: 20, + }); + const r = await runSheet(driver, parseSheet(opening(), { verbs: VERBS }), ACTIONS, {}); + if (r.failure) throw r.failure.error; + return { game: r.total.game, frames: r.total.frames, splits: r.splits.map((s) => `${s.name} ${s.game}`) }; +} + +test("the sheet's opening, played headless twice, ends on the same pass", async () => { + const a = await play(); + const b = await play(); + expect(a.splits).toHaveLength(2); + expect(a.game).toBeGreaterThan(0); + expect(b).toEqual(a); +}, 120_000); diff --git a/taoot/tests/harness.ts b/taoot/tests/harness.ts index da109d0e..0a4cc89e 100644 --- a/taoot/tests/harness.ts +++ b/taoot/tests/harness.ts @@ -42,6 +42,12 @@ export async function newHost( * this (taoot/src/main.ts), so how OFTEN it fires is part of the host's contract. */ onShowStage?: () => void; + /** + * Leave the boot library for `coldBoot` to open, as the page does. A + * speedrun counts the game's time from that moment (#508), so a host + * opened early would count the boot films a page does not (#509). + */ + cold?: boolean; } = {}, ): Promise<{ host: GameHost; @@ -99,7 +105,7 @@ export async function newHost( // a game whose `boot()` opens its own resources has to arrive at that boot // un-booted, or the stand-in has already opened the interface band and in-game // stage its menu never asked for. - if ((await host.bootPlan()).landingSet) await host.session.ensureBooted(); + if (!opts.cold && (await host.bootPlan()).landingSet) await host.session.ensureBooted(); // Seed `random()`, so a suite run is a repeatable one. // // Left on Math.random, any test whose timing a script draws is a coin toss — diff --git a/taoot/tests/speedrun/driver.ts b/taoot/tests/speedrun/driver.ts index 0c56228a..3bec5c56 100644 --- a/taoot/tests/speedrun/driver.ts +++ b/taoot/tests/speedrun/driver.ts @@ -116,12 +116,13 @@ export async function speedrunDriver(page: Page, opts: SpeedrunDriverOptions = { * condition and closes the hold on the pass it comes true; without one, * nothing is registered and the wait below is the plain predicate. */ - const openHold = (expr: string, id: number): Promise => + const openHold = (expr: string, id: number, watch: boolean): Promise => page .evaluate(`(() => { const s = window.dbg && window.dbg.session; if (!s || !s.sheetClock) return false; s.sheetHolds.set(${id}, () => !!(${expr})); + ${watch ? "s.sheetWatchHolds.add(" + id + ");" : ""} return true; })()`) .then((on) => !!on, () => false); @@ -132,414 +133,424 @@ export async function speedrunDriver(page: Page, opts: SpeedrunDriverOptions = { .evaluate(`(() => { const s = window.dbg && window.dbg.session; if (s && s.sheetHolds) s.sheetHolds.delete(${id}); })()`) .then(() => undefined, () => undefined); - const hold = async (expr: string, what: string, budget = timeout): Promise => { - const id = ++holdIds; - const held = await openHold(expr, id); - return page - .waitForFunction(waitExpr(expr, id, held), null, { timeout: budget }) - .then(() => undefined) - .catch(async (e: Error) => { - await closeHold(id); - throw new Error(`stuck waiting for ${what}: ${e.message}`); - }); - }; - - /** the same, but running out is an answer rather than a failure */ - const tryHold = async (expr: string, budget: number): Promise => { - const id = ++holdIds; - const held = await openHold(expr, id); - return page - .waitForFunction(waitExpr(expr, id, held), null, { timeout: budget }) - .then(() => true) - .catch(async () => { - await closeHold(id); - return false; - }); - }; - - /** a pause the game runs through: passes under a sheet's clock, ms otherwise */ - const sleep = async (ms: number): Promise => { - const passes = Math.max(1, Math.round(ms / 50)); - const stepped = await page - .evaluate(`(() => { const s = window.dbg && window.dbg.session; if (!s || !s.sheetClock) return false; s.sheetPasses = ${passes}; return true; })()`) - .catch(() => false); - if (!stepped) return page.waitForTimeout(ms); - await page - .waitForFunction(`!window.dbg || !window.dbg.session.sheetClock || window.dbg.session.sheetPasses === 0`, null, { timeout }) - .catch(() => undefined); - }; - - /** - * Put the game back to a cold boot. - * - * From out here a reload is nothing special — the Node side of the run - * survives it, so the promise resolves and the sheet carries on. That is the - * whole difference from the workbench, where the reload takes the run with it. - */ - const restart = async (): Promise => { - await page.reload(); - await page.waitForFunction(() => !!(window as unknown as { dbg?: unknown }).dbg, null, { - timeout: 30_000, - }); - await opts.onReload?.(); - log("reset: the game was reloaded"); - }; - - /** - * Wall clock, engine frames and the load remover's total (#251). - * - * The two page-side numbers come back in ONE evaluate, and that is not - * tidiness: this driver's readings cost a round trip each, `clock()` is called - * at the top and bottom of every action, and a wall clock taken here with a - * loading total taken a round trip later are readings of two different - * instants — with the second subtracted from the first, so the gap would be - * charged to the route. - */ - const clock = async (): Promise => { - const [frames, loading, game] = await evaluate<[number, number, number]>( - "[window.dbg.session.frameCounter, window.dbg.loading().ms, window.dbg.session.gameNow]", - ); - return { ms: Date.now(), frames, loading, game }; - }; - - /** canvas pixel (512x384) -> page point, so the click is a real mouse event */ - /** the gap a click holds the button down for — see clickAt */ - const CLICK_FRAMES = 3; - const heldFrames = (): Promise => - // anonymous arrows only: this is serialized into the page, and a NAMED - // function would carry tsx's `__name` helper across with it and throw there - page.evaluate(async (n: number) => { - for (let i = 0; i < n; i++) await new Promise((r) => requestAnimationFrame(() => r(0))); - }, CLICK_FRAMES); - /** - * Canvas pixel -> the client point that lands on it. The arithmetic is - * {@link clientPointFor}'s and is shared with the page driver on purpose - * (#277): the page reads a coordinate back with `Math.floor`, so aiming at the - * half-pixel misses by one below a 2x scale. Only the RECT is measured in the - * page; the sum is done here so there is one copy of the rule. + * The driver, twice over (#509): the run's own, and `forWatch`'s for a + * standing watch's action, whose holds and pauses are the watch's — the only + * ones that move the game while a risen watch has halted it. */ - const pagePoint = async (x: number, y: number): Promise => { - const m = await page.evaluate(() => { - const c = document.getElementById("screen") as HTMLCanvasElement; - const r = c.getBoundingClientRect(); - return { left: r.left, top: r.top, width: r.width, height: r.height, cw: c.width, ch: c.height }; - }); - return clientPointFor(x, y, m, { width: m.cw, height: m.ch }); - }; - - /** the wait half of every gesture, and the only thing a `wait:` option moves */ - const settle = async (mode: WaitMode, what: string, budget = timeout): Promise => { - if (mode === "none") return; - if (mode === "taken") return hold(QUEUE_EMPTY, `${what} to be taken`, budget); - if (mode === "ready") return hold(KEY_SAFE, `the engine to be ready after ${what}`, budget); - return hold(QUIET, `${what} to settle`, budget); - }; + const make = (watch: boolean) => { + const passesField = watch ? "sheetWatchPasses" : "sheetPasses"; + const hold = async (expr: string, what: string, budget = timeout): Promise => { + const id = ++holdIds; + const held = await openHold(expr, id, watch); + return page + .waitForFunction(waitExpr(expr, id, held), null, { timeout: budget }) + .then(() => undefined) + .catch(async (e: Error) => { + await closeHold(id); + throw new Error(`stuck waiting for ${what}: ${e.message}`); + }); + }; - const pad = async (ms: number): Promise => { - if (!ms) return; - paddedMs += ms; - await sleep(ms); - }; + /** the same, but running out is an answer rather than a failure */ + const tryHold = async (expr: string, budget: number): Promise => { + const id = ++holdIds; + const held = await openHold(expr, id, watch); + return page + .waitForFunction(waitExpr(expr, id, held), null, { timeout: budget }) + .then(() => true) + .catch(async () => { + await closeHold(id); + return false; + }); + }; - return { - page, - clock, - pagePoint, - settle, - pad, - hold, - tryHold, - evaluate, - /** ms spent in `after:` padding — dead time, reported as such */ - padded: () => paddedMs, + /** a pause the game runs through: passes under a sheet's clock, ms otherwise */ + const sleep = async (ms: number): Promise => { + const passes = Math.max(1, Math.round(ms / 50)); + const stepped = await page + .evaluate(`(() => { const s = window.dbg && window.dbg.session; if (!s || !s.sheetClock) return false; s[${JSON.stringify(passesField)}] = ${passes}; return true; })()`) + .catch(() => false); + if (!stepped) return page.waitForTimeout(ms); + await page + .waitForFunction(`!window.dbg || !window.dbg.session.sheetClock || (!window.dbg.session[${JSON.stringify(passesField)}] && !window.dbg.session.loadingFiles)`, null, { timeout }) + .catch(() => undefined); + }; /** - * A key press, gated so it cannot be eaten by a fade. + * Put the game back to a cold boot. * - * The gate is BEFORE the press and is not skippable by `wait: none`: `wait` - * says how much of the CONSEQUENCE to wait for, never whether the gesture is - * allowed to be thrown away. See {@link KEY_SAFE}. + * From out here a reload is nothing special — the Node side of the run + * survives it, so the promise resolves and the sheet carries on. That is the + * whole difference from the workbench, where the reload takes the run with it. */ - /** plain delay — the one thing a run should never need and sometimes does */ - sleep, - wallWait: (ms: number): Promise => page.waitForTimeout(ms), + const restart = async (): Promise => { + await page.reload(); + await page.waitForFunction(() => !!(window as unknown as { dbg?: unknown }).dbg, null, { + timeout: 30_000, + }); + await opts.onReload?.(); + log("reset: the game was reloaded"); + }; /** - * An UNGATED key press, for the few aimed at something that is not the - * engine — the Nightdive intro answers before there is a viewer at all, so - * {@link KEY_SAFE} has nothing to ask and would refuse forever. + * Wall clock, engine frames and the load remover's total (#251). + * + * The two page-side numbers come back in ONE evaluate, and that is not + * tidiness: this driver's readings cost a round trip each, `clock()` is called + * at the top and bottom of every action, and a wall clock taken here with a + * loading total taken a round trip later are readings of two different + * instants — with the second subtracted from the first, so the gap would be + * charged to the route. */ - rawKey: async (name: string): Promise => { - await page.keyboard.press(name); - }, + const clock = async (): Promise => { + const [frames, loading, game] = await evaluate<[number, number, number]>( + "[window.dbg.session.frameCounter, window.dbg.loading().ms, window.dbg.session.gameNow]", + ); + return { ms: Date.now(), frames, loading, game }; + }; - key: async (name: string, wait: WaitMode = "ready", budget = timeout): Promise => { - await hold(KEY_SAFE, `the engine to accept ${name}`, budget); - await page.keyboard.press(name); - await settle(wait, `key ${name}`, budget); - }, + /** canvas pixel (512x384) -> page point, so the click is a real mouse event */ + /** the gap a click holds the button down for — see clickAt */ + const CLICK_FRAMES = 3; + const heldFrames = (): Promise => + // anonymous arrows only: this is serialized into the page, and a NAMED + // function would carry tsx's `__name` helper across with it and throw there + page.evaluate(async (n: number) => { + for (let i = 0; i < n; i++) await new Promise((r) => requestAnimationFrame(() => r(0))); + }, CLICK_FRAMES); /** - * A click at a canvas pixel. Defaults to `taken` rather than `none` because - * a click that was never consumed and a click that did nothing are the same - * thing from out here, and only one of them is a bug worth stopping for. - */ - /** - * A click, with the button held down long enough for the game to notice. - * - * `page.mouse.click` is press-and-release with no gap, and no gap is not what - * a hand does — the game reads the time between them. `while stilldown()` - * loops carry a held item and read `mouse()` every turn, and INVEN.SHP's - * `stdmouse` decides where a carried object LANDS from what `hittest` finds - * when the button comes UP. Measured on the coal lever, whose mousedown is - * such a loop, all three ways of clicking it: - * - * playwright deg 9 -> 9 coal 50 -> 50 stilldown turns 1 - * page-side deg 9 -> 9 coal 50 -> 50 stilldown turns 1 - * held 3 frames deg 9 -> 11 coal 50 -> 47 stilldown turns 3 - * - * One turn means the loop was entered and `stilldown()` was already false. - * Being outside the browser is no protection: two CDP commands back to back - * still leave the renderer no frame in between. - * - * The wait is counted in the page's own frames rather than milliseconds, - * because what has to fit in the gap is a turn of an engine loop. + * Canvas pixel -> the client point that lands on it. The arithmetic is + * {@link clientPointFor}'s and is shared with the page driver on purpose + * (#277): the page reads a coordinate back with `Math.floor`, so aiming at the + * half-pixel misses by one below a 2x scale. Only the RECT is measured in the + * page; the sum is done here so there is one copy of the rule. */ - clickAt: async (x: number, y: number, wait: WaitMode = "taken", budget = timeout): Promise => { - const pt = await pagePoint(x, y); - await page.mouse.move(pt.x, pt.y); - await page.mouse.down(); - await heldFrames(); - await page.mouse.up(); - await settle(wait, `click ${x},${y}`, budget); - }, + const pagePoint = async (x: number, y: number): Promise => { + const m = await page.evaluate(() => { + const c = document.getElementById("screen") as HTMLCanvasElement; + const r = c.getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height, cw: c.width, ch: c.height }; + }); + return clientPointFor(x, y, m, { width: m.cw, height: m.ch }); + }; + + /** the wait half of every gesture, and the only thing a `wait:` option moves */ + const settle = async (mode: WaitMode, what: string, budget = timeout): Promise => { + if (mode === "none") return; + if (mode === "taken") return hold(QUEUE_EMPTY, `${what} to be taken`, budget); + if (mode === "ready") return hold(KEY_SAFE, `the engine to be ready after ${what}`, budget); + return hold(QUIET, `${what} to settle`, budget); + }; + + const pad = async (ms: number): Promise => { + if (!ms) return; + paddedMs += ms; + await sleep(ms); + }; + + return { + page, + clock, + pagePoint, + settle, + pad, + hold, + tryHold, + evaluate, + /** ms spent in `after:` padding — dead time, reported as such */ + padded: () => paddedMs, + + /** + * A key press, gated so it cannot be eaten by a fade. + * + * The gate is BEFORE the press and is not skippable by `wait: none`: `wait` + * says how much of the CONSEQUENCE to wait for, never whether the gesture is + * allowed to be thrown away. See {@link KEY_SAFE}. + */ + /** plain delay — the one thing a run should never need and sometimes does */ + sleep, + wallWait: (ms: number): Promise => page.waitForTimeout(ms), + + /** + * An UNGATED key press, for the few aimed at something that is not the + * engine — the Nightdive intro answers before there is a viewer at all, so + * {@link KEY_SAFE} has nothing to ask and would refuse forever. + */ + rawKey: async (name: string): Promise => { + await page.keyboard.press(name); + }, - /** press and hold until a condition holds — see SpeedrunDriver.holdAt */ - holdAt: async (x: number, y: number, opts: HoldOptions, budget = timeout): Promise => { - // ARM FIRST, then press — see HoldOptions.arm - const armed = opts.arm ? await tryHold(opts.arm, opts.armBudget ?? Math.min(budget, 10_000)) : true; - if (!armed) return { armed, held: false }; - const pt = await pagePoint(x, y); - await page.mouse.move(pt.x, pt.y); - await page.mouse.down(); - let held = false; - try { - held = await tryHold(opts.until, budget); - } finally { - // released whatever happened: leaving the button down would make every - // later gesture a drag + key: async (name: string, wait: WaitMode = "ready", budget = timeout): Promise => { + await hold(KEY_SAFE, `the engine to accept ${name}`, budget); + await page.keyboard.press(name); + await settle(wait, `key ${name}`, budget); + }, + + /** + * A click at a canvas pixel. Defaults to `taken` rather than `none` because + * a click that was never consumed and a click that did nothing are the same + * thing from out here, and only one of them is a bug worth stopping for. + */ + /** + * A click, with the button held down long enough for the game to notice. + * + * `page.mouse.click` is press-and-release with no gap, and no gap is not what + * a hand does — the game reads the time between them. `while stilldown()` + * loops carry a held item and read `mouse()` every turn, and INVEN.SHP's + * `stdmouse` decides where a carried object LANDS from what `hittest` finds + * when the button comes UP. Measured on the coal lever, whose mousedown is + * such a loop, all three ways of clicking it: + * + * playwright deg 9 -> 9 coal 50 -> 50 stilldown turns 1 + * page-side deg 9 -> 9 coal 50 -> 50 stilldown turns 1 + * held 3 frames deg 9 -> 11 coal 50 -> 47 stilldown turns 3 + * + * One turn means the loop was entered and `stilldown()` was already false. + * Being outside the browser is no protection: two CDP commands back to back + * still leave the renderer no frame in between. + * + * The wait is counted in the page's own frames rather than milliseconds, + * because what has to fit in the gap is a turn of an engine loop. + */ + clickAt: async (x: number, y: number, wait: WaitMode = "taken", budget = timeout): Promise => { + const pt = await pagePoint(x, y); + await page.mouse.move(pt.x, pt.y); + await page.mouse.down(); + await heldFrames(); await page.mouse.up(); - } - await heldFrames(); - return { armed, held }; - }, + await settle(wait, `click ${x},${y}`, budget); + }, - /** - * Press a key repeatedly until a page-side predicate holds. - * - * This is `skipMovie` and the conversation line-skipper underneath — the - * "hammering ESC" of the sheet. Two things keep it honest. It re-checks the - * ARM predicate before every press, so it cannot press into a state where - * that key means something else (ESC at a plaque ANSWERS -1 and walks the - * player out of the conversation, #131 — hammering blindly there loses the - * story). And it gates on {@link KEY_SAFE} like any other key. - * - * `gap` is the tuning knob and the reason this is a driver primitive rather - * than a loop in the runner: how fast ESC may be repeated before the engine - * stops distinguishing the presses is a per-clip fact, and a sheet finds it - * by trying. - */ - hammer: async ( - name: string, - { until, arm, gap = defaultGap, budget = timeout, what }: - { until: string; arm?: string; gap?: number; budget?: number; what: string }, - ): Promise => { - const deadline = Date.now() + budget; - let pressed = 0; - for (;;) { - if (await evaluate(`(() => !!(${until}))()`)) return pressed; - if (Date.now() > deadline) { - throw new Error(`stuck waiting for ${what}: ${pressed} presses of ${name} in ${budget} ms`); + /** press and hold until a condition holds — see SpeedrunDriver.holdAt */ + holdAt: async (x: number, y: number, opts: HoldOptions, budget = timeout): Promise => { + // ARM FIRST, then press — see HoldOptions.arm + const armed = opts.arm ? await tryHold(opts.arm, opts.armBudget ?? Math.min(budget, 10_000)) : true; + if (!armed) return { armed, held: false }; + const pt = await pagePoint(x, y); + await page.mouse.move(pt.x, pt.y); + await page.mouse.down(); + let held = false; + try { + held = await tryHold(opts.until, budget); + } finally { + // released whatever happened: leaving the button down would make every + // later gesture a drag + await page.mouse.up(); } - // only press when the key means what we think it means, and only when it - // will not be dropped; otherwise give the engine the gap and look again - const armed = arm ? await evaluate(`(() => !!(${arm}))()`) : true; - if (armed && (await evaluate(KEY_SAFE))) { - await page.keyboard.press(name); - pressed++; + await heldFrames(); + return { armed, held }; + }, + + /** + * Press a key repeatedly until a page-side predicate holds. + * + * This is `skipMovie` and the conversation line-skipper underneath — the + * "hammering ESC" of the sheet. Two things keep it honest. It re-checks the + * ARM predicate before every press, so it cannot press into a state where + * that key means something else (ESC at a plaque ANSWERS -1 and walks the + * player out of the conversation, #131 — hammering blindly there loses the + * story). And it gates on {@link KEY_SAFE} like any other key. + * + * `gap` is the tuning knob and the reason this is a driver primitive rather + * than a loop in the runner: how fast ESC may be repeated before the engine + * stops distinguishing the presses is a per-clip fact, and a sheet finds it + * by trying. + */ + hammer: async ( + name: string, + { until, arm, gap = defaultGap, budget = timeout, what }: + { until: string; arm?: string; gap?: number; budget?: number; what: string }, + ): Promise => { + const deadline = Date.now() + budget; + let pressed = 0; + for (;;) { + if (await evaluate(`(() => !!(${until}))()`)) return pressed; + if (Date.now() > deadline) { + throw new Error(`stuck waiting for ${what}: ${pressed} presses of ${name} in ${budget} ms`); + } + // only press when the key means what we think it means, and only when it + // will not be dropped; otherwise give the engine the gap and look again + const armed = arm ? await evaluate(`(() => !!(${arm}))()`) : true; + if (armed && (await evaluate(KEY_SAFE))) { + await page.keyboard.press(name); + pressed++; + } + await sleep(gap); } - await sleep(gap); - } - }, + }, - /** - * Aim at a named thing the way the browser suite does — through the engine's - * OWN hit test, never a hardcoded pixel. - * - * Shared with engine/src/web/speedrun/aim.ts rather than reimplemented, for the - * reason that file gives at length: whether a thing is clickable from where - * you stand decides whether a route walks on, so two different sweeps explore - * a room differently and end up facing different ways. A speedrun that aimed - * its own way would be running a different game. - */ - aim: async (kind: "thing" | "hotspot", name: string): Promise => { - const { aimSource } = await import("@dreamfactory/engine/web/speedrun/aim"); - const adapter = `(() => { - const dbg = window.dbg, s = dbg.session, v = dbg.viewer; - return { - width: dbg.host.screen.width, - height: dbg.host.screen.height, - hitTest: (x, y) => s.hitTestAt(x, y), - propUnder: (x, y) => { const p = v.propUnder(x, y); return p ? p.group.name : null; }, - inFlat: !s.viewShowing && !!s.stageScript, - hotspot: (n) => { - const obj = v.scene.views[v.viewIdx].objects.find( - (o) => (o.identifier || "").toLowerCase() === n.toLowerCase()); - return obj ? { x0: obj.startRegionX, y0: obj.startRegionY, x1: obj.endRegionX, y1: obj.endRegionY } : null; - }, - }; - })()`; - const fn = kind === "thing" ? "aimAtThing" : "aimAtHotspot"; - return evaluate( - `(() => { ${aimSource()} return ${fn}(${adapter}, ${JSON.stringify(name)}); })()`, - ); - }, + /** + * Aim at a named thing the way the browser suite does — through the engine's + * OWN hit test, never a hardcoded pixel. + * + * Shared with engine/src/web/speedrun/aim.ts rather than reimplemented, for the + * reason that file gives at length: whether a thing is clickable from where + * you stand decides whether a route walks on, so two different sweeps explore + * a room differently and end up facing different ways. A speedrun that aimed + * its own way would be running a different game. + */ + aim: async (kind: "thing" | "hotspot", name: string): Promise => { + const { aimSource } = await import("@dreamfactory/engine/web/speedrun/aim"); + const adapter = `(() => { + const dbg = window.dbg, s = dbg.session, v = dbg.viewer; + return { + width: dbg.host.screen.width, + height: dbg.host.screen.height, + hitTest: (x, y) => s.hitTestAt(x, y), + propUnder: (x, y) => { const p = v.propUnder(x, y); return p ? p.group.name : null; }, + inFlat: !s.viewShowing && !!s.stageScript, + hotspot: (n) => { + const obj = v.scene.views[v.viewIdx].objects.find( + (o) => (o.identifier || "").toLowerCase() === n.toLowerCase()); + return obj ? { x0: obj.startRegionX, y0: obj.startRegionY, x1: obj.endRegionX, y1: obj.endRegionY } : null; + }, + }; + })()`; + const fn = kind === "thing" ? "aimAtThing" : "aimAtHotspot"; + return evaluate( + `(() => { ${aimSource()} return ${fn}(${adapter}, ${JSON.stringify(name)}); })()`, + ); + }, - /** - * A held drag over a series of points — a dial, a lever, a pump handle. - * - * The wait between moves is the important part and is not a sleep: the prop's - * script is sitting in a `while stilldown()` loop, and `session.realYieldSeq` - * counts the frames a script has given up, bumped once per turn of exactly - * that loop (builtins/pointer.ts). Waiting for it to advance means a whole - * iteration has begun and finished SINCE the cursor moved — so the dial has - * seen where the cursor now is. - * - * FOUR because a loop body gives up more than one frame (the `stilldown()` - * that begins the turn and the `forceupdate()` that ends it both bump the - * counter), so +2 can be satisfied with the body in between never having run. - * A speedrun cannot shave this one: waiting less does not make the dial move - * sooner, it makes the next read a frame stale, and a stale `deg` sends the - * next swing the wrong way — which costs a whole extra pass around the dial. - */ - /** the twin of the page driver's — see {@link SpeedrunDriver.dragOnto} for - * why this one returns at the release and `dragProp` does not */ - dragOnto: async ( - from: Point, - to: Point, - o: { armed?: string; landed?: string; budget?: number } = {}, - ): Promise<{ armed: boolean; landed: boolean }> => { - const budget = o.budget ?? timeout; - const a = await pagePoint(from.x, from.y); - const b = await pagePoint(to.x, to.y); - await page.mouse.move(a.x, a.y); - await page.mouse.down(); - let armed = true; - let landed = true; - try { - if (o.armed) armed = await tryHold(o.armed, Math.min(budget, 10_000)); - await page.mouse.move(b.x, b.y); - if (o.landed) landed = await tryHold(o.landed, Math.min(budget, 10_000)); - } finally { - // released whatever the waits said, and at the far end — a button left - // down turns every later gesture into a drag - await page.mouse.up(); - } - return { armed, landed }; - }, + /** + * A held drag over a series of points — a dial, a lever, a pump handle. + * + * The wait between moves is the important part and is not a sleep: the prop's + * script is sitting in a `while stilldown()` loop, and `session.realYieldSeq` + * counts the frames a script has given up, bumped once per turn of exactly + * that loop (builtins/pointer.ts). Waiting for it to advance means a whole + * iteration has begun and finished SINCE the cursor moved — so the dial has + * seen where the cursor now is. + * + * FOUR because a loop body gives up more than one frame (the `stilldown()` + * that begins the turn and the `forceupdate()` that ends it both bump the + * counter), so +2 can be satisfied with the body in between never having run. + * A speedrun cannot shave this one: waiting less does not make the dial move + * sooner, it makes the next read a frame stale, and a stale `deg` sends the + * next swing the wrong way — which costs a whole extra pass around the dial. + */ + /** the twin of the page driver's — see {@link SpeedrunDriver.dragOnto} for + * why this one returns at the release and `dragProp` does not */ + dragOnto: async ( + from: Point, + to: Point, + o: { armed?: string; landed?: string; budget?: number } = {}, + ): Promise<{ armed: boolean; landed: boolean }> => { + const budget = o.budget ?? timeout; + const a = await pagePoint(from.x, from.y); + const b = await pagePoint(to.x, to.y); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + let armed = true; + let landed = true; + try { + if (o.armed) armed = await tryHold(o.armed, Math.min(budget, 10_000)); + await page.mouse.move(b.x, b.y); + if (o.landed) landed = await tryHold(o.landed, Math.min(budget, 10_000)); + } finally { + // released whatever the waits said, and at the far end — a button left + // down turns every later gesture into a drag + await page.mouse.up(); + } + return { armed, landed }; + }, - dragProp: async ( - at: Point, - next: (start: Point) => Point | null | Promise, - budget = timeout, - ): Promise => { - const from = await pagePoint(at.x, at.y); - const seq = () => evaluate("window.dbg.session.realYieldSeq"); - // one turn of the control's `while stilldown()` loop — see HELD_YIELDS - const held = async (): Promise => { - const was = await seq(); - await tryHold(`window.dbg.session.realYieldSeq >= ${was + HELD_YIELDS}`, Math.min(budget, 20_000)); - }; - await page.mouse.move(from.x, from.y); - await page.mouse.down(); - try { - // a turn of the loop before the first move, cursor unmoved: the dial does - // not move but the body publishes its global - await held(); - for (let to = await next(at); to; to = await next(at)) { - const pt = await pagePoint(to.x, to.y); - await page.mouse.move(pt.x, pt.y); + dragProp: async ( + at: Point, + next: (start: Point) => Point | null | Promise, + budget = timeout, + ): Promise => { + const from = await pagePoint(at.x, at.y); + const seq = () => evaluate("window.dbg.session.realYieldSeq"); + // one turn of the control's `while stilldown()` loop — see HELD_YIELDS + const held = async (): Promise => { + const was = await seq(); + await tryHold(`window.dbg.session.realYieldSeq >= ${was + HELD_YIELDS}`, Math.min(budget, 20_000)); + }; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + try { + // a turn of the loop before the first move, cursor unmoved: the dial does + // not move but the body publishes its global await held(); + for (let to = await next(at); to; to = await next(at)) { + const pt = await pagePoint(to.x, to.y); + await page.mouse.move(pt.x, pt.y); + await held(); + } + } finally { + await page.mouse.up(); + /** + * THE RELEASE IS A GESTURE TOO, and this is it being acted on. + * + * Half the controls in this game snap on the button coming up rather + * than as the cursor moves — the coal lever, the wireless breaker and + * sender — so the owner or the deg a caller is about to read is set by + * the release and not by any move. `page.mouse.up()` resolves when the + * event is DISPATCHED, which is several frames before the held script + * notices `stilldown()` is false, leaves its loop and runs that snap. + * + * Read in that gap and the answer is the setting from before the drag. + * That is what "the sender went to off at y=40, not on" was: the drag + * was perfect, the reading was early. The dials never showed it because + * `turnDial` and `setLever` take hold up to three times and the next + * grab's opening `held()` paid this wait by accident. + * + * `pollingInput()` going false is the loop letting go and `scriptBusy` + * going false is the script that owned it running out — which is the + * snap, and on the sender also `senderon()` lighting its four lamps a + * frame apart. Both halves, because the first alone can be true in the + * step between leaving the loop and executing the line after it. + * + * NOT `held()`. That waits for four more yields, and the whole point of + * this moment is that nothing is yielding any more: the loop that was + * bumping `realYieldSeq` has gone. Waiting on it costs its full 20 s + * timeout and then reads the right answer for the wrong reason — + * measured, 20.8 s on `wireless(sender, on)` and the same on the + * breaker. + */ + await tryHold( + `!window.dbg.session.pollingInput() && !window.dbg.session.scriptBusy`, + Math.min(budget, 5_000), + ); } - } finally { - await page.mouse.up(); - /** - * THE RELEASE IS A GESTURE TOO, and this is it being acted on. - * - * Half the controls in this game snap on the button coming up rather - * than as the cursor moves — the coal lever, the wireless breaker and - * sender — so the owner or the deg a caller is about to read is set by - * the release and not by any move. `page.mouse.up()` resolves when the - * event is DISPATCHED, which is several frames before the held script - * notices `stilldown()` is false, leaves its loop and runs that snap. - * - * Read in that gap and the answer is the setting from before the drag. - * That is what "the sender went to off at y=40, not on" was: the drag - * was perfect, the reading was early. The dials never showed it because - * `turnDial` and `setLever` take hold up to three times and the next - * grab's opening `held()` paid this wait by accident. - * - * `pollingInput()` going false is the loop letting go and `scriptBusy` - * going false is the script that owned it running out — which is the - * snap, and on the sender also `senderon()` lighting its four lamps a - * frame apart. Both halves, because the first alone can be true in the - * step between leaving the loop and executing the line after it. - * - * NOT `held()`. That waits for four more yields, and the whole point of - * this moment is that nothing is yielding any more: the loop that was - * bumping `realYieldSeq` has gone. Waiting on it costs its full 20 s - * timeout and then reads the right answer for the wrong reason — - * measured, 20.8 s on `wireless(sender, on)` and the same on the - * breaker. - */ - await tryHold( - `!window.dbg.session.pollingInput() && !window.dbg.session.scriptBusy`, - Math.min(budget, 5_000), - ); - } - }, + }, - /** a drag, for the inventory — press, carry, release */ - drag: async (from: Point, to: Point, steps = 8): Promise => { - const a = await pagePoint(from.x, from.y); - const b = await pagePoint(to.x, to.y); - // the steps matter: main.ts publishes the pointer on mousemove and the - // held script's `while stilldown()` loop reads it every frame, so a jump - // from press to release drops the item where it was picked up - await page.mouse.move(a.x, a.y); - await page.mouse.down(); - await page.mouse.move(b.x, b.y, { steps }); - await page.mouse.up(); - }, + /** a drag, for the inventory — press, carry, release */ + drag: async (from: Point, to: Point, steps = 8): Promise => { + const a = await pagePoint(from.x, from.y); + const b = await pagePoint(to.x, to.y); + // the steps matter: main.ts publishes the pointer on mousemove and the + // held script's `while stilldown()` loop reads it every frame, so a jump + // from press to release drops the item where it was picked up + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + await page.mouse.move(b.x, b.y, { steps }); + await page.mouse.up(); + }, - // Disk, because Playwright starts a fresh browser profile every run and - // anything in localStorage would go with it. Under out/ so it is ignored by - // git: a load point is a working file, not something to commit. - putSave: async (name: string, bytes: Uint8Array) => { - const dir = join(process.cwd(), "out", "speedrun"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, `${name}.ti`), bytes); - }, - getSave: async (name: string) => { - const file = join(process.cwd(), "out", "speedrun", `${name}.ti`); - return existsSync(file) ? new Uint8Array(readFileSync(file)) : null; - }, - seed: opts.seed ?? null, + // Disk, because Playwright starts a fresh browser profile every run and + // anything in localStorage would go with it. Under out/ so it is ignored by + // git: a load point is a working file, not something to commit. + putSave: async (name: string, bytes: Uint8Array) => { + const dir = join(process.cwd(), "out", "speedrun"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.ti`), bytes); + }, + getSave: async (name: string) => { + const file = join(process.cwd(), "out", "speedrun", `${name}.ti`); + return existsSync(file) ? new Uint8Array(readFileSync(file)) : null; + }, + seed: opts.seed ?? null, - restart, - log, + restart, + log, + }; }; + const run = make(false); + return { ...run, forWatch: () => make(true) }; } /** what this driver is, concretely: the shared contract plus the Playwright-only diff --git a/taoot/tests/speedrun/headless.ts b/taoot/tests/speedrun/headless.ts new file mode 100644 index 00000000..eebaeaff --- /dev/null +++ b/taoot/tests/speedrun/headless.ts @@ -0,0 +1,264 @@ +/** + * A speedrun sheet played in node, with no browser and no drawing (#509). + * + * npm run speedrun -w taoot -- --headless + * + * Under a sheet's clock (#508) a run's in-game time is passes of the game, not + * milliseconds of anybody's machine, so the browser is not needed to MEASURE a + * sheet — only to watch one. This plays it against the same `GameHost` the + * playthrough suites drive (tests/harness.ts), as fast as node goes. + * + * The driver is the WORKBENCH's own (engine/src/web/speedrun/page-driver.ts), + * not a third one: it is handed a stand-in window and canvas instead of the + * play page's. So every gesture — the held click, the drag, the dial's turn, + * the hammer — is the same code the workbench runs, and only two things are + * this file's: + * + * - the FRAME. A browser calls `director.tick` on every animation frame; here + * a frame is run whenever something asks for one (the driver's wait, or a + * script's `forceupdate`), with a virtual 60 Hz clock. The sheet clock takes + * a pass of game time from that clock every 50 ms, exactly as on a page. + * - the INPUT. A pointer or key event goes where taoot/src/main.ts sends it — + * {@link deliver} is that page's handlers with the page taken out (the touch + * recogniser, the input log, the gamma and pane keys). Keep the two in step. + * + * What it cannot do is what the workbench cannot: `travel`, `hunt` and `stand` + * through the pathfinder. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { GameHost } from "@dreamfactory/engine/web/host"; +import { snapshotState } from "@dreamfactory/engine/runtime/trace"; +import { pageDriver, saveKeys } from "@dreamfactory/engine/web/speedrun/page-driver"; +import type { SpeedrunDriver } from "@dreamfactory/engine/web/speedrun/driver"; +import { overlayKey } from "../../src/play-rules"; +import { newHost } from "../harness"; +import { shippedSaveTemplate } from "../playthrough/play"; + +/** a browser's animation frame, which is what the page's loop runs on */ +const FRAME_MS = 1000 / 60; + +interface Ev { + type: string; + clientX?: number; + clientY?: number; + key?: string; + shiftKey?: boolean; +} + +/** what the page driver constructs; it reads only the fields it set */ +class StandInEvent implements Ev { + type: string; + constructor(type: string, init: Record = {}) { + this.type = type; + Object.assign(this, init); + } +} + +/** + * taoot/src/main.ts's input handlers, minus the page: a press goes to the + * director, a release ends a drag where the pointer last was, a move tracks the + * pointer mid-drag and hovers otherwise, and a key is routed as the keydown + * handler routes it. + */ +function deliver(host: GameHost, e: Ev): void { + const session = host.session; + const x = Math.floor(e.clientX ?? 0); + const y = Math.floor(e.clientY ?? 0); + const toGame = (name: string, special = false): void => + void session.track(host.director.keyDown(name, special)); + const arrow = (name: "uparrow" | "leftarrow" | "rightarrow"): void => { + const v = host.viewer; + if (!v) return; + if (!session.viewShowing && session.stageCtrl.keydownTarget()) void session.track(v.keyDown(name, false)); + else void session.track(v.pressNav(name)); + }; + switch (e.type) { + case "pointerdown": + session.setPointer(x, y); + session.pointerDown = true; + session.shiftDown = !!e.shiftKey; + void session.track(host.director.press(x, y), `press ${x},${y}`); + return; + case "pointerup": + session.pointerDown = false; + host.director.release(session.pointerX, session.pointerY); + return; + case "mousemove": + if (session.pointerDown) session.setPointer(x, y); + else void host.director.hover(x, y); + return; + case "keydown": { + const key = e.key ?? ""; + if (!session.viewShowing && session.stageCtrl.keydownTarget()) { + const df = overlayKey(key); + if (df) toGame(df, key === "Escape"); + return; + } + if (key === "ArrowRight") arrow("rightarrow"); + else if (key === "ArrowLeft") arrow("leftarrow"); + else if (key === "ArrowUp") arrow("uparrow"); + else if (key === "ArrowDown") toGame("downarrow"); + else if (key === "Escape") toGame(".", true); + else if (key.length === 1) toGame(key.toLowerCase()); + return; + } + default: + // pointermove is the page's touch recogniser; keyup is nobody's + return; + } +} + +export interface HeadlessRun { + driver: SpeedrunDriver; + host: () => GameHost; +} + +/** + * A booted game and a driver over it. + * + * `prepare` runs on every fresh session, before its boot — the CLI's `seedIt` + * (the sheet clock, and a pinned seed). + */ +export async function headlessRun(opts: { + prepare: (session: GameHost["session"]) => void; + seed: number | null; + log?: (message: string) => void; +}): Promise { + let host!: GameHost; + let wall = 0; + let queued: FrameRequestCallback[] = []; + let pending = false; + + /** + * One animation frame: the page loop's tick, then everyone else who asked — + * EACH IN A TASK OF ITS OWN, because that is what a browser gives them: it + * settles every promise between one frame callback and the next. Called + * back in the tick's own task, the driver looked at the game before the + * pass's work had finished resolving, saw the boot still busy, and paid two + * passes more than a page for the same line (#509). + */ + const frame = (): void => { + wall += FRAME_MS; + host.director.tick(wall); + const due = queued; + queued = []; + const next = (i: number): void => { + if (i >= due.length) { + pending = false; + if (queued.length) { + pending = true; + setImmediate(frame); + } + return; + } + due[i](wall); + setImmediate(() => next(i + 1)); + }; + setImmediate(() => next(0)); + }; + const requestAnimationFrame = (cb: FrameRequestCallback): number => { + queued.push(cb); + if (!pending) { + pending = true; + setImmediate(frame); + } + return queued.length; + }; + + const boot = async (): Promise => { + ({ host } = await newHost({ cold: true })); + const s = host.session; + // what main.ts sets: script poll loops wait on a real frame, and a fresh + // game borrows a shipped save to write its own over + s.hasRealFrames = true; + s.nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); + s.saveTemplate = () => { + const mission = s.interp.globals.get("mission"); + return shippedSaveTemplate(typeof mission === "number" && mission >= 4 ? "2" : "1"); + }; + opts.prepare(s); + // the first reading of the clock, as a page's first frame takes it — or the + // first line of the sheet is charged the frame before it + host.director.tick(wall); + // the page's boot, from the click on GAME on: the films, the menu, the flat + void s.track(host.coldBoot(), "coldBoot"); + }; + await boot(); + + const realm = globalThis; + // the play page's `window.dbg`, for a sheet's predicates + const dbg = { + get viewer() { + return host.viewer; + }, + intro: null, + get session() { + return host.session; + }, + get host() { + return host; + }, + snapshotState, + loading: () => ({ ms: 0, waiting: false }), + }; + const win = { + dbg, + // the driver compiles a predicate with `new win.Function(body)`; this one + // hands it `window` as the stand-in, and nothing else of this module + Function: function (body: string) { + const f = new realm.Function("window", body) as (w: unknown) => unknown; + return () => f(win); + }, + requestAnimationFrame, + setTimeout, + clearTimeout, + PointerEvent: StandInEvent, + MouseEvent: StandInEvent, + KeyboardEvent: StandInEvent, + dispatchEvent: (e: Ev) => (deliver(host, e), true), + }; + // one client pixel per canvas pixel: `clientPointFor` then aims at the pixel + // itself, and `deliver` reads it back unchanged + const canvas = { + get width() { + return host.screen.width; + }, + get height() { + return host.screen.height; + }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: host.screen.width, height: host.screen.height }), + dispatchEvent: (e: Ev) => (deliver(host, e), true), + }; + + const page = pageDriver({ + win: win as unknown as Window & typeof globalThis, + canvas: canvas as unknown as HTMLCanvasElement, + sheet: () => "", + keys: saveKeys("taoot"), + log: opts.log, + // budgets on this run's own frames: the game runs as fast as node does, + // and ten seconds of the wall would be hours of it + budgetNow: () => wall, + }); + // Saves on disk where the Playwright runner keeps them (driver.ts), so a load + // point written by either runner is there for the other. + const saves = join(process.cwd(), "out", "speedrun"); + const driver: SpeedrunDriver = { + ...page, + putSave: async (name, bytes) => { + mkdirSync(saves, { recursive: true }); + writeFileSync(join(saves, `${name}.ti`), bytes); + }, + getSave: async (name) => { + const file = join(saves, `${name}.ti`); + return existsSync(file) ? new Uint8Array(readFileSync(file)) : null; + }, + seed: opts.seed, + // a fresh session, booted as the page would boot it after a reload + restart: boot, + // nobody to press Play again + pause: undefined, + }; + return { driver, host: () => host }; +} diff --git a/taoot/tests/speedrun/speedrun.ts b/taoot/tests/speedrun/speedrun.ts index 78e3617a..0a4da94f 100644 --- a/taoot/tests/speedrun/speedrun.ts +++ b/taoot/tests/speedrun/speedrun.ts @@ -7,6 +7,7 @@ * npm run speedrun:lint -w taoot # parse the sheet and say nothing else * npm run speedrun -w taoot -- --verbs # what a sheet may contain * npm run speedrun -w taoot -- --from="m4p0 cabin" # enter at one of its save points + * npm run speedrun -w taoot -- --headless # in node, no browser (headless.ts) * SHEET=taoot/tests/speedrun/any.sheet npm run speedrun -w taoot * * This is NOT a test and does not gate anything. `npm run test:browser:playthrough -w taoot` @@ -94,6 +95,7 @@ import { ACTIONS, VERBS, setPlanner } from "../../src/speedrun/actions"; import { speedrunDriver } from "./driver"; import { runSheet, type Split, type Timing } from "@dreamfactory/engine/web/speedrun/runner"; import { playwrightPlanner } from "./planner"; +import { headlessRun } from "./headless"; import { playUrl } from "../browser/driver"; import { DEFAULT_LANGUAGE } from "../../src/languages"; @@ -102,6 +104,12 @@ const ROOT = join(HERE, "..", ".."); const argv = process.argv.slice(2); const flag = (name: string) => argv.includes(`--${name}`); +/** + * `--headless`: play the sheet in node, with no browser at all (#509) — see + * headless.ts. The same in-game time and frame count as the browser, in a + * fraction of the wall time; what it cannot run is `travel`/`hunt`/`stand`. + */ +const HEADLESS = flag("headless"); const HEADED = flag("headed") || (!!process.env.HEADED && process.env.HEADED !== "0"); /** * Slow motion is a debugging aid and the enemy of the thing being measured, so @@ -255,6 +263,27 @@ async function main(): Promise { return; } + if (HEADLESS) { + const { driver } = await headlessRun({ + prepare: (s) => { + s.nominalTime = true; + s.sheetClock = true; + if (SEED !== null) s.seedRandom(SEED); + }, + seed: SEED, + log: (m) => process.env.VERBOSE && console.log(` ${m}`), + }); + const r = await runSheet(driver, steps, ACTIONS, { + onWatch: (w, said) => + console.log(` WATCH ${w.source} -> ${w.action.source}${said.length ? ` (${said.join("; ")})` : ""}`), + patience: PATIENCE, + onStep: (step, i, total) => + process.env.VERBOSE && console.log(` [${i + 1}/${total}] ${step.source}`), + }); + report({ steps, ...r, errors: [], seeded: SEED }); + process.exit(r.failure ? 1 : 0); + } + const url = playUrl(); url.searchParams.set("edition", process.env.TAOOT_LANG ?? DEFAULT_LANGUAGE); From fffba6f3891de0408561a0882364b2adcfa5de1b Mon Sep 17 00:00:00 2001 From: Daniel Hobi Date: Mon, 5 Oct 2026 22:02:03 +0000 Subject: [PATCH 2/6] Speedrun workbench: Calculate a sheet headless in a Web Worker (#509) A Calculate button plays the open sheet from a cold boot in a worker, headless and at full CPU speed, with a progress bar, and ends in either the sheet's in-game time and frames or the line it stopped at, how far in, and a button that puts the editor on that line. - the headless driver moves into the engine (speedrun/headless.ts), shared by the node runner and the worker; Titanic's input routing into src - budgets count only frames in which the game could run: not while a file loads, not while a standing watch has halted it - the save verb says why the engine refused a save Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/tests.md | 10 + engine/src/web/speedrun/actions-core.ts | 5 +- engine/src/web/speedrun/calculate.ts | 77 +++++++ engine/src/web/speedrun/headless.ts | 227 ++++++++++++++++++++ engine/src/web/speedrun/panel.css | 21 +- engine/src/web/speedrun/panel.ts | 10 + engine/src/web/speedrun/workbench.ts | 102 +++++++++ taoot/src/speedrun-page.ts | 37 ++++ taoot/src/speedrun/calc-worker.ts | 62 ++++++ taoot/src/speedrun/calc.ts | 54 +++++ taoot/src/speedrun/input.ts | 65 ++++++ taoot/tests/speedrun/headless.ts | 262 +++--------------------- taoot/vite.config.ts | 6 + 13 files changed, 693 insertions(+), 245 deletions(-) create mode 100644 engine/src/web/speedrun/calculate.ts create mode 100644 engine/src/web/speedrun/headless.ts create mode 100644 taoot/src/speedrun/calc-worker.ts create mode 100644 taoot/src/speedrun/calc.ts create mode 100644 taoot/src/speedrun/input.ts diff --git a/docs/reference/tests.md b/docs/reference/tests.md index 04cf4f74..dacd54d6 100644 --- a/docs/reference/tests.md +++ b/docs/reference/tests.md @@ -485,6 +485,16 @@ browser). Load points are shared with the browser runner (`out/speedrun/`). What it cannot run is what the workbench cannot: `travel`, `hunt` and `stand`. Its budgets count its own frames, not the wall. +**On the workbench, Calculate** does the same in a Web Worker beside the page +(`taoot/src/speedrun/calc-worker.ts`): it plays the open sheet from a cold boot, +headless and at full CPU speed, with a bar for how far it has got, and ends in +one of two answers — the sheet's in-game time and frames, or the line it stopped +at, how far in, and a button that puts the editor on that line. The game on +screen is not touched; the files come through the browser's cache, and the +sheet's checkpoints go with it for its `load()` lines. The dice are live, as on +the workbench, so a sheet that depends on them can come out differently on the +next press. + Two rules make the runners agree, and both are the sheet clock's, not the headless runner's: diff --git a/engine/src/web/speedrun/actions-core.ts b/engine/src/web/speedrun/actions-core.ts index ed2dc05c..60265119 100644 --- a/engine/src/web/speedrun/actions-core.ts +++ b/engine/src/web/speedrun/actions-core.ts @@ -1039,7 +1039,10 @@ export const CORE_ACTIONS: ActionTable = { try { b = s.snapshotSave(); } finally { s.onLog = prev; } return { bytes: b ? Array.from(b) : null, notes: notes.filter((n) => /^savegame:/.test(n)) }; })()`); - if (!got.bytes) throw new Error(`the engine would not produce a save here`); + if (!got.bytes) { + const why = got.notes.length ? " (" + got.notes.join("; ") + ")" : ""; + throw new Error(`the engine would not produce a save here${why}`); + } await c.d.putSave(name, new Uint8Array(got.bytes)); c.say(`${(got.bytes.length / 1024).toFixed(1)} kB`); for (const note of got.notes) c.say(note.replace(/^savegame: /, "")); diff --git a/engine/src/web/speedrun/calculate.ts b/engine/src/web/speedrun/calculate.ts new file mode 100644 index 00000000..55a7d6c0 --- /dev/null +++ b/engine/src/web/speedrun/calculate.ts @@ -0,0 +1,77 @@ +/** + * A sheet's time, worked out rather than watched (#509): the workbench's + * Calculate button. + * + * The sheet is played headless (headless.ts) on a sheet's clock, as fast as the + * CPU goes, in a Web Worker beside the page — so the answer is the in-game time + * the same sheet shows when it is played on screen, without the minutes it takes + * to play it there. Everything here is plain data in and out, because it crosses + * a worker's message boundary both ways. + */ +import { verbsOf, type ActionTable } from "./action"; +import { headlessRun, type HeadlessGame } from "./headless"; +import { runSheet } from "./runner"; +import { SheetError, parseSheet } from "./sheet"; + +/** how far a calculation has got — the line it is on, of how many */ +export interface CalcProgress { + /** actions done, of {@link total} */ + done: number; + total: number; + /** the sheet line now running */ + line: number; + /** in-game ms so far */ + game: number; +} + +export type CalcResult = + | { ok: true; game: number; frames: number; real: number } + | { + ok: false; + /** the sheet line it stopped at */ + line: number; + error: string; + /** in-game ms before it stopped */ + game: number; + real: number; + }; + +/** play `text` to its end, or to the line it fails at */ +export async function calculateSheet( + game: Omit & { prepare?: HeadlessGame["prepare"] }, + text: string, + actions: ActionTable, + onProgress: (p: CalcProgress) => void, +): Promise { + const started = performance.now(); + const real = (): number => performance.now() - started; + let steps; + try { + steps = parseSheet(text, { verbs: verbsOf(actions) }); + } catch (e) { + const line = e instanceof SheetError ? e.line : 1; + return { ok: false, line, error: (e as Error).message, game: 0, real: real() }; + } + const { driver, host } = await headlessRun({ + ...game, + prepare: (s) => { + // a sheet's clock, as on the workbench while a sheet runs (#508) + s.nominalTime = true; + s.sheetClock = true; + if (game.seed !== null) s.seedRandom(game.seed); + game.prepare?.(s); + }, + }); + let from: number | null = null; + const gameSoFar = (): number => host().session.gameNow - (from ?? host().session.gameNow); + const r = await runSheet(driver, steps, actions, { + onStep: (step, done, total) => { + from ??= host().session.gameNow; + onProgress({ done, total, line: step.line, game: gameSoFar() }); + }, + }); + if (r.failure) { + return { ok: false, line: r.failure.step.line, error: r.failure.error.message, game: r.total.game, real: real() }; + } + return { ok: true, game: r.total.game, frames: r.total.frames, real: real() }; +} diff --git a/engine/src/web/speedrun/headless.ts b/engine/src/web/speedrun/headless.ts new file mode 100644 index 00000000..c4a24a00 --- /dev/null +++ b/engine/src/web/speedrun/headless.ts @@ -0,0 +1,227 @@ +/** + * A speedrun sheet played with no page and no drawing, as fast as the CPU goes + * (#509) — in node (`npm run speedrun -w taoot -- --headless`) and in a Web + * Worker beside the workbench (its Calculate button). + * + * Under a sheet's clock (#508) a run's in-game time is passes of the game, not + * milliseconds of anybody's machine, so a page is not needed to MEASURE a + * sheet — only to watch one. + * + * The driver is the WORKBENCH's own (page-driver.ts), not another one: it is + * handed a stand-in window and canvas instead of the play page's, so every + * gesture — the held click, the drag, the dial's turn, the hammer — is the same + * code the workbench runs. Two things are this file's: + * + * - the FRAME. A page calls `director.tick` on every animation frame; here a + * frame is run whenever something asks for one (the driver's wait, or a + * script's `forceupdate`), on a virtual 60 Hz clock. The sheet clock takes + * a pass of game time from that clock every 50 ms, exactly as on a page. + * - where an INPUT goes, which is the game's page's business and so is handed + * in ({@link HeadlessGame.deliver}). + */ +import type { GameHost } from "../host"; +import type { GameSession } from "../../runtime/session"; +import { snapshotState } from "../../runtime/trace"; +import type { SpeedrunDriver } from "./driver"; +import { pageDriver, saveKeys } from "./page-driver"; + +/** a browser's animation frame, which is what a page's loop runs on */ +const FRAME_MS = 1000 / 60; + +/** a pointer or key event, as the driver builds one — the fields it sets */ +export interface InputEvent { + type: string; + clientX?: number; + clientY?: number; + key?: string; + shiftKey?: boolean; +} + +/** what the page driver constructs; it reads only the fields it set */ +class StandInEvent implements InputEvent { + type: string; + constructor(type: string, init: Record = {}) { + this.type = type; + Object.assign(this, init); + } +} + +/** + * Run `fn` in a task of its own: `setImmediate` in node, a message to itself + * elsewhere — a worker's `setTimeout(0)` is clamped to 4 ms once nested, which + * at several tasks a frame would be most of a headless run's time. + */ +const nextTask: (fn: () => void) => void = (() => { + const immediate = (globalThis as { setImmediate?: (fn: () => void) => void }).setImmediate; + if (immediate) return (fn: () => void) => immediate(fn); + const channel = new MessageChannel(); + const queue: (() => void)[] = []; + channel.port1.onmessage = () => queue.shift()?.(); + return (fn: () => void) => { + queue.push(fn); + channel.port2.postMessage(0); + }; +})(); + +export interface HeadlessGame { + /** the game's word for itself, as the workbench's — `taoot` */ + game: string; + /** a fresh host, not yet booted */ + makeHost(): Promise; + /** + * What the game's page sets on a session before its boot, beyond the frame + * (which is this file's): a save template, the sheet clock, a seed. + */ + prepare(session: GameSession): void; + /** where the game's page sends a pointer or key event */ + deliver(host: GameHost, e: InputEvent): void; + getSave(name: string): Promise; + putSave(name: string, bytes: Uint8Array): Promise; + seed: number | null; + log?(message: string): void; +} + +export interface HeadlessRun { + driver: SpeedrunDriver; + host: () => GameHost; +} + +/** a booted game and the workbench's driver over it */ +export async function headlessRun(game: HeadlessGame): Promise { + let host!: GameHost; + let wall = 0; + /** + * The frames a budget counts: those in which the game could run. While the + * engine waits on a file the game stands still and the frames spin as fast + * as the task queue goes — over a network, a film's download was tens of + * "seconds" of them, and a line ran out of budget waiting for nothing. The + * same while a standing watch has halted it: the line it interrupted gave up + * before the runner's next look at the watches came round. + */ + let budgetWall = 0; + let queued: FrameRequestCallback[] = []; + let pending = false; + + /** + * One animation frame: the page loop's tick, then everyone else who asked — + * EACH IN A TASK OF ITS OWN, because that is what a browser gives them: it + * settles every promise between one frame callback and the next. Called + * back in the tick's own task, the driver looked at the game before the + * pass's work had finished resolving, saw the boot still busy, and paid two + * passes more than a page for the same line (#509). + */ + const frame = (): void => { + wall += FRAME_MS; + if (!host.session.loadingFiles && !host.session.sheetHalted) budgetWall += FRAME_MS; + host.director.tick(wall); + const due = queued; + queued = []; + const next = (i: number): void => { + if (i >= due.length) { + pending = false; + if (queued.length) { + pending = true; + nextTask(frame); + } + return; + } + due[i](wall); + nextTask(() => next(i + 1)); + }; + nextTask(() => next(0)); + }; + const requestAnimationFrame = (cb: FrameRequestCallback): number => { + queued.push(cb); + if (!pending) { + pending = true; + nextTask(frame); + } + return queued.length; + }; + + const boot = async (): Promise => { + host = await game.makeHost(); + const s = host.session; + // what a page sets: script poll loops wait on a real frame + s.hasRealFrames = true; + s.nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); + game.prepare(s); + // the first reading of the clock, as a page's first frame takes it — or the + // first line of the sheet is charged the frame before it + host.director.tick(wall); + // the page's boot, from the click on GAME on: the films, the menu, the flat + void s.track(host.coldBoot(), "coldBoot"); + }; + await boot(); + + const realm = globalThis; + // a page's `window.dbg`, for a sheet's predicates + const dbg = { + get viewer() { + return host.viewer; + }, + intro: null, + get session() { + return host.session; + }, + get host() { + return host; + }, + snapshotState, + loading: () => ({ ms: 0, waiting: false }), + }; + const send = (e: InputEvent): boolean => { + game.deliver(host, e); + return true; + }; + const win = { + dbg, + // the driver compiles a predicate with `new win.Function(body)`; this one + // hands it `window` as the stand-in, and nothing else of this module + Function: function (body: string) { + const f = new realm.Function("window", body) as (w: unknown) => unknown; + return () => f(win); + }, + requestAnimationFrame, + setTimeout, + clearTimeout, + PointerEvent: StandInEvent, + MouseEvent: StandInEvent, + KeyboardEvent: StandInEvent, + dispatchEvent: send, + }; + // one client pixel per canvas pixel: `clientPointFor` then aims at the pixel + // itself, and the game's `deliver` reads it back unchanged + const canvas = { + get width() { + return host.screen.width; + }, + get height() { + return host.screen.height; + }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: host.screen.width, height: host.screen.height }), + dispatchEvent: send, + }; + + const page = pageDriver({ + win: win as unknown as Window & typeof globalThis, + canvas: canvas as unknown as HTMLCanvasElement, + sheet: () => "", + keys: saveKeys(game.game), + log: game.log, + // budgets on this run's own frames: the game runs as fast as the CPU goes, + // and ten seconds of the wall would be hours of it + budgetNow: () => budgetWall, + }); + const driver: SpeedrunDriver = { + ...page, + putSave: (name, bytes) => game.putSave(name, bytes), + getSave: (name) => game.getSave(name), + seed: game.seed, + // a fresh session, booted as a page would boot it after a reload + restart: boot, + // nobody to press Play again + pause: undefined, + }; + return { driver, host: () => host }; +} diff --git a/engine/src/web/speedrun/panel.css b/engine/src/web/speedrun/panel.css index 9327a097..6fa39658 100644 --- a/engine/src/web/speedrun/panel.css +++ b/engine/src/web/speedrun/panel.css @@ -872,21 +872,21 @@ body.sr-resizing * { } #srparts button.points:hover { opacity: 1; color: #e0928f; border-color: #e0928f; } /* ---- the cache warmup (taoot/src/cache-warmup.ts) ---- */ -#srwarmbar { margin-top: 0.55rem; } -#srwarmtrack { +#srwarmbar, #srcalcbar { margin-top: 0.55rem; } +#srwarmtrack, #srcalctrack { height: 3px; background: var(--inset); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; } -#srwarmfill { +#srwarmfill, #srcalcfill { height: 100%; width: 0; background: linear-gradient(to right, var(--accent-line), var(--accent)); transition: width 140ms linear; } -#srwarmnum { +#srwarmnum, #srcalcnum { margin-top: 0.35rem; font-family: var(--font-mono); font-size: 0.7rem; @@ -894,7 +894,7 @@ body.sr-resizing * { } /* Warming. The same on-state as the record dot's, in ice rather than red: it is running, it is not urgent, and it is stopped by the same button. */ -#srwarm.on { +#srwarm.on, #srcalc.on { color: var(--accent); border-color: var(--accent); } @@ -1526,3 +1526,14 @@ body.sr-resizing * { #dbgState .quiet { color: var(--text-mute); } + +/* Calculate's "go to line" — a link-sized button inside the bar's reading */ +.sr-calc-go { + font: inherit; + color: var(--accent); + background: none; + border: 0; + padding: 0; + text-decoration: underline; + cursor: pointer; +} diff --git a/engine/src/web/speedrun/panel.ts b/engine/src/web/speedrun/panel.ts index 482dd4b2..a3e4a0ef 100644 --- a/engine/src/web/speedrun/panel.ts +++ b/engine/src/web/speedrun/panel.ts @@ -124,6 +124,10 @@ split(boot)"> while it waits for them, so the reading is the same on a slow machine as on a fast one. A word, not a glyph: it is not the sheet's transport. --> + + +