Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/reference/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions engine/src/runtime/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
74 changes: 66 additions & 8 deletions engine/src/runtime/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -1136,17 +1139,60 @@ export class GameSession {
readonly sheetHolds = new Map<number, () => 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<number, { met: () => 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<number>();
/** 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 */
Expand Down Expand Up @@ -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}.
*
Expand Down
46 changes: 31 additions & 15 deletions engine/src/web/speedrun/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<TalkState>(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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"})`);
Expand Down
21 changes: 12 additions & 9 deletions engine/src/web/speedrun/actions-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean>(`!!(${until})`)) {
c.say(`${c.step.opts.until} already — no click needed`);
return;
Expand All @@ -259,7 +259,7 @@ export const CORE_ACTIONS: ActionTable = {
let taps = 0;
for (;;) {
if (await c.d.evaluate<boolean>(`!!(${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`,
);
Expand Down Expand Up @@ -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<boolean>(`(() => !!(${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.
*
Expand Down Expand Up @@ -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<boolean>(`!!(${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`,
);
Expand Down Expand Up @@ -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: /, ""));
Expand Down
Loading
Loading