@moku-labs/game 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
- import { $ as Position, Dt as Api, dt as FlowGraph, h as Api$1, l as RenderStats, pt as JournalEntry, t as Api$2, z as EntitySnapshot, zt as Snapshot } from "./types-Cjf3ywai.mjs";
2
- import { f as Usage, t as Api$3 } from "./types-DgwbzDLP.mjs";
3
- import { a as InputOf, c as Source, d as HeadlessApp, i as InputArguments, l as WatchApp, o as InputSchema, t as CheatEntry, u as WatchInput } from "./types-D0uYTTC3.mjs";
4
- import { c as UiNode } from "./types-C0SLQsx0.mjs";
5
- import { b as UiApi } from "./types-KlE_GL1z.mjs";
6
- import { n as defineSource } from "./define-BMo0pHe_.mjs";
7
- import { _ as EffectsApi, a as SoundEntry, t as AudioApi, v as EffectsStats } from "./types-BTycq8_Z.mjs";
1
+ import { $ as Position, Dt as Api, dt as FlowGraph, h as Api$1, l as RenderStats, pt as JournalEntry, t as Api$2, z as EntitySnapshot, zt as Snapshot } from "./types-BZ1OLf24.mjs";
2
+ import { f as Usage, t as Api$3 } from "./types-C47fKNcn.mjs";
3
+ import { a as InputOf, c as Source, d as HeadlessApp, i as InputArguments, l as WatchApp, o as InputSchema, t as CheatEntry, u as WatchInput } from "./types-DmhTHRVW.mjs";
4
+ import { c as UiNode } from "./types-X9jXjka3.mjs";
5
+ import { A as UiApi } from "./types-CqkrEY06.mjs";
6
+ import { n as defineSource } from "./define-CjP_4H0G.mjs";
7
+ import { _ as EffectsApi, a as SoundEntry, t as AudioApi, v as EffectsStats } from "./types-CxZy7Pi4.mjs";
8
8
 
9
9
  //#region src/plugins/flow/doors/read.d.ts
10
10
  /**
@@ -1,2 +1,2 @@
1
- import { a as jsxs, i as jsxDEV, n as JSX, r as jsx, t as Fragment } from "./runtime-BstIkrhH.mjs";
1
+ import { a as jsxs, i as jsxDEV, n as JSX, r as jsx, t as Fragment } from "./runtime-Bn5TXNxP.mjs";
2
2
  export { Fragment, type JSX, jsx, jsxDEV, jsxs };
@@ -1,2 +1,2 @@
1
- import { a as jsxs, n as JSX, r as jsx, t as Fragment } from "./runtime-BstIkrhH.mjs";
1
+ import { a as jsxs, n as JSX, r as jsx, t as Fragment } from "./runtime-Bn5TXNxP.mjs";
2
2
  export { Fragment, type JSX, jsx, jsxs };
@@ -1,4 +1,4 @@
1
- import { a as JsxChild, m as UiIntrinsicElements, t as DescriptionNode } from "./types-C0SLQsx0.mjs";
1
+ import { a as JsxChild, m as UiIntrinsicElements, t as DescriptionNode } from "./types-X9jXjka3.mjs";
2
2
 
3
3
  //#region src/plugins/ui/jsx/runtime.d.ts
4
4
  /** What the compiler passes as props: the children plus whatever the tag declared. */
@@ -1,8 +1,8 @@
1
- import { At as Json, Dt as Api$2, Ft as PlayerStateProvider, It as ProviderCall, Lt as SaveDoc, Ut as Api$3, Zt as FakeClock, h as Api$1, t as Api } from "./types-Cjf3ywai.mjs";
2
- import { _ as stepFrames, a as InputOf, d as HeadlessApp, f as HeadlessGame, g as runRepro, h as createHeadless, m as ReproResult, p as Repro, r as ControlApp } from "./types-D0uYTTC3.mjs";
3
- import { H as InputApi } from "./types-C0SLQsx0.mjs";
4
- import { b as UiApi, t as AnimApi } from "./types-KlE_GL1z.mjs";
5
- import { t as commands } from "./commands-BOxqlv7w.mjs";
1
+ import { At as Json, Dt as Api$2, Ft as PlayerStateProvider, It as ProviderCall, Lt as SaveDoc, Ut as Api$3, Zt as FakeClock, h as Api$1, t as Api } from "./types-BZ1OLf24.mjs";
2
+ import { _ as stepFrames, a as InputOf, d as HeadlessApp, f as HeadlessGame, g as runRepro, h as createHeadless, m as ReproResult, p as Repro, r as ControlApp } from "./types-DmhTHRVW.mjs";
3
+ import { H as InputApi } from "./types-X9jXjka3.mjs";
4
+ import { A as UiApi, t as AnimApi } from "./types-CqkrEY06.mjs";
5
+ import { t as commands } from "./commands-C0BljsM5.mjs";
6
6
 
7
7
  //#region src/plugins/clock/fake.d.ts
8
8
  /**
@@ -116,8 +116,8 @@ type VisualStep = {
116
116
  checkpoint: string;
117
117
  } | { [K in StepCommand]: { [P in K]: InputOf<Catalogue[K]["input"]> } }[StepCommand];
118
118
  /**
119
- * A visual test as `defineVisualTest` answers it: its name, where it starts and its steps.
120
- * Frozen data; nothing runs at definition.
119
+ * A visual test as `defineVisualTest` answers it: its name, where it starts, its steps, and
120
+ * `webgl`, true when it also runs in the WebGL leg. Frozen data; nothing runs at definition.
121
121
  *
122
122
  * @example
123
123
  * ```ts
@@ -126,6 +126,7 @@ type VisualStep = {
126
126
  * steps: [{ tap: { key: "play" } }, { checkpoint: "board" }]
127
127
  * });
128
128
  * test.name; // "reward-popup"
129
+ * test.webgl; // false
129
130
  * Object.isFrozen(test.steps); // true
130
131
  * ```
131
132
  */
@@ -133,6 +134,7 @@ type VisualTest = {
133
134
  readonly name: string;
134
135
  readonly start: Readonly<VisualStart>;
135
136
  readonly steps: readonly VisualStep[];
137
+ readonly webgl: boolean;
136
138
  };
137
139
  /**
138
140
  * What the runner needs of an app: the control app, the plugins the base commands act on and
@@ -200,22 +202,36 @@ type VisualTolerance = {
200
202
  ratio: number;
201
203
  threshold: number;
202
204
  };
205
+ /**
206
+ * The backend the browser leg draws with: `"webgpu"`, or `"webgl"` for the WebGL leg.
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * const renderer: VisualRenderer = "webgl"; // what --webgl asks for
211
+ * ```
212
+ */
213
+ type VisualRenderer = "webgpu" | "webgl";
203
214
  /**
204
215
  * The options of a run. Each one left out comes from the command line (`argv`), then from its
205
216
  * default: `dir` `"tests/visual"`, `update` false, `pixels` true with a `page` on a Mac,
206
- * `settleFrames` 600, `tolerance` `{ ratio: 0.001, threshold: 24 }`, `argv`
207
- * `process.argv.slice(2)`. An option given here wins over a flag.
217
+ * `renderer` `"webgpu"`, `settleFrames` 600, `tolerance` `{ ratio: 0.001, threshold: 24 }`,
218
+ * `argv` `process.argv.slice(2)`. An option given here wins over a flag. `renderer: "webgl"`
219
+ * runs only the tests with `webgl: true`, on the page with `?renderer=webgl`, and compares their
220
+ * pictures with `screen.webgl.webp`.
208
221
  *
209
222
  * @example
210
223
  * ```ts
211
224
  * // The same as `bun tests/visual/run.ts --only reward-popup --no-pixels`.
212
225
  * const options: VisualOptions = { only: ["reward-popup"], pixels: false };
226
+ * // The same as `bun tests/visual/run.ts --webgl`.
227
+ * const webgl: VisualOptions = { renderer: "webgl" };
213
228
  * ```
214
229
  */
215
230
  type VisualOptions = {
216
231
  dir?: string;
217
232
  update?: boolean;
218
233
  pixels?: boolean;
234
+ renderer?: VisualRenderer;
219
235
  only?: readonly string[];
220
236
  settleFrames?: number;
221
237
  tolerance?: VisualTolerance;
@@ -288,6 +304,7 @@ type VisualFlags = {
288
304
  dir?: string;
289
305
  update?: boolean;
290
306
  pixels?: boolean;
307
+ renderer?: VisualRenderer;
291
308
  only?: readonly string[];
292
309
  };
293
310
  //#endregion
@@ -297,33 +314,34 @@ type VisualFlags = {
297
314
  * `/control` command by its short name with that command's input, or `{ checkpoint: name }`,
298
315
  * where the runner saves `state.json` and `describe.json` next to the test. `walk` runs fast: a
299
316
  * popup its last node opens never mounts, so the step before a checkpoint that shows a node's
300
- * result is `answer` or `tap`. The answer is a frozen copy; nothing runs here.
317
+ * result is `answer` or `tap`. With `webgl: true` the test also runs in the WebGL leg
318
+ * (`--webgl`), whose pictures go to `screen.webgl.webp`: set it on a screen with a custom filter.
319
+ * The answer is a frozen copy; nothing runs here.
301
320
  *
302
321
  * @param name - The test name, also its folder: lowercase letters, digits and dashes.
303
- * @param test - Where it starts and its steps.
322
+ * @param test - Where it starts, its steps and its WebGL leg.
304
323
  * @param test.start - The player, and optionally the session, the rng and the checkpoint.
305
324
  * @param test.steps - The commands and the checkpoints, in order.
325
+ * @param test.webgl - True to run the test in the WebGL leg too; false by default.
306
326
  * @returns The test, frozen.
307
327
  * @throws {Error} When the name or a checkpoint name is not a file name, a checkpoint name is
308
328
  * used twice, or a step is not one step command.
309
329
  * @example
310
330
  * ```ts
311
- * // tests/visual/reward-popup.visual.ts: Play, deliver the first order, claim the reward.
312
- * export const rewardPopup = defineVisualTest("reward-popup", {
313
- * start: { player: fixtures.ready, checkpoint: "home" },
314
- * steps: [{ tap: { key: "play" } }, { answer: { intent: "deliver", payload: { orderId: "o1" } } },
315
- * { checkpoint: "open" }, { tap: { key: "claim" } }, { checkpoint: "claimed" }]
316
- * });
331
+ * // tests/visual/home.visual.ts: Home with the glowing Play plank, in both legs.
332
+ * const home = defineVisualTest("home", { start: atHome, steps: [{ checkpoint: "rest" }], webgl: true });
333
+ * home.webgl; // true
317
334
  * ```
318
335
  */
319
336
  declare function defineVisualTest(name: string, test: {
320
337
  start: VisualStart;
321
338
  steps: readonly VisualStep[];
339
+ webgl?: boolean;
322
340
  }): VisualTest;
323
341
  //#endregion
324
342
  //#region src/visual/run.d.ts
325
343
  /**
326
- * Reads the flags of the visual tests from a command line: `--update`, `--no-pixels`,
344
+ * Reads the flags of the visual tests from a command line: `--update`, `--no-pixels`, `--webgl`,
327
345
  * `--only <name>` (repeatable) and `--dir <path>`. An argument it does not know is left to the
328
346
  * script that got it, so a test runner's own arguments pass through.
329
347
  *
@@ -335,12 +353,16 @@ declare function defineVisualTest(name: string, test: {
335
353
  * // `bun tests/visual/run.ts --update --only reward-popup`
336
354
  * parseVisualArgv(["--update", "--only", "reward-popup"]); // { update: true, only: ["reward-popup"] }
337
355
  * parseVisualArgv(["--no-pixels", "--dir", "shots"]); // { pixels: false, dir: "shots" }
356
+ * parseVisualArgv(["--webgl"]); // { renderer: "webgl" }
338
357
  * ```
339
358
  */
340
359
  declare function parseVisualArgv(argv: readonly string[]): VisualFlags;
341
360
  /**
342
361
  * Runs visual tests: the headless leg over every selected test, then the browser leg over the
343
- * same tests when `pixels` is on (by default only with a `page` on a Mac). A checkpoint saves
362
+ * same tests when `pixels` is on (by default only with a `page` on a Mac). With `renderer:
363
+ * "webgl"` (`--webgl`) only the tests with `webgl: true` run, the page opens with
364
+ * `?renderer=webgl` and its pictures go to `screen.webgl.webp`; `state.json` and `describe.json`
365
+ * are shared with the WebGPU leg and compared as always. A checkpoint saves
344
366
  * `state.json` and `describe.json` in `<dir>/<test>/<checkpoint>/`, and the browser leg
345
367
  * `screen.webp`, lossless: a missing file is written, `--update` rewrites every file of the tests
346
368
  * run, a JSON file is compared exactly. The browser leg opens the dev page in Chrome with WebGPU
@@ -359,7 +381,8 @@ declare function parseVisualArgv(argv: readonly string[]): VisualFlags;
359
381
  * @throws {Error} When a test is not well formed, two tests share a name, `only` names no test,
360
382
  * a flag misses its value, or the browser leg fails as a whole: no `page`, no
361
383
  * `playwright-core`, no Chrome, or a page that does not open, expose `game` and `doors`, start,
362
- * draw with WebGPU or stop loading.
384
+ * draw with the requested backend or stop loading. In the WebGL leg, also when `only` names a
385
+ * test without `webgl: true`.
363
386
  * @example
364
387
  * ```ts
365
388
  * // tests/visual/run.ts of a game, `bun tests/visual/run.ts --update` on a Mac
@@ -372,4 +395,4 @@ declare function parseVisualArgv(argv: readonly string[]): VisualFlags;
372
395
  */
373
396
  declare function runVisualTests(setup: VisualSetup, tests: readonly VisualTest[], options?: VisualOptions): Promise<VisualReport>;
374
397
  //#endregion
375
- export { type CheckpointResult, type HeadlessApp, type HeadlessGame, type Repro, type ReproResult, type VisualApp, type VisualOptions, type VisualPage, type VisualReport, type VisualSetup, type VisualStart, type VisualStep, type VisualTest, type VisualTestResult, type VisualTolerance, createHeadless, defineVisualTest, fakeClock, memory, parseVisualArgv, runRepro, runVisualTests, saveOf, stepFrames };
398
+ export { type CheckpointResult, type HeadlessApp, type HeadlessGame, type Repro, type ReproResult, type VisualApp, type VisualOptions, type VisualPage, type VisualRenderer, type VisualReport, type VisualSetup, type VisualStart, type VisualStep, type VisualTest, type VisualTestResult, type VisualTolerance, createHeadless, defineVisualTest, fakeClock, memory, parseVisualArgv, runRepro, runVisualTests, saveOf, stepFrames };
package/dist/testing.mjs CHANGED
@@ -199,8 +199,8 @@ function runStep(app, name, input) {
199
199
  //#endregion
200
200
  //#region src/visual/define.ts
201
201
  /**
202
- * @file visual — `defineVisualTest`: a name, where the test starts and its steps, checked and
203
- * frozen. Nothing runs at definition.
202
+ * @file visual — `defineVisualTest`: a name, where the test starts, its steps and whether it runs
203
+ * in the WebGL leg, checked and frozen. Nothing runs at definition.
204
204
  */
205
205
  /** A file name: lowercase letters and digits, words joined by single dashes. */
206
206
  const fileName = /^[\da-z]+(?:-[\da-z]+)*$/u;
@@ -262,31 +262,33 @@ function checkVisualTest(name, steps) {
262
262
  * `/control` command by its short name with that command's input, or `{ checkpoint: name }`,
263
263
  * where the runner saves `state.json` and `describe.json` next to the test. `walk` runs fast: a
264
264
  * popup its last node opens never mounts, so the step before a checkpoint that shows a node's
265
- * result is `answer` or `tap`. The answer is a frozen copy; nothing runs here.
265
+ * result is `answer` or `tap`. With `webgl: true` the test also runs in the WebGL leg
266
+ * (`--webgl`), whose pictures go to `screen.webgl.webp`: set it on a screen with a custom filter.
267
+ * The answer is a frozen copy; nothing runs here.
266
268
  *
267
269
  * @param name - The test name, also its folder: lowercase letters, digits and dashes.
268
- * @param test - Where it starts and its steps.
270
+ * @param test - Where it starts, its steps and its WebGL leg.
269
271
  * @param test.start - The player, and optionally the session, the rng and the checkpoint.
270
272
  * @param test.steps - The commands and the checkpoints, in order.
273
+ * @param test.webgl - True to run the test in the WebGL leg too; false by default.
271
274
  * @returns The test, frozen.
272
275
  * @throws {Error} When the name or a checkpoint name is not a file name, a checkpoint name is
273
276
  * used twice, or a step is not one step command.
274
277
  * @example
275
278
  * ```ts
276
- * // tests/visual/reward-popup.visual.ts: Play, deliver the first order, claim the reward.
277
- * export const rewardPopup = defineVisualTest("reward-popup", {
278
- * start: { player: fixtures.ready, checkpoint: "home" },
279
- * steps: [{ tap: { key: "play" } }, { answer: { intent: "deliver", payload: { orderId: "o1" } } },
280
- * { checkpoint: "open" }, { tap: { key: "claim" } }, { checkpoint: "claimed" }]
281
- * });
279
+ * // tests/visual/home.visual.ts: Home with the glowing Play plank, in both legs.
280
+ * const home = defineVisualTest("home", { start: atHome, steps: [{ checkpoint: "rest" }], webgl: true });
281
+ * home.webgl; // true
282
282
  * ```
283
283
  */
284
284
  function defineVisualTest(name, test) {
285
285
  checkVisualTest(name, test.steps);
286
+ const webgl = test.webgl ?? false;
286
287
  return freezeDeep(structuredClone({
287
288
  name,
288
289
  start: test.start,
289
- steps: test.steps
290
+ steps: test.steps,
291
+ webgl
290
292
  }));
291
293
  }
292
294
  //#endregion
@@ -1489,13 +1491,54 @@ const READY_MS = 15e3;
1489
1491
  * multiple.
1490
1492
  */
1491
1493
  const START_MS = 5e3;
1492
- /** The baseline picture of a checkpoint, and the two the leg leaves beside it on a difference. */
1493
- const SCREEN = {
1494
- baseline: "screen.webp",
1495
- actual: "screen.actual.webp",
1496
- diff: "screen.diff.webp"
1494
+ /** The name of each backend in a message. */
1495
+ const BACKEND = {
1496
+ webgpu: "WebGPU",
1497
+ webgl: "WebGL"
1498
+ };
1499
+ /** What to do about a page that draws with another backend than the leg asked for. */
1500
+ const OTHER_BACKEND_FIX = {
1501
+ webgpu: "The WebGPU baselines need a Chrome with WebGPU on a Mac: use one, or run with --no-pixels.",
1502
+ webgl: "The WebGL baselines need a page that passes renderer=webgl from its query to the renderer: serve one, or run without --webgl."
1497
1503
  };
1498
1504
  /**
1505
+ * Names the URL a leg opens: the dev page as it is for WebGPU, with `renderer=webgl` in its query
1506
+ * for WebGL.
1507
+ *
1508
+ * @param url - The URL of the dev page.
1509
+ * @param renderer - The backend of the leg.
1510
+ * @returns The URL to open.
1511
+ * @example
1512
+ * ```ts
1513
+ * rendererUrl("http://localhost:3000/", "webgl"); // "http://localhost:3000/?renderer=webgl"
1514
+ * ```
1515
+ */
1516
+ function rendererUrl(url, renderer) {
1517
+ if (renderer === "webgpu") return url;
1518
+ const withRenderer = new URL(url);
1519
+ withRenderer.searchParams.set("renderer", renderer);
1520
+ return withRenderer.href;
1521
+ }
1522
+ /**
1523
+ * Names the pictures of a checkpoint for a backend: `screen.webp` and its actual and diff for
1524
+ * WebGPU, the same names with `.webgl` before the end for WebGL.
1525
+ *
1526
+ * @param renderer - The backend of the leg.
1527
+ * @returns The file names of the baseline, the actual and the diff.
1528
+ * @example
1529
+ * ```ts
1530
+ * screenFiles("webgl"); // { baseline: "screen.webgl.webp", actual: "screen.webgl.actual.webp", diff: "screen.webgl.diff.webp" }
1531
+ * ```
1532
+ */
1533
+ function screenFiles(renderer) {
1534
+ const stem = renderer === "webgpu" ? "screen" : `screen.${renderer}`;
1535
+ return {
1536
+ baseline: `${stem}.webp`,
1537
+ actual: `${stem}.actual.webp`,
1538
+ diff: `${stem}.diff.webp`
1539
+ };
1540
+ }
1541
+ /**
1499
1542
  * Reads the first line of a Playwright error, without the name of the call that failed.
1500
1543
  *
1501
1544
  * @param error - What Playwright threw.
@@ -1687,15 +1730,16 @@ async function startPage(page, url, run) {
1687
1730
  }
1688
1731
  /**
1689
1732
  * Opens the dev page for one test: the page script first, then the URL, then the wait for
1690
- * `game` and `doors`, for the graph at its first gate, the check that it draws with WebGPU, and
1691
- * the wait until no request ran for half a second, so no load is left for the stepped frames.
1733
+ * `game` and `doors`, for the graph at its first gate, the check that it draws with the backend
1734
+ * of the leg, and the wait until no request ran for half a second, so no load is left for the
1735
+ * stepped frames.
1692
1736
  *
1693
1737
  * @param context - The browser context.
1694
- * @param url - The URL of the dev page.
1695
- * @param run - The run options, for `settleFrames`.
1738
+ * @param url - The URL of the page the leg opens.
1739
+ * @param run - The run options, for `settleFrames` and `renderer`.
1696
1740
  * @returns The open page.
1697
1741
  * @throws {Error} When the page does not open, never exposes `game` and `doors`, does not start,
1698
- * does not draw with WebGPU or never stops loading. The page is closed then.
1742
+ * draws with another backend or never stops loading. The page is closed then.
1699
1743
  */
1700
1744
  async function openPage(context, url, run) {
1701
1745
  const page = await context.newPage();
@@ -1708,7 +1752,7 @@ async function openPage(context, url, run) {
1708
1752
  throw new Error(`[game] The page at ${url} exposes no game and doors.\n Serve the dev page (bun ./web/serve.ts) and set globalThis.game and globalThis.doors in it.`);
1709
1753
  });
1710
1754
  const kind = await startPage(page, url, run);
1711
- if (kind !== "webgpu") throw new Error(`[game] The page at ${url} draws with ${kind}, not WebGPU.\n The pixel baselines are WebGPU only: use a Chrome with WebGPU on a Mac, or run with --no-pixels.`);
1755
+ if (kind !== run.renderer) throw new Error(`[game] The page at ${url} draws with ${kind}, not ${BACKEND[run.renderer]}.\n ${OTHER_BACKEND_FIX[run.renderer]}`);
1712
1756
  await page.waitForLoadState("networkidle", { timeout: READY_MS }).catch(() => {
1713
1757
  throw new Error(`[game] The page at ${url} did not stop loading in ${READY_MS / 1e3} s.\n Let it finish its requests: the leg pauses the game once nothing loads.`);
1714
1758
  });
@@ -1719,9 +1763,9 @@ async function openPage(context, url, run) {
1719
1763
  }
1720
1764
  }
1721
1765
  /**
1722
- * Checks the picture of a checkpoint against `screen.webp`: a missing baseline, or `--update`,
1723
- * writes it; a difference writes `screen.actual.webp`, and `screen.diff.webp` when the sizes
1724
- * match.
1766
+ * Checks the picture of a checkpoint against `screen.webp` (`screen.webgl.webp` in the WebGL
1767
+ * leg): a missing baseline, or `--update`, writes it; a difference writes `screen.actual.webp`,
1768
+ * and `screen.diff.webp` when the sizes match, named the same way.
1725
1769
  *
1726
1770
  * @param play - The test being played.
1727
1771
  * @param file - Names a file of the checkpoint.
@@ -1731,9 +1775,10 @@ async function openPage(context, url, run) {
1731
1775
  */
1732
1776
  async function checkScreen(play, file, where, screen) {
1733
1777
  const { run } = play;
1734
- const baseline = run.update ? void 0 : await readScreen(file(SCREEN.baseline));
1778
+ const names = screenFiles(run.renderer);
1779
+ const baseline = run.update ? void 0 : await readScreen(file(names.baseline));
1735
1780
  if (baseline === void 0) {
1736
- await writeScreen(file(SCREEN.baseline), screen);
1781
+ await writeScreen(file(names.baseline), screen);
1737
1782
  return { outcome: "written" };
1738
1783
  }
1739
1784
  const compared = await callPage(play.page, pageCompare, {
@@ -1745,14 +1790,14 @@ async function checkScreen(play, file, where, screen) {
1745
1790
  });
1746
1791
  const found = pixelOutcome(compared, run.tolerance);
1747
1792
  if (found.outcome === "different") {
1748
- await writeScreen(file(SCREEN.actual), screen);
1749
- if (compared.diff !== void 0) await writeScreen(file(SCREEN.diff), compared.diff);
1793
+ await writeScreen(file(names.actual), screen);
1794
+ if (compared.diff !== void 0) await writeScreen(file(names.diff), compared.diff);
1750
1795
  }
1751
1796
  return found;
1752
1797
  }
1753
1798
  /**
1754
1799
  * Plays a checkpoint in the page and checks what it found: the state against `state.json` the
1755
- * headless leg wrote, the picture against `screen.webp`.
1800
+ * headless leg wrote, the picture against the baseline of the leg's backend.
1756
1801
  *
1757
1802
  * @param play - The test being played.
1758
1803
  * @param name - The checkpoint name.
@@ -1767,7 +1812,7 @@ async function checkpoint(play, name, where) {
1767
1812
  where,
1768
1813
  settleFrames: run.settleFrames
1769
1814
  });
1770
- if (shot.screen === void 0) throw new Error(`[game] Visual test "${test.name}", ${where}: the page gave no picture.\n Serve a dev build with WebGPU: renderer.capture() answers only there.`);
1815
+ if (shot.screen === void 0) throw new Error(`[game] Visual test "${test.name}", ${where}: the page gave no picture.\n Serve a dev build with ${BACKEND[run.renderer]}: renderer.capture() answers only there.`);
1771
1816
  const file = (kind) => baselineFile(run.dir, test.name, name, kind);
1772
1817
  return {
1773
1818
  state: await compareBaseline(file("state.json"), stableJson(shot.state)),
@@ -1826,7 +1871,7 @@ async function playSteps(play, found) {
1826
1871
  * @param play - The test and the run options; the page is opened here.
1827
1872
  * @param play.test - The test.
1828
1873
  * @param play.run - The run options.
1829
- * @param url - The URL of the dev page.
1874
+ * @param url - The URL of the page the leg opens.
1830
1875
  * @param headless - The result of the test in the headless leg.
1831
1876
  * @returns The result of the test with its pixel fields.
1832
1877
  * @throws {Error} When the page fails to open: that fails the leg.
@@ -1860,8 +1905,9 @@ async function browserTest(context, play, url, headless) {
1860
1905
  }
1861
1906
  /**
1862
1907
  * Runs the browser leg over the tests the headless leg played, one page per test, and answers
1863
- * their results with the pixel fields. A test the headless leg failed is left as it is. The
1864
- * browser closes at the end, also after an error.
1908
+ * their results with the pixel fields. The page opens with `?renderer=webgl` in the WebGL leg. A
1909
+ * test the headless leg failed is left as it is. The browser closes at the end, also after an
1910
+ * error.
1865
1911
  *
1866
1912
  * @param setup - The setup: its `page`, and its app for the size of the page.
1867
1913
  * @param tests - The selected tests.
@@ -1870,13 +1916,15 @@ async function browserTest(context, play, url, headless) {
1870
1916
  * @param load - Loads `chromium`; `playwright-core` by default.
1871
1917
  * @returns One result per test, in order.
1872
1918
  * @throws {Error} When the setup has no page, `playwright-core` or Chrome is missing, or the page
1873
- * does not open, expose `game` and `doors`, start, draw with WebGPU or stop loading.
1919
+ * does not open, expose `game` and `doors`, start, draw with the backend of the leg or stop
1920
+ * loading.
1874
1921
  */
1875
1922
  async function runBrowserLeg(setup, tests, run, headless, load = loadChromium) {
1876
1923
  const page = setup.page;
1877
1924
  if (page === void 0) throw new Error("[game] The pixel leg needs the dev page.\n Pass page: { url } in the setup, or run with --no-pixels.");
1878
1925
  const chromium = await load();
1879
1926
  const options = pageSize(setup, page);
1927
+ const url = rendererUrl(page.url, run.renderer);
1880
1928
  const browser = await launchChrome(chromium, page.browser);
1881
1929
  try {
1882
1930
  const context = await browser.newContext(options);
@@ -1889,7 +1937,7 @@ async function runBrowserLeg(setup, tests, run, headless, load = loadChromium) {
1889
1937
  results.push(before.error === void 0 ? await browserTest(context, {
1890
1938
  test,
1891
1939
  run
1892
- }, page.url, before) : before);
1940
+ }, url, before) : before);
1893
1941
  }
1894
1942
  return results;
1895
1943
  } finally {
@@ -1900,8 +1948,8 @@ async function runBrowserLeg(setup, tests, run, headless, load = loadChromium) {
1900
1948
  //#region src/visual/run.ts
1901
1949
  /**
1902
1950
  * @file visual — `runVisualTests`, the runner a game's script calls, and `parseVisualArgv`, its
1903
- * command line. It resolves the options, selects the tests, runs the headless leg and then, with
1904
- * `pixels` on, the browser leg, prints one line per checkpoint through the branded console of
1951
+ * command line. It resolves the options, selects the tests (in the WebGL leg, the ones with
1952
+ * `webgl: true`), runs the headless leg and then, with `pixels` on, the browser leg, prints one line per checkpoint through the branded console of
1905
1953
  * `@moku-labs/common/cli` and answers one report. The game's script sets the exit code.
1906
1954
  */
1907
1955
  /** The folder of the visual tests, relative to where the script runs. */
@@ -1929,7 +1977,7 @@ function valueAfter(argv, index, placeholder) {
1929
1977
  throw new Error(`[game] The flag ${flag} needs a value.\n Write it as ${flag} ${placeholder}.`);
1930
1978
  }
1931
1979
  /**
1932
- * Reads the flags of the visual tests from a command line: `--update`, `--no-pixels`,
1980
+ * Reads the flags of the visual tests from a command line: `--update`, `--no-pixels`, `--webgl`,
1933
1981
  * `--only <name>` (repeatable) and `--dir <path>`. An argument it does not know is left to the
1934
1982
  * script that got it, so a test runner's own arguments pass through.
1935
1983
  *
@@ -1941,6 +1989,7 @@ function valueAfter(argv, index, placeholder) {
1941
1989
  * // `bun tests/visual/run.ts --update --only reward-popup`
1942
1990
  * parseVisualArgv(["--update", "--only", "reward-popup"]); // { update: true, only: ["reward-popup"] }
1943
1991
  * parseVisualArgv(["--no-pixels", "--dir", "shots"]); // { pixels: false, dir: "shots" }
1992
+ * parseVisualArgv(["--webgl"]); // { renderer: "webgl" }
1944
1993
  * ```
1945
1994
  */
1946
1995
  function parseVisualArgv(argv) {
@@ -1949,6 +1998,7 @@ function parseVisualArgv(argv) {
1949
1998
  for (const [index, argument] of argv.entries()) {
1950
1999
  if (argument === "--update") flags.update = true;
1951
2000
  if (argument === "--no-pixels") flags.pixels = false;
2001
+ if (argument === "--webgl") flags.renderer = "webgl";
1952
2002
  if (argument === "--only") only.push(valueAfter(argv, index, "<name>"));
1953
2003
  if (argument === "--dir") flags.dir = valueAfter(argv, index, "<path>");
1954
2004
  }
@@ -1959,7 +2009,7 @@ function parseVisualArgv(argv) {
1959
2009
  }
1960
2010
  /**
1961
2011
  * Fills in the options of a run: an explicit option wins over a flag of `argv`, a flag over the
1962
- * default. `pixels` is on by default only with a `page` on a Mac.
2012
+ * default. `pixels` is on by default only with a `page` on a Mac; `renderer` is `"webgpu"`.
1963
2013
  *
1964
2014
  * @param setup - The setup, for its `page`.
1965
2015
  * @param options - The options the script passed.
@@ -1973,6 +2023,7 @@ function resolveVisualOptions(setup, options = {}) {
1973
2023
  dir: options.dir ?? flags.dir ?? DEFAULT_DIR,
1974
2024
  update: options.update ?? flags.update ?? false,
1975
2025
  pixels: options.pixels ?? flags.pixels ?? (setup.page !== void 0 && process.platform === "darwin"),
2026
+ renderer: options.renderer ?? flags.renderer ?? "webgpu",
1976
2027
  settleFrames: options.settleFrames ?? DEFAULT_SETTLE_FRAMES,
1977
2028
  tolerance: options.tolerance ?? { ...DEFAULT_TOLERANCE }
1978
2029
  };
@@ -1982,6 +2033,21 @@ function resolveVisualOptions(setup, options = {}) {
1982
2033
  };
1983
2034
  }
1984
2035
  /**
2036
+ * Keeps the tests the WebGL leg plays: the ones with `webgl: true`. The WebGPU leg plays every
2037
+ * test.
2038
+ *
2039
+ * @param tests - The tests `only` selected.
2040
+ * @param run - The run options, for `renderer` and `only`.
2041
+ * @returns The tests of the leg.
2042
+ * @throws {Error} When `only` names a test without `webgl: true` in the WebGL leg.
2043
+ */
2044
+ function legTests(tests, run) {
2045
+ if (run.renderer === "webgpu") return [...tests];
2046
+ const withoutLeg = run.only === void 0 ? void 0 : tests.find((test) => !test.webgl);
2047
+ if (withoutLeg !== void 0) throw new Error(`[game] The visual test "${withoutLeg.name}" has no WebGL leg.\n Pass webgl: true to its defineVisualTest, or run without --webgl.`);
2048
+ return tests.filter((test) => test.webgl);
2049
+ }
2050
+ /**
1985
2051
  * Picks the tests a run plays: all of them, or the ones `only` names, in the order given.
1986
2052
  *
1987
2053
  * @param tests - Every test of the script.
@@ -2097,7 +2163,10 @@ async function withPixelLeg(ui, setup, tests, run, headless) {
2097
2163
  }
2098
2164
  /**
2099
2165
  * Runs visual tests: the headless leg over every selected test, then the browser leg over the
2100
- * same tests when `pixels` is on (by default only with a `page` on a Mac). A checkpoint saves
2166
+ * same tests when `pixels` is on (by default only with a `page` on a Mac). With `renderer:
2167
+ * "webgl"` (`--webgl`) only the tests with `webgl: true` run, the page opens with
2168
+ * `?renderer=webgl` and its pictures go to `screen.webgl.webp`; `state.json` and `describe.json`
2169
+ * are shared with the WebGPU leg and compared as always. A checkpoint saves
2101
2170
  * `state.json` and `describe.json` in `<dir>/<test>/<checkpoint>/`, and the browser leg
2102
2171
  * `screen.webp`, lossless: a missing file is written, `--update` rewrites every file of the tests
2103
2172
  * run, a JSON file is compared exactly. The browser leg opens the dev page in Chrome with WebGPU
@@ -2116,7 +2185,8 @@ async function withPixelLeg(ui, setup, tests, run, headless) {
2116
2185
  * @throws {Error} When a test is not well formed, two tests share a name, `only` names no test,
2117
2186
  * a flag misses its value, or the browser leg fails as a whole: no `page`, no
2118
2187
  * `playwright-core`, no Chrome, or a page that does not open, expose `game` and `doors`, start,
2119
- * draw with WebGPU or stop loading.
2188
+ * draw with the requested backend or stop loading. In the WebGL leg, also when `only` names a
2189
+ * test without `webgl: true`.
2120
2190
  * @example
2121
2191
  * ```ts
2122
2192
  * // tests/visual/run.ts of a game, `bun tests/visual/run.ts --update` on a Mac
@@ -2129,7 +2199,7 @@ async function withPixelLeg(ui, setup, tests, run, headless) {
2129
2199
  */
2130
2200
  async function runVisualTests(setup, tests, options = {}) {
2131
2201
  const run = resolveVisualOptions(setup, options);
2132
- const selected = selectTests(tests, run.only);
2202
+ const selected = legTests(selectTests(tests, run.only), run);
2133
2203
  const ui = createBrandConsole();
2134
2204
  const headless = await runHeadlessLeg(setup, selected, run);
2135
2205
  const results = run.pixels ? await withPixelLeg(ui, setup, selected, run, headless) : headless;
@@ -1527,7 +1527,7 @@ type FxApi = {
1527
1527
  * @example
1528
1528
  * ```ts
1529
1529
  * // The screen wants a haptic tick outside any node. Nothing waits for it.
1530
- * app.flow.fx.dispatch({ kind: "haptic", payload: { style: "light" } });
1530
+ * app.flow.fx.dispatch({ kind: "haptic", payload: { kind: "light" } });
1531
1531
  * ```
1532
1532
  */
1533
1533
  dispatch(descriptor: Descriptor | Hint): void;
@@ -4755,6 +4755,21 @@ type HostApi = {
4755
4755
  * ```
4756
4756
  */
4757
4757
  device(): GPUDevice | undefined;
4758
+ /**
4759
+ * The WebGL2 context of the live WebGL application, read at call time like `device()`. Pixi
4760
+ * restores a lost context itself, so a caller asks again instead of keeping it.
4761
+ *
4762
+ * @returns The context, or `undefined` while inert, before `ready()`, while the context is lost,
4763
+ * on the unsupported screen and on WebGPU.
4764
+ * @example
4765
+ * ```ts
4766
+ * // effects compiles a custom filter's GLSL once in a dev build, on the WebGL fallback only
4767
+ * const gl = ctx.require(rendererPlugin).host.gl();
4768
+ * if (gl === undefined) return; // WebGPU, inert or a lost context: nothing to compile on
4769
+ * const shader = gl.createShader(gl.FRAGMENT_SHADER); // then shaderSource and compileShader
4770
+ * ```
4771
+ */
4772
+ gl(): WebGL2RenderingContext | undefined;
4758
4773
  };
4759
4774
  /**
4760
4775
  * The textures the GPU holds: how many sources, and their estimated bytes.
@@ -5776,7 +5791,8 @@ declare namespace types_d_exports {
5776
5791
  * The part of Pixi the engine uses: the classes `sync` builds views from (a `Rectangle` frames the
5777
5792
  * crop of a `"cover"` sprite), the bitmap-font pieces `fonts.install` needs, the `BitmapText` a
5778
5793
  * plugin above builds through `host.pixi()`, the filter and particle classes `effects` builds
5779
- * through it, and the extension registry with the three WebGPU draw classes the dev draw-call
5794
+ * through it (a custom filter carries a `GpuProgram` and a `GlProgram` over `defaultFilterVert`),
5795
+ * and the extension registry with the three WebGPU draw classes the dev draw-call
5780
5796
  * counter swaps and the bitmap text pipe `host` swaps for one whose SDF shader applies the alpha
5781
5797
  * once (with the WGSL and GLSL shader pieces it compiles that shader from). The module object
5782
5798
  * never arrives through a static import: `config.loadPixi()` returns it, so a game without a screen carries no
@@ -5788,9 +5804,10 @@ declare namespace types_d_exports {
5788
5804
  * pixi.Texture.WHITE.width; // 1
5789
5805
  * pixi.GpuBatchAdaptor.extension.name; // "batch"
5790
5806
  * pixi.BitmapTextPipe.extension.name; // "bitmapText"
5807
+ * pixi.defaultFilterVert.startsWith("in vec2 aPosition;"); // true
5791
5808
  * ```
5792
5809
  */
5793
- type PixiModule = Pick<typeof import("pixi.js"), "AlphaFilter" | "Application" | "BitmapFont" | "BitmapText" | "BitmapTextPipe" | "BlurFilter" | "Cache" | "ColorMatrixFilter" | "Container" | "DisplacementFilter" | "Filter" | "GpuBatchAdaptor" | "GpuEncoderSystem" | "GpuGraphicsAdaptor" | "GpuProgram" | "Graphics" | "Matrix" | "NineSliceSprite" | "NoiseFilter" | "Particle" | "ParticleContainer" | "Rectangle" | "Shader" | "Sprite" | "Texture" | "UniformGroup" | "bitmapFontTextParser" | "bitmapFontXMLStringParser" | "colorBit" | "colorBitGl" | "compileHighShaderGlProgram" | "compileHighShaderGpuProgram" | "extensions" | "generateTextureBatchBit" | "generateTextureBatchBitGl" | "getBatchSamplersUniformGroup" | "localUniformMSDFBit" | "localUniformMSDFBitGl" | "mSDFBit" | "mSDFBitGl" | "roundPixelsBit" | "roundPixelsBitGl">;
5810
+ type PixiModule = Pick<typeof import("pixi.js"), "AlphaFilter" | "Application" | "BitmapFont" | "BitmapText" | "BitmapTextPipe" | "BlurFilter" | "Cache" | "ColorMatrixFilter" | "Container" | "DisplacementFilter" | "Filter" | "GlProgram" | "GpuBatchAdaptor" | "GpuEncoderSystem" | "GpuGraphicsAdaptor" | "GpuProgram" | "Graphics" | "Matrix" | "NineSliceSprite" | "NoiseFilter" | "Particle" | "ParticleContainer" | "Rectangle" | "Shader" | "Sprite" | "Texture" | "UniformGroup" | "bitmapFontTextParser" | "bitmapFontXMLStringParser" | "colorBit" | "colorBitGl" | "compileHighShaderGlProgram" | "compileHighShaderGpuProgram" | "defaultFilterVert" | "extensions" | "generateTextureBatchBit" | "generateTextureBatchBitGl" | "getBatchSamplersUniformGroup" | "localUniformMSDFBit" | "localUniformMSDFBitGl" | "mSDFBit" | "mSDFBitGl" | "roundPixelsBit" | "roundPixelsBitGl">;
5794
5811
  /**
5795
5812
  * One Pixi application: renderer, canvas and stage.
5796
5813
  */
@@ -1,4 +1,4 @@
1
- import { St as Api$3, f as SliceFrame, mt as NodeInfo, nn as Require, o as PixiTexture, q as Api$1, t as Api$2, vt as Descriptor } from "./types-Cjf3ywai.mjs";
1
+ import { St as Api$3, f as SliceFrame, mt as NodeInfo, nn as Require, o as PixiTexture, q as Api$1, t as Api$2, vt as Descriptor } from "./types-BZ1OLf24.mjs";
2
2
  import { Log } from "@moku-labs/common/browser";
3
3
  import { PluginCtx } from "@moku-labs/core";
4
4
 
@@ -59,11 +59,15 @@ type CreateTextureOptions = {
59
59
  /**
60
60
  * The part of a fetch response the plugin reads: the manifest is `json`, a texture is a `blob`,
61
61
  * a font file is `text` and an audio file stays the raw `arrayBuffer`. The global `Response`
62
- * fits it.
62
+ * fits it. `headers` gives the `content-type`: on `tauri://` a missing file comes back `200` with
63
+ * `index.html`, so `text/html` for a path that is not `.html` counts as missing.
63
64
  */
64
65
  type FetchResponse = {
65
66
  ok: boolean;
66
67
  status: number;
68
+ headers: {
69
+ get(name: string): string | null;
70
+ };
67
71
  json(): Promise<unknown>;
68
72
  blob(): Promise<Blob>;
69
73
  text(): Promise<string>;
@@ -80,6 +84,7 @@ type FetchResponse = {
80
84
  * fetch: async () => ({
81
85
  * ok: true,
82
86
  * status: 200,
87
+ * headers: { get: () => null },
83
88
  * json: async () => ({}),
84
89
  * blob: async () => blob,
85
90
  * text: async () => 'info face="body"',