@moku-labs/game 0.11.0 → 0.13.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, {",
@@ -353,6 +355,11 @@ const FORWARDED = [
353
355
  const STOPPING = ["SIGINT", "SIGTERM"];
354
356
  /** How often the dev child checks that its parent still runs. */
355
357
  const PARENT_CHECK_MS = 1e3;
358
+ /**
359
+ * The dev manifest `moku-game keys` writes, relative to the game folder, next to
360
+ * `generated/assets.ts`. The dev server answers `/manifest.json` with it.
361
+ */
362
+ const DEV_MANIFEST = "generated/manifest.json";
356
363
  /** The folders a `.dev.ts` module is never looked for in. */
357
364
  const NO_DEV_MODULES = /^(?:node_modules|dist|generated|tests|__tests__)$/;
358
365
  /** A `.gitignore` line that keeps `.moku/` of its own folder out. */
@@ -514,47 +521,52 @@ function writeIfChanged(file, text) {
514
521
  * @param root - The game folder.
515
522
  * @param settings - The resolved config.
516
523
  * @param agents - The agent modules, when the page has any.
524
+ * @param toRoot - The path from `main.ts` to the game folder.
517
525
  * @returns The text.
518
526
  */
519
- function mainOf(root, settings, agents) {
527
+ function mainOf(root, settings, agents, toRoot = "../") {
520
528
  const scenarios = scenarioNamesOf(root);
521
529
  return agents === void 0 || agents.length === 0 ? devMain(settings, {
522
530
  scenarios,
523
531
  devModules: []
524
- }) : devMain(settings, {
532
+ }, toRoot) : devMain(settings, {
525
533
  scenarios,
526
534
  devModules: devModulesOf(root),
527
535
  agents
528
- });
536
+ }, toRoot);
529
537
  }
530
538
  /**
531
539
  * 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.
540
+ * and `bunfig.toml`. `moku-game visual` writes its own page into `.moku/visual/`, so the page of
541
+ * `dev` or of the editor stays as it is; the links of the page reach the game from its depth.
542
+ * Every text is made before the first write, so a refused value writes nothing.
534
543
  *
535
544
  * @param root - The game folder, absolute.
536
545
  * @param settings - The resolved config.
537
546
  * @param options - The agents, the preloads and the extra plugins, absolute.
538
547
  * @param resolve - Bun's resolver, to find the hot plugin.
548
+ * @param page - The folder of the page under the game, `/` separated: `.moku` by default.
539
549
  * @returns The paths of the HTML and the bunfig.
540
550
  * @throws {Error} When the hot plugin does not resolve, a preload or plugin is not a file, or a
541
551
  * page value is refused.
542
552
  */
543
- function writePage(root, settings, options, resolve) {
553
+ function writePage(root, settings, options, resolve, page = ".moku") {
544
554
  const preload = options.preload ?? [];
545
555
  const servePlugins = options.servePlugins ?? [];
546
556
  checkFiles("--preload", preload);
547
557
  checkFiles("--serve-plugin", servePlugins);
548
- const folder = path.join(root, ".moku");
558
+ const segments = page.split("/");
559
+ const toRoot = "../".repeat(segments.length);
560
+ const folder = path.join(root, ...segments);
549
561
  const html = path.join(folder, "index.html");
550
562
  const bunfig = path.join(folder, "bunfig.toml");
551
563
  const texts = [
552
564
  [html, pageHtml(settings.page, {
553
- toRoot: "../",
565
+ toRoot,
554
566
  script: "./main.ts"
555
567
  })],
556
568
  [path.join(folder, "dev.ts"), devFlag()],
557
- [path.join(folder, "main.ts"), mainOf(root, settings, options.agents)],
569
+ [path.join(folder, "main.ts"), mainOf(root, settings, options.agents, toRoot)],
558
570
  [bunfig, bunfigText({
559
571
  hotPlugin: hotPluginOf(root, resolve),
560
572
  servePlugins,
@@ -656,13 +668,30 @@ function devChildCommand(run, page, deps) {
656
668
  async function serveParent(run, deps) {
657
669
  const settings = await loadGame(run.root);
658
670
  const hasPack = isFile(path.join(run.root, "dist", "assets", "manifest.json"));
659
- const hasManifest = isFile(path.join(run.root, "manifest.json"));
671
+ const hasManifest = isFile(path.join(run.root, DEV_MANIFEST));
660
672
  if (run.packed && !hasPack) throw new Error(`[game] dev: no packed build in "${path.join(run.root, "dist", "assets")}".\n Run "moku-game pack" first.`);
661
- if (!run.packed && !hasManifest) deps.ui.warn(`[game] dev: no manifest.json in "${run.root}".\n Run "moku-game keys" first.`);
673
+ if (!run.packed && !hasManifest) deps.ui.warn(`[game] dev: no ${DEV_MANIFEST} in "${run.root}".\n Run "moku-game keys" first.`);
662
674
  const page = writePage(run.root, settings, run, deps.resolve);
663
675
  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), {
676
+ return runChild(devChildCommand(run, page, deps), deps, {
665
677
  cwd: run.root,
678
+ whileRunning: () => watchScenarios(run.root, settings, deps)
679
+ });
680
+ }
681
+ /**
682
+ * Runs the bin again as a marked child, `MOKU_GAME_CHILD=1`, in its own process group: Ctrl+C
683
+ * reaches the parent, which hands it and the other stop signals on. `dev` and `visual` run their
684
+ * page server this way, under the page's bunfig.
685
+ *
686
+ * @param command - The words of the command: the Bun, its flags, the bin and its arguments.
687
+ * @param deps - The spawn, the signals and the environment.
688
+ * @param child - The working directory, and what lives as long as the child.
689
+ * @returns The exit code of the child.
690
+ * @throws {Error} When the child does not start.
691
+ */
692
+ async function runChild(command, deps, child) {
693
+ const started = deps.spawn(command, {
694
+ cwd: child.cwd,
666
695
  env: {
667
696
  ...deps.env,
668
697
  MOKU_GAME_CHILD: "1"
@@ -674,11 +703,11 @@ async function serveParent(run, deps) {
674
703
  ],
675
704
  detached: true
676
705
  });
677
- const removers = FORWARDED.map((signal) => deps.onSignal(signal, () => child.kill(signal)));
706
+ const removers = FORWARDED.map((signal) => deps.onSignal(signal, () => started.kill(signal)));
678
707
  let watcher;
679
708
  try {
680
- watcher = watchScenarios(run.root, settings, deps);
681
- return await child.exited;
709
+ watcher = child.whileRunning?.();
710
+ return await started.exited;
682
711
  } finally {
683
712
  watcher?.close();
684
713
  for (const remove of removers) remove();
@@ -731,15 +760,19 @@ function fileResponse(file) {
731
760
  }
732
761
  /**
733
762
  * 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.
763
+ * manifest on `/manifest.json`, and the files of the served folder as static files. The manifest
764
+ * is the dev manifest `generated/manifest.json` of the game, or `manifest.json` of `dist/assets`
765
+ * with `--packed`; its paths are relative to the served folder. The dev child and
766
+ * `moku-game visual` serve the page with it.
735
767
  *
736
768
  * @param run - The flags of the run.
737
- * @param page - The page bundle the child imported.
769
+ * @param page - The page bundle the process imported.
738
770
  * @returns The server.
739
771
  * @throws {Error} When the port is in use.
740
772
  */
741
773
  function serveOn(run, page) {
742
774
  const base = run.packed ? path.join(run.root, "dist", "assets") : run.root;
775
+ const manifest = path.join(base, run.packed ? "manifest.json" : DEV_MANIFEST);
743
776
  try {
744
777
  return Bun.serve({
745
778
  hostname: "127.0.0.1",
@@ -747,7 +780,7 @@ function serveOn(run, page) {
747
780
  development: true,
748
781
  routes: {
749
782
  "/": page,
750
- "/manifest.json": () => fileResponse(path.join(base, "manifest.json"))
783
+ "/manifest.json": () => fileResponse(manifest)
751
784
  },
752
785
  fetch: (request) => fileResponse(staticPath(base, new URL(request.url).pathname))
753
786
  });
@@ -757,6 +790,22 @@ function serveOn(run, page) {
757
790
  }
758
791
  }
759
792
  /**
793
+ * Watches the parent of a child run: a parent that died hands the child to another process, so
794
+ * its parent id changes. The dev child and the visual child stop their server then.
795
+ *
796
+ * @param onGone - Called once when the parent is gone.
797
+ * @returns Stops watching.
798
+ */
799
+ function watchParent(onGone) {
800
+ const parent = process.ppid;
801
+ const timer = setInterval(() => {
802
+ if (process.ppid === parent) return;
803
+ clearInterval(timer);
804
+ onGone();
805
+ }, PARENT_CHECK_MS);
806
+ return () => clearInterval(timer);
807
+ }
808
+ /**
760
809
  * Waits until the dev child should stop: Ctrl+C, a stop signal, or a parent that died (its
761
810
  * parent id changed). Then stops the server.
762
811
  *
@@ -765,17 +814,14 @@ function serveOn(run, page) {
765
814
  * @returns Resolves to the exit code `0` once the server stopped.
766
815
  */
767
816
  function untilStopped(server, deps) {
768
- const parent = process.ppid;
769
817
  return new Promise((resolve) => {
770
818
  const removers = [];
771
- const timer = setInterval(() => {
772
- if (process.ppid !== parent) stop();
773
- }, PARENT_CHECK_MS);
774
819
  const stop = () => {
775
- clearInterval(timer);
820
+ unwatch();
776
821
  for (const remove of removers.splice(0)) remove();
777
822
  resolve(server.stop(true).then(() => 0));
778
823
  };
824
+ const unwatch = watchParent(stop);
779
825
  for (const signal of STOPPING) removers.push(deps.onSignal(signal, stop));
780
826
  });
781
827
  }
@@ -853,7 +899,7 @@ function hasEnglishStrings(root, settings) {
853
899
  return [...featureFolders, ...layerFolders].some((folder) => statSync(path.join(folder, "strings", "en.json"), { throwIfNoEntry: false })?.isFile() === true);
854
900
  }
855
901
  /**
856
- * The flags of the asset scan for `moku-game keys`: the dev manifest beside the game, the
902
+ * The flags of the asset scan for `moku-game keys`: the dev manifest in `generated/`, the
857
903
  * pseudo-locale when the game has an English string file, and `--check` for a check run.
858
904
  *
859
905
  * @param root - The game folder.
@@ -862,7 +908,7 @@ function hasEnglishStrings(root, settings) {
862
908
  * @returns The flags for the scanner's command line.
863
909
  * @example
864
910
  * ```ts
865
- * keysArguments("/g", resolveConfig({ page: { title: "T" } }), true).slice(4); // ["--manifest", "/g/manifest.json", "--check"]: no strings
911
+ * keysArguments("/g", resolveConfig({ page: { title: "T" } }), true).slice(4); // ["--manifest", "/g/generated/manifest.json", "--check"]: no strings
866
912
  * keysArguments("/g", resolveConfig({ page: { title: "T" } }), false).at(-1); // "--pseudo" once /g/features/home/strings/en.json exists
867
913
  * ```
868
914
  */
@@ -870,7 +916,7 @@ function keysArguments(root, settings, check) {
870
916
  return [
871
917
  ...scanArguments(root, settings),
872
918
  "--manifest",
873
- path.join(root, "manifest.json"),
919
+ path.join(root, DEV_MANIFEST),
874
920
  ...hasEnglishStrings(root, settings) ? ["--pseudo"] : [],
875
921
  ...check ? ["--check"] : []
876
922
  ];
@@ -1086,13 +1132,188 @@ async function runBuild(run, deps) {
1086
1132
  return 0;
1087
1133
  }
1088
1134
  //#endregion
1135
+ //#region src/app/visual.ts
1136
+ /**
1137
+ * @file `moku-game visual`: the visual tests of a game from the bin. It imports the tests module,
1138
+ * `tests/visual/index.ts` by default, whose default export is `{ app, tests }`, the two arguments
1139
+ * of `runVisualTests`: `app` is a `VisualSetup`. The flags of the runner are read by
1140
+ * `parseVisualArgv`. When the pixel leg runs without `--url`, the command writes its own dev page
1141
+ * into `.moku/visual/`, so the page of `dev` or of the editor stays as it is, runs the bin again
1142
+ * under that page's bunfig as `dev` does, and the child serves the page with the server of `dev`
1143
+ * on a free port and stops it after. Node and Bun only: the bin bundles it.
1144
+ */
1145
+ /** The baselines of a game, under the game folder. */
1146
+ const BASELINES = path.join("tests", "visual", "baselines");
1147
+ /** The folder of the visual page under the game: its own, beside the page of `dev`. */
1148
+ const VISUAL_PAGE = ".moku/visual";
1149
+ /**
1150
+ * Tells whether a value is a setup of the runner: an object with an `app` function.
1151
+ *
1152
+ * @param value - The `app` of the module.
1153
+ * @returns True for a `VisualSetup`.
1154
+ */
1155
+ function isSetup(value) {
1156
+ return typeof value === "object" && value !== null && "app" in value && typeof value.app === "function";
1157
+ }
1158
+ /**
1159
+ * Reads the default export of a tests module: `app` is the setup of the runner, `{ app, page? }`,
1160
+ * and `tests` is a list. Each test is checked by the runner.
1161
+ *
1162
+ * @param value - The default export.
1163
+ * @returns The suite, or `undefined` for another shape.
1164
+ */
1165
+ function suiteOf(value) {
1166
+ if (typeof value !== "object" || value === null || !("app" in value) || !("tests" in value)) return;
1167
+ const { app, tests } = value;
1168
+ return Array.isArray(tests) && isSetup(app) ? {
1169
+ setup: app,
1170
+ tests
1171
+ } : void 0;
1172
+ }
1173
+ /**
1174
+ * Imports the tests module of the run.
1175
+ *
1176
+ * @param file - The module, absolute.
1177
+ * @param cwd - The working directory, to name the module in the message.
1178
+ * @returns The setup and the tests.
1179
+ * @throws {Error} When the module is missing or its default export is not `{ app, tests }`.
1180
+ */
1181
+ async function loadSuite(file, cwd) {
1182
+ const suite = suiteOf((statSync(file, { throwIfNoEntry: false })?.isFile() === true ? await import(pathToFileURL(file).href) : void 0)?.default);
1183
+ 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 }.`);
1184
+ return suite;
1185
+ }
1186
+ /**
1187
+ * Writes the visual page of the game into `.moku/visual/`: `index.html`, `dev.ts`, `main.ts` and
1188
+ * `bunfig.toml`, the hot plugin, the serve plugins and the preloads in it, as `dev` writes its page.
1189
+ *
1190
+ * @param page - The game folder, the preloads and the serve plugins.
1191
+ * @param resolve - Bun's resolver, to find the hot plugin.
1192
+ * @returns The paths of the HTML and the bunfig.
1193
+ * @throws {Error} The errors of `moku-game dev` before its server starts, and a game without
1194
+ * `generated/manifest.json`.
1195
+ */
1196
+ async function writeVisualPage(page, resolve) {
1197
+ const settings = await loadGame(page.root);
1198
+ const manifest = path.join(page.root, DEV_MANIFEST);
1199
+ if (statSync(manifest, { throwIfNoEntry: false })?.isFile() !== true) throw new Error(`[game] visual: no ${DEV_MANIFEST} in "${page.root}".\n Run "moku-game keys" first.`);
1200
+ return writePage(page.root, settings, {
1201
+ preload: page.preload,
1202
+ servePlugins: page.servePlugins
1203
+ }, resolve, VISUAL_PAGE);
1204
+ }
1205
+ /**
1206
+ * Writes the visual page and serves it in this process on a free port, as the dev child serves
1207
+ * its page. It runs in the child that runs under the page's bunfig, so the hot plugin, the serve
1208
+ * plugins and the preloads apply.
1209
+ *
1210
+ * @param page - The game folder, the preloads and the serve plugins.
1211
+ * @param deps - Bun's resolver and the page loader.
1212
+ * @returns The running server.
1213
+ * @throws {Error} The errors of `writeVisualPage`.
1214
+ */
1215
+ async function serveVisualPage(page, deps) {
1216
+ const files = await writeVisualPage(page, deps.resolve);
1217
+ const loaded = await deps.loadPage(files.html);
1218
+ return serveOn({
1219
+ root: page.root,
1220
+ port: 0,
1221
+ packed: false,
1222
+ preload: page.preload,
1223
+ servePlugins: page.servePlugins
1224
+ }, loaded.default);
1225
+ }
1226
+ /**
1227
+ * Runs the bin again for a run that has to: under the visual page's bunfig when the page is
1228
+ * served, so it is bundled as `dev` bundles its page; with Bun's preloads otherwise.
1229
+ *
1230
+ * @param command - The run.
1231
+ * @param serves - True when the page is served for the run.
1232
+ * @param deps - The process seams.
1233
+ * @returns The exit code of the child.
1234
+ * @throws {Error} The errors of `writeVisualPage`.
1235
+ */
1236
+ async function rerun(command, serves, deps) {
1237
+ const page = serves ? await writeVisualPage(command, deps.resolve) : void 0;
1238
+ const bun = page === void 0 ? command.preload.map((file) => `--preload=${file}`) : [`--config=${page.bunfig}`];
1239
+ return runChild([
1240
+ deps.execPath,
1241
+ ...bun,
1242
+ ...deps.self,
1243
+ ...command.argv
1244
+ ], deps, { cwd: deps.cwd });
1245
+ }
1246
+ /**
1247
+ * Plays the tests in this process: the headless leg, then the pixel leg against `--url` or the
1248
+ * page served here, which stops when the run ends or the parent of the run is gone.
1249
+ *
1250
+ * @param command - The run.
1251
+ * @param run - The runner, its flags, and whether the pixel leg runs.
1252
+ * @param deps - The process seams.
1253
+ * @returns `0` when the report is ok, `1` otherwise.
1254
+ * @throws {Error} When the tests module is refused, the page cannot be served, or the runner
1255
+ * throws.
1256
+ */
1257
+ async function play(command, run, deps) {
1258
+ const suite = await loadSuite(command.tests, deps.cwd);
1259
+ const dir = path.resolve(deps.cwd, run.flags.dir ?? path.join(command.root, BASELINES));
1260
+ const server = run.serves ? await serveVisualPage(command, deps) : void 0;
1261
+ const unwatch = server === void 0 ? void 0 : watchParent(() => server.stop(true));
1262
+ const url = command.url ?? server?.url.href;
1263
+ try {
1264
+ const setup = url === void 0 ? suite.setup : {
1265
+ ...suite.setup,
1266
+ page: {
1267
+ ...suite.setup.page,
1268
+ url
1269
+ }
1270
+ };
1271
+ return (await run.visual.runVisualTests(setup, suite.tests, {
1272
+ ...run.flags,
1273
+ pixels: run.pixels,
1274
+ dir,
1275
+ argv: []
1276
+ })).ok ? 0 : 1;
1277
+ } finally {
1278
+ unwatch?.();
1279
+ await server?.stop(true);
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Runs `moku-game visual`: the headless leg, then the pixel leg on a Mac (or with `--pixels`)
1284
+ * against `--url` or the page served for the run. A run that serves the page runs the bin again
1285
+ * under `.moku/visual/bunfig.toml`, as `dev` does, and a run with `--preload` with the preloads;
1286
+ * the marked child plays the tests. The baselines live in `<game>/tests/visual/baselines/` unless
1287
+ * `--dir` names another folder.
1288
+ *
1289
+ * @param command - The run.
1290
+ * @param deps - The process seams.
1291
+ * @returns `0` when the report is ok, `1` otherwise, or the exit code of the child.
1292
+ * @throws {Error} When the tests module is refused, a flag misses its value, the page cannot be
1293
+ * served, or the runner throws.
1294
+ */
1295
+ async function runVisual(command, deps) {
1296
+ const visual = await deps.visual();
1297
+ const flags = visual.parseVisualArgv(command.flags);
1298
+ const pixels = flags.pixels ?? process.platform === "darwin";
1299
+ const serves = pixels && command.url === void 0;
1300
+ if (!(deps.env.MOKU_GAME_CHILD === "1") && (serves || command.preload.length > 0)) return rerun(command, serves, deps);
1301
+ return play(command, {
1302
+ visual,
1303
+ flags,
1304
+ pixels,
1305
+ serves
1306
+ }, deps);
1307
+ }
1308
+ //#endregion
1089
1309
  //#region src/app/cli.ts
1090
1310
  /**
1091
1311
  * @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.
1312
+ * `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack`, `visual` or `help`.
1313
+ * Every seam of the process (the console, the environment, spawning, signals, the watcher, the
1314
+ * asset scanner, native, Bun's resolver, the visual test runner) comes in as `deps`, so a test
1315
+ * runs a command with stubs. It returns the exit code and never exits itself. A plain function,
1316
+ * on no API.
1096
1317
  */
1097
1318
  /** The flags every command takes. */
1098
1319
  const GLOBAL_OPTIONS = [
@@ -1121,15 +1342,38 @@ const ALL_OPTIONS = {
1121
1342
  out: { type: "string" },
1122
1343
  simulator: { type: "boolean" },
1123
1344
  check: { type: "boolean" },
1124
- "no-cache": { type: "boolean" }
1345
+ "no-cache": { type: "boolean" },
1346
+ tests: { type: "string" },
1347
+ url: { type: "string" },
1348
+ update: { type: "boolean" },
1349
+ only: {
1350
+ type: "string",
1351
+ multiple: true
1352
+ },
1353
+ "no-pixels": { type: "boolean" },
1354
+ pixels: { type: "boolean" },
1355
+ dir: { type: "string" },
1356
+ webgl: { type: "boolean" }
1125
1357
  };
1358
+ /** The flags of `visual` that `parseVisualArgv` reads, in the runner's words. */
1359
+ const VISUAL_FLAGS = /^(?:update|only|no-pixels|pixels|dir|webgl)$/;
1126
1360
  /** The flags of one command besides the global ones. */
1127
1361
  const COMMAND_OPTIONS = {
1128
1362
  dev: ["port", "packed"],
1129
1363
  build: ["out"],
1130
1364
  native: ["simulator"],
1131
1365
  keys: ["check"],
1132
- pack: ["no-cache"]
1366
+ pack: ["no-cache"],
1367
+ visual: [
1368
+ "tests",
1369
+ "url",
1370
+ "update",
1371
+ "only",
1372
+ "no-pixels",
1373
+ "pixels",
1374
+ "dir",
1375
+ "webgl"
1376
+ ]
1133
1377
  };
1134
1378
  /** The native verbs, in the order the messages name them. */
1135
1379
  const NATIVE_VERBS = [
@@ -1138,6 +1382,10 @@ const NATIVE_VERBS = [
1138
1382
  "doctor",
1139
1383
  "clean"
1140
1384
  ];
1385
+ /** The command words, in the order the messages name them. */
1386
+ const COMMAND_NAMES = "dev, build, native, keys, pack, visual, help";
1387
+ /** The tests module of a game, under the game folder. */
1388
+ const VISUAL_TESTS = path.join("tests", "visual", "index.ts");
1141
1389
  /** The port of `moku-game dev` without `--port`. */
1142
1390
  const DEFAULT_PORT = "3000";
1143
1391
  /** The highest port number. */
@@ -1151,8 +1399,11 @@ const USAGE = [
1151
1399
  " native build <target> [--simulator] build the native app (ios, macos, android)",
1152
1400
  " native dev <target> run the native shell on the dev server",
1153
1401
  " native doctor | native clean check or remove the native project",
1154
- " keys [--check] write generated/assets.ts and manifest.json",
1402
+ " keys [--check] write generated/assets.ts and generated/manifest.json",
1155
1403
  " pack [--no-cache] pack the assets into dist/assets",
1404
+ " visual [--update] [--only <name>] run tests/visual/index.ts: headless, pixels on a Mac",
1405
+ " [--no-pixels | --pixels] [--webgl] [--dir tests/visual/baselines]",
1406
+ " [--tests tests/visual/index.ts] [--url <url>]",
1156
1407
  " help print this text",
1157
1408
  "",
1158
1409
  "Every command: --root <dir> (default .), --preload <path>, --serve-plugin <path>."
@@ -1161,7 +1412,7 @@ const USAGE = [
1161
1412
  * Tells whether a word is a command of the line that runs something.
1162
1413
  *
1163
1414
  * @param word - The first positional argument.
1164
- * @returns True for `dev`, `build`, `native`, `keys` and `pack`.
1415
+ * @returns True for `dev`, `build`, `native`, `keys`, `pack` and `visual`.
1165
1416
  */
1166
1417
  function isCommand(word) {
1167
1418
  return Object.hasOwn(COMMAND_OPTIONS, word);
@@ -1238,7 +1489,7 @@ function parseCommand(argv, cwd) {
1238
1489
  const word = loose.positionals[0];
1239
1490
  if (loose.values.help === true || word === "help") return "help";
1240
1491
  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.`);
1492
+ if (!isCommand(word)) throw new Error(`[game] moku-game: no command "${word}". Name one of ${COMMAND_NAMES}.`);
1242
1493
  checkTokens(word, loose.tokens);
1243
1494
  const { values, positionals } = parseStrict(argv);
1244
1495
  const resolveAll = (files) => (files ?? []).map((file) => path.resolve(cwd, file));
@@ -1354,6 +1605,41 @@ async function runScan(parsed, deps) {
1354
1605
  return deps.assets(argv);
1355
1606
  }
1356
1607
  /**
1608
+ * The flags of `visual` that `parseVisualArgv` reads, in the order given, each value after its
1609
+ * flag: `--dir=shots` becomes `--dir shots`.
1610
+ *
1611
+ * @param argv - The arguments after the bin, their flags checked.
1612
+ * @returns The flags of the runner.
1613
+ */
1614
+ function visualFlags(argv) {
1615
+ return parseLoose(argv).tokens.flatMap((token) => {
1616
+ if (token.kind !== "option" || !VISUAL_FLAGS.test(token.name)) return [];
1617
+ return token.value === void 0 ? [`--${token.name}`] : [`--${token.name}`, token.value];
1618
+ });
1619
+ }
1620
+ /**
1621
+ * Runs `moku-game visual` with the tests module and the baselines of the game.
1622
+ *
1623
+ * @param argv - The arguments after the bin.
1624
+ * @param parsed - The command line.
1625
+ * @param deps - The process seams.
1626
+ * @returns `0` when every checkpoint passed, else `1`.
1627
+ */
1628
+ function runVisualCommand(argv, parsed, deps) {
1629
+ refuseExtra(parsed, 0);
1630
+ const tests = parsed.values.tests ?? path.join(parsed.root, VISUAL_TESTS);
1631
+ const url = parsed.values.url;
1632
+ return runVisual({
1633
+ argv,
1634
+ root: parsed.root,
1635
+ tests: path.resolve(deps.cwd, tests),
1636
+ ...url === void 0 ? {} : { url },
1637
+ flags: visualFlags(argv),
1638
+ preload: parsed.preload,
1639
+ servePlugins: parsed.servePlugins
1640
+ }, deps);
1641
+ }
1642
+ /**
1357
1643
  * Runs the bin once more with every `--preload` as a Bun preload, so the preload runs before the
1358
1644
  * engine loads. The re-run is marked, so it does not hop again.
1359
1645
  *
@@ -1392,7 +1678,7 @@ async function runParsed(argv, parsed, deps) {
1392
1678
  const usesPlugins = parsed.command !== "keys" && parsed.command !== "pack";
1393
1679
  checkFiles("--preload", parsed.preload);
1394
1680
  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);
1681
+ if (parsed.command !== "dev" && parsed.command !== "visual" && parsed.preload.length > 0 && deps.env.MOKU_GAME_CHILD !== "1") return hop(argv, parsed, deps);
1396
1682
  switch (parsed.command) {
1397
1683
  case "dev": return runServe(parsed, deps);
1398
1684
  case "build": {
@@ -1407,6 +1693,7 @@ async function runParsed(argv, parsed, deps) {
1407
1693
  case "native": return runNativeVerb(parsed, deps);
1408
1694
  case "keys":
1409
1695
  case "pack": return runScan(parsed, deps);
1696
+ case "visual": return runVisualCommand(argv, parsed, deps);
1410
1697
  }
1411
1698
  }
1412
1699
  /**
@@ -1419,8 +1706,8 @@ function printUsage(ui) {
1419
1706
  }
1420
1707
  /**
1421
1708
  * 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.
1709
+ * `native <verb>`, `keys`, `pack`, `visual` or `help`. Every failure is printed through the
1710
+ * console as a `[game] …` message; nothing exits the process.
1424
1711
  *
1425
1712
  * @param argv - The arguments after the bin.
1426
1713
  * @param deps - The process seams.
@@ -1435,7 +1722,7 @@ async function runCommand(argv, deps) {
1435
1722
  }
1436
1723
  if (parsed === void 0) {
1437
1724
  printUsage(deps.ui);
1438
- deps.ui.error("[game] moku-game: name a command: dev, build, native, keys, pack, help.");
1725
+ deps.ui.error(`[game] moku-game: name a command: ${COMMAND_NAMES}.`);
1439
1726
  return 1;
1440
1727
  }
1441
1728
  return await runParsed(argv, parsed, deps);
@@ -1593,15 +1880,15 @@ async function runNative(verb, settings, where, options) {
1593
1880
  //#region src/cli.ts
1594
1881
  /**
1595
1882
  * @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
1883
+ * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, visual,
1884
+ * help) in this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
1598
1885
  * caller starts itself, as the editor does. It imports no Pixi, and the root entry never imports
1599
1886
  * it. The code lives in `app/`; this file wires the real process into it.
1600
1887
  */
1601
1888
  /**
1602
1889
  * The real process behind the command line: the branded console, the environment the bin
1603
1890
  * started with, Bun's spawn and resolver, the signals, `fs.watch`, the asset scanner with the
1604
- * string tools, and native.
1891
+ * string tools, native, and the visual test runner, loaded only by `moku-game visual`.
1605
1892
  *
1606
1893
  * @returns The seams of this process.
1607
1894
  */
@@ -1634,13 +1921,14 @@ function processDeps() {
1634
1921
  }, ui),
1635
1922
  native: runNative,
1636
1923
  resolve: (specifier, from) => Bun.resolveSync(specifier, from),
1637
- loadPage: (file) => import(file)
1924
+ loadPage: (file) => import(file),
1925
+ visual: () => import("./visual.mjs")
1638
1926
  };
1639
1927
  }
1640
1928
  /**
1641
1929
  * 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.
1930
+ * `pack`, `visual` or `help`, with `--root`, `--preload` and `--serve-plugin` before or after the
1931
+ * command word. Every line goes through the branded console; nothing exits the process.
1644
1932
  *
1645
1933
  * @param argv - The arguments after the bin.
1646
1934
  * @returns The exit code.
@@ -1648,6 +1936,7 @@ function processDeps() {
1648
1936
  * ```ts
1649
1937
  * // bin/moku-game.mjs in a game folder:
1650
1938
  * await runCli(["dev", "--port", "0"]); // 0 after Ctrl+C; stdout had "http://localhost:<port>/"
1939
+ * await runCli(["visual", "--no-pixels"]); // 0 when every checkpoint of tests/visual/index.ts is the same
1651
1940
  * await runCli(["help"]); // 0
1652
1941
  * ```
1653
1942
  */
@@ -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