diff --git a/docs/reference/tests.md b/docs/reference/tests.md index 5660b8d9..dacd54d6 100644 --- a/docs/reference/tests.md +++ b/docs/reference/tests.md @@ -462,6 +462,51 @@ 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. + +**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: + +- **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/props.ts b/engine/src/runtime/props.ts index 86b17fc5..b3b66273 100644 --- a/engine/src/runtime/props.ts +++ b/engine/src/runtime/props.ts @@ -787,6 +787,11 @@ export class PropRuntime { return ended; } + /** the props drawn on screen right now, bottom first — the ones a click can hit */ + drawn(): PropInstance[] { + return this.drawList(false); + } + /** * Blit all visible props into an RGBA view buffer, colorizing through the * ACTIVE SET's palette (the engine shares one CLUT across set and props). 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..f8b6070c 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; @@ -624,6 +624,7 @@ export const TALK_STATE = `(() => { if (!v) return { conversing: false }; return { conversing: !!v.conversing, + open: !!window.dbg.session.puppet, with: v.conversingWith || "", awaiting: !!v.awaitingChoice, speaking: !!v.speaking, @@ -636,6 +637,8 @@ export const TALK_STATE = `(() => { interface TalkState { conversing: boolean; + /** a puppet is open — hidden behind an inline clip counts, closed does not */ + open?: boolean; with?: string; awaiting?: boolean; speaking?: boolean; @@ -765,17 +768,39 @@ 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)); + + /** the conversation is over: report it, or say what it ended before */ + const finish = (): void => { + if (wanted.length) { + throw new Error( + `conversation ended before saying ${wanted.join(",")} (picked ${picked.join(",") || "nothing"})`, + ); + } + const times = bailed > 1 ? ` x${bailed}` : ""; + c.say(`said ${picked.join(",") || "nothing"}` + (bailed ? `, then left (-1${times})` : "")); + }; 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"})`, ); } const s = await d.evaluate(TALK_STATE); + /** + * The conversation is OVER once its puppet has closed — whatever is on the + * screen. A film then is the room's, not the conversation's: the purser + * leaving opens `maino1.mov`, his office with the cargo manifest on it, on + * the very pass he goes, and between its parked frames it shows no regions + * — so the inline-clip skip below took it for a `spotmovie` and pressed + * ESC at it, which shut the office and left Frank in the corridor. Played + * on the wall clock the loop used to see the puppet go before the film + * came; on a sheet's clock (#508) both land on one pass. + */ + if (!s.open && !s.conversing) return finish(); /** * A FILM FIRST, and before asking whether the conversation is still going. * @@ -826,16 +851,7 @@ export async function converse( c.say(`skipped an inline clip (${n} ESC)`); continue; } - if (!s.conversing) { - if (wanted.length) { - throw new Error( - `conversation ended before saying ${wanted.join(",")} (picked ${picked.join(",") || "nothing"})`, - ); - } - const times = bailed > 1 ? ` x${bailed}` : ""; - c.say(`said ${picked.join(",") || "nothing"}` + (bailed ? `, then left (-1${times})` : "")); - return; - } + if (!s.conversing) return finish(); if (!s.awaiting) { /* * ONE Escape per spoken line — never a hammer. @@ -941,7 +957,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..60265119 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`, ); @@ -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/aim.ts b/engine/src/web/speedrun/aim.ts index 35bcff49..6923224c 100644 --- a/engine/src/web/speedrun/aim.ts +++ b/engine/src/web/speedrun/aim.ts @@ -51,6 +51,17 @@ export interface Aim { propUnder(x: number, y: number): string | null; /** an overlay flat is covering the room, so there is no room to aim into */ inFlat: boolean; + /** + * Where a thing by this name could be hit right now, when that is known + * (#509): a list of rectangles to search instead of the screen — empty for + * nowhere — or null for "could be anywhere", which sweeps it all. + * + * A whole-screen sweep is twelve thousand hit tests, about 50 ms, and a verb + * that waits for something to REAPPEAR asks every pass — `hammer(startfence)` + * through a fencing bout swept 743 times, 39 s of CPU, more than each pass + * lasts. A flat's button can only be hit inside its own rectangle. + */ + where?(name: string): { x0: number; y0: number; x1: number; y1: number }[] | null; /** the named hotspot's rectangle in the current view, or null */ hotspot(name: string): { x0: number; y0: number; x1: number; y1: number } | null; /** the game's framebuffer width — `host.screen.width`, never the canvas's */ @@ -92,6 +103,18 @@ export function aimAtThing(a: Aim, name: string): { x: number; y: number } | nul if (spot) return spot; } const kinds = new Set(["actor", "prop", "button", "painting"]); + const only = a.where?.(want) ?? null; + if (only) { + for (const r of only) { + for (let y = r.y0; y <= r.y1; y += AIM_STEP) { + for (let x = r.x0; x <= r.x1; x += AIM_STEP) { + const hit = a.hitTest(x, y); + if (hit.name?.toLowerCase() === want && kinds.has(hit.type)) return { x, y }; + } + } + } + return null; + } for (let y = 2; y < a.height; y += AIM_STEP) { for (let x = 2; x < a.width; x += AIM_STEP) { const hit = a.hitTest(x, y); @@ -108,9 +131,41 @@ export function aimAtThing(a: Aim, name: string): { x: number; y: number } | nul * definitions and the real constant, so there is nothing to keep in step by hand: * evaluate this, then call `aimAtThing(adapter, name)` or `aimAtHotspot(...)`. */ +/** + * {@link Aim.where} for Titanic's engine, asked of a live session: over a + * DreamFactory 4 flat with the room away, a hit is one of the flat's buttons or + * a prop drawn over it (ScreenDirector's `hitTestAt`). A name no drawn prop + * carries can then only be a button, inside its own rectangle — or nowhere. + * Anywhere else anything may answer, and it says so with null. + * + * Self-contained, because the Playwright driver ships it into the page as + * source ({@link aimSource}) and it may close over nothing. + */ +export function flatWhere(s: any, n: string): { x0: number; y0: number; x1: number; y1: number }[] | null { + if (s.viewShowing || !s.stageScript || s.isV5 || s.isV1) return null; + // No named inner function: the source of this one is shipped into a page, + // and a bundler's `__name` wrapper around one is a helper the page has not got. + // + // The props the hit test asks — the ones drawn — not every prop loaded: the + // fencing bout's start button is a prop too, hidden while it is not offered. + for (const p of s.propRuntime.drawn()) { + if ((p.name ?? "").toLowerCase() === n || (p.group?.name ?? "").toLowerCase() === n) return null; + } + return s.stageCtrl + .currentFlatRegions() + .filter((r: { name: string }) => (r.name ?? "").toLowerCase() === n) + .map((r: { left: number; top: number; right: number; bottom: number }) => ({ + x0: r.left, + y0: r.top, + x1: r.right, + y1: r.bottom, + })); +} + export function aimSource(): string { return ( `const AIM_STEP = ${AIM_STEP};\n` + + `const flatWhere = ${flatWhere.toString()};\n` + `const aimAtHotspot = ${aimAtHotspot.toString()};\n` + `const aimAtThing = ${aimAtThing.toString()};\n` ); 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/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/headless.ts b/engine/src/web/speedrun/headless.ts new file mode 100644 index 00000000..a93b8146 --- /dev/null +++ b/engine/src/web/speedrun/headless.ts @@ -0,0 +1,230 @@ +/** + * 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, + // wrapped, not handed over: the driver calls them as `win.setTimeout(…)`, + // and a browser's own refuses a `this` that is not its global ("Illegal + // invocation" — in the worker, at the first standing watch's poll) + setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms), + clearTimeout: (t?: ReturnType) => clearTimeout(t), + 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/page-driver.ts b/engine/src/web/speedrun/page-driver.ts index 1e25ac85..a4258160 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,455 @@ 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, flatWhere } = 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, + where: (n: string) => flatWhere(s, n), + 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/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. --> + + +