@moku-labs/game 0.13.0 → 0.13.2

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.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/dist/app/page.d.mts +2 -1
  3. package/dist/app/page.mjs +1 -0
  4. package/dist/app/system.d.mts +3 -3
  5. package/dist/app.d.mts +2 -2
  6. package/dist/app.mjs +1 -1
  7. package/dist/assets.d.mts +1 -1
  8. package/dist/assets.mjs +1 -1
  9. package/dist/cli.d.mts +69 -3
  10. package/dist/cli.mjs +482 -57
  11. package/dist/{commands-DvFr8Uwp.d.mts → commands-BrgeMP5y.d.mts} +5 -5
  12. package/dist/control.d.mts +4 -4
  13. package/dist/{define-CCc0ozKe.d.mts → define-ZmWbWxon.d.mts} +2 -2
  14. package/dist/{headless-G4nuZ-xz.d.mts → headless-Dgxvp6Ym.d.mts} +1 -1
  15. package/dist/hot.d.mts +3 -3
  16. package/dist/hot.mjs +11 -9
  17. package/dist/{index-rnOYAriI.d.mts → index-CdbE_jGs.d.mts} +12 -4
  18. package/dist/index.d.mts +5 -5
  19. package/dist/index.mjs +1 -1
  20. package/dist/inspect.d.mts +7 -7
  21. package/dist/jsx-dev-runtime.d.mts +1 -1
  22. package/dist/jsx-runtime.d.mts +1 -1
  23. package/dist/{memory-DwDhfFWK.d.mts → memory-DJ2sPExv.d.mts} +1 -1
  24. package/dist/project.mjs +7 -2954
  25. package/dist/{runtime-B276Gg0s.d.mts → runtime-6qakboTm.d.mts} +1 -1
  26. package/dist/{src-SmT-XxVf.mjs → src-CltFFKtc.mjs} +955 -108
  27. package/dist/testing.d.mts +4 -4
  28. package/dist/testing.mjs +1 -1
  29. package/dist/{types-D_2G6Q68.d.mts → types-B3T2X6To.d.mts} +8 -4
  30. package/dist/{types-ZQLYXo5X.d.mts → types-C6y8EfEB.d.mts} +54 -5
  31. package/dist/{types-BrTpz9oL.d.mts → types-CmuwAjkV.d.mts} +19 -6
  32. package/dist/{types-DAdwEJMp.d.mts → types-D57SntUt.d.mts} +2 -2
  33. package/dist/{types-DLghaGEy.d.mts → types-DGIkfZB7.d.mts} +4 -4
  34. package/dist/{types-Bcdo5-2w.d.mts → types-zRGJdFGX.d.mts} +7 -3
  35. package/dist/visual.d.mts +6 -6
  36. package/dist/watch-CWI_on4T.mjs +2960 -0
  37. package/llms.txt +11 -7
  38. package/package.json +4 -2
  39. package/dist/{assets-DYiKxPBX.mjs → assets-BrKSg1O6.mjs} +1 -1
package/dist/cli.mjs CHANGED
@@ -1,6 +1,7 @@
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-DYiKxPBX.mjs";
3
- import { constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
2
+ import { r as watchTree, t as createBatcher, x as isSkippedFolder$1 } from "./watch-CWI_on4T.mjs";
3
+ import { a as runCli$1, i as compileStrings, n as importStrings, t as exportStrings } from "./assets-BrKSg1O6.mjs";
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";
@@ -8,6 +9,11 @@ import { pathToFileURL } from "node:url";
8
9
  //#region src/app/generate.ts
9
10
  /** The first line of a generated file a person may open. */
10
11
  const WRITTEN = "// Written by moku-game dev. Do not edit.";
12
+ /**
13
+ * The stamp module of a game, a file of `<game>/.moku/`. One per game: the dev page and the page
14
+ * of `moku-game visual` import the same one.
15
+ */
16
+ const ASSETS_STAMP = "assets-stamp.ts";
11
17
  /** A colour or a language tag: it lands in a `<style>` and in attributes, so nothing else passes. */
12
18
  const SAFE_VALUE = /^[#\w(),.%\s-]+$/;
13
19
  /** The files of `tests/scenarios/` that are not scenarios. */
@@ -223,10 +229,40 @@ function importLine(binding, specifier) {
223
229
  return `import ${binding} from ${JSON.stringify(specifier)};`;
224
230
  }
225
231
  /**
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.
232
+ * Orders two texts by their code units, as `toSorted()` does without a comparer.
233
+ *
234
+ * @param first - One text.
235
+ * @param second - The other text.
236
+ * @returns A negative number, zero or a positive number.
237
+ * @example
238
+ * ```ts
239
+ * compareText("a/b.png", "a/c.png"); // -1
240
+ * ```
241
+ */
242
+ function compareText(first, second) {
243
+ if (first === second) return 0;
244
+ return first < second ? -1 : 1;
245
+ }
246
+ /**
247
+ * The path from a page under `.moku/` to `.moku/` itself, where the one stamp of a game lives.
248
+ *
249
+ * @param toRoot - The path from the page to the game folder.
250
+ * @returns `"./"` for the dev page in `.moku/`, `"../"` for the page in `.moku/visual/`.
251
+ * @example
252
+ * ```ts
253
+ * toStampFolder("../../"); // "../"
254
+ * ```
255
+ */
256
+ function toStampFolder(toRoot) {
257
+ return toRoot.slice(3) || "./";
258
+ }
259
+ /**
260
+ * The `main.ts` of the dev page: the dev flag first, then the assets stamp, the page entry, the
261
+ * game, its config, one import per scenario, and, with agents, one per `.dev` module and one per
262
+ * agent. It calls `startPage` with the scenarios by file stem, the shell with the loaders of the
263
+ * named plugins when the game needs it, the agents and the `.dev` modules. The stamp import puts
264
+ * the stamp module on the page: when the keys watch writes a new stamp, the hot plugin hands it to
265
+ * the running game.
230
266
  *
231
267
  * @param settings - The resolved `config.ts`.
232
268
  * @param sources - The scenario files, the `.dev` modules and the agents.
@@ -252,6 +288,7 @@ function devMain(settings, sources, toRoot = "../") {
252
288
  return [
253
289
  WRITTEN,
254
290
  "import \"./dev.ts\";",
291
+ `import "${toStampFolder(toRoot)}${ASSETS_STAMP}";`,
255
292
  ...pageImports(settings, toRoot),
256
293
  ...scenarios.map((file, index) => importLine(`scenario${index}`, `${toRoot}tests/scenarios/${file}`)),
257
294
  ...devModules.map((file, index) => importLine(`* as devModule${index}`, `${toRoot}${file}`)),
@@ -302,6 +339,36 @@ function devFlag() {
302
339
  return `${WRITTEN}\nglobalThis.__MOKU_GAME_DEV__ = true;\n`;
303
340
  }
304
341
  /**
342
+ * The `assets-stamp.ts` module the dev `main.ts` imports: the asset files of the game with their
343
+ * stamps, and the ones the last save changed. The hot plugin takes it for a module that swaps, so
344
+ * a new stamp reaches the running game, which reads the changed files again. The page starts with
345
+ * the empty stamp; the keys watch writes the files. Both lists are sorted here, and every path
346
+ * and stamp is written by `JSON.stringify`: a file name may hold a quote or a backslash.
347
+ *
348
+ * @param files - Every watched asset file: its path relative to the game with `/`, and its stamp
349
+ * `<size>:<mtimeMs>`. None before the first watch.
350
+ * @param changed - The paths whose stamp differs from the batch before, the new ones too. None in
351
+ * the first stamp of a run.
352
+ * @returns The text of `.moku/assets-stamp.ts`.
353
+ * @example
354
+ * ```ts
355
+ * assetsStamp([], []); // '// Written by moku-game dev. Do not edit.\nexport default {\n files: {},\n changed: []\n};\n'
356
+ * ```
357
+ */
358
+ function assetsStamp(files, changed) {
359
+ const entries = files.toSorted(([first], [second]) => compareText(first, second)).map(([file, stamp]) => ` ${JSON.stringify(file)}: ${JSON.stringify(stamp)}`);
360
+ const map = entries.length === 0 ? "{}" : `{\n${entries.join(",\n")}\n }`;
361
+ const list = changed.toSorted(compareText).map((file) => JSON.stringify(file)).join(", ");
362
+ return [
363
+ WRITTEN,
364
+ "export default {",
365
+ ` files: ${map},`,
366
+ ` changed: [${list}]`,
367
+ "};",
368
+ ""
369
+ ].join("\n");
370
+ }
371
+ /**
305
372
  * A TOML array of strings. `JSON.stringify` of a path is a valid TOML basic string, also for a
306
373
  * Windows path.
307
374
  *
@@ -338,12 +405,31 @@ function bunfigText(paths) {
338
405
  ].join("\n");
339
406
  }
340
407
  //#endregion
408
+ //#region src/app/files.ts
409
+ /**
410
+ * @file The file write of the files `moku-game` generates for a game. The dev page and the stamp
411
+ * of the keys watch go through it: a file the dev server watches is touched only when its text
412
+ * changed. Node and Bun only: the bin bundles it.
413
+ */
414
+ /**
415
+ * Writes a file only when its text changed, so a second run wakes no watcher.
416
+ *
417
+ * @param file - The absolute path.
418
+ * @param text - The text.
419
+ */
420
+ function writeIfChanged(file, text) {
421
+ if (existsSync(file) && readFileSync(file, "utf8") === text) return;
422
+ mkdirSync(path.dirname(file), { recursive: true });
423
+ writeFileSync(file, text);
424
+ }
425
+ //#endregion
341
426
  //#region src/app/serve.ts
342
427
  /**
343
428
  * @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.
429
+ * `<game>/.moku/`, the parent that starts the keys watch and re-runs the bin under the generated
430
+ * bunfig, and the child that serves the page with `Bun.serve`. The hot plugin comes from the
431
+ * engine the game resolves, so the game writes no bunfig, no HTML and no dev file. Node and Bun
432
+ * only: the bin bundles it.
347
433
  */
348
434
  /** The signals the dev parent hands to its child. */
349
435
  const FORWARDED = [
@@ -356,8 +442,8 @@ const STOPPING = ["SIGINT", "SIGTERM"];
356
442
  /** How often the dev child checks that its parent still runs. */
357
443
  const PARENT_CHECK_MS = 1e3;
358
444
  /**
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.
445
+ * The dev manifest `moku-game keys` and the keys watch of `moku-game dev` write, relative to the
446
+ * game folder, next to `generated/assets.ts`. The dev server answers `/manifest.json` with it.
361
447
  */
362
448
  const DEV_MANIFEST = "generated/manifest.json";
363
449
  /** The folders a `.dev.ts` module is never looked for in. */
@@ -505,17 +591,6 @@ function devModulesOf(root, relative = "") {
505
591
  });
506
592
  }
507
593
  /**
508
- * Writes a file only when its text changed, so a second run wakes no watcher.
509
- *
510
- * @param file - The absolute path.
511
- * @param text - The text.
512
- */
513
- function writeIfChanged(file, text) {
514
- if (existsSync(file) && readFileSync(file, "utf8") === text) return;
515
- mkdirSync(path.dirname(file), { recursive: true });
516
- writeFileSync(file, text);
517
- }
518
- /**
519
594
  * The text of the dev `main.ts` for the game as it is on disk now.
520
595
  *
521
596
  * @param root - The game folder.
@@ -539,7 +614,9 @@ function mainOf(root, settings, agents, toRoot = "../") {
539
614
  * Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `dev.ts`, `main.ts`
540
615
  * and `bunfig.toml`. `moku-game visual` writes its own page into `.moku/visual/`, so the page of
541
616
  * `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.
617
+ * Every text is made before the first write, so a refused value writes nothing. A game without
618
+ * `.moku/assets-stamp.ts` gets the empty one, so the import of `main.ts` resolves with or without
619
+ * a keys watch; a stamp that is there is the watch's and stays.
543
620
  *
544
621
  * @param root - The game folder, absolute.
545
622
  * @param settings - The resolved config.
@@ -573,15 +650,18 @@ function writePage(root, settings, options, resolve, page = ".moku") {
573
650
  preload
574
651
  })]
575
652
  ];
653
+ const stamp = path.join(root, ".moku", ASSETS_STAMP);
576
654
  for (const [file, text] of texts) writeIfChanged(file, text);
655
+ if (!existsSync(stamp)) writeIfChanged(stamp, assetsStamp([], []));
577
656
  return {
578
657
  html,
579
658
  bunfig
580
659
  };
581
660
  }
582
661
  /**
583
- * Reads and checks a game, then writes its dev page into `<game>/.moku/`. The editor's step and
584
- * the dev parent's step 3; it starts no server and no watcher.
662
+ * Reads and checks a game, then writes its dev page into `<game>/.moku/`, the first
663
+ * `assets-stamp.ts` with it. The editor's step and the dev parent's step 3; it starts no server
664
+ * and no watcher.
585
665
  *
586
666
  * @param root - The game folder, absolute.
587
667
  * @param options - The agents, the preloads and the extra plugins, absolute.
@@ -656,9 +736,12 @@ function devChildCommand(run, page, deps) {
656
736
  ];
657
737
  }
658
738
  /**
659
- * The dev parent: checks the game, writes the page, re-runs the bin under the generated bunfig
660
- * with the game as its working directory, and watches the scenarios while the child runs. It
661
- * hands Ctrl+C and the other stop signals to the child and answers the child's exit code.
739
+ * The dev parent: checks the game, starts the keys watch on the raw files, writes the page,
740
+ * re-runs the bin under the generated bunfig with the game as its working directory, and watches
741
+ * the scenarios while the child runs. The first scan of the keys watch writes
742
+ * `generated/manifest.json` before the page is served, and a save of an asset or a strings file
743
+ * scans again; `--packed` serves the pack and watches no keys. It hands Ctrl+C and the other stop
744
+ * signals to the child and answers the child's exit code.
662
745
  *
663
746
  * @param run - The flags of the run.
664
747
  * @param deps - The process seams.
@@ -668,15 +751,18 @@ function devChildCommand(run, page, deps) {
668
751
  async function serveParent(run, deps) {
669
752
  const settings = await loadGame(run.root);
670
753
  const hasPack = isFile(path.join(run.root, "dist", "assets", "manifest.json"));
671
- const hasManifest = isFile(path.join(run.root, DEV_MANIFEST));
672
754
  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.`);
673
- if (!run.packed && !hasManifest) deps.ui.warn(`[game] dev: no ${DEV_MANIFEST} in "${run.root}".\n Run "moku-game keys" first.`);
674
- const page = writePage(run.root, settings, run, deps.resolve);
675
- if (!ignoresMoku(run.root)) deps.ui.warn("[game] dev: add \".moku/\" to .gitignore. moku-game writes its dev page there.");
676
- return runChild(devChildCommand(run, page, deps), deps, {
677
- cwd: run.root,
678
- whileRunning: () => watchScenarios(run.root, settings, deps)
679
- });
755
+ const keys = run.packed ? void 0 : await deps.watchKeys(run.root, settings);
756
+ try {
757
+ const page = writePage(run.root, settings, run, deps.resolve);
758
+ if (!ignoresMoku(run.root)) deps.ui.warn("[game] dev: add \".moku/\" to .gitignore. moku-game writes its dev page there.");
759
+ return await runChild(devChildCommand(run, page, deps), deps, {
760
+ cwd: run.root,
761
+ whileRunning: () => watchScenarios(run.root, settings, deps)
762
+ });
763
+ } finally {
764
+ keys?.close();
765
+ }
680
766
  }
681
767
  /**
682
768
  * Runs the bin again as a marked child, `MOKU_GAME_CHILD=1`, in its own process group: Ctrl+C
@@ -749,21 +835,22 @@ function safeDecode(pathname) {
749
835
  }
750
836
  }
751
837
  /**
752
- * Answers a request with a file, or a 404 for a missing or empty one.
838
+ * Answers a request with a file, or a 404 for a missing or empty one. A file is never cached:
839
+ * after a reload the page shows the new bytes of an edited image.
753
840
  *
754
841
  * @param file - The absolute path, or `undefined` for a refused path.
755
842
  * @returns The response.
756
843
  */
757
844
  function fileResponse(file) {
758
845
  const found = file === void 0 ? void 0 : statSync(file, { throwIfNoEntry: false });
759
- return file !== void 0 && found?.isFile() === true && found.size > 0 ? new Response(Bun.file(file)) : new Response("Not found", { status: 404 });
846
+ 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 });
760
847
  }
761
848
  /**
762
849
  * Starts the dev server on `127.0.0.1` only, never on every interface: the page on `/`, the
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.
850
+ * manifest on `/manifest.json`, and the files of the served folder as static files, each with
851
+ * `Cache-Control: no-store`. The manifest is the dev manifest `generated/manifest.json` of the
852
+ * game, or `manifest.json` of `dist/assets` with `--packed`; its paths are relative to the served
853
+ * folder. The dev child and `moku-game visual` serve the page with it.
767
854
  *
768
855
  * @param run - The flags of the run.
769
856
  * @param page - The page bundle the process imported.
@@ -1311,9 +1398,9 @@ async function runVisual(command, deps) {
1311
1398
  * @file The command line of `moku-game`: the arguments read per command with `node:util`
1312
1399
  * `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack`, `visual` or `help`.
1313
1400
  * 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.
1401
+ * keys watch, the asset scanner, native, Bun's resolver, the visual test runner) comes in as
1402
+ * `deps`, so a test runs a command with stubs. It returns the exit code and never exits itself. A
1403
+ * plain function, on no API.
1317
1404
  */
1318
1405
  /** The flags every command takes. */
1319
1406
  const GLOBAL_OPTIONS = [
@@ -1732,6 +1819,282 @@ async function runCommand(argv, deps) {
1732
1819
  }
1733
1820
  }
1734
1821
  //#endregion
1822
+ //#region src/app/keys.ts
1823
+ /**
1824
+ * @file The keys watch of a game folder, behind `moku-game dev` and the editor's `watchKeys`: it
1825
+ * keeps `generated/` fresh while a dev page is open. A file event is only a trigger. When the
1826
+ * events stop for a quiet period, a batch walks the game and reads the size and the time stamp of
1827
+ * every asset file and every `strings/<locale>.json`; it runs the scan of `moku-game keys` only
1828
+ * when they differ from the batch before. After a scan it writes `.moku/assets-stamp.ts` from the
1829
+ * asset files: every file with its stamp, and the ones this batch found changed, so the dev page
1830
+ * reads only those again. One scan runs at a time, and a failed scan never stops the watch. A
1831
+ * batch that scanned is followed by one more walk: the platform reports only the first path of a
1832
+ * burst of writes, so a save made in the same instant as the scan's own writes under `generated/`
1833
+ * may never be reported. The scan, `fs.watch` and the console come in as seams, so a test drives
1834
+ * it. Node and Bun only: the bin bundles it.
1835
+ */
1836
+ /** The quiet period that closes a batch of file events. */
1837
+ const QUIET_MS = 100;
1838
+ /** The asset files the scanner reads, by extension. */
1839
+ const ASSET_FILE = /\.(?:png|webp|fnt|mp3|m4a)$/i;
1840
+ /** A strings file: a `.json` directly in a `strings/` folder. */
1841
+ const STRINGS_FILE = /(?:^|\/)strings\/[^/]+\.json$/;
1842
+ /**
1843
+ * Lists one folder of the walk.
1844
+ *
1845
+ * @param folder - The absolute folder.
1846
+ * @returns Its entries; none when it cannot be read or vanished during the walk.
1847
+ */
1848
+ function entriesOf(folder) {
1849
+ try {
1850
+ return readdirSync(folder, { withFileTypes: true });
1851
+ } catch {
1852
+ return [];
1853
+ }
1854
+ }
1855
+ /**
1856
+ * Tells whether a file is an input of the scan: an asset file, or a strings file.
1857
+ *
1858
+ * @param file - The path relative to the game, with `/`.
1859
+ * @returns True for a file the watch stamps.
1860
+ * @example
1861
+ * ```ts
1862
+ * isInput("features/home/strings/en.json"); // true
1863
+ * ```
1864
+ */
1865
+ function isInput(file) {
1866
+ return ASSET_FILE.test(file) || STRINGS_FILE.test(file);
1867
+ }
1868
+ /**
1869
+ * Reads one folder of the walk and the folders below it, outside the skipped ones: every asset
1870
+ * file and every strings file joins the inputs with its stamp. Symlinks are not followed.
1871
+ *
1872
+ * @param root - The real game folder.
1873
+ * @param folder - The folder, relative to the game; `""` is the game folder.
1874
+ * @param walk - The inputs and the folders so far.
1875
+ */
1876
+ function readFolder(root, folder, walk) {
1877
+ walk.folders.push(folder);
1878
+ for (const entry of entriesOf(path.join(root, folder))) {
1879
+ const inGame = folder === "" ? entry.name : `${folder}/${entry.name}`;
1880
+ if (entry.isDirectory() && !isSkippedFolder$1(entry.name)) readFolder(root, inGame, walk);
1881
+ if (!entry.isFile() || !isInput(inGame)) continue;
1882
+ const stats = statSync(path.join(root, inGame), { throwIfNoEntry: false });
1883
+ if (stats !== void 0) walk.inputs.set(inGame, `${stats.size}:${stats.mtimeMs}`);
1884
+ }
1885
+ }
1886
+ /**
1887
+ * Walks a game folder for the inputs of the scan.
1888
+ *
1889
+ * @param root - The real game folder.
1890
+ * @returns The stamps of the inputs, and the folders on the way.
1891
+ */
1892
+ function walkInputs(root) {
1893
+ const walk = {
1894
+ inputs: /* @__PURE__ */ new Map(),
1895
+ folders: []
1896
+ };
1897
+ readFolder(root, "", walk);
1898
+ return walk;
1899
+ }
1900
+ /**
1901
+ * Lists the inputs that differ between two walks: added, removed, or saved again.
1902
+ *
1903
+ * @param before - The stamps of the walk before.
1904
+ * @param after - The stamps of this walk.
1905
+ * @returns The root-relative paths, sorted.
1906
+ * @example
1907
+ * ```ts
1908
+ * changedPaths(new Map([["a.png", "3:1"]]), new Map([["a.png", "4:2"], ["b.png", "1:1"]])); // ["a.png", "b.png"]
1909
+ * ```
1910
+ */
1911
+ function changedPaths(before, after) {
1912
+ return [.../* @__PURE__ */ new Set([...before.keys(), ...after.keys()])].filter((file) => before.get(file) !== after.get(file)).toSorted();
1913
+ }
1914
+ /**
1915
+ * The text of the stamp module after one batch: every asset file of the walk with its stamp, and
1916
+ * the asset files that differ from the batch before. The strings files stay out, so a batch that
1917
+ * saved only strings has no stamp to write. A removed file is not listed as changed: it shows by
1918
+ * its absence from the files. The first batch of a run has no batch before it, so it lists none.
1919
+ *
1920
+ * @param inputs - The stamps of this walk.
1921
+ * @param changed - The inputs that differ from the batch before: added, removed, or saved again.
1922
+ * @param first - True for the first batch of the watch.
1923
+ * @returns The text, or `undefined` when no asset file changed in a later batch.
1924
+ * @example
1925
+ * ```ts
1926
+ * stampText(new Map([["a.png", "4:2"], ["strings/en.json", "9:1"]]), ["a.png", "strings/en.json"], false); // '// Written by moku-game dev. Do not edit.\nexport default {\n files: {\n "a.png": "4:2"\n },\n changed: ["a.png"]\n};\n'
1927
+ * ```
1928
+ */
1929
+ function stampText(inputs, changed, first) {
1930
+ const moved = changed.filter((file) => ASSET_FILE.test(file));
1931
+ if (!first && moved.length === 0) return void 0;
1932
+ return assetsStamp([...inputs].filter(([file]) => ASSET_FILE.test(file)), first ? [] : moved.filter((file) => inputs.has(file)));
1933
+ }
1934
+ /**
1935
+ * The line of a later scan that worked: the first changed input, and how many more.
1936
+ *
1937
+ * @param changed - The changed inputs, sorted, at least one.
1938
+ * @returns The line.
1939
+ * @example
1940
+ * ```ts
1941
+ * changeLine(["features/ui/assets/a.png", "features/ui/assets/b.png"]); // "keys: features/ui/assets/a.png and 1 more"
1942
+ * ```
1943
+ */
1944
+ function changeLine(changed) {
1945
+ const more = changed.length - 1;
1946
+ return more === 0 ? `keys: ${changed[0]}` : `keys: ${changed[0]} and ${more} more`;
1947
+ }
1948
+ /**
1949
+ * The console a scan is handed. It records the scanner's errors and prints none: `onError` is
1950
+ * their only printer. The first scan prints its other lines as `moku-game keys` does; a later
1951
+ * scan drops them, the batch prints one line of its own.
1952
+ *
1953
+ * @param ui - The real console.
1954
+ * @param errors - Takes every error line of the scan.
1955
+ * @param first - True for the first scan of the watch.
1956
+ * @returns The console of one scan.
1957
+ */
1958
+ function scanUi(ui, errors, first) {
1959
+ return {
1960
+ info: (message) => {
1961
+ if (first) ui.info(message);
1962
+ },
1963
+ warn: (message) => {
1964
+ if (first) ui.warn(message);
1965
+ },
1966
+ line: (text) => {
1967
+ if (first) ui.line(text);
1968
+ },
1969
+ error: (message) => {
1970
+ errors.push(message);
1971
+ }
1972
+ };
1973
+ }
1974
+ /**
1975
+ * The message of a scan that answered an exit code other than 0: the error lines it printed, one
1976
+ * per line, or the code itself when it printed none.
1977
+ *
1978
+ * @param code - The exit code of the scan, not 0.
1979
+ * @param errors - The error lines the scan printed.
1980
+ * @returns The message for `onError`.
1981
+ * @example
1982
+ * ```ts
1983
+ * failureText(1, []); // "the scan exited with code 1."
1984
+ * ```
1985
+ */
1986
+ function failureText(code, errors) {
1987
+ return errors.length > 0 ? errors.join("\n") : `the scan exited with code ${code}.`;
1988
+ }
1989
+ /**
1990
+ * Runs the scan of `moku-game keys` for the game as it is on disk now. The flags are read again
1991
+ * for every scan: `--pseudo` depends on a `strings/en.json` being there.
1992
+ *
1993
+ * @param watch - The watch.
1994
+ * @param first - True for the first scan of the watch.
1995
+ * @returns The message of a failed scan, or `undefined` when it worked.
1996
+ */
1997
+ async function failureOf(watch, first) {
1998
+ const errors = [];
1999
+ try {
2000
+ const argv = keysArguments(watch.root, watch.settings, false);
2001
+ const code = await watch.seams.scan(argv, scanUi(watch.seams.ui, errors, first));
2002
+ return code === 0 ? void 0 : failureText(code, errors);
2003
+ } catch (error) {
2004
+ return error instanceof Error ? error.message : String(error);
2005
+ }
2006
+ }
2007
+ /**
2008
+ * Runs the scan and says how it went: a failure goes to `onError`, a later scan that worked
2009
+ * prints one line. The first scan printed the scanner's own lines.
2010
+ *
2011
+ * @param watch - The watch.
2012
+ * @param changed - The changed inputs, sorted.
2013
+ * @param first - True for the first scan of the watch.
2014
+ */
2015
+ async function scanAndReport(watch, changed, first) {
2016
+ const failure = await failureOf(watch, first);
2017
+ if (failure !== void 0) watch.onError(failure);
2018
+ else if (!first) watch.seams.ui.info(changeLine(changed));
2019
+ }
2020
+ /**
2021
+ * One batch: walks the game, and scans when an input differs from the batch before; the first
2022
+ * batch always scans. Then it writes the stamp when an asset file changed, asks for one trailing
2023
+ * batch, and arms the folder watchers with the folders of the walk. A batch that did not scan
2024
+ * asks for nothing, so the trailing batch of a quiet game ends the chain.
2025
+ *
2026
+ * @param watch - The watch.
2027
+ */
2028
+ async function runBatch(watch) {
2029
+ const walk = walkInputs(watch.root);
2030
+ const first = watch.inputs === void 0;
2031
+ const changed = changedPaths(watch.inputs ?? /* @__PURE__ */ new Map(), walk.inputs);
2032
+ watch.inputs = walk.inputs;
2033
+ if (first || changed.length > 0) {
2034
+ await scanAndReport(watch, changed, first);
2035
+ const stamp = stampText(walk.inputs, changed, first);
2036
+ if (stamp !== void 0) writeIfChanged(path.join(watch.root, ".moku", ASSETS_STAMP), stamp);
2037
+ watch.again();
2038
+ }
2039
+ if (!watch.closed) watch.tree.rearm(walk.folders);
2040
+ }
2041
+ /**
2042
+ * Runs one batch after the batch before it ended: two scans never write `generated/` at the same
2043
+ * time. A batch the batcher starts during the first scan waits here.
2044
+ *
2045
+ * @param watch - The watch.
2046
+ * @returns Resolves when the batch ended.
2047
+ */
2048
+ function serialized(watch) {
2049
+ const batch = watch.scanning.then(() => runBatch(watch));
2050
+ watch.scanning = batch.catch(() => {});
2051
+ return batch;
2052
+ }
2053
+ /**
2054
+ * Starts the keys watch of a checked game: watches the folder, runs the scan of `moku-game keys`
2055
+ * once, and resolves after it, so `generated/` is fresh before a page is served. The watcher
2056
+ * starts first: a save the platform reports during the first scan starts a batch after it. Then
2057
+ * every batch of file events outside the skipped folders scans again when an asset file or a
2058
+ * strings file changed. A failed scan, the first one too, goes to `onError` and the watch goes on.
2059
+ *
2060
+ * @param root - The game folder, absolute.
2061
+ * @param settings - The resolved config.
2062
+ * @param seams - The scan, `fs.watch` and the console.
2063
+ * @param options - Where the message of a failed scan goes.
2064
+ * @returns The running watch.
2065
+ * @throws {Error} When the game folder is gone, or the first batch breaks outside the scan.
2066
+ */
2067
+ async function watchKeysAt(root, settings, seams, options = {}) {
2068
+ const real = realpathSync(root);
2069
+ const onError = options.onError ?? ((message) => seams.ui.warn(`[game] keys: ${message}`));
2070
+ const batcher = createBatcher(QUIET_MS, () => serialized(watch));
2071
+ const tree = watchTree(real, () => batcher.poke(), seams.watch);
2072
+ const watch = {
2073
+ root: real,
2074
+ settings,
2075
+ seams,
2076
+ onError,
2077
+ tree,
2078
+ again: () => batcher.poke(),
2079
+ inputs: void 0,
2080
+ scanning: Promise.resolve(),
2081
+ closed: false
2082
+ };
2083
+ const close = () => {
2084
+ if (watch.closed) return;
2085
+ watch.closed = true;
2086
+ batcher.cancel();
2087
+ tree.close();
2088
+ };
2089
+ try {
2090
+ await serialized(watch);
2091
+ } catch (error) {
2092
+ close();
2093
+ throw error;
2094
+ }
2095
+ return { close };
2096
+ }
2097
+ //#endregion
1735
2098
  //#region src/app/native.ts
1736
2099
  /**
1737
2100
  * @file The native verbs of `moku-game`: a game's `config.ts` as the config of `@moku-labs/native`,
@@ -1882,13 +2245,44 @@ async function runNative(verb, settings, where, options) {
1882
2245
  * @file The `@moku-labs/game/cli` door, node and bun only: the `moku-game` bin and the editor's
1883
2246
  * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, visual,
1884
2247
  * help) in this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
1885
- * caller starts itself, as the editor does. It imports no Pixi, and the root entry never imports
1886
- * it. The code lives in `app/`; this file wires the real process into it.
2248
+ * caller starts itself, as the editor does, and `watchKeys` keeps the game's `generated/` fresh
2249
+ * while that page is open. It imports no Pixi, and the root entry never imports it. The code
2250
+ * lives in `app/`; this file wires the real process into it.
2251
+ */
2252
+ /**
2253
+ * Runs the asset scanner's command line with the string tools of i18n: the scan behind
2254
+ * `moku-game keys`, `pack`, and the keys watch.
2255
+ *
2256
+ * @param argv - The flags of the scanner.
2257
+ * @param ui - Where its lines go.
2258
+ * @returns The exit code of the scanner.
1887
2259
  */
2260
+ function runScanner(argv, ui) {
2261
+ return runCli$1(argv, {
2262
+ compile: compileStrings,
2263
+ exportStrings,
2264
+ importStrings
2265
+ }, ui);
2266
+ }
2267
+ /**
2268
+ * The real seams of the keys watch: the asset scanner with the string tools, as `moku-game keys`
2269
+ * runs it, `fs.watch`, and the console.
2270
+ *
2271
+ * @param ui - The console of the process.
2272
+ * @returns The seams.
2273
+ */
2274
+ function keysSeams(ui) {
2275
+ return {
2276
+ scan: runScanner,
2277
+ watch,
2278
+ ui
2279
+ };
2280
+ }
1888
2281
  /**
1889
2282
  * The real process behind the command line: the branded console, the environment the bin
1890
- * started with, Bun's spawn and resolver, the signals, `fs.watch`, the asset scanner with the
1891
- * string tools, native, and the visual test runner, loaded only by `moku-game visual`.
2283
+ * started with, Bun's spawn and resolver, the signals, `fs.watch`, the keys watch, the asset
2284
+ * scanner with the string tools, native, and the visual test runner, loaded only by
2285
+ * `moku-game visual`.
1892
2286
  *
1893
2287
  * @returns The seams of this process.
1894
2288
  */
@@ -1914,11 +2308,8 @@ function processDeps() {
1914
2308
  watcher.on("error", () => watcher.close());
1915
2309
  return watcher;
1916
2310
  },
1917
- assets: (argv) => runCli$1(argv, {
1918
- compile: compileStrings,
1919
- exportStrings,
1920
- importStrings
1921
- }, ui),
2311
+ watchKeys: (root, settings) => watchKeysAt(root, settings, keysSeams(ui)),
2312
+ assets: (argv) => runScanner(argv, ui),
1922
2313
  native: runNative,
1923
2314
  resolve: (specifier, from) => Bun.resolveSync(specifier, from),
1924
2315
  loadPage: (file) => import(file),
@@ -1946,8 +2337,11 @@ async function runCli(argv) {
1946
2337
  /**
1947
2338
  * Writes a game's dev page into `<root>/.moku/` for a server the caller starts: `index.html`,
1948
2339
  * `dev.ts`, `main.ts` with the scenarios of `tests/scenarios/` (and, with agents, the agents and
1949
- * the game's `.dev` modules), and `bunfig.toml` with the engine's hot plugin. It starts no server
1950
- * and no watcher, and throws the `[game]` errors of `moku-game dev`.
2340
+ * the game's `.dev` modules), and `bunfig.toml` with the engine's hot plugin. It also writes the
2341
+ * first `assets-stamp.ts`, the empty one, when the game has none: `main.ts` imports it, and
2342
+ * `watchKeys` rewrites it when an asset file changes. The page accepts the new stamp: new bytes
2343
+ * of a file swap in place, a new or removed file reloads the page. It starts no server and no
2344
+ * watcher, and throws the `[game]` errors of `moku-game dev`.
1951
2345
  *
1952
2346
  * @param root - The game folder, relative to the cwd or absolute.
1953
2347
  * @param options - The agents, the preloads and the extra bundler plugins.
@@ -1970,5 +2364,36 @@ async function preparePage(root, options = {}) {
1970
2364
  servePlugins: absolute(options.servePlugins)
1971
2365
  }, (specifier, from) => Bun.resolveSync(specifier, from));
1972
2366
  }
2367
+ /**
2368
+ * Keeps `generated/` of a game fresh while its dev page is open, for a server the caller runs:
2369
+ * the editor calls it next to `preparePage`, and raw `moku-game dev` runs the same watch itself.
2370
+ * It watches the game folder, runs the scan of `moku-game keys` once and resolves after it. Then
2371
+ * a save of an asset file (`.png`, `.webp`, `.fnt`, `.mp3`, `.m4a`) or of a
2372
+ * `strings/<locale>.json` scans again, with the same flags, outputs and layers; an output that
2373
+ * did not change is not rewritten. On the page a rewritten `generated/strings.<locale>.ts` hot
2374
+ * swaps. An asset file that is new, gone or saved again rewrites `.moku/assets-stamp.ts`, which
2375
+ * the page imports and accepts as a hot swap: the new bytes of a file swap in place in the loaded
2376
+ * bundles, with no reload, and a new or removed file reloads the page. A failed scan goes to
2377
+ * `onError` and the watch goes on, the first scan too. The layers of `config.ts` are read once:
2378
+ * a changed layer needs a new `watchKeys`.
2379
+ *
2380
+ * @param root - The game folder, relative to the cwd or absolute.
2381
+ * @param options - Where the message of a failed scan goes.
2382
+ * @returns The running watch; `close()` stops it.
2383
+ * @throws {Error} When the game has no `config.ts` or `index.ts`, or a config value is refused.
2384
+ * @example
2385
+ * ```ts
2386
+ * // The editor bin, next to preparePage, started with --root games/timber:
2387
+ * const { preparePage, watchKeys } = await import(Bun.resolveSync("@moku-labs/game/cli", root));
2388
+ * await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] });
2389
+ * const keys = await watchKeys("games/timber", { onError: message => log.warn(message) });
2390
+ * // generated/ is fresh; a save of features/home/strings/en.json rewrites generated/strings.en.ts
2391
+ * keys.close(); // when the editor closes the game
2392
+ * ```
2393
+ */
2394
+ async function watchKeys(root, options = {}) {
2395
+ const game = path.resolve(root);
2396
+ return watchKeysAt(game, await loadGame(game), keysSeams(createBrandConsole()), options);
2397
+ }
1973
2398
  //#endregion
1974
- export { preparePage, runCli };
2399
+ export { preparePage, runCli, watchKeys };