@moku-labs/game 0.13.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/README.md +3 -3
- package/dist/app/page.d.mts +1 -0
- package/dist/app/page.mjs +1 -0
- package/dist/cli.d.mts +67 -3
- package/dist/cli.mjs +443 -56
- package/dist/project.mjs +7 -2954
- package/dist/watch-CB8IWDBi.mjs +2960 -0
- package/llms.txt +7 -5
- package/package.json +1 -1
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
345
|
-
* serves the page with `Bun.serve`. The hot plugin comes from the
|
|
346
|
-
* the game writes no bunfig, no HTML and no dev file. Node and Bun
|
|
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 = [
|
|
@@ -356,8 +413,8 @@ const STOPPING = ["SIGINT", "SIGTERM"];
|
|
|
356
413
|
/** How often the dev child checks that its parent still runs. */
|
|
357
414
|
const PARENT_CHECK_MS = 1e3;
|
|
358
415
|
/**
|
|
359
|
-
* The dev manifest `moku-game keys`
|
|
360
|
-
* `generated/assets.ts`. The dev server answers `/manifest.json` with it.
|
|
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.
|
|
361
418
|
*/
|
|
362
419
|
const DEV_MANIFEST = "generated/manifest.json";
|
|
363
420
|
/** The folders a `.dev.ts` module is never looked for in. */
|
|
@@ -505,17 +562,6 @@ function devModulesOf(root, relative = "") {
|
|
|
505
562
|
});
|
|
506
563
|
}
|
|
507
564
|
/**
|
|
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
565
|
* The text of the dev `main.ts` for the game as it is on disk now.
|
|
520
566
|
*
|
|
521
567
|
* @param root - The game folder.
|
|
@@ -539,7 +585,9 @@ function mainOf(root, settings, agents, toRoot = "../") {
|
|
|
539
585
|
* Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `dev.ts`, `main.ts`
|
|
540
586
|
* and `bunfig.toml`. `moku-game visual` writes its own page into `.moku/visual/`, so the page of
|
|
541
587
|
* `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.
|
|
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.
|
|
543
591
|
*
|
|
544
592
|
* @param root - The game folder, absolute.
|
|
545
593
|
* @param settings - The resolved config.
|
|
@@ -573,15 +621,18 @@ function writePage(root, settings, options, resolve, page = ".moku") {
|
|
|
573
621
|
preload
|
|
574
622
|
})]
|
|
575
623
|
];
|
|
624
|
+
const stamp = path.join(root, ".moku", ASSETS_STAMP);
|
|
576
625
|
for (const [file, text] of texts) writeIfChanged(file, text);
|
|
626
|
+
if (!existsSync(stamp)) writeIfChanged(stamp, assetsStamp(""));
|
|
577
627
|
return {
|
|
578
628
|
html,
|
|
579
629
|
bunfig
|
|
580
630
|
};
|
|
581
631
|
}
|
|
582
632
|
/**
|
|
583
|
-
* Reads and checks a game, then writes its dev page into `<game>/.moku
|
|
584
|
-
* the dev parent's step 3; it starts no server
|
|
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.
|
|
585
636
|
*
|
|
586
637
|
* @param root - The game folder, absolute.
|
|
587
638
|
* @param options - The agents, the preloads and the extra plugins, absolute.
|
|
@@ -656,9 +707,12 @@ function devChildCommand(run, page, deps) {
|
|
|
656
707
|
];
|
|
657
708
|
}
|
|
658
709
|
/**
|
|
659
|
-
* The dev parent: checks the game,
|
|
660
|
-
* with the game as its working directory, and watches
|
|
661
|
-
*
|
|
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.
|
|
662
716
|
*
|
|
663
717
|
* @param run - The flags of the run.
|
|
664
718
|
* @param deps - The process seams.
|
|
@@ -668,15 +722,18 @@ function devChildCommand(run, page, deps) {
|
|
|
668
722
|
async function serveParent(run, deps) {
|
|
669
723
|
const settings = await loadGame(run.root);
|
|
670
724
|
const hasPack = isFile(path.join(run.root, "dist", "assets", "manifest.json"));
|
|
671
|
-
const hasManifest = isFile(path.join(run.root, DEV_MANIFEST));
|
|
672
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.`);
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
}
|
|
680
737
|
}
|
|
681
738
|
/**
|
|
682
739
|
* Runs the bin again as a marked child, `MOKU_GAME_CHILD=1`, in its own process group: Ctrl+C
|
|
@@ -749,21 +806,22 @@ function safeDecode(pathname) {
|
|
|
749
806
|
}
|
|
750
807
|
}
|
|
751
808
|
/**
|
|
752
|
-
* 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.
|
|
753
811
|
*
|
|
754
812
|
* @param file - The absolute path, or `undefined` for a refused path.
|
|
755
813
|
* @returns The response.
|
|
756
814
|
*/
|
|
757
815
|
function fileResponse(file) {
|
|
758
816
|
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 });
|
|
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 });
|
|
760
818
|
}
|
|
761
819
|
/**
|
|
762
820
|
* 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
|
|
764
|
-
* is the dev manifest `generated/manifest.json` of the
|
|
765
|
-
* with `--packed`; its paths are relative to the served
|
|
766
|
-
* `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.
|
|
767
825
|
*
|
|
768
826
|
* @param run - The flags of the run.
|
|
769
827
|
* @param page - The page bundle the process imported.
|
|
@@ -1311,9 +1369,9 @@ async function runVisual(command, deps) {
|
|
|
1311
1369
|
* @file The command line of `moku-game`: the arguments read per command with `node:util`
|
|
1312
1370
|
* `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack`, `visual` or `help`.
|
|
1313
1371
|
* 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
|
|
1315
|
-
* runs a command with stubs. It returns the exit code and never exits itself. A
|
|
1316
|
-
* 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.
|
|
1317
1375
|
*/
|
|
1318
1376
|
/** The flags every command takes. */
|
|
1319
1377
|
const GLOBAL_OPTIONS = [
|
|
@@ -1732,6 +1790,275 @@ async function runCommand(argv, deps) {
|
|
|
1732
1790
|
}
|
|
1733
1791
|
}
|
|
1734
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
|
|
1735
2062
|
//#region src/app/native.ts
|
|
1736
2063
|
/**
|
|
1737
2064
|
* @file The native verbs of `moku-game`: a game's `config.ts` as the config of `@moku-labs/native`,
|
|
@@ -1882,13 +2209,44 @@ async function runNative(verb, settings, where, options) {
|
|
|
1882
2209
|
* @file The `@moku-labs/game/cli` door, node and bun only: the `moku-game` bin and the editor's
|
|
1883
2210
|
* seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, visual,
|
|
1884
2211
|
* 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
|
|
1886
|
-
*
|
|
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.
|
|
1887
2215
|
*/
|
|
1888
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.
|
|
2237
|
+
*/
|
|
2238
|
+
function keysSeams(ui) {
|
|
2239
|
+
return {
|
|
2240
|
+
scan: runScanner,
|
|
2241
|
+
watch,
|
|
2242
|
+
ui
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
1889
2246
|
* 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
|
|
1891
|
-
* string tools, native, and the visual test runner, loaded only by
|
|
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`.
|
|
1892
2250
|
*
|
|
1893
2251
|
* @returns The seams of this process.
|
|
1894
2252
|
*/
|
|
@@ -1914,11 +2272,8 @@ function processDeps() {
|
|
|
1914
2272
|
watcher.on("error", () => watcher.close());
|
|
1915
2273
|
return watcher;
|
|
1916
2274
|
},
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
exportStrings,
|
|
1920
|
-
importStrings
|
|
1921
|
-
}, ui),
|
|
2275
|
+
watchKeys: (root, settings) => watchKeysAt(root, settings, keysSeams(ui)),
|
|
2276
|
+
assets: (argv) => runScanner(argv, ui),
|
|
1922
2277
|
native: runNative,
|
|
1923
2278
|
resolve: (specifier, from) => Bun.resolveSync(specifier, from),
|
|
1924
2279
|
loadPage: (file) => import(file),
|
|
@@ -1946,8 +2301,10 @@ async function runCli(argv) {
|
|
|
1946
2301
|
/**
|
|
1947
2302
|
* Writes a game's dev page into `<root>/.moku/` for a server the caller starts: `index.html`,
|
|
1948
2303
|
* `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
|
|
1950
|
-
*
|
|
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`.
|
|
1951
2308
|
*
|
|
1952
2309
|
* @param root - The game folder, relative to the cwd or absolute.
|
|
1953
2310
|
* @param options - The agents, the preloads and the extra bundler plugins.
|
|
@@ -1970,5 +2327,35 @@ async function preparePage(root, options = {}) {
|
|
|
1970
2327
|
servePlugins: absolute(options.servePlugins)
|
|
1971
2328
|
}, (specifier, from) => Bun.resolveSync(specifier, from));
|
|
1972
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
|
+
}
|
|
1973
2360
|
//#endregion
|
|
1974
|
-
export { preparePage, runCli };
|
|
2361
|
+
export { preparePage, runCli, watchKeys };
|