@moku-labs/game 0.12.0 → 0.13.1

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,13 +1,20 @@
1
1
  import { t as resolveConfig } from "./config-CdARrrhU.mjs";
2
+ import { r as watchTree, t as createBatcher, x as isSkippedFolder$1 } from "./watch-CB8IWDBi.mjs";
2
3
  import { a as runCli$1, i as compileStrings, n as importStrings, t as exportStrings } from "./assets-DYiKxPBX.mjs";
3
- import { constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
+ import { constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
5
  import path from "node:path";
5
6
  import { createBrandConsole } from "@moku-labs/common/cli";
6
7
  import { parseArgs } from "node:util";
7
8
  import { pathToFileURL } from "node:url";
9
+ import { createHash } from "node:crypto";
8
10
  //#region src/app/generate.ts
9
11
  /** The first line of a generated file a person may open. */
10
12
  const WRITTEN = "// Written by moku-game dev. Do not edit.";
13
+ /**
14
+ * The stamp module of a game, a file of `<game>/.moku/`. One per game: the dev page and the page
15
+ * of `moku-game visual` import the same one.
16
+ */
17
+ const ASSETS_STAMP = "assets-stamp.ts";
11
18
  /** A colour or a language tag: it lands in a `<style>` and in attributes, so nothing else passes. */
12
19
  const SAFE_VALUE = /^[#\w(),.%\s-]+$/;
13
20
  /** The files of `tests/scenarios/` that are not scenarios. */
@@ -223,10 +230,24 @@ function importLine(binding, specifier) {
223
230
  return `import ${binding} from ${JSON.stringify(specifier)};`;
224
231
  }
225
232
  /**
226
- * The `main.ts` of the dev page: the dev flag first, then the page entry, the game, its config,
227
- * one import per scenario, and, with agents, one per `.dev` module and one per agent. It calls
228
- * `startPage` with the scenarios by file stem, the shell with the loaders of the named plugins
229
- * when the game needs it, the agents and the `.dev` modules.
233
+ * The path from a page under `.moku/` to `.moku/` itself, where the one stamp of a game lives.
234
+ *
235
+ * @param toRoot - The path from the page to the game folder.
236
+ * @returns `"./"` for the dev page in `.moku/`, `"../"` for the page in `.moku/visual/`.
237
+ * @example
238
+ * ```ts
239
+ * toStampFolder("../../"); // "../"
240
+ * ```
241
+ */
242
+ function toStampFolder(toRoot) {
243
+ return toRoot.slice(3) || "./";
244
+ }
245
+ /**
246
+ * The `main.ts` of the dev page: the dev flag first, then the assets stamp, the page entry, the
247
+ * game, its config, one import per scenario, and, with agents, one per `.dev` module and one per
248
+ * agent. It calls `startPage` with the scenarios by file stem, the shell with the loaders of the
249
+ * named plugins when the game needs it, the agents and the `.dev` modules. The stamp import makes
250
+ * the page reload when the keys watch writes a new stamp: an asset file changed.
230
251
  *
231
252
  * @param settings - The resolved `config.ts`.
232
253
  * @param sources - The scenario files, the `.dev` modules and the agents.
@@ -252,6 +273,7 @@ function devMain(settings, sources, toRoot = "../") {
252
273
  return [
253
274
  WRITTEN,
254
275
  "import \"./dev.ts\";",
276
+ `import "${toStampFolder(toRoot)}${ASSETS_STAMP}";`,
255
277
  ...pageImports(settings, toRoot),
256
278
  ...scenarios.map((file, index) => importLine(`scenario${index}`, `${toRoot}tests/scenarios/${file}`)),
257
279
  ...devModules.map((file, index) => importLine(`* as devModule${index}`, `${toRoot}${file}`)),
@@ -302,6 +324,22 @@ function devFlag() {
302
324
  return `${WRITTEN}\nglobalThis.__MOKU_GAME_DEV__ = true;\n`;
303
325
  }
304
326
  /**
327
+ * The `assets-stamp.ts` module the dev `main.ts` imports: one string that changes when an asset
328
+ * file of the game changes. The hot plugin takes it for a logic module, so a new stamp reloads the
329
+ * page, and the page shows the new bytes of an image whose key stayed the same. The page starts
330
+ * with the empty stamp; the keys watch writes the hash of the asset files.
331
+ *
332
+ * @param hash - The hash of the asset files, `""` before the first watch.
333
+ * @returns The text of `.moku/assets-stamp.ts`.
334
+ * @example
335
+ * ```ts
336
+ * assetsStamp(""); // '// Written by moku-game dev. Do not edit.\nexport default "";\n'
337
+ * ```
338
+ */
339
+ function assetsStamp(hash) {
340
+ return `${WRITTEN}\nexport default ${JSON.stringify(hash)};\n`;
341
+ }
342
+ /**
305
343
  * A TOML array of strings. `JSON.stringify` of a path is a valid TOML basic string, also for a
306
344
  * Windows path.
307
345
  *
@@ -338,12 +376,31 @@ function bunfigText(paths) {
338
376
  ].join("\n");
339
377
  }
340
378
  //#endregion
379
+ //#region src/app/files.ts
380
+ /**
381
+ * @file The file write of the files `moku-game` generates for a game. The dev page and the stamp
382
+ * of the keys watch go through it: a file the dev server watches is touched only when its text
383
+ * changed. Node and Bun only: the bin bundles it.
384
+ */
385
+ /**
386
+ * Writes a file only when its text changed, so a second run wakes no watcher.
387
+ *
388
+ * @param file - The absolute path.
389
+ * @param text - The text.
390
+ */
391
+ function writeIfChanged(file, text) {
392
+ if (existsSync(file) && readFileSync(file, "utf8") === text) return;
393
+ mkdirSync(path.dirname(file), { recursive: true });
394
+ writeFileSync(file, text);
395
+ }
396
+ //#endregion
341
397
  //#region src/app/serve.ts
342
398
  /**
343
399
  * @file `moku-game dev`: the game folder read and checked, the dev page written into
344
- * `<game>/.moku/`, the parent that re-runs the bin under the generated bunfig, and the child that
345
- * serves the page with `Bun.serve`. The hot plugin comes from the engine the game resolves, so
346
- * the game writes no bunfig, no HTML and no dev file. Node and Bun only: the bin bundles it.
400
+ * `<game>/.moku/`, the parent that starts the keys watch and re-runs the bin under the generated
401
+ * bunfig, and the child that serves the page with `Bun.serve`. The hot plugin comes from the
402
+ * engine the game resolves, so the game writes no bunfig, no HTML and no dev file. Node and Bun
403
+ * only: the bin bundles it.
347
404
  */
348
405
  /** The signals the dev parent hands to its child. */
349
406
  const FORWARDED = [
@@ -355,6 +412,11 @@ const FORWARDED = [
355
412
  const STOPPING = ["SIGINT", "SIGTERM"];
356
413
  /** How often the dev child checks that its parent still runs. */
357
414
  const PARENT_CHECK_MS = 1e3;
415
+ /**
416
+ * The dev manifest `moku-game keys` and the keys watch of `moku-game dev` write, relative to the
417
+ * game folder, next to `generated/assets.ts`. The dev server answers `/manifest.json` with it.
418
+ */
419
+ const DEV_MANIFEST = "generated/manifest.json";
358
420
  /** The folders a `.dev.ts` module is never looked for in. */
359
421
  const NO_DEV_MODULES = /^(?:node_modules|dist|generated|tests|__tests__)$/;
360
422
  /** A `.gitignore` line that keeps `.moku/` of its own folder out. */
@@ -500,17 +562,6 @@ function devModulesOf(root, relative = "") {
500
562
  });
501
563
  }
502
564
  /**
503
- * Writes a file only when its text changed, so a second run wakes no watcher.
504
- *
505
- * @param file - The absolute path.
506
- * @param text - The text.
507
- */
508
- function writeIfChanged(file, text) {
509
- if (existsSync(file) && readFileSync(file, "utf8") === text) return;
510
- mkdirSync(path.dirname(file), { recursive: true });
511
- writeFileSync(file, text);
512
- }
513
- /**
514
565
  * The text of the dev `main.ts` for the game as it is on disk now.
515
566
  *
516
567
  * @param root - The game folder.
@@ -534,7 +585,9 @@ function mainOf(root, settings, agents, toRoot = "../") {
534
585
  * Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `dev.ts`, `main.ts`
535
586
  * and `bunfig.toml`. `moku-game visual` writes its own page into `.moku/visual/`, so the page of
536
587
  * `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.
588
+ * Every text is made before the first write, so a refused value writes nothing. A game without
589
+ * `.moku/assets-stamp.ts` gets the empty one, so the import of `main.ts` resolves with or without
590
+ * a keys watch; a stamp that is there is the watch's and stays.
538
591
  *
539
592
  * @param root - The game folder, absolute.
540
593
  * @param settings - The resolved config.
@@ -568,15 +621,18 @@ function writePage(root, settings, options, resolve, page = ".moku") {
568
621
  preload
569
622
  })]
570
623
  ];
624
+ const stamp = path.join(root, ".moku", ASSETS_STAMP);
571
625
  for (const [file, text] of texts) writeIfChanged(file, text);
626
+ if (!existsSync(stamp)) writeIfChanged(stamp, assetsStamp(""));
572
627
  return {
573
628
  html,
574
629
  bunfig
575
630
  };
576
631
  }
577
632
  /**
578
- * Reads and checks a game, then writes its dev page into `<game>/.moku/`. The editor's step and
579
- * the dev parent's step 3; it starts no server and no watcher.
633
+ * Reads and checks a game, then writes its dev page into `<game>/.moku/`, the first
634
+ * `assets-stamp.ts` with it. The editor's step and the dev parent's step 3; it starts no server
635
+ * and no watcher.
580
636
  *
581
637
  * @param root - The game folder, absolute.
582
638
  * @param options - The agents, the preloads and the extra plugins, absolute.
@@ -651,9 +707,12 @@ function devChildCommand(run, page, deps) {
651
707
  ];
652
708
  }
653
709
  /**
654
- * The dev parent: checks the game, writes the page, re-runs the bin under the generated bunfig
655
- * with the game as its working directory, and watches the scenarios while the child runs. It
656
- * hands Ctrl+C and the other stop signals to the child and answers the child's exit code.
710
+ * The dev parent: checks the game, starts the keys watch on the raw files, writes the page,
711
+ * re-runs the bin under the generated bunfig with the game as its working directory, and watches
712
+ * the scenarios while the child runs. The first scan of the keys watch writes
713
+ * `generated/manifest.json` before the page is served, and a save of an asset or a strings file
714
+ * scans again; `--packed` serves the pack and watches no keys. It hands Ctrl+C and the other stop
715
+ * signals to the child and answers the child's exit code.
657
716
  *
658
717
  * @param run - The flags of the run.
659
718
  * @param deps - The process seams.
@@ -663,15 +722,18 @@ function devChildCommand(run, page, deps) {
663
722
  async function serveParent(run, deps) {
664
723
  const settings = await loadGame(run.root);
665
724
  const hasPack = isFile(path.join(run.root, "dist", "assets", "manifest.json"));
666
- const hasManifest = isFile(path.join(run.root, "manifest.json"));
667
725
  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.`);
668
- if (!run.packed && !hasManifest) deps.ui.warn(`[game] dev: no manifest.json in "${run.root}".\n Run "moku-game keys" first.`);
669
- const page = writePage(run.root, settings, run, deps.resolve);
670
- if (!ignoresMoku(run.root)) deps.ui.warn("[game] dev: add \".moku/\" to .gitignore. moku-game writes its dev page there.");
671
- return runChild(devChildCommand(run, page, deps), deps, {
672
- cwd: run.root,
673
- whileRunning: () => watchScenarios(run.root, settings, deps)
674
- });
726
+ const keys = run.packed ? void 0 : await deps.watchKeys(run.root, settings);
727
+ try {
728
+ const page = writePage(run.root, settings, run, deps.resolve);
729
+ if (!ignoresMoku(run.root)) deps.ui.warn("[game] dev: add \".moku/\" to .gitignore. moku-game writes its dev page there.");
730
+ return await runChild(devChildCommand(run, page, deps), deps, {
731
+ cwd: run.root,
732
+ whileRunning: () => watchScenarios(run.root, settings, deps)
733
+ });
734
+ } finally {
735
+ keys?.close();
736
+ }
675
737
  }
676
738
  /**
677
739
  * Runs the bin again as a marked child, `MOKU_GAME_CHILD=1`, in its own process group: Ctrl+C
@@ -744,19 +806,22 @@ function safeDecode(pathname) {
744
806
  }
745
807
  }
746
808
  /**
747
- * Answers a request with a file, or a 404 for a missing or empty one.
809
+ * Answers a request with a file, or a 404 for a missing or empty one. A file is never cached:
810
+ * after a reload the page shows the new bytes of an edited image.
748
811
  *
749
812
  * @param file - The absolute path, or `undefined` for a refused path.
750
813
  * @returns The response.
751
814
  */
752
815
  function fileResponse(file) {
753
816
  const found = file === void 0 ? void 0 : statSync(file, { throwIfNoEntry: false });
754
- return file !== void 0 && found?.isFile() === true && found.size > 0 ? new Response(Bun.file(file)) : new Response("Not found", { status: 404 });
817
+ return file !== void 0 && found?.isFile() === true && found.size > 0 ? new Response(Bun.file(file), { headers: { "Cache-Control": "no-store" } }) : new Response("Not found", { status: 404 });
755
818
  }
756
819
  /**
757
820
  * Starts the dev server on `127.0.0.1` only, never on every interface: the page on `/`, the
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.
821
+ * manifest on `/manifest.json`, and the files of the served folder as static files, each with
822
+ * `Cache-Control: no-store`. The manifest is the dev manifest `generated/manifest.json` of the
823
+ * game, or `manifest.json` of `dist/assets` with `--packed`; its paths are relative to the served
824
+ * folder. The dev child and `moku-game visual` serve the page with it.
760
825
  *
761
826
  * @param run - The flags of the run.
762
827
  * @param page - The page bundle the process imported.
@@ -765,6 +830,7 @@ function fileResponse(file) {
765
830
  */
766
831
  function serveOn(run, page) {
767
832
  const base = run.packed ? path.join(run.root, "dist", "assets") : run.root;
833
+ const manifest = path.join(base, run.packed ? "manifest.json" : DEV_MANIFEST);
768
834
  try {
769
835
  return Bun.serve({
770
836
  hostname: "127.0.0.1",
@@ -772,7 +838,7 @@ function serveOn(run, page) {
772
838
  development: true,
773
839
  routes: {
774
840
  "/": page,
775
- "/manifest.json": () => fileResponse(path.join(base, "manifest.json"))
841
+ "/manifest.json": () => fileResponse(manifest)
776
842
  },
777
843
  fetch: (request) => fileResponse(staticPath(base, new URL(request.url).pathname))
778
844
  });
@@ -891,7 +957,7 @@ function hasEnglishStrings(root, settings) {
891
957
  return [...featureFolders, ...layerFolders].some((folder) => statSync(path.join(folder, "strings", "en.json"), { throwIfNoEntry: false })?.isFile() === true);
892
958
  }
893
959
  /**
894
- * The flags of the asset scan for `moku-game keys`: the dev manifest beside the game, the
960
+ * The flags of the asset scan for `moku-game keys`: the dev manifest in `generated/`, the
895
961
  * pseudo-locale when the game has an English string file, and `--check` for a check run.
896
962
  *
897
963
  * @param root - The game folder.
@@ -900,7 +966,7 @@ function hasEnglishStrings(root, settings) {
900
966
  * @returns The flags for the scanner's command line.
901
967
  * @example
902
968
  * ```ts
903
- * keysArguments("/g", resolveConfig({ page: { title: "T" } }), true).slice(4); // ["--manifest", "/g/manifest.json", "--check"]: no strings
969
+ * keysArguments("/g", resolveConfig({ page: { title: "T" } }), true).slice(4); // ["--manifest", "/g/generated/manifest.json", "--check"]: no strings
904
970
  * keysArguments("/g", resolveConfig({ page: { title: "T" } }), false).at(-1); // "--pseudo" once /g/features/home/strings/en.json exists
905
971
  * ```
906
972
  */
@@ -908,7 +974,7 @@ function keysArguments(root, settings, check) {
908
974
  return [
909
975
  ...scanArguments(root, settings),
910
976
  "--manifest",
911
- path.join(root, "manifest.json"),
977
+ path.join(root, DEV_MANIFEST),
912
978
  ...hasEnglishStrings(root, settings) ? ["--pseudo"] : [],
913
979
  ...check ? ["--check"] : []
914
980
  ];
@@ -1183,12 +1249,12 @@ async function loadSuite(file, cwd) {
1183
1249
  * @param resolve - Bun's resolver, to find the hot plugin.
1184
1250
  * @returns The paths of the HTML and the bunfig.
1185
1251
  * @throws {Error} The errors of `moku-game dev` before its server starts, and a game without
1186
- * `manifest.json`.
1252
+ * `generated/manifest.json`.
1187
1253
  */
1188
1254
  async function writeVisualPage(page, resolve) {
1189
1255
  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.`);
1256
+ const manifest = path.join(page.root, DEV_MANIFEST);
1257
+ if (statSync(manifest, { throwIfNoEntry: false })?.isFile() !== true) throw new Error(`[game] visual: no ${DEV_MANIFEST} in "${page.root}".\n Run "moku-game keys" first.`);
1192
1258
  return writePage(page.root, settings, {
1193
1259
  preload: page.preload,
1194
1260
  servePlugins: page.servePlugins
@@ -1303,9 +1369,9 @@ async function runVisual(command, deps) {
1303
1369
  * @file The command line of `moku-game`: the arguments read per command with `node:util`
1304
1370
  * `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack`, `visual` or `help`.
1305
1371
  * 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.
1372
+ * keys watch, the asset scanner, native, Bun's resolver, the visual test runner) comes in as
1373
+ * `deps`, so a test runs a command with stubs. It returns the exit code and never exits itself. A
1374
+ * plain function, on no API.
1309
1375
  */
1310
1376
  /** The flags every command takes. */
1311
1377
  const GLOBAL_OPTIONS = [
@@ -1391,7 +1457,7 @@ const USAGE = [
1391
1457
  " native build <target> [--simulator] build the native app (ios, macos, android)",
1392
1458
  " native dev <target> run the native shell on the dev server",
1393
1459
  " native doctor | native clean check or remove the native project",
1394
- " keys [--check] write generated/assets.ts and manifest.json",
1460
+ " keys [--check] write generated/assets.ts and generated/manifest.json",
1395
1461
  " pack [--no-cache] pack the assets into dist/assets",
1396
1462
  " visual [--update] [--only <name>] run tests/visual/index.ts: headless, pixels on a Mac",
1397
1463
  " [--no-pixels | --pixels] [--webgl] [--dir tests/visual/baselines]",
@@ -1724,6 +1790,275 @@ async function runCommand(argv, deps) {
1724
1790
  }
1725
1791
  }
1726
1792
  //#endregion
1793
+ //#region src/app/keys.ts
1794
+ /**
1795
+ * @file The keys watch of a game folder, behind `moku-game dev` and the editor's `watchKeys`: it
1796
+ * keeps `generated/` fresh while a dev page is open. A file event is only a trigger. When the
1797
+ * events stop for a quiet period, a batch walks the game and reads the size and the time stamp of
1798
+ * every asset file and every `strings/<locale>.json`; it runs the scan of `moku-game keys` only
1799
+ * when they differ from the batch before. After a scan it writes `.moku/assets-stamp.ts` from the
1800
+ * asset files, so the dev page reloads and shows the new bytes of an image. One scan runs at a
1801
+ * time, and a failed scan never stops the watch. A batch that scanned is followed by one more
1802
+ * walk: the platform reports only the first path of a burst of writes, so a save made in the same
1803
+ * instant as the scan's own writes under `generated/` may never be reported. The scan, `fs.watch`
1804
+ * and the console come in as seams, so a test drives it. Node and Bun only: the bin bundles it.
1805
+ */
1806
+ /** The quiet period that closes a batch of file events. */
1807
+ const QUIET_MS = 100;
1808
+ /** The asset files the scanner reads, by extension. */
1809
+ const ASSET_FILE = /\.(?:png|webp|fnt|mp3|m4a)$/i;
1810
+ /** A strings file: a `.json` directly in a `strings/` folder. */
1811
+ const STRINGS_FILE = /(?:^|\/)strings\/[^/]+\.json$/;
1812
+ /**
1813
+ * Lists one folder of the walk.
1814
+ *
1815
+ * @param folder - The absolute folder.
1816
+ * @returns Its entries; none when it cannot be read or vanished during the walk.
1817
+ */
1818
+ function entriesOf(folder) {
1819
+ try {
1820
+ return readdirSync(folder, { withFileTypes: true });
1821
+ } catch {
1822
+ return [];
1823
+ }
1824
+ }
1825
+ /**
1826
+ * Tells whether a file is an input of the scan: an asset file, or a strings file.
1827
+ *
1828
+ * @param file - The path relative to the game, with `/`.
1829
+ * @returns True for a file the watch stamps.
1830
+ * @example
1831
+ * ```ts
1832
+ * isInput("features/home/strings/en.json"); // true
1833
+ * ```
1834
+ */
1835
+ function isInput(file) {
1836
+ return ASSET_FILE.test(file) || STRINGS_FILE.test(file);
1837
+ }
1838
+ /**
1839
+ * Reads one folder of the walk and the folders below it, outside the skipped ones: every asset
1840
+ * file and every strings file joins the inputs with its stamp. Symlinks are not followed.
1841
+ *
1842
+ * @param root - The real game folder.
1843
+ * @param folder - The folder, relative to the game; `""` is the game folder.
1844
+ * @param walk - The inputs and the folders so far.
1845
+ */
1846
+ function readFolder(root, folder, walk) {
1847
+ walk.folders.push(folder);
1848
+ for (const entry of entriesOf(path.join(root, folder))) {
1849
+ const inGame = folder === "" ? entry.name : `${folder}/${entry.name}`;
1850
+ if (entry.isDirectory() && !isSkippedFolder$1(entry.name)) readFolder(root, inGame, walk);
1851
+ if (!entry.isFile() || !isInput(inGame)) continue;
1852
+ const stats = statSync(path.join(root, inGame), { throwIfNoEntry: false });
1853
+ if (stats !== void 0) walk.inputs.set(inGame, `${stats.size}:${stats.mtimeMs}`);
1854
+ }
1855
+ }
1856
+ /**
1857
+ * Walks a game folder for the inputs of the scan.
1858
+ *
1859
+ * @param root - The real game folder.
1860
+ * @returns The stamps of the inputs, and the folders on the way.
1861
+ */
1862
+ function walkInputs(root) {
1863
+ const walk = {
1864
+ inputs: /* @__PURE__ */ new Map(),
1865
+ folders: []
1866
+ };
1867
+ readFolder(root, "", walk);
1868
+ return walk;
1869
+ }
1870
+ /**
1871
+ * Lists the inputs that differ between two walks: added, removed, or saved again.
1872
+ *
1873
+ * @param before - The stamps of the walk before.
1874
+ * @param after - The stamps of this walk.
1875
+ * @returns The root-relative paths, sorted.
1876
+ * @example
1877
+ * ```ts
1878
+ * changedPaths(new Map([["a.png", "3:1"]]), new Map([["a.png", "4:2"], ["b.png", "1:1"]])); // ["a.png", "b.png"]
1879
+ * ```
1880
+ */
1881
+ function changedPaths(before, after) {
1882
+ return [.../* @__PURE__ */ new Set([...before.keys(), ...after.keys()])].filter((file) => before.get(file) !== after.get(file)).toSorted();
1883
+ }
1884
+ /**
1885
+ * Hashes the asset files of a walk: their paths, sizes and time stamps. The strings files stay
1886
+ * out, so a strings save leaves the hash as it is.
1887
+ *
1888
+ * @param inputs - The stamps of a walk.
1889
+ * @returns The SHA-1 as 40 hex digits.
1890
+ * @example
1891
+ * ```ts
1892
+ * assetsHash(new Map([["features/home/strings/en.json", "9:1"]])); // "da39a3ee5e6b4b0d3255bfef95601890afd80709": no asset file
1893
+ * ```
1894
+ */
1895
+ function assetsHash(inputs) {
1896
+ const assets = [...inputs].filter(([file]) => ASSET_FILE.test(file)).map(([file, stamp]) => `${file}:${stamp}`).toSorted();
1897
+ return createHash("sha1").update(assets.join("\n")).digest("hex");
1898
+ }
1899
+ /**
1900
+ * The line of a later scan that worked: the first changed input, and how many more.
1901
+ *
1902
+ * @param changed - The changed inputs, sorted, at least one.
1903
+ * @returns The line.
1904
+ * @example
1905
+ * ```ts
1906
+ * changeLine(["features/ui/assets/a.png", "features/ui/assets/b.png"]); // "keys: features/ui/assets/a.png and 1 more"
1907
+ * ```
1908
+ */
1909
+ function changeLine(changed) {
1910
+ const more = changed.length - 1;
1911
+ return more === 0 ? `keys: ${changed[0]}` : `keys: ${changed[0]} and ${more} more`;
1912
+ }
1913
+ /**
1914
+ * The console a scan is handed. It records the scanner's errors and prints none: `onError` is
1915
+ * their only printer. The first scan prints its other lines as `moku-game keys` does; a later
1916
+ * scan drops them, the batch prints one line of its own.
1917
+ *
1918
+ * @param ui - The real console.
1919
+ * @param errors - Takes every error line of the scan.
1920
+ * @param first - True for the first scan of the watch.
1921
+ * @returns The console of one scan.
1922
+ */
1923
+ function scanUi(ui, errors, first) {
1924
+ return {
1925
+ info: (message) => {
1926
+ if (first) ui.info(message);
1927
+ },
1928
+ warn: (message) => {
1929
+ if (first) ui.warn(message);
1930
+ },
1931
+ line: (text) => {
1932
+ if (first) ui.line(text);
1933
+ },
1934
+ error: (message) => {
1935
+ errors.push(message);
1936
+ }
1937
+ };
1938
+ }
1939
+ /**
1940
+ * The message of a scan that answered an exit code other than 0: the error lines it printed, one
1941
+ * per line, or the code itself when it printed none.
1942
+ *
1943
+ * @param code - The exit code of the scan, not 0.
1944
+ * @param errors - The error lines the scan printed.
1945
+ * @returns The message for `onError`.
1946
+ * @example
1947
+ * ```ts
1948
+ * failureText(1, []); // "the scan exited with code 1."
1949
+ * ```
1950
+ */
1951
+ function failureText(code, errors) {
1952
+ return errors.length > 0 ? errors.join("\n") : `the scan exited with code ${code}.`;
1953
+ }
1954
+ /**
1955
+ * Runs the scan of `moku-game keys` for the game as it is on disk now. The flags are read again
1956
+ * for every scan: `--pseudo` depends on a `strings/en.json` being there.
1957
+ *
1958
+ * @param watch - The watch.
1959
+ * @param first - True for the first scan of the watch.
1960
+ * @returns The message of a failed scan, or `undefined` when it worked.
1961
+ */
1962
+ async function failureOf(watch, first) {
1963
+ const errors = [];
1964
+ try {
1965
+ const argv = keysArguments(watch.root, watch.settings, false);
1966
+ const code = await watch.seams.scan(argv, scanUi(watch.seams.ui, errors, first));
1967
+ return code === 0 ? void 0 : failureText(code, errors);
1968
+ } catch (error) {
1969
+ return error instanceof Error ? error.message : String(error);
1970
+ }
1971
+ }
1972
+ /**
1973
+ * Runs the scan and says how it went: a failure goes to `onError`, a later scan that worked
1974
+ * prints one line. The first scan printed the scanner's own lines.
1975
+ *
1976
+ * @param watch - The watch.
1977
+ * @param changed - The changed inputs, sorted.
1978
+ * @param first - True for the first scan of the watch.
1979
+ */
1980
+ async function scanAndReport(watch, changed, first) {
1981
+ const failure = await failureOf(watch, first);
1982
+ if (failure !== void 0) watch.onError(failure);
1983
+ else if (!first) watch.seams.ui.info(changeLine(changed));
1984
+ }
1985
+ /**
1986
+ * One batch: walks the game, and scans when an input differs from the batch before; the first
1987
+ * batch always scans. Then it writes the stamp of the asset files, asks for one trailing batch,
1988
+ * and arms the folder watchers with the folders of the walk. A batch that did not scan asks for
1989
+ * nothing, so the trailing batch of a quiet game ends the chain.
1990
+ *
1991
+ * @param watch - The watch.
1992
+ */
1993
+ async function runBatch(watch) {
1994
+ const walk = walkInputs(watch.root);
1995
+ const first = watch.inputs === void 0;
1996
+ const changed = changedPaths(watch.inputs ?? /* @__PURE__ */ new Map(), walk.inputs);
1997
+ watch.inputs = walk.inputs;
1998
+ if (first || changed.length > 0) {
1999
+ await scanAndReport(watch, changed, first);
2000
+ writeIfChanged(path.join(watch.root, ".moku", ASSETS_STAMP), assetsStamp(assetsHash(walk.inputs)));
2001
+ watch.again();
2002
+ }
2003
+ if (!watch.closed) watch.tree.rearm(walk.folders);
2004
+ }
2005
+ /**
2006
+ * Runs one batch after the batch before it ended: two scans never write `generated/` at the same
2007
+ * time. A batch the batcher starts during the first scan waits here.
2008
+ *
2009
+ * @param watch - The watch.
2010
+ * @returns Resolves when the batch ended.
2011
+ */
2012
+ function serialized(watch) {
2013
+ const batch = watch.scanning.then(() => runBatch(watch));
2014
+ watch.scanning = batch.catch(() => {});
2015
+ return batch;
2016
+ }
2017
+ /**
2018
+ * Starts the keys watch of a checked game: watches the folder, runs the scan of `moku-game keys`
2019
+ * once, and resolves after it, so `generated/` is fresh before a page is served. The watcher
2020
+ * starts first: a save the platform reports during the first scan starts a batch after it. Then
2021
+ * every batch of file events outside the skipped folders scans again when an asset file or a
2022
+ * strings file changed. A failed scan, the first one too, goes to `onError` and the watch goes on.
2023
+ *
2024
+ * @param root - The game folder, absolute.
2025
+ * @param settings - The resolved config.
2026
+ * @param seams - The scan, `fs.watch` and the console.
2027
+ * @param options - Where the message of a failed scan goes.
2028
+ * @returns The running watch.
2029
+ * @throws {Error} When the game folder is gone, or the first batch breaks outside the scan.
2030
+ */
2031
+ async function watchKeysAt(root, settings, seams, options = {}) {
2032
+ const real = realpathSync(root);
2033
+ const onError = options.onError ?? ((message) => seams.ui.warn(`[game] keys: ${message}`));
2034
+ const batcher = createBatcher(QUIET_MS, () => serialized(watch));
2035
+ const tree = watchTree(real, () => batcher.poke(), seams.watch);
2036
+ const watch = {
2037
+ root: real,
2038
+ settings,
2039
+ seams,
2040
+ onError,
2041
+ tree,
2042
+ again: () => batcher.poke(),
2043
+ inputs: void 0,
2044
+ scanning: Promise.resolve(),
2045
+ closed: false
2046
+ };
2047
+ const close = () => {
2048
+ if (watch.closed) return;
2049
+ watch.closed = true;
2050
+ batcher.cancel();
2051
+ tree.close();
2052
+ };
2053
+ try {
2054
+ await serialized(watch);
2055
+ } catch (error) {
2056
+ close();
2057
+ throw error;
2058
+ }
2059
+ return { close };
2060
+ }
2061
+ //#endregion
1727
2062
  //#region src/app/native.ts
1728
2063
  /**
1729
2064
  * @file The native verbs of `moku-game`: a game's `config.ts` as the config of `@moku-labs/native`,
@@ -1874,13 +2209,44 @@ async function runNative(verb, settings, where, options) {
1874
2209
  * @file The `@moku-labs/game/cli` door, node and bun only: the `moku-game` bin and the editor's
1875
2210
  * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, visual,
1876
2211
  * help) in this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
1877
- * caller starts itself, as the editor does. It imports no Pixi, and the root entry never imports
1878
- * it. The code lives in `app/`; this file wires the real process into it.
2212
+ * caller starts itself, as the editor does, and `watchKeys` keeps the game's `generated/` fresh
2213
+ * while that page is open. It imports no Pixi, and the root entry never imports it. The code
2214
+ * lives in `app/`; this file wires the real process into it.
2215
+ */
2216
+ /**
2217
+ * Runs the asset scanner's command line with the string tools of i18n: the scan behind
2218
+ * `moku-game keys`, `pack`, and the keys watch.
2219
+ *
2220
+ * @param argv - The flags of the scanner.
2221
+ * @param ui - Where its lines go.
2222
+ * @returns The exit code of the scanner.
2223
+ */
2224
+ function runScanner(argv, ui) {
2225
+ return runCli$1(argv, {
2226
+ compile: compileStrings,
2227
+ exportStrings,
2228
+ importStrings
2229
+ }, ui);
2230
+ }
2231
+ /**
2232
+ * The real seams of the keys watch: the asset scanner with the string tools, as `moku-game keys`
2233
+ * runs it, `fs.watch`, and the console.
2234
+ *
2235
+ * @param ui - The console of the process.
2236
+ * @returns The seams.
1879
2237
  */
2238
+ function keysSeams(ui) {
2239
+ return {
2240
+ scan: runScanner,
2241
+ watch,
2242
+ ui
2243
+ };
2244
+ }
1880
2245
  /**
1881
2246
  * The real process behind the command line: the branded console, the environment the bin
1882
- * started with, Bun's spawn and resolver, the signals, `fs.watch`, the asset scanner with the
1883
- * string tools, native, and the visual test runner, loaded only by `moku-game visual`.
2247
+ * started with, Bun's spawn and resolver, the signals, `fs.watch`, the keys watch, the asset
2248
+ * scanner with the string tools, native, and the visual test runner, loaded only by
2249
+ * `moku-game visual`.
1884
2250
  *
1885
2251
  * @returns The seams of this process.
1886
2252
  */
@@ -1906,11 +2272,8 @@ function processDeps() {
1906
2272
  watcher.on("error", () => watcher.close());
1907
2273
  return watcher;
1908
2274
  },
1909
- assets: (argv) => runCli$1(argv, {
1910
- compile: compileStrings,
1911
- exportStrings,
1912
- importStrings
1913
- }, ui),
2275
+ watchKeys: (root, settings) => watchKeysAt(root, settings, keysSeams(ui)),
2276
+ assets: (argv) => runScanner(argv, ui),
1914
2277
  native: runNative,
1915
2278
  resolve: (specifier, from) => Bun.resolveSync(specifier, from),
1916
2279
  loadPage: (file) => import(file),
@@ -1938,8 +2301,10 @@ async function runCli(argv) {
1938
2301
  /**
1939
2302
  * Writes a game's dev page into `<root>/.moku/` for a server the caller starts: `index.html`,
1940
2303
  * `dev.ts`, `main.ts` with the scenarios of `tests/scenarios/` (and, with agents, the agents and
1941
- * the game's `.dev` modules), and `bunfig.toml` with the engine's hot plugin. It starts no server
1942
- * and no watcher, and throws the `[game]` errors of `moku-game dev`.
2304
+ * the game's `.dev` modules), and `bunfig.toml` with the engine's hot plugin. It also writes the
2305
+ * first `assets-stamp.ts`, the empty one, when the game has none: `main.ts` imports it, and
2306
+ * `watchKeys` rewrites it when an asset file changes. It starts no server and no watcher, and
2307
+ * throws the `[game]` errors of `moku-game dev`.
1943
2308
  *
1944
2309
  * @param root - The game folder, relative to the cwd or absolute.
1945
2310
  * @param options - The agents, the preloads and the extra bundler plugins.
@@ -1962,5 +2327,35 @@ async function preparePage(root, options = {}) {
1962
2327
  servePlugins: absolute(options.servePlugins)
1963
2328
  }, (specifier, from) => Bun.resolveSync(specifier, from));
1964
2329
  }
2330
+ /**
2331
+ * Keeps `generated/` of a game fresh while its dev page is open, for a server the caller runs:
2332
+ * the editor calls it next to `preparePage`, and raw `moku-game dev` runs the same watch itself.
2333
+ * It watches the game folder, runs the scan of `moku-game keys` once and resolves after it. Then
2334
+ * a save of an asset file (`.png`, `.webp`, `.fnt`, `.mp3`, `.m4a`) or of a
2335
+ * `strings/<locale>.json` scans again, with the same flags, outputs and layers; an output that
2336
+ * did not change is not rewritten. On the page a rewritten `generated/strings.<locale>.ts` hot
2337
+ * swaps. An asset file that is new, gone or saved again rewrites `.moku/assets-stamp.ts`, which
2338
+ * the page imports, so the page reloads and shows the new image or the new bytes of an old one. A
2339
+ * failed scan goes to `onError` and the watch goes on, the first scan too. The layers of
2340
+ * `config.ts` are read once: a changed layer needs a new `watchKeys`.
2341
+ *
2342
+ * @param root - The game folder, relative to the cwd or absolute.
2343
+ * @param options - Where the message of a failed scan goes.
2344
+ * @returns The running watch; `close()` stops it.
2345
+ * @throws {Error} When the game has no `config.ts` or `index.ts`, or a config value is refused.
2346
+ * @example
2347
+ * ```ts
2348
+ * // The editor bin, next to preparePage, started with --root games/timber:
2349
+ * const { preparePage, watchKeys } = await import(Bun.resolveSync("@moku-labs/game/cli", root));
2350
+ * await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] });
2351
+ * const keys = await watchKeys("games/timber", { onError: message => log.warn(message) });
2352
+ * // generated/ is fresh; a save of features/home/strings/en.json rewrites generated/strings.en.ts
2353
+ * keys.close(); // when the editor closes the game
2354
+ * ```
2355
+ */
2356
+ async function watchKeys(root, options = {}) {
2357
+ const game = path.resolve(root);
2358
+ return watchKeysAt(game, await loadGame(game), keysSeams(createBrandConsole()), options);
2359
+ }
1965
2360
  //#endregion
1966
- export { preparePage, runCli };
2361
+ export { preparePage, runCli, watchKeys };