@moku-labs/game 0.11.0 → 0.12.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.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as resolveConfig } from "./config-CdARrrhU.mjs";
2
- import { a as runCli$1, i as compileStrings, n as importStrings, t as exportStrings } from "./assets-BHwTLVta.mjs";
2
+ import { a as runCli$1, i as compileStrings, n as importStrings, t as exportStrings } from "./assets-DYiKxPBX.mjs";
3
3
  import { constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { createBrandConsole } from "@moku-labs/common/cli";
@@ -230,13 +230,15 @@ function importLine(binding, specifier) {
230
230
  *
231
231
  * @param settings - The resolved `config.ts`.
232
232
  * @param sources - The scenario files, the `.dev` modules and the agents.
233
+ * @param toRoot - The path from `main.ts` to the game folder: `"../"` for `.moku/`, `"../../"`
234
+ * for the page of `moku-game visual` in `.moku/visual/`.
233
235
  * @returns The text of `.moku/main.ts`.
234
236
  * @example
235
237
  * ```ts
236
238
  * devMain(resolveConfig({ page: { title: "T" } }), { scenarios: ["ready.ts"], devModules: [] }).includes('scenarios: { "ready": scenario0 }'); // true
237
239
  * ```
238
240
  */
239
- function devMain(settings, sources) {
241
+ function devMain(settings, sources, toRoot = "../") {
240
242
  const scenarios = scenarioFiles(sources.scenarios);
241
243
  const agents = sources.agents ?? [];
242
244
  const devModules = agents.length === 0 ? [] : sources.devModules.toSorted();
@@ -250,9 +252,9 @@ function devMain(settings, sources) {
250
252
  return [
251
253
  WRITTEN,
252
254
  "import \"./dev.ts\";",
253
- ...pageImports(settings, "../"),
254
- ...scenarios.map((file, index) => importLine(`scenario${index}`, `../tests/scenarios/${file}`)),
255
- ...devModules.map((file, index) => importLine(`* as devModule${index}`, `../${file}`)),
255
+ ...pageImports(settings, toRoot),
256
+ ...scenarios.map((file, index) => importLine(`scenario${index}`, `${toRoot}tests/scenarios/${file}`)),
257
+ ...devModules.map((file, index) => importLine(`* as devModule${index}`, `${toRoot}${file}`)),
256
258
  ...agents.map((agent, index) => importLine(`agent${index}`, agent)),
257
259
  "",
258
260
  "await startPage(game, config, {",
@@ -514,47 +516,52 @@ function writeIfChanged(file, text) {
514
516
  * @param root - The game folder.
515
517
  * @param settings - The resolved config.
516
518
  * @param agents - The agent modules, when the page has any.
519
+ * @param toRoot - The path from `main.ts` to the game folder.
517
520
  * @returns The text.
518
521
  */
519
- function mainOf(root, settings, agents) {
522
+ function mainOf(root, settings, agents, toRoot = "../") {
520
523
  const scenarios = scenarioNamesOf(root);
521
524
  return agents === void 0 || agents.length === 0 ? devMain(settings, {
522
525
  scenarios,
523
526
  devModules: []
524
- }) : devMain(settings, {
527
+ }, toRoot) : devMain(settings, {
525
528
  scenarios,
526
529
  devModules: devModulesOf(root),
527
530
  agents
528
- });
531
+ }, toRoot);
529
532
  }
530
533
  /**
531
534
  * Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `dev.ts`, `main.ts`
532
- * and `bunfig.toml`. Every text is made before the first write, so a refused value writes
533
- * nothing.
535
+ * and `bunfig.toml`. `moku-game visual` writes its own page into `.moku/visual/`, so the page of
536
+ * `dev` or of the editor stays as it is; the links of the page reach the game from its depth.
537
+ * Every text is made before the first write, so a refused value writes nothing.
534
538
  *
535
539
  * @param root - The game folder, absolute.
536
540
  * @param settings - The resolved config.
537
541
  * @param options - The agents, the preloads and the extra plugins, absolute.
538
542
  * @param resolve - Bun's resolver, to find the hot plugin.
543
+ * @param page - The folder of the page under the game, `/` separated: `.moku` by default.
539
544
  * @returns The paths of the HTML and the bunfig.
540
545
  * @throws {Error} When the hot plugin does not resolve, a preload or plugin is not a file, or a
541
546
  * page value is refused.
542
547
  */
543
- function writePage(root, settings, options, resolve) {
548
+ function writePage(root, settings, options, resolve, page = ".moku") {
544
549
  const preload = options.preload ?? [];
545
550
  const servePlugins = options.servePlugins ?? [];
546
551
  checkFiles("--preload", preload);
547
552
  checkFiles("--serve-plugin", servePlugins);
548
- const folder = path.join(root, ".moku");
553
+ const segments = page.split("/");
554
+ const toRoot = "../".repeat(segments.length);
555
+ const folder = path.join(root, ...segments);
549
556
  const html = path.join(folder, "index.html");
550
557
  const bunfig = path.join(folder, "bunfig.toml");
551
558
  const texts = [
552
559
  [html, pageHtml(settings.page, {
553
- toRoot: "../",
560
+ toRoot,
554
561
  script: "./main.ts"
555
562
  })],
556
563
  [path.join(folder, "dev.ts"), devFlag()],
557
- [path.join(folder, "main.ts"), mainOf(root, settings, options.agents)],
564
+ [path.join(folder, "main.ts"), mainOf(root, settings, options.agents, toRoot)],
558
565
  [bunfig, bunfigText({
559
566
  hotPlugin: hotPluginOf(root, resolve),
560
567
  servePlugins,
@@ -661,8 +668,25 @@ async function serveParent(run, deps) {
661
668
  if (!run.packed && !hasManifest) deps.ui.warn(`[game] dev: no manifest.json in "${run.root}".\n Run "moku-game keys" first.`);
662
669
  const page = writePage(run.root, settings, run, deps.resolve);
663
670
  if (!ignoresMoku(run.root)) deps.ui.warn("[game] dev: add \".moku/\" to .gitignore. moku-game writes its dev page there.");
664
- const child = deps.spawn(devChildCommand(run, page, deps), {
671
+ return runChild(devChildCommand(run, page, deps), deps, {
665
672
  cwd: run.root,
673
+ whileRunning: () => watchScenarios(run.root, settings, deps)
674
+ });
675
+ }
676
+ /**
677
+ * Runs the bin again as a marked child, `MOKU_GAME_CHILD=1`, in its own process group: Ctrl+C
678
+ * reaches the parent, which hands it and the other stop signals on. `dev` and `visual` run their
679
+ * page server this way, under the page's bunfig.
680
+ *
681
+ * @param command - The words of the command: the Bun, its flags, the bin and its arguments.
682
+ * @param deps - The spawn, the signals and the environment.
683
+ * @param child - The working directory, and what lives as long as the child.
684
+ * @returns The exit code of the child.
685
+ * @throws {Error} When the child does not start.
686
+ */
687
+ async function runChild(command, deps, child) {
688
+ const started = deps.spawn(command, {
689
+ cwd: child.cwd,
666
690
  env: {
667
691
  ...deps.env,
668
692
  MOKU_GAME_CHILD: "1"
@@ -674,11 +698,11 @@ async function serveParent(run, deps) {
674
698
  ],
675
699
  detached: true
676
700
  });
677
- const removers = FORWARDED.map((signal) => deps.onSignal(signal, () => child.kill(signal)));
701
+ const removers = FORWARDED.map((signal) => deps.onSignal(signal, () => started.kill(signal)));
678
702
  let watcher;
679
703
  try {
680
- watcher = watchScenarios(run.root, settings, deps);
681
- return await child.exited;
704
+ watcher = child.whileRunning?.();
705
+ return await started.exited;
682
706
  } finally {
683
707
  watcher?.close();
684
708
  for (const remove of removers) remove();
@@ -731,10 +755,11 @@ function fileResponse(file) {
731
755
  }
732
756
  /**
733
757
  * Starts the dev server on `127.0.0.1` only, never on every interface: the page on `/`, the
734
- * manifest of the served folder on `/manifest.json`, and its files as static files.
758
+ * manifest of the served folder on `/manifest.json`, and its files as static files. The dev child
759
+ * and `moku-game visual` serve the page with it.
735
760
  *
736
761
  * @param run - The flags of the run.
737
- * @param page - The page bundle the child imported.
762
+ * @param page - The page bundle the process imported.
738
763
  * @returns The server.
739
764
  * @throws {Error} When the port is in use.
740
765
  */
@@ -757,6 +782,22 @@ function serveOn(run, page) {
757
782
  }
758
783
  }
759
784
  /**
785
+ * Watches the parent of a child run: a parent that died hands the child to another process, so
786
+ * its parent id changes. The dev child and the visual child stop their server then.
787
+ *
788
+ * @param onGone - Called once when the parent is gone.
789
+ * @returns Stops watching.
790
+ */
791
+ function watchParent(onGone) {
792
+ const parent = process.ppid;
793
+ const timer = setInterval(() => {
794
+ if (process.ppid === parent) return;
795
+ clearInterval(timer);
796
+ onGone();
797
+ }, PARENT_CHECK_MS);
798
+ return () => clearInterval(timer);
799
+ }
800
+ /**
760
801
  * Waits until the dev child should stop: Ctrl+C, a stop signal, or a parent that died (its
761
802
  * parent id changed). Then stops the server.
762
803
  *
@@ -765,17 +806,14 @@ function serveOn(run, page) {
765
806
  * @returns Resolves to the exit code `0` once the server stopped.
766
807
  */
767
808
  function untilStopped(server, deps) {
768
- const parent = process.ppid;
769
809
  return new Promise((resolve) => {
770
810
  const removers = [];
771
- const timer = setInterval(() => {
772
- if (process.ppid !== parent) stop();
773
- }, PARENT_CHECK_MS);
774
811
  const stop = () => {
775
- clearInterval(timer);
812
+ unwatch();
776
813
  for (const remove of removers.splice(0)) remove();
777
814
  resolve(server.stop(true).then(() => 0));
778
815
  };
816
+ const unwatch = watchParent(stop);
779
817
  for (const signal of STOPPING) removers.push(deps.onSignal(signal, stop));
780
818
  });
781
819
  }
@@ -1086,13 +1124,188 @@ async function runBuild(run, deps) {
1086
1124
  return 0;
1087
1125
  }
1088
1126
  //#endregion
1127
+ //#region src/app/visual.ts
1128
+ /**
1129
+ * @file `moku-game visual`: the visual tests of a game from the bin. It imports the tests module,
1130
+ * `tests/visual/index.ts` by default, whose default export is `{ app, tests }`, the two arguments
1131
+ * of `runVisualTests`: `app` is a `VisualSetup`. The flags of the runner are read by
1132
+ * `parseVisualArgv`. When the pixel leg runs without `--url`, the command writes its own dev page
1133
+ * into `.moku/visual/`, so the page of `dev` or of the editor stays as it is, runs the bin again
1134
+ * under that page's bunfig as `dev` does, and the child serves the page with the server of `dev`
1135
+ * on a free port and stops it after. Node and Bun only: the bin bundles it.
1136
+ */
1137
+ /** The baselines of a game, under the game folder. */
1138
+ const BASELINES = path.join("tests", "visual", "baselines");
1139
+ /** The folder of the visual page under the game: its own, beside the page of `dev`. */
1140
+ const VISUAL_PAGE = ".moku/visual";
1141
+ /**
1142
+ * Tells whether a value is a setup of the runner: an object with an `app` function.
1143
+ *
1144
+ * @param value - The `app` of the module.
1145
+ * @returns True for a `VisualSetup`.
1146
+ */
1147
+ function isSetup(value) {
1148
+ return typeof value === "object" && value !== null && "app" in value && typeof value.app === "function";
1149
+ }
1150
+ /**
1151
+ * Reads the default export of a tests module: `app` is the setup of the runner, `{ app, page? }`,
1152
+ * and `tests` is a list. Each test is checked by the runner.
1153
+ *
1154
+ * @param value - The default export.
1155
+ * @returns The suite, or `undefined` for another shape.
1156
+ */
1157
+ function suiteOf(value) {
1158
+ if (typeof value !== "object" || value === null || !("app" in value) || !("tests" in value)) return;
1159
+ const { app, tests } = value;
1160
+ return Array.isArray(tests) && isSetup(app) ? {
1161
+ setup: app,
1162
+ tests
1163
+ } : void 0;
1164
+ }
1165
+ /**
1166
+ * Imports the tests module of the run.
1167
+ *
1168
+ * @param file - The module, absolute.
1169
+ * @param cwd - The working directory, to name the module in the message.
1170
+ * @returns The setup and the tests.
1171
+ * @throws {Error} When the module is missing or its default export is not `{ app, tests }`.
1172
+ */
1173
+ async function loadSuite(file, cwd) {
1174
+ const suite = suiteOf((statSync(file, { throwIfNoEntry: false })?.isFile() === true ? await import(pathToFileURL(file).href) : void 0)?.default);
1175
+ if (suite === void 0) throw new Error(`[game] visual: ${path.relative(cwd, file)} must export default { app, tests }.\n app is a VisualSetup { app, page? }: export default { app: { app: () => game.screen().app }, tests }.`);
1176
+ return suite;
1177
+ }
1178
+ /**
1179
+ * Writes the visual page of the game into `.moku/visual/`: `index.html`, `dev.ts`, `main.ts` and
1180
+ * `bunfig.toml`, the hot plugin, the serve plugins and the preloads in it, as `dev` writes its page.
1181
+ *
1182
+ * @param page - The game folder, the preloads and the serve plugins.
1183
+ * @param resolve - Bun's resolver, to find the hot plugin.
1184
+ * @returns The paths of the HTML and the bunfig.
1185
+ * @throws {Error} The errors of `moku-game dev` before its server starts, and a game without
1186
+ * `manifest.json`.
1187
+ */
1188
+ async function writeVisualPage(page, resolve) {
1189
+ const settings = await loadGame(page.root);
1190
+ const manifest = path.join(page.root, "manifest.json");
1191
+ if (statSync(manifest, { throwIfNoEntry: false })?.isFile() !== true) throw new Error(`[game] visual: no manifest.json in "${page.root}".\n Run "moku-game keys" first.`);
1192
+ return writePage(page.root, settings, {
1193
+ preload: page.preload,
1194
+ servePlugins: page.servePlugins
1195
+ }, resolve, VISUAL_PAGE);
1196
+ }
1197
+ /**
1198
+ * Writes the visual page and serves it in this process on a free port, as the dev child serves
1199
+ * its page. It runs in the child that runs under the page's bunfig, so the hot plugin, the serve
1200
+ * plugins and the preloads apply.
1201
+ *
1202
+ * @param page - The game folder, the preloads and the serve plugins.
1203
+ * @param deps - Bun's resolver and the page loader.
1204
+ * @returns The running server.
1205
+ * @throws {Error} The errors of `writeVisualPage`.
1206
+ */
1207
+ async function serveVisualPage(page, deps) {
1208
+ const files = await writeVisualPage(page, deps.resolve);
1209
+ const loaded = await deps.loadPage(files.html);
1210
+ return serveOn({
1211
+ root: page.root,
1212
+ port: 0,
1213
+ packed: false,
1214
+ preload: page.preload,
1215
+ servePlugins: page.servePlugins
1216
+ }, loaded.default);
1217
+ }
1218
+ /**
1219
+ * Runs the bin again for a run that has to: under the visual page's bunfig when the page is
1220
+ * served, so it is bundled as `dev` bundles its page; with Bun's preloads otherwise.
1221
+ *
1222
+ * @param command - The run.
1223
+ * @param serves - True when the page is served for the run.
1224
+ * @param deps - The process seams.
1225
+ * @returns The exit code of the child.
1226
+ * @throws {Error} The errors of `writeVisualPage`.
1227
+ */
1228
+ async function rerun(command, serves, deps) {
1229
+ const page = serves ? await writeVisualPage(command, deps.resolve) : void 0;
1230
+ const bun = page === void 0 ? command.preload.map((file) => `--preload=${file}`) : [`--config=${page.bunfig}`];
1231
+ return runChild([
1232
+ deps.execPath,
1233
+ ...bun,
1234
+ ...deps.self,
1235
+ ...command.argv
1236
+ ], deps, { cwd: deps.cwd });
1237
+ }
1238
+ /**
1239
+ * Plays the tests in this process: the headless leg, then the pixel leg against `--url` or the
1240
+ * page served here, which stops when the run ends or the parent of the run is gone.
1241
+ *
1242
+ * @param command - The run.
1243
+ * @param run - The runner, its flags, and whether the pixel leg runs.
1244
+ * @param deps - The process seams.
1245
+ * @returns `0` when the report is ok, `1` otherwise.
1246
+ * @throws {Error} When the tests module is refused, the page cannot be served, or the runner
1247
+ * throws.
1248
+ */
1249
+ async function play(command, run, deps) {
1250
+ const suite = await loadSuite(command.tests, deps.cwd);
1251
+ const dir = path.resolve(deps.cwd, run.flags.dir ?? path.join(command.root, BASELINES));
1252
+ const server = run.serves ? await serveVisualPage(command, deps) : void 0;
1253
+ const unwatch = server === void 0 ? void 0 : watchParent(() => server.stop(true));
1254
+ const url = command.url ?? server?.url.href;
1255
+ try {
1256
+ const setup = url === void 0 ? suite.setup : {
1257
+ ...suite.setup,
1258
+ page: {
1259
+ ...suite.setup.page,
1260
+ url
1261
+ }
1262
+ };
1263
+ return (await run.visual.runVisualTests(setup, suite.tests, {
1264
+ ...run.flags,
1265
+ pixels: run.pixels,
1266
+ dir,
1267
+ argv: []
1268
+ })).ok ? 0 : 1;
1269
+ } finally {
1270
+ unwatch?.();
1271
+ await server?.stop(true);
1272
+ }
1273
+ }
1274
+ /**
1275
+ * Runs `moku-game visual`: the headless leg, then the pixel leg on a Mac (or with `--pixels`)
1276
+ * against `--url` or the page served for the run. A run that serves the page runs the bin again
1277
+ * under `.moku/visual/bunfig.toml`, as `dev` does, and a run with `--preload` with the preloads;
1278
+ * the marked child plays the tests. The baselines live in `<game>/tests/visual/baselines/` unless
1279
+ * `--dir` names another folder.
1280
+ *
1281
+ * @param command - The run.
1282
+ * @param deps - The process seams.
1283
+ * @returns `0` when the report is ok, `1` otherwise, or the exit code of the child.
1284
+ * @throws {Error} When the tests module is refused, a flag misses its value, the page cannot be
1285
+ * served, or the runner throws.
1286
+ */
1287
+ async function runVisual(command, deps) {
1288
+ const visual = await deps.visual();
1289
+ const flags = visual.parseVisualArgv(command.flags);
1290
+ const pixels = flags.pixels ?? process.platform === "darwin";
1291
+ const serves = pixels && command.url === void 0;
1292
+ if (!(deps.env.MOKU_GAME_CHILD === "1") && (serves || command.preload.length > 0)) return rerun(command, serves, deps);
1293
+ return play(command, {
1294
+ visual,
1295
+ flags,
1296
+ pixels,
1297
+ serves
1298
+ }, deps);
1299
+ }
1300
+ //#endregion
1089
1301
  //#region src/app/cli.ts
1090
1302
  /**
1091
1303
  * @file The command line of `moku-game`: the arguments read per command with `node:util`
1092
- * `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack` or `help`. Every seam
1093
- * of the process (the console, the environment, spawning, signals, the watcher, the asset
1094
- * scanner, native, Bun's resolver) comes in as `deps`, so a test runs a command with stubs. It
1095
- * returns the exit code and never exits itself. A plain function, on no API.
1304
+ * `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack`, `visual` or `help`.
1305
+ * Every seam of the process (the console, the environment, spawning, signals, the watcher, the
1306
+ * asset scanner, native, Bun's resolver, the visual test runner) comes in as `deps`, so a test
1307
+ * runs a command with stubs. It returns the exit code and never exits itself. A plain function,
1308
+ * on no API.
1096
1309
  */
1097
1310
  /** The flags every command takes. */
1098
1311
  const GLOBAL_OPTIONS = [
@@ -1121,15 +1334,38 @@ const ALL_OPTIONS = {
1121
1334
  out: { type: "string" },
1122
1335
  simulator: { type: "boolean" },
1123
1336
  check: { type: "boolean" },
1124
- "no-cache": { type: "boolean" }
1337
+ "no-cache": { type: "boolean" },
1338
+ tests: { type: "string" },
1339
+ url: { type: "string" },
1340
+ update: { type: "boolean" },
1341
+ only: {
1342
+ type: "string",
1343
+ multiple: true
1344
+ },
1345
+ "no-pixels": { type: "boolean" },
1346
+ pixels: { type: "boolean" },
1347
+ dir: { type: "string" },
1348
+ webgl: { type: "boolean" }
1125
1349
  };
1350
+ /** The flags of `visual` that `parseVisualArgv` reads, in the runner's words. */
1351
+ const VISUAL_FLAGS = /^(?:update|only|no-pixels|pixels|dir|webgl)$/;
1126
1352
  /** The flags of one command besides the global ones. */
1127
1353
  const COMMAND_OPTIONS = {
1128
1354
  dev: ["port", "packed"],
1129
1355
  build: ["out"],
1130
1356
  native: ["simulator"],
1131
1357
  keys: ["check"],
1132
- pack: ["no-cache"]
1358
+ pack: ["no-cache"],
1359
+ visual: [
1360
+ "tests",
1361
+ "url",
1362
+ "update",
1363
+ "only",
1364
+ "no-pixels",
1365
+ "pixels",
1366
+ "dir",
1367
+ "webgl"
1368
+ ]
1133
1369
  };
1134
1370
  /** The native verbs, in the order the messages name them. */
1135
1371
  const NATIVE_VERBS = [
@@ -1138,6 +1374,10 @@ const NATIVE_VERBS = [
1138
1374
  "doctor",
1139
1375
  "clean"
1140
1376
  ];
1377
+ /** The command words, in the order the messages name them. */
1378
+ const COMMAND_NAMES = "dev, build, native, keys, pack, visual, help";
1379
+ /** The tests module of a game, under the game folder. */
1380
+ const VISUAL_TESTS = path.join("tests", "visual", "index.ts");
1141
1381
  /** The port of `moku-game dev` without `--port`. */
1142
1382
  const DEFAULT_PORT = "3000";
1143
1383
  /** The highest port number. */
@@ -1153,6 +1393,9 @@ const USAGE = [
1153
1393
  " native doctor | native clean check or remove the native project",
1154
1394
  " keys [--check] write generated/assets.ts and manifest.json",
1155
1395
  " pack [--no-cache] pack the assets into dist/assets",
1396
+ " visual [--update] [--only <name>] run tests/visual/index.ts: headless, pixels on a Mac",
1397
+ " [--no-pixels | --pixels] [--webgl] [--dir tests/visual/baselines]",
1398
+ " [--tests tests/visual/index.ts] [--url <url>]",
1156
1399
  " help print this text",
1157
1400
  "",
1158
1401
  "Every command: --root <dir> (default .), --preload <path>, --serve-plugin <path>."
@@ -1161,7 +1404,7 @@ const USAGE = [
1161
1404
  * Tells whether a word is a command of the line that runs something.
1162
1405
  *
1163
1406
  * @param word - The first positional argument.
1164
- * @returns True for `dev`, `build`, `native`, `keys` and `pack`.
1407
+ * @returns True for `dev`, `build`, `native`, `keys`, `pack` and `visual`.
1165
1408
  */
1166
1409
  function isCommand(word) {
1167
1410
  return Object.hasOwn(COMMAND_OPTIONS, word);
@@ -1238,7 +1481,7 @@ function parseCommand(argv, cwd) {
1238
1481
  const word = loose.positionals[0];
1239
1482
  if (loose.values.help === true || word === "help") return "help";
1240
1483
  if (word === void 0) return void 0;
1241
- if (!isCommand(word)) throw new Error(`[game] moku-game: no command "${word}". Name one of dev, build, native, keys, pack, help.`);
1484
+ if (!isCommand(word)) throw new Error(`[game] moku-game: no command "${word}". Name one of ${COMMAND_NAMES}.`);
1242
1485
  checkTokens(word, loose.tokens);
1243
1486
  const { values, positionals } = parseStrict(argv);
1244
1487
  const resolveAll = (files) => (files ?? []).map((file) => path.resolve(cwd, file));
@@ -1354,6 +1597,41 @@ async function runScan(parsed, deps) {
1354
1597
  return deps.assets(argv);
1355
1598
  }
1356
1599
  /**
1600
+ * The flags of `visual` that `parseVisualArgv` reads, in the order given, each value after its
1601
+ * flag: `--dir=shots` becomes `--dir shots`.
1602
+ *
1603
+ * @param argv - The arguments after the bin, their flags checked.
1604
+ * @returns The flags of the runner.
1605
+ */
1606
+ function visualFlags(argv) {
1607
+ return parseLoose(argv).tokens.flatMap((token) => {
1608
+ if (token.kind !== "option" || !VISUAL_FLAGS.test(token.name)) return [];
1609
+ return token.value === void 0 ? [`--${token.name}`] : [`--${token.name}`, token.value];
1610
+ });
1611
+ }
1612
+ /**
1613
+ * Runs `moku-game visual` with the tests module and the baselines of the game.
1614
+ *
1615
+ * @param argv - The arguments after the bin.
1616
+ * @param parsed - The command line.
1617
+ * @param deps - The process seams.
1618
+ * @returns `0` when every checkpoint passed, else `1`.
1619
+ */
1620
+ function runVisualCommand(argv, parsed, deps) {
1621
+ refuseExtra(parsed, 0);
1622
+ const tests = parsed.values.tests ?? path.join(parsed.root, VISUAL_TESTS);
1623
+ const url = parsed.values.url;
1624
+ return runVisual({
1625
+ argv,
1626
+ root: parsed.root,
1627
+ tests: path.resolve(deps.cwd, tests),
1628
+ ...url === void 0 ? {} : { url },
1629
+ flags: visualFlags(argv),
1630
+ preload: parsed.preload,
1631
+ servePlugins: parsed.servePlugins
1632
+ }, deps);
1633
+ }
1634
+ /**
1357
1635
  * Runs the bin once more with every `--preload` as a Bun preload, so the preload runs before the
1358
1636
  * engine loads. The re-run is marked, so it does not hop again.
1359
1637
  *
@@ -1392,7 +1670,7 @@ async function runParsed(argv, parsed, deps) {
1392
1670
  const usesPlugins = parsed.command !== "keys" && parsed.command !== "pack";
1393
1671
  checkFiles("--preload", parsed.preload);
1394
1672
  if (usesPlugins) checkFiles("--serve-plugin", parsed.servePlugins);
1395
- if (parsed.command !== "dev" && parsed.preload.length > 0 && deps.env.MOKU_GAME_CHILD !== "1") return hop(argv, parsed, deps);
1673
+ if (parsed.command !== "dev" && parsed.command !== "visual" && parsed.preload.length > 0 && deps.env.MOKU_GAME_CHILD !== "1") return hop(argv, parsed, deps);
1396
1674
  switch (parsed.command) {
1397
1675
  case "dev": return runServe(parsed, deps);
1398
1676
  case "build": {
@@ -1407,6 +1685,7 @@ async function runParsed(argv, parsed, deps) {
1407
1685
  case "native": return runNativeVerb(parsed, deps);
1408
1686
  case "keys":
1409
1687
  case "pack": return runScan(parsed, deps);
1688
+ case "visual": return runVisualCommand(argv, parsed, deps);
1410
1689
  }
1411
1690
  }
1412
1691
  /**
@@ -1419,8 +1698,8 @@ function printUsage(ui) {
1419
1698
  }
1420
1699
  /**
1421
1700
  * Runs one `moku-game` command line: reads the command and its flags, then runs `dev`, `build`,
1422
- * `native <verb>`, `keys`, `pack` or `help`. Every failure is printed through the console as a
1423
- * `[game] …` message; nothing exits the process.
1701
+ * `native <verb>`, `keys`, `pack`, `visual` or `help`. Every failure is printed through the
1702
+ * console as a `[game] …` message; nothing exits the process.
1424
1703
  *
1425
1704
  * @param argv - The arguments after the bin.
1426
1705
  * @param deps - The process seams.
@@ -1435,7 +1714,7 @@ async function runCommand(argv, deps) {
1435
1714
  }
1436
1715
  if (parsed === void 0) {
1437
1716
  printUsage(deps.ui);
1438
- deps.ui.error("[game] moku-game: name a command: dev, build, native, keys, pack, help.");
1717
+ deps.ui.error(`[game] moku-game: name a command: ${COMMAND_NAMES}.`);
1439
1718
  return 1;
1440
1719
  }
1441
1720
  return await runParsed(argv, parsed, deps);
@@ -1593,15 +1872,15 @@ async function runNative(verb, settings, where, options) {
1593
1872
  //#region src/cli.ts
1594
1873
  /**
1595
1874
  * @file The `@moku-labs/game/cli` door, node and bun only: the `moku-game` bin and the editor's
1596
- * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, help) in
1597
- * this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
1875
+ * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, visual,
1876
+ * help) in this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
1598
1877
  * caller starts itself, as the editor does. It imports no Pixi, and the root entry never imports
1599
1878
  * it. The code lives in `app/`; this file wires the real process into it.
1600
1879
  */
1601
1880
  /**
1602
1881
  * The real process behind the command line: the branded console, the environment the bin
1603
1882
  * started with, Bun's spawn and resolver, the signals, `fs.watch`, the asset scanner with the
1604
- * string tools, and native.
1883
+ * string tools, native, and the visual test runner, loaded only by `moku-game visual`.
1605
1884
  *
1606
1885
  * @returns The seams of this process.
1607
1886
  */
@@ -1634,13 +1913,14 @@ function processDeps() {
1634
1913
  }, ui),
1635
1914
  native: runNative,
1636
1915
  resolve: (specifier, from) => Bun.resolveSync(specifier, from),
1637
- loadPage: (file) => import(file)
1916
+ loadPage: (file) => import(file),
1917
+ visual: () => import("./visual.mjs")
1638
1918
  };
1639
1919
  }
1640
1920
  /**
1641
1921
  * Runs one `moku-game` command line in this process: `dev`, `build`, `native <verb>`, `keys`,
1642
- * `pack` or `help`, with `--root`, `--preload` and `--serve-plugin` before or after the command
1643
- * word. Every line goes through the branded console; nothing exits the process.
1922
+ * `pack`, `visual` or `help`, with `--root`, `--preload` and `--serve-plugin` before or after the
1923
+ * command word. Every line goes through the branded console; nothing exits the process.
1644
1924
  *
1645
1925
  * @param argv - The arguments after the bin.
1646
1926
  * @returns The exit code.
@@ -1648,6 +1928,7 @@ function processDeps() {
1648
1928
  * ```ts
1649
1929
  * // bin/moku-game.mjs in a game folder:
1650
1930
  * await runCli(["dev", "--port", "0"]); // 0 after Ctrl+C; stdout had "http://localhost:<port>/"
1931
+ * await runCli(["visual", "--no-pixels"]); // 0 when every checkpoint of tests/visual/index.ts is the same
1651
1932
  * await runCli(["help"]); // 0
1652
1933
  * ```
1653
1934
  */
@@ -1,6 +1,6 @@
1
1
  import { A as Api$2, E as DebugSwitches, Ot as Bookmark, h as Api$1, jt as FlowState, ln as Api, vn as Time, w as Captured } from "./types-ZQLYXo5X.mjs";
2
- import { a as AudioApi, r as UiApi, v as ScenesApi, x as AnimApi } from "./types-BM4shh8K.mjs";
3
- import { A as InputApi } from "./types-BUwDHKXm.mjs";
2
+ import { a as AudioApi, r as UiApi, v as ScenesApi, x as AnimApi } from "./types-D_2G6Q68.mjs";
3
+ import { A as InputApi } from "./types-BrTpz9oL.mjs";
4
4
  import { t as HeadlessApp } from "./headless-G4nuZ-xz.mjs";
5
5
  import { n as Command, r as ControlApp } from "./types-DAdwEJMp.mjs";
6
6
 
@@ -1,6 +1,6 @@
1
1
  import { t as HeadlessApp } from "./headless-G4nuZ-xz.mjs";
2
2
  import { i as InputArguments, n as Command, o as InputSchema, s as Ran } from "./types-DAdwEJMp.mjs";
3
- import { t as commands } from "./commands-C4QlqijJ.mjs";
3
+ import { t as commands } from "./commands-DvFr8Uwp.mjs";
4
4
  import { t as defineCommand } from "./define-CCc0ozKe.mjs";
5
5
 
6
6
  //#region src/plugins/flow/doors/dev.d.ts