@moku-labs/game 0.14.12 → 0.14.13

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 (56) hide show
  1. package/README.md +13 -12
  2. package/dist/app/page.d.mts +8 -3
  3. package/dist/app/page.mjs +57 -12
  4. package/dist/app/system.d.mts +5 -3
  5. package/dist/app/system.mjs +2 -0
  6. package/dist/app.d.mts +15 -7
  7. package/dist/app.mjs +131 -27
  8. package/dist/{assets-BrKSg1O6.mjs → assets-CEMfBogh.mjs} +471 -120
  9. package/dist/assets.d.mts +30 -13
  10. package/dist/assets.mjs +1 -1
  11. package/dist/cli.d.mts +105 -8
  12. package/dist/cli.mjs +1036 -128
  13. package/dist/{commands-BaCx2B9j.d.mts → commands-DAETmK86.d.mts} +43 -7
  14. package/dist/{config-CdARrrhU.mjs → config-B87aFTW-.mjs} +25 -4
  15. package/dist/control.d.mts +130 -17
  16. package/dist/control.mjs +4 -4
  17. package/dist/{define-Ce8SVEN-.d.mts → define-BASd9ZUh.d.mts} +32 -4
  18. package/dist/dev-DbHgx-R-.mjs +89 -0
  19. package/dist/{fake-MpzwZhIZ.mjs → fake-6mazmHK3.mjs} +20 -2
  20. package/dist/features-DowEilZm.mjs +266 -0
  21. package/dist/{headless-CMHNsKjW.d.mts → headless-cFveciuY.d.mts} +1 -1
  22. package/dist/hot.d.mts +4 -2
  23. package/dist/hot.mjs +7 -4
  24. package/dist/{index-CLQ8NOTw.d.mts → index-DedbED3T.d.mts} +317 -69
  25. package/dist/index.d.mts +5 -6
  26. package/dist/index.mjs +3 -3
  27. package/dist/inspect.d.mts +12 -7
  28. package/dist/inspect.mjs +2 -2
  29. package/dist/jsx-dev-runtime.d.mts +1 -1
  30. package/dist/jsx-runtime.d.mts +1 -1
  31. package/dist/lint.d.mts +8 -3
  32. package/dist/lint.mjs +432 -20
  33. package/dist/project.d.mts +15 -1
  34. package/dist/project.mjs +1 -1
  35. package/dist/{run-Bf2hKyjZ.mjs → run-BbipFRc6.mjs} +250 -51
  36. package/dist/{runtime-BCnKn0xf.d.mts → runtime-DoSlRx8X.d.mts} +1 -1
  37. package/dist/{session-DgukhR_7.mjs → session-CeKgO2hy.mjs} +30 -2
  38. package/dist/{sources-Duzj6lhV.mjs → sources-Cdvb0rOS.mjs} +33 -6
  39. package/dist/{src-CtC8w3_z.mjs → src-BEbJ_sSx.mjs} +35536 -17789
  40. package/dist/testing.d.mts +23 -13
  41. package/dist/testing.mjs +10 -6
  42. package/dist/{types-DOlpeuGx.d.mts → types-7GXcXxxv.d.mts} +174 -242
  43. package/dist/{types-BjTtGSFb.d.mts → types-BGcda5Le.d.mts} +4459 -732
  44. package/dist/{types-gisvSHoC.d.mts → types-C73hbmxi.d.mts} +129 -17
  45. package/dist/{types-CAPv9l29.d.mts → types-DngFQJro.d.mts} +43 -5
  46. package/dist/{types-Y_rCuXtj.d.mts → types-GHE4J5Uf.d.mts} +1025 -191
  47. package/dist/{visual-dMW8iqAE.mjs → visual-2m4wJL_z.mjs} +111 -7
  48. package/dist/visual-_XhoGeIC.d.mts +345 -0
  49. package/dist/visual.d.mts +1 -332
  50. package/dist/visual.mjs +130 -20
  51. package/dist/{watch-CWI_on4T.mjs → watch-BEdrp80i.mjs} +149 -30
  52. package/llms.txt +99 -48
  53. package/package.json +8 -6
  54. package/dist/dev-Cc50Dk7H.mjs +0 -47
  55. package/dist/memory-pUQ7YZWC.d.mts +0 -50
  56. package/dist/types-BX4Ge_j5.d.mts +0 -656
package/dist/cli.mjs CHANGED
@@ -1,14 +1,239 @@
1
- import { t as resolveConfig } from "./config-CdARrrhU.mjs";
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";
1
+ import { t as resolveConfig } from "./config-B87aFTW-.mjs";
2
+ import { t as findFeatureOwners } from "./features-DowEilZm.mjs";
3
+ import { r as watchTree, t as createBatcher, x as isSkippedFolder$1 } from "./watch-BEdrp80i.mjs";
4
+ import { a as runCli$1, i as compileStrings, n as importStrings, t as exportStrings } from "./assets-CEMfBogh.mjs";
4
5
  import { constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
5
6
  import path from "node:path";
6
7
  import { createBrandConsole } from "@moku-labs/common/cli";
7
8
  import { parseArgs } from "node:util";
8
9
  import { pathToFileURL } from "node:url";
10
+ import { createHash } from "node:crypto";
11
+ //#region src/app/engine.ts
12
+ /**
13
+ * @file app — the engine a page is served or built with: its version, a hash of its bundled dist and of any declared
14
+ * patch. Imports `node:fs`, `node:path` and `node:crypto` only, nothing from a plugin. The dev page, the editor's
15
+ * `preparePage` and a debug build write its id into the page as `globalThis.__MOKU_GAME_ENGINE__`.
16
+ */
17
+ /** The package name of the engine. */
18
+ const ENGINE_PACKAGE = "@moku-labs/game";
19
+ /** The lock files that mark the install root, where the patch walk stops. */
20
+ const LOCK_FILES$1 = ["bun.lock", "bun.lockb"];
21
+ /**
22
+ * Tells whether a path is a file.
23
+ *
24
+ * @param file - The path.
25
+ * @returns True for an existing file.
26
+ */
27
+ function isFile$1(file) {
28
+ return statSync(file, { throwIfNoEntry: false })?.isFile() === true;
29
+ }
30
+ /**
31
+ * Reads the `package.json` of a folder.
32
+ *
33
+ * @param folder - The folder.
34
+ * @returns Its fields, or `undefined` without a readable `package.json` object.
35
+ */
36
+ function packageOf(folder) {
37
+ const file = path.join(folder, "package.json");
38
+ if (!isFile$1(file)) return void 0;
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
41
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
42
+ } catch {
43
+ return;
44
+ }
45
+ }
46
+ /**
47
+ * Tells whether a path lies inside a folder, the folder itself left out.
48
+ *
49
+ * @param file - The path.
50
+ * @param folder - The folder.
51
+ * @returns True when `file` is below `folder`.
52
+ * @example
53
+ * ```ts
54
+ * isBelow("/g/dist/hot.mjs", "/g/dist"); // true
55
+ * ```
56
+ */
57
+ function isBelow(file, folder) {
58
+ const relative = path.relative(folder, file);
59
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
60
+ }
61
+ /**
62
+ * Walks up from a folder to the first one whose `package.json` names the engine.
63
+ *
64
+ * @param start - The folder to start in.
65
+ * @returns The engine folder and its fields, or `undefined` at the top of the disk.
66
+ */
67
+ function engineFolderOf(start) {
68
+ for (let folder = start;; folder = path.dirname(folder)) {
69
+ const fields = packageOf(folder);
70
+ if (fields?.name === ENGINE_PACKAGE) return {
71
+ folder,
72
+ fields
73
+ };
74
+ if (path.dirname(folder) === folder) return void 0;
75
+ }
76
+ }
77
+ /**
78
+ * Lists the files under a folder, at any depth.
79
+ *
80
+ * @param folder - The folder.
81
+ * @param relative - The folder under it to list, `/` separated.
82
+ * @returns The files, relative to `folder`, `/` separated, in no set order.
83
+ */
84
+ function filesUnder$1(folder, relative = "") {
85
+ return readdirSync(path.join(folder, relative), { withFileTypes: true }).flatMap((entry) => {
86
+ const inner = relative === "" ? entry.name : `${relative}/${entry.name}`;
87
+ if (entry.isDirectory()) return filesUnder$1(folder, inner);
88
+ return entry.isFile() ? [inner] : [];
89
+ });
90
+ }
91
+ /**
92
+ * Hashes every file under a `dist` folder: each fed as `<relative path>\0<bytes>\0`, in sorted
93
+ * order, so a renamed file changes the hash as a changed byte does.
94
+ *
95
+ * @param dist - The folder.
96
+ * @returns The SHA-256, hex.
97
+ */
98
+ function distHash(dist) {
99
+ const hash = createHash("sha256");
100
+ for (const file of filesUnder$1(dist).toSorted()) {
101
+ hash.update(`${file}\0`);
102
+ hash.update(readFileSync(path.join(dist, file)));
103
+ hash.update("\0");
104
+ }
105
+ return hash.digest("hex");
106
+ }
107
+ /**
108
+ * The folders the patch walk reads, from the game up: it stops after the first folder that holds
109
+ * `bun.lock` or `bun.lockb`, or at the top of the disk.
110
+ *
111
+ * @param root - The game folder.
112
+ * @returns The folders, the game first.
113
+ */
114
+ function foldersToInstallRoot(root) {
115
+ const folders = [];
116
+ for (let folder = root;; folder = path.dirname(folder)) {
117
+ folders.push(folder);
118
+ if (LOCK_FILES$1.some((lock) => isFile$1(path.join(folder, lock))) || path.dirname(folder) === folder) return folders;
119
+ }
120
+ }
121
+ /**
122
+ * The patch file a `package.json` declares for one dependency key.
123
+ *
124
+ * @param fields - The fields of the `package.json`.
125
+ * @param key - The key, `@moku-labs/game@<version>`.
126
+ * @returns The declared file, relative to the folder of the `package.json`, or `undefined`.
127
+ */
128
+ function declaredPatch(fields, key) {
129
+ const declared = fields?.patchedDependencies;
130
+ if (typeof declared !== "object" || declared === null) return void 0;
131
+ const file = Reflect.get(declared, key);
132
+ return typeof file === "string" ? file : void 0;
133
+ }
134
+ /**
135
+ * Finds the patch the install root declares for the installed engine version: the first
136
+ * `package.json` on the walk whose `patchedDependencies` has the key decides.
137
+ *
138
+ * @param root - The game folder.
139
+ * @param version - The installed engine version.
140
+ * @returns The patch file and its hash; `undefined` when none is declared or the file is missing.
141
+ */
142
+ function patchOf(root, version) {
143
+ const key = `${ENGINE_PACKAGE}@${version}`;
144
+ for (const folder of foldersToInstallRoot(root)) {
145
+ const declared = declaredPatch(packageOf(folder), key);
146
+ if (declared === void 0) continue;
147
+ const file = path.resolve(folder, declared);
148
+ if (!isFile$1(file)) return void 0;
149
+ return {
150
+ file,
151
+ hash: createHash("sha256").update(readFileSync(file)).digest("hex")
152
+ };
153
+ }
154
+ }
155
+ /**
156
+ * The install root of a game: the first folder from the game up that holds `bun.lock` or
157
+ * `bun.lockb`, where the patch walk of `engineFingerprint` stops. The dev server watches it for an
158
+ * engine change.
159
+ *
160
+ * @param root - The game folder.
161
+ * @returns The folder, or `undefined` when no folder up to the top of the disk holds a lock file.
162
+ * @example
163
+ * ```ts
164
+ * // A game in a workspace, installed at the workspace root.
165
+ * installRootOf("/w/games/timber"); // "/w"
166
+ * ```
167
+ */
168
+ function installRootOf(root) {
169
+ const last = foldersToInstallRoot(root).at(-1);
170
+ return last !== void 0 && LOCK_FILES$1.some((lock) => isFile$1(path.join(last, lock))) ? last : void 0;
171
+ }
172
+ /**
173
+ * Reads the fingerprint of the engine a game resolves: walks up from the hot plugin to the engine's package.json,
174
+ * hashes its `dist/`, and finds the patch the install root declares for that version. A hot plugin with no engine
175
+ * `package.json` above it (a stub path, a broken install) gives the id `"unknown"` and throws nothing.
176
+ *
177
+ * @param root - The game folder.
178
+ * @param hotPlugin - The resolved path of `@moku-labs/game/hot`.
179
+ * @returns The fingerprint.
180
+ * @example
181
+ * ```ts
182
+ * engineFingerprint("/g", "/g/node_modules/@moku-labs/game/dist/hot.mjs").id; // "0.14.12+9c41e0"
183
+ * ```
184
+ */
185
+ function engineFingerprint(root, hotPlugin) {
186
+ const found = engineFolderOf(path.dirname(hotPlugin));
187
+ if (found === void 0) return {
188
+ id: "unknown",
189
+ version: "unknown",
190
+ dist: "unknown",
191
+ folder: path.dirname(hotPlugin)
192
+ };
193
+ const { folder, fields } = found;
194
+ const version = typeof fields.version === "string" ? fields.version : "unknown";
195
+ const distFolder = path.join(folder, "dist");
196
+ const dist = isBelow(hotPlugin, distFolder) ? distHash(distFolder) : "src";
197
+ const patch = patchOf(root, version);
198
+ const id = `${version}+${dist === "src" ? dist : dist.slice(0, 6)}${patch === void 0 ? "" : `.patch.${patch.hash.slice(0, 6)}`}`;
199
+ return patch === void 0 ? {
200
+ id,
201
+ version,
202
+ dist,
203
+ folder
204
+ } : {
205
+ id,
206
+ version,
207
+ dist,
208
+ patch,
209
+ folder
210
+ };
211
+ }
212
+ /**
213
+ * The one line `dev` and `build` print about the engine: its id, and the patch file relative to the game when there is one.
214
+ *
215
+ * @param engine - The fingerprint.
216
+ * @param root - The game folder.
217
+ * @returns The line.
218
+ * @example
219
+ * ```ts
220
+ * engineLine({ id: "0.14.12+9c41e0.patch.3f2a7b", version: "0.14.12", dist: "9c41e0", folder: "/g/node_modules/@moku-labs/game", patch: { file: "/g/patches/@moku-labs%2Fgame@0.14.12.patch", hash: "3f2a7b" } }, "/g"); // "engine 0.14.12+9c41e0.patch.3f2a7b (patches/@moku-labs%2Fgame@0.14.12.patch)"
221
+ * ```
222
+ */
223
+ function engineLine(engine, root) {
224
+ if (engine.patch === void 0) return `engine ${engine.id}`;
225
+ const { file } = engine.patch;
226
+ const shown = isBelow(file, root) ? path.relative(root, file) : file;
227
+ return `engine ${engine.id} (${shown})`;
228
+ }
229
+ //#endregion
9
230
  //#region src/app/generate.ts
10
231
  /** The first line of a generated file a person may open. */
11
232
  const WRITTEN = "// Written by moku-game dev. Do not edit.";
233
+ /** The startup mark every generated main writes first after its imports: they all evaluated. */
234
+ const MAIN_MARK = "performance.mark(\"moku:main\");";
235
+ /** The one agent a debug page carries: the editor's page agent, resolved from the game. */
236
+ const DEBUG_AGENT = "@moku-labs/editor/agent/page";
12
237
  /**
13
238
  * The stamp module of a game, a file of `<game>/.moku/`. One per game: the dev page and the page
14
239
  * of `moku-game visual` import the same one.
@@ -257,12 +482,13 @@ function toStampFolder(toRoot) {
257
482
  return toRoot.slice(3) || "./";
258
483
  }
259
484
  /**
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.
485
+ * The `main.ts` of the dev page: the startup mark module first, the dev flag second, then the
486
+ * assets stamp, the page entry, the game, its config, one import per scenario, and, with agents,
487
+ * one per `.dev` module and one per agent. Its first statement marks `moku:main`, then it calls
488
+ * `startPage` with the scenarios by file stem, the shell with the loaders of the named plugins
489
+ * when the game needs it, the agents and the `.dev` modules. The stamp import puts the stamp
490
+ * module on the page: when the keys watch writes a new stamp, the hot plugin hands it to the
491
+ * running game.
266
492
  *
267
493
  * @param settings - The resolved `config.ts`.
268
494
  * @param sources - The scenario files, the `.dev` modules and the agents.
@@ -287,6 +513,7 @@ function devMain(settings, sources, toRoot = "../") {
287
513
  ];
288
514
  return [
289
515
  WRITTEN,
516
+ "import \"./script.ts\";",
290
517
  "import \"./dev.ts\";",
291
518
  `import "${toStampFolder(toRoot)}${ASSETS_STAMP}";`,
292
519
  ...pageImports(settings, toRoot),
@@ -294,6 +521,7 @@ function devMain(settings, sources, toRoot = "../") {
294
521
  ...devModules.map((file, index) => importLine(`* as devModule${index}`, `${toRoot}${file}`)),
295
522
  ...agents.map((agent, index) => importLine(`agent${index}`, agent)),
296
523
  "",
524
+ MAIN_MARK,
297
525
  "await startPage(game, config, {",
298
526
  options.join(",\n"),
299
527
  "});",
@@ -301,9 +529,10 @@ function devMain(settings, sources, toRoot = "../") {
301
529
  ].join("\n");
302
530
  }
303
531
  /**
304
- * The `main.ts` of the production page: the page entry, the game and its config, and the shell
305
- * with the loaders of the named plugins when the game needs it. No dev flag, no scenario, no
306
- * agent, no `.dev` module, no door: none of them ships.
532
+ * The `main.ts` of the production page: the startup mark module, the page entry, the game and its
533
+ * config, and the shell with the loaders of the named plugins when the game needs it; its first
534
+ * statement marks `moku:main`. No dev flag, no scenario, no agent, no `.dev` module, no door: none
535
+ * of them ships. The release page: `moku-game build --debug` uses `debugMain`.
307
536
  *
308
537
  * @param settings - The resolved `config.ts`.
309
538
  * @returns The text of the in-memory `.moku/build/main.ts`.
@@ -319,24 +548,111 @@ function buildMain(settings) {
319
548
  "});"
320
549
  ] : ["await startPage(game, config);"];
321
550
  return [
551
+ "import \"./script.ts\";",
322
552
  ...pageImports(settings, "../../"),
323
553
  "",
554
+ MAIN_MARK,
324
555
  ...call,
325
556
  ""
326
557
  ].join("\n");
327
558
  }
328
559
  /**
329
- * The `dev.ts` module the dev `main.ts` imports first: it sets the dev flag on `globalThis` for
330
- * code that reads the global. The bunfig `define` already makes the bare flag true in the bundle.
560
+ * The `main.ts` of a debug page, `moku-game build --debug`: the startup mark module first, the
561
+ * agent flag module second, then the page entry, the game, its config, one import per `.dev`
562
+ * module, sorted, and the editor's page agent. Its first statement marks `moku:main`, then it
563
+ * calls `startPage` with the shell when the game needs it, the agent and the `.dev` modules. No
564
+ * scenario (`?player=` stays a dev feature), no assets stamp, no dev flag.
565
+ *
566
+ * @param settings - The resolved `config.ts`.
567
+ * @param devModules - The `.dev.ts` modules of the game, relative to the game folder, `/` separated.
568
+ * @returns The text of the in-memory `.moku/build/main.ts`.
569
+ * @example
570
+ * ```ts
571
+ * debugMain(resolveConfig({ page: { title: "T" } }), []).includes("agents: [agent0]"); // true
572
+ * ```
573
+ */
574
+ function debugMain(settings, devModules) {
575
+ const sorted = devModules.toSorted();
576
+ const options = [
577
+ ...needsShell(settings) ? [shellOption(settings)] : [],
578
+ " agents: [agent0]",
579
+ ...sorted.length === 0 ? [] : [` devModules: [${numbered("devModule", sorted.length)}]`]
580
+ ];
581
+ return [
582
+ "import \"./script.ts\";",
583
+ "import \"./agent.ts\";",
584
+ ...pageImports(settings, "../../"),
585
+ ...sorted.map((file, index) => importLine(`* as devModule${index}`, `../../${file}`)),
586
+ importLine("agent0", DEBUG_AGENT),
587
+ "",
588
+ MAIN_MARK,
589
+ "await startPage(game, config, {",
590
+ options.join(",\n"),
591
+ "});",
592
+ ""
593
+ ].join("\n");
594
+ }
595
+ /**
596
+ * The `script.ts` module every generated main imports first: it marks `moku:script` with the URL
597
+ * of the chunk it lands in, the first statement the bundle runs. Two marks cost nothing, so no
598
+ * flag guards them.
331
599
  *
600
+ * @returns The text of `.moku/script.ts` and of the in-memory `script.ts` of a build.
601
+ * @example
602
+ * ```ts
603
+ * scriptMark().startsWith('performance.mark("moku:script"'); // true
604
+ * ```
605
+ */
606
+ function scriptMark() {
607
+ return "performance.mark(\"moku:script\", { detail: import.meta.url });\n";
608
+ }
609
+ /**
610
+ * The `dev.ts` module the dev `main.ts` imports after the startup mark: it sets on `globalThis`,
611
+ * for code that reads the globals, the build id `"dev"`, the engine id, the control flag, the
612
+ * agent flag and, last, the dev flag. The bunfig `define` already makes the three bare flags true
613
+ * in the bundle.
614
+ *
615
+ * @param engine - The id of the engine fingerprint the page was written for.
332
616
  * @returns The text of `.moku/dev.ts`.
333
617
  * @example
334
618
  * ```ts
335
- * devFlag().endsWith("globalThis.__MOKU_GAME_DEV__ = true;\n"); // true
619
+ * devFlag("0.0.0+src").endsWith("globalThis.__MOKU_GAME_DEV__ = true;\n"); // true
620
+ * ```
621
+ */
622
+ function devFlag(engine) {
623
+ return [
624
+ WRITTEN,
625
+ "globalThis.__MOKU_GAME_BUILD__ = \"dev\";",
626
+ `globalThis.__MOKU_GAME_ENGINE__ = ${JSON.stringify(engine)};`,
627
+ "globalThis.__MOKU_GAME_CONTROL__ = true;",
628
+ "globalThis.__MOKU_GAME_AGENT__ = true;",
629
+ "globalThis.__MOKU_GAME_DEV__ = true;",
630
+ ""
631
+ ].join("\n");
632
+ }
633
+ /**
634
+ * The in-memory `agent.ts` module of a debug page, imported after the startup mark: six lines
635
+ * that set the build id, the engine id and the editor address, each through `JSON.stringify`, the
636
+ * control flag of the profile, the agent flag, and the dev flag `false`. No header: nobody opens
637
+ * it.
638
+ *
639
+ * @param page - What the page is built with.
640
+ * @returns The text.
641
+ * @example
642
+ * ```ts
643
+ * agentFlag({ build: "debug-20261014T153012Z", control: false, engine: "0.14.12+9c41e0", editor: "127.0.0.1:47600" }).split("\n")[0]; // 'globalThis.__MOKU_GAME_BUILD__ = "debug-20261014T153012Z";'
336
644
  * ```
337
645
  */
338
- function devFlag() {
339
- return `${WRITTEN}\nglobalThis.__MOKU_GAME_DEV__ = true;\n`;
646
+ function agentFlag(page) {
647
+ return [
648
+ `globalThis.__MOKU_GAME_BUILD__ = ${JSON.stringify(page.build)};`,
649
+ `globalThis.__MOKU_GAME_ENGINE__ = ${JSON.stringify(page.engine)};`,
650
+ `globalThis.__MOKU_GAME_EDITOR__ = ${JSON.stringify(page.editor)};`,
651
+ `globalThis.__MOKU_GAME_CONTROL__ = ${String(page.control)};`,
652
+ "globalThis.__MOKU_GAME_AGENT__ = true;",
653
+ "globalThis.__MOKU_GAME_DEV__ = false;",
654
+ ""
655
+ ].join("\n");
340
656
  }
341
657
  /**
342
658
  * The `assets-stamp.ts` module the dev `main.ts` imports: the asset files of the game with their
@@ -384,7 +700,7 @@ function tomlList(paths) {
384
700
  }
385
701
  /**
386
702
  * The bunfig of the dev server: the preloads when given, the hot plugin first among the bundler
387
- * plugins of the page, and the dev flag defined true.
703
+ * plugins of the page, and the dev, agent and control flags defined true.
388
704
  *
389
705
  * @param paths - The absolute paths of the hot plugin, the extra plugins and the preloads.
390
706
  * @returns The text of `.moku/bunfig.toml`.
@@ -400,11 +716,171 @@ function bunfigText(paths) {
400
716
  "",
401
717
  "[serve.static]",
402
718
  `plugins = ${tomlList([paths.hotPlugin, ...paths.servePlugins])}`,
403
- "define = { \"__MOKU_GAME_DEV__\" = \"true\" }",
719
+ "define = { \"__MOKU_GAME_DEV__\" = \"true\", \"__MOKU_GAME_AGENT__\" = \"true\", \"__MOKU_GAME_CONTROL__\" = \"true\" }",
404
720
  ""
405
721
  ].join("\n");
406
722
  }
407
723
  //#endregion
724
+ //#region src/app/engine-watch.ts
725
+ /**
726
+ * @file app — watches the installed engine and tells when the served engine or its patch changed:
727
+ * the dev server restarts its child on a new engine, and the editor's dev server does the same
728
+ * through `watchEngine` of `@moku-labs/game/cli`. Node and Bun only; the fingerprint is the one of
729
+ * `./engine`.
730
+ */
731
+ /** The quiet that closes one batch of engine events: an install writes many files. */
732
+ const ENGINE_QUIET_MS = 300;
733
+ /** The lock files of an install root, the first one found is watched. */
734
+ const LOCK_FILES = ["bun.lock", "bun.lockb"];
735
+ /** The length of a hash as an engine id shows it. */
736
+ const SHORT_HASH = 6;
737
+ /**
738
+ * Tells whether a path exists.
739
+ *
740
+ * @param file - The path.
741
+ * @returns True for an existing file or folder.
742
+ */
743
+ function exists(file) {
744
+ return statSync(file, { throwIfNoEntry: false }) !== void 0;
745
+ }
746
+ /**
747
+ * Lists a folder and every folder under it.
748
+ *
749
+ * @param folder - The folder.
750
+ * @returns The folders, `folder` first; none when it is missing.
751
+ */
752
+ function foldersUnder(folder) {
753
+ if (statSync(folder, { throwIfNoEntry: false })?.isDirectory() !== true) return [];
754
+ return [folder, ...readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => foldersUnder(path.join(folder, entry.name)))];
755
+ }
756
+ /**
757
+ * The paths the watch opens for an engine: the scope folder (an install replaces the engine
758
+ * folder), the engine folder, every folder of its `dist`, the folder of the patch, and the
759
+ * `package.json` and lock file of the install root. An engine from its sources or an unidentified
760
+ * one opens none.
761
+ *
762
+ * @param start - The fingerprint to watch.
763
+ * @param root - The game folder.
764
+ * @returns The existing paths, each once.
765
+ */
766
+ function watchedPaths(start, root) {
767
+ if (start.dist === "src" || start.dist === "unknown") return [];
768
+ const installRoot = installRootOf(root);
769
+ const rootFiles = installRoot === void 0 ? [] : [path.join(installRoot, "package.json"), ...LOCK_FILES.map((file) => path.join(installRoot, file)).filter((file) => exists(file)).slice(0, 1)];
770
+ const paths = [
771
+ path.dirname(start.folder),
772
+ start.folder,
773
+ ...foldersUnder(path.join(start.folder, "dist")),
774
+ ...start.patch === void 0 ? [] : [path.dirname(start.patch.file)],
775
+ ...rootFiles
776
+ ];
777
+ return [...new Set(paths)].filter((file) => exists(file));
778
+ }
779
+ /**
780
+ * The short hash of a patch, as an engine id shows it.
781
+ *
782
+ * @param patch - The patch, or `undefined` without one.
783
+ * @returns The first six hex digits, or `none`.
784
+ * @example
785
+ * ```ts
786
+ * patchName(undefined); // "none"
787
+ * ```
788
+ */
789
+ function patchName(patch) {
790
+ return patch === void 0 ? "none" : patch.hash.slice(0, SHORT_HASH);
791
+ }
792
+ /**
793
+ * The warning lines `moku-game dev` prints for a change.
794
+ *
795
+ * @param change - The change.
796
+ * @returns The lines, the first one a `[game]` message.
797
+ * @example
798
+ * ```ts
799
+ * engineChangeLines({ before: { id: "0.14.12+1a2b3c", version: "0.14.12", dist: "1a2b3c", folder: "/e" }, after: { id: "0.14.12+9c41e0", version: "0.14.12", dist: "9c41e0", folder: "/e" }, restart: true }); // ["[game] dev: the engine changed (0.14.12+1a2b3c -> 0.14.12+9c41e0), restarting the dev server."]
800
+ * ```
801
+ */
802
+ function engineChangeLines(change) {
803
+ const { before, after } = change;
804
+ if (!change.restart) return [`[game] dev: the engine's patch changed (${patchName(before.patch)} -> ${patchName(after.patch)}), but the installed engine did not.`, " Run bun install; the dev server restarts by itself when the engine changes."];
805
+ const changed = `[game] dev: the engine changed (${before.id} -> ${after.id}), restarting the dev server.`;
806
+ return before.version === after.version ? [changed] : [changed, ` This process keeps the tools of ${before.version} (the keys watch); restart moku-game dev to use ${after.version}'s.`];
807
+ }
808
+ /**
809
+ * Closes every open watcher of the watch.
810
+ *
811
+ * @param watch - The running watch.
812
+ */
813
+ function closeWatchers(watch) {
814
+ for (const watcher of watch.watchers.splice(0)) watcher.close();
815
+ }
816
+ /**
817
+ * One batch: fingerprints the engine again, calls `onChange` when the served engine or its patch
818
+ * changed, and opens the watchers again: a replaced folder killed its watcher, and a new `dist`
819
+ * folder needs one. An engine that cannot be read goes to `onError`.
820
+ *
821
+ * @param root - The game folder.
822
+ * @param watch - The running watch.
823
+ * @param seams - The resolver.
824
+ * @param onChange - Called with the change.
825
+ * @param onError - Called when the engine cannot be read.
826
+ */
827
+ function checkEngine(root, watch, seams, onChange, onError) {
828
+ const before = watch.current;
829
+ let after;
830
+ try {
831
+ after = engineFingerprint(root, seams.resolve("@moku-labs/game/hot", root));
832
+ } catch (error) {
833
+ const reason = error instanceof Error ? error.message : String(error);
834
+ onError?.(`[game] dev: the engine cannot be read (${reason}). Waiting for the next change.`);
835
+ watch.open?.();
836
+ return;
837
+ }
838
+ const restart = before.version !== after.version || before.dist !== after.dist;
839
+ const repatched = before.patch?.hash !== after.patch?.hash;
840
+ watch.current = after;
841
+ watch.open?.();
842
+ if (restart || repatched) onChange({
843
+ before,
844
+ after,
845
+ restart
846
+ });
847
+ }
848
+ /**
849
+ * Watches the engine's folders, its patch and the install root's lock. Every event waits for 300 ms
850
+ * of quiet, then the engine is fingerprinted again: a new version or dist calls `onChange` with
851
+ * `restart: true`, a new patch alone with `restart: false`, no change calls nothing. An engine that
852
+ * cannot be read goes to `onError`, and the next event checks again. After each batch the watchers
853
+ * open again from the current fingerprint.
854
+ *
855
+ * @param root - The game folder.
856
+ * @param start - The fingerprint the server started with.
857
+ * @param seams - The watcher and the resolver.
858
+ * @param onChange - Called with each change.
859
+ * @param onError - Called when the engine cannot be read; nothing by default.
860
+ * @returns The running watch; `close()` ends it.
861
+ */
862
+ function watchEngineAt(root, start, seams, onChange, onError) {
863
+ const watch = {
864
+ current: start,
865
+ watchers: [],
866
+ closed: false
867
+ };
868
+ const batcher = createBatcher(ENGINE_QUIET_MS, async () => {
869
+ if (!watch.closed) checkEngine(root, watch, seams, onChange, onError);
870
+ });
871
+ watch.open = () => {
872
+ closeWatchers(watch);
873
+ for (const file of watchedPaths(watch.current, root)) watch.watchers.push(seams.watch(file, () => batcher.poke()));
874
+ };
875
+ watch.open();
876
+ return { close: () => {
877
+ if (watch.closed) return;
878
+ watch.closed = true;
879
+ batcher.cancel();
880
+ closeWatchers(watch);
881
+ } };
882
+ }
883
+ //#endregion
408
884
  //#region src/app/files.ts
409
885
  /**
410
886
  * @file The file write of the files `moku-game` generates for a game. The dev page and the stamp
@@ -535,7 +1011,8 @@ function checkFiles(flag, files) {
535
1011
  }
536
1012
  /**
537
1013
  * Finds the hot plugin of the engine the game resolves: the installed `dist/hot.mjs`, or
538
- * `src/hot.ts` through the tsconfig `paths` of the engine repo.
1014
+ * `src/hot.ts` through the tsconfig `paths` of the engine repo. A debug build resolves it the
1015
+ * same way, for the engine fingerprint.
539
1016
  *
540
1017
  * @param root - The game folder.
541
1018
  * @param resolve - Bun's resolver.
@@ -576,7 +1053,7 @@ function isSkippedFolder(name) {
576
1053
  }
577
1054
  /**
578
1055
  * Lists the `.dev.ts` modules under a folder of the game, outside the dot folders and the folders
579
- * of tools and tests.
1056
+ * of tools and tests: what the editor's dev page and a debug page hand to their agents.
580
1057
  *
581
1058
  * @param root - The game folder.
582
1059
  * @param relative - The folder under the game, `/` separated, `""` for the game itself.
@@ -611,19 +1088,21 @@ function mainOf(root, settings, agents, toRoot = "../") {
611
1088
  }, toRoot);
612
1089
  }
613
1090
  /**
614
- * Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `dev.ts`, `main.ts`
615
- * and `bunfig.toml`. `moku-game visual` writes its own page into `.moku/visual/`, so the page of
616
- * `dev` or of the editor stays as it is; the links of the page reach the game from its depth.
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.
1091
+ * Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `script.ts` (the
1092
+ * startup mark), `dev.ts` (the flags and the engine id), `main.ts` and `bunfig.toml`. `moku-game
1093
+ * visual` writes its own page into `.moku/visual/`, so the page of `dev` or of the editor stays as
1094
+ * it is; the links of the page reach the game from its depth. The hot plugin is resolved and the
1095
+ * engine fingerprinted before any text is made, and every text is made before the first write, so
1096
+ * a refused value writes nothing. A game without `.moku/assets-stamp.ts` gets the empty one, so
1097
+ * the import of `main.ts` resolves with or without a keys watch; a stamp that is there is the
1098
+ * watch's and stays.
620
1099
  *
621
1100
  * @param root - The game folder, absolute.
622
1101
  * @param settings - The resolved config.
623
1102
  * @param options - The agents, the preloads and the extra plugins, absolute.
624
1103
  * @param resolve - Bun's resolver, to find the hot plugin.
625
1104
  * @param page - The folder of the page under the game, `/` separated: `.moku` by default.
626
- * @returns The paths of the HTML and the bunfig.
1105
+ * @returns The paths of the HTML and the bunfig, and the engine the page was written for.
627
1106
  * @throws {Error} When the hot plugin does not resolve, a preload or plugin is not a file, or a
628
1107
  * page value is refused.
629
1108
  */
@@ -637,15 +1116,18 @@ function writePage(root, settings, options, resolve, page = ".moku") {
637
1116
  const folder = path.join(root, ...segments);
638
1117
  const html = path.join(folder, "index.html");
639
1118
  const bunfig = path.join(folder, "bunfig.toml");
1119
+ const hotPlugin = hotPluginOf(root, resolve);
1120
+ const engine = engineFingerprint(root, hotPlugin);
640
1121
  const texts = [
641
1122
  [html, pageHtml(settings.page, {
642
1123
  toRoot,
643
1124
  script: "./main.ts"
644
1125
  })],
645
- [path.join(folder, "dev.ts"), devFlag()],
1126
+ [path.join(folder, "script.ts"), scriptMark()],
1127
+ [path.join(folder, "dev.ts"), devFlag(engine.id)],
646
1128
  [path.join(folder, "main.ts"), mainOf(root, settings, options.agents, toRoot)],
647
1129
  [bunfig, bunfigText({
648
- hotPlugin: hotPluginOf(root, resolve),
1130
+ hotPlugin,
649
1131
  servePlugins,
650
1132
  preload
651
1133
  })]
@@ -655,7 +1137,8 @@ function writePage(root, settings, options, resolve, page = ".moku") {
655
1137
  if (!existsSync(stamp)) writeIfChanged(stamp, assetsStamp([], []));
656
1138
  return {
657
1139
  html,
658
- bunfig
1140
+ bunfig,
1141
+ engine
659
1142
  };
660
1143
  }
661
1144
  /**
@@ -666,7 +1149,7 @@ function writePage(root, settings, options, resolve, page = ".moku") {
666
1149
  * @param root - The game folder, absolute.
667
1150
  * @param options - The agents, the preloads and the extra plugins, absolute.
668
1151
  * @param resolve - Bun's resolver, to find the hot plugin.
669
- * @returns The paths of the HTML and the bunfig.
1152
+ * @returns The paths of the HTML and the bunfig, and the engine the page was written for.
670
1153
  * @throws {Error} The errors of `moku-game dev` before its server starts.
671
1154
  */
672
1155
  async function preparePageAt(root, options, resolve) {
@@ -736,12 +1219,54 @@ function devChildCommand(run, page, deps) {
736
1219
  ];
737
1220
  }
738
1221
  /**
1222
+ * Hands one engine change to the dev parent: prints its lines, and when the served engine changed
1223
+ * marks the run for a restart and stops the child with SIGTERM, the way Ctrl+C stops it.
1224
+ *
1225
+ * @param change - The change.
1226
+ * @param live - The running child and the restart mark.
1227
+ * @param deps - The console.
1228
+ */
1229
+ function restartOnEngineChange(change, live, deps) {
1230
+ for (const line of engineChangeLines(change)) deps.ui.warn(line);
1231
+ if (!change.restart) return;
1232
+ live.restart = true;
1233
+ live.child?.kill("SIGTERM");
1234
+ }
1235
+ /**
1236
+ * Serves a written page in a child until it exits without a restart: prints the engine line, runs
1237
+ * the child with the scenario watcher, and when the engine changed writes the page again (the new
1238
+ * engine id, the hot plugin of the new install) and runs a new child.
1239
+ *
1240
+ * @param run - The flags of the run.
1241
+ * @param page - The written page.
1242
+ * @param settings - The resolved config.
1243
+ * @param deps - The process seams.
1244
+ * @param live - The running child and the restart mark.
1245
+ * @returns The exit code of the last child.
1246
+ */
1247
+ async function serveUntilStopped(run, page, settings, deps, live) {
1248
+ deps.ui.info(engineLine(page.engine, run.root));
1249
+ const code = await runChild(devChildCommand(run, page, deps), deps, {
1250
+ cwd: run.root,
1251
+ whileRunning: (child) => {
1252
+ live.child = child;
1253
+ return watchScenarios(run.root, settings, deps);
1254
+ }
1255
+ });
1256
+ live.child = void 0;
1257
+ if (!live.restart) return code;
1258
+ live.restart = false;
1259
+ return serveUntilStopped(run, writePage(run.root, settings, run, deps.resolve), settings, deps, live);
1260
+ }
1261
+ /**
739
1262
  * 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
1263
+ * prints the engine line, re-runs the bin under the generated bunfig with the game as its working
1264
+ * directory, and watches the scenarios while the child runs. The first scan of the keys watch writes
742
1265
  * `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.
1266
+ * scans again; `--packed` serves the pack and watches no keys. The installed engine is watched: a
1267
+ * new engine stops the child with SIGTERM, writes the page again and starts a new child; a patch
1268
+ * that changed while the engine did not is a warning. It hands Ctrl+C and the other stop signals to
1269
+ * the child and answers the exit code of a child that ends by itself.
745
1270
  *
746
1271
  * @param run - The flags of the run.
747
1272
  * @param deps - The process seams.
@@ -753,14 +1278,18 @@ async function serveParent(run, deps) {
753
1278
  const hasPack = isFile(path.join(run.root, "dist", "assets", "manifest.json"));
754
1279
  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.`);
755
1280
  const keys = run.packed ? void 0 : await deps.watchKeys(run.root, settings);
1281
+ const live = {
1282
+ child: void 0,
1283
+ restart: false
1284
+ };
1285
+ let engine;
756
1286
  try {
757
1287
  const page = writePage(run.root, settings, run, deps.resolve);
758
1288
  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
- });
1289
+ engine = watchEngineAt(run.root, page.engine, deps, (change) => restartOnEngineChange(change, live, deps), (message) => deps.ui.warn(message));
1290
+ return await serveUntilStopped(run, page, settings, deps, live);
763
1291
  } finally {
1292
+ engine?.close();
764
1293
  keys?.close();
765
1294
  }
766
1295
  }
@@ -792,7 +1321,7 @@ async function runChild(command, deps, child) {
792
1321
  const removers = FORWARDED.map((signal) => deps.onSignal(signal, () => started.kill(signal)));
793
1322
  let watcher;
794
1323
  try {
795
- watcher = child.whileRunning?.();
1324
+ watcher = child.whileRunning?.(started);
796
1325
  return await started.exited;
797
1326
  } finally {
798
1327
  watcher?.close();
@@ -872,7 +1401,7 @@ function serveOn(run, page) {
872
1401
  fetch: (request) => fileResponse(staticPath(base, new URL(request.url).pathname))
873
1402
  });
874
1403
  } catch (error) {
875
- if (error.code === "EADDRINUSE") throw new Error(`[game] dev: port ${run.port} is in use.\n Pass --port 0 for a free port.`, { cause: error });
1404
+ if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") throw new Error(`[game] dev: port ${run.port} is in use.\n Pass --port 0 for a free port.`, { cause: error });
876
1405
  throw error;
877
1406
  }
878
1407
  }
@@ -932,11 +1461,21 @@ async function serveChild(run, deps) {
932
1461
  //#region src/app/build.ts
933
1462
  /**
934
1463
  * @file `moku-game build`: the assets packed with the config's layers, then the production page
935
- * bundled by `Bun.build` in the game folder, with its HTML and `main.ts` in memory under a virtual
936
- * `<game>/.moku/build/` that is never written, then the packed assets copied beside the page. The
937
- * output works from any http sub-path and from the Tauri protocol: every link starts with `./`,
938
- * and the page reads `manifest.json` next to itself. Node and Bun only: the bin bundles it.
1464
+ * bundled by `Bun.build` in the game folder, with its HTML, `main.ts` and `script.ts` in memory
1465
+ * under a virtual `<game>/.moku/build/` that is never written, then the packed assets copied
1466
+ * beside the page. A release page that carries the editor's agent is refused and removed. With
1467
+ * `--debug` the page is a debug page instead: the editor's page agent, the game's `.dev` modules,
1468
+ * the agent flag and a control profile, a marked title and a build id. The output works from any
1469
+ * http sub-path and from the Tauri protocol: every link starts with `./`, and the page reads
1470
+ * `manifest.json` next to itself. Node and Bun only: the bin bundles it.
1471
+ */
1472
+ /** The address the page agent of a debug page dials without `--editor`. */
1473
+ const DEFAULT_EDITOR = "127.0.0.1:47600";
1474
+ /**
1475
+ * The two strings the editor's agent bundle always carries: the bridge's default path and the
1476
+ * prefix of every editor error. A contract between the engine, the editor and `@moku-labs/native`.
939
1477
  */
1478
+ const AGENT_MARKERS = ["/__editor/hello", "[moku-editor]"];
940
1479
  /** The link prefix of the in-memory page, two folders under the game. */
941
1480
  const TO_ROOT = "../../";
942
1481
  /** Bytes in a kilobyte, for the size line of a run. */
@@ -963,27 +1502,32 @@ function scanArguments(root, settings) {
963
1502
  ];
964
1503
  }
965
1504
  /**
966
- * Lists the folders right inside a folder.
1505
+ * The folders of every feature owner of a game, at any depth (`src/project/features.ts`). A
1506
+ * feature name two folders share counts as no owner: the scan that follows finds the same clash
1507
+ * and reports it.
967
1508
  *
968
- * @param folder - The folder.
969
- * @returns Their absolute paths; none when the folder is missing.
1509
+ * @param root - The game folder.
1510
+ * @returns Their absolute paths; none when the features folder is missing.
970
1511
  */
971
- function foldersIn(folder) {
972
- if (statSync(folder, { throwIfNoEntry: false })?.isDirectory() !== true) return [];
973
- return readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(folder, entry.name));
1512
+ function featureFolders(root) {
1513
+ try {
1514
+ return findFeatureOwners(root).map((owner) => path.join(root, owner.dir));
1515
+ } catch {
1516
+ return [];
1517
+ }
974
1518
  }
975
1519
  /**
976
1520
  * Tells whether a game has an English string file to derive the pseudo-locale from: a
977
- * `strings/en.json` in a feature folder or in a layer folder of the config.
1521
+ * `strings/en.json` in a feature folder at any depth (a feature owner of
1522
+ * `src/project/features.ts`) or in a layer folder of the config.
978
1523
  *
979
1524
  * @param root - The game folder.
980
1525
  * @param settings - The resolved config, for its layer folders.
981
1526
  * @returns True when at least one such file exists.
982
1527
  */
983
1528
  function hasEnglishStrings(root, settings) {
984
- const featureFolders = foldersIn(path.join(root, "features"));
985
1529
  const layerFolders = Object.keys(settings.assets.layers).map((folder) => path.join(root, folder));
986
- return [...featureFolders, ...layerFolders].some((folder) => statSync(path.join(folder, "strings", "en.json"), { throwIfNoEntry: false })?.isFile() === true);
1530
+ return [...featureFolders(root), ...layerFolders].some((folder) => statSync(path.join(folder, "strings", "en.json"), { throwIfNoEntry: false })?.isFile() === true);
987
1531
  }
988
1532
  /**
989
1533
  * The flags of the asset scan for `moku-game keys`: the dev manifest in `generated/`, the
@@ -1085,32 +1629,106 @@ async function loadPlugins(files) {
1085
1629
  }));
1086
1630
  }
1087
1631
  /**
1088
- * Bundles the production page into the output folder: the HTML and `main.ts` in memory, every
1089
- * link `./`, the dev flag defined false, no hot plugin.
1632
+ * The id of a debug build: the profile and the UTC time of the run, `YYYYMMDDTHHMMSSZ`.
1633
+ *
1634
+ * @param debug - The profile.
1635
+ * @param now - The wall-clock time of the run, ms.
1636
+ * @returns The id.
1637
+ * @example
1638
+ * ```ts
1639
+ * buildIdOf("read", Date.UTC(2026, 9, 14, 15, 30, 12)); // "debug-20261014T153012Z"
1640
+ * ```
1641
+ */
1642
+ function buildIdOf(debug, now) {
1643
+ const stamp = `${new Date(now).toISOString().slice(0, 19).replaceAll(/[-:]/g, "")}Z`;
1644
+ return debug === "full" ? `debug-full-${stamp}` : `debug-${stamp}`;
1645
+ }
1646
+ /**
1647
+ * The `define` map of a page: a release page defines the three flags `false`; a debug page the
1648
+ * dev flag `false`, the agent flag `true`, the control flag of its profile, and the build id, the
1649
+ * engine id and the editor address as strings.
1650
+ *
1651
+ * @param page - What a debug page is built with; `undefined` for a release page.
1652
+ * @returns The defines for `Bun.build`.
1653
+ * @example
1654
+ * ```ts
1655
+ * definesOf(undefined).__MOKU_GAME_AGENT__; // "false"
1656
+ * ```
1657
+ */
1658
+ function definesOf(page) {
1659
+ if (page === void 0) return {
1660
+ __MOKU_GAME_DEV__: "false",
1661
+ __MOKU_GAME_AGENT__: "false",
1662
+ __MOKU_GAME_CONTROL__: "false"
1663
+ };
1664
+ return {
1665
+ __MOKU_GAME_DEV__: "false",
1666
+ __MOKU_GAME_AGENT__: "true",
1667
+ __MOKU_GAME_CONTROL__: page.level === "full" ? "true" : "false",
1668
+ __MOKU_GAME_BUILD__: JSON.stringify(page.build),
1669
+ __MOKU_GAME_ENGINE__: JSON.stringify(page.engine),
1670
+ __MOKU_GAME_EDITOR__: JSON.stringify(page.editor)
1671
+ };
1672
+ }
1673
+ /**
1674
+ * The in-memory files of the page under `<game>/.moku/build/`: a release page has `index.html`,
1675
+ * `main.ts` and `script.ts`; a debug page also `agent.ts`, its `main.ts` from `debugMain` with the
1676
+ * game's `.dev` modules, and its title marked `Debug`.
1677
+ *
1678
+ * @param run - The flags of the run.
1679
+ * @param settings - The resolved config.
1680
+ * @param page - What a debug page is built with; `undefined` for a release page.
1681
+ * @returns The texts by absolute path.
1682
+ */
1683
+ function pageFiles(run, settings, page) {
1684
+ const folder = path.join(run.root, ".moku", "build");
1685
+ const links = {
1686
+ toRoot: TO_ROOT,
1687
+ script: "./main.ts"
1688
+ };
1689
+ const release = {
1690
+ [path.join(folder, "index.html")]: pageHtml(settings.page, links),
1691
+ [path.join(folder, "main.ts")]: buildMain(settings),
1692
+ [path.join(folder, "script.ts")]: scriptMark()
1693
+ };
1694
+ if (page === void 0) return release;
1695
+ const flags = {
1696
+ build: page.build,
1697
+ control: page.level === "full",
1698
+ engine: page.engine
1699
+ };
1700
+ return {
1701
+ ...release,
1702
+ [path.join(folder, "index.html")]: pageHtml({
1703
+ ...settings.page,
1704
+ title: `${settings.page.title} Debug`
1705
+ }, links),
1706
+ [path.join(folder, "main.ts")]: debugMain(settings, devModulesOf(run.root)),
1707
+ [path.join(folder, "agent.ts")]: agentFlag({
1708
+ ...flags,
1709
+ editor: page.editor
1710
+ })
1711
+ };
1712
+ }
1713
+ /**
1714
+ * Bundles the page into the output folder: its files in memory, every link `./`, the flags
1715
+ * defined, no hot plugin.
1090
1716
  *
1091
1717
  * @param run - The flags of the run.
1092
1718
  * @param settings - The resolved config.
1719
+ * @param page - What a debug page is built with; `undefined` for a release page.
1093
1720
  * @returns Resolves when the page is written.
1094
1721
  * @throws {Error} With one Bun log per line when the page did not bundle.
1095
1722
  */
1096
- async function bundlePage(run, settings) {
1097
- const folder = path.join(run.root, ".moku", "build");
1098
- const html = path.join(folder, "index.html");
1099
- const main = path.join(folder, "main.ts");
1723
+ async function bundlePage(run, settings, page) {
1100
1724
  const result = await Bun.build({
1101
- entrypoints: [html],
1102
- files: {
1103
- [html]: pageHtml(settings.page, {
1104
- toRoot: TO_ROOT,
1105
- script: "./main.ts"
1106
- }),
1107
- [main]: buildMain(settings)
1108
- },
1725
+ entrypoints: [path.join(run.root, ".moku", "build", "index.html")],
1726
+ files: pageFiles(run, settings, page),
1109
1727
  outdir: run.out,
1110
1728
  publicPath: "./",
1111
1729
  minify: true,
1112
1730
  target: "browser",
1113
- define: { __MOKU_GAME_DEV__: "false" },
1731
+ define: definesOf(page),
1114
1732
  naming: {
1115
1733
  entry: "[name].[ext]",
1116
1734
  chunk: "page/[name]-[hash].[ext]",
@@ -1157,6 +1775,26 @@ function filesUnder(folder, relative = "") {
1157
1775
  });
1158
1776
  }
1159
1777
  /**
1778
+ * Refuses a release page that carries the editor's agent: reads every `.js` file of the output
1779
+ * for the two marker strings of the agent bundle. On a hit the output folder is removed, so no
1780
+ * release page with an agent is left on disk.
1781
+ *
1782
+ * @param out - The output folder.
1783
+ * @throws {Error} Naming the marker and the file, when a page file carries one.
1784
+ */
1785
+ function refuseAgent(out) {
1786
+ for (const file of filesUnder(out).filter((name) => name.endsWith(".js")).toSorted()) {
1787
+ const code = readFileSync(path.join(out, file), "utf8");
1788
+ const marker = AGENT_MARKERS.find((text) => code.includes(text));
1789
+ if (marker === void 0) continue;
1790
+ rmSync(out, {
1791
+ recursive: true,
1792
+ force: true
1793
+ });
1794
+ throw new Error(`[game] build: the release page carries the editor agent ("${marker}" in ${file}).\n Import @moku-labs/editor only behind __MOKU_GAME_DEV__, or build a debug page with --debug.`);
1795
+ }
1796
+ }
1797
+ /**
1160
1798
  * Copies the packed assets beside the page, `manifest.json` next to `index.html`. File by file:
1161
1799
  * the output folder and its page folders exist already, and a packed file never replaces a page
1162
1800
  * file.
@@ -1193,28 +1831,91 @@ async function inFolder(folder, step) {
1193
1831
  }
1194
1832
  }
1195
1833
  /**
1196
- * Builds the production page of a game: packs the assets, replaces the output folder with the
1197
- * bundled page, and copies the pack beside it.
1834
+ * Refuses `--debug` when Tauri runs the web build of a store app: `MOKU_NATIVE_PROFILE=store`.
1835
+ *
1836
+ * @param run - The flags of the run.
1837
+ * @param env - The environment the bin started with.
1838
+ * @throws {Error} When the profile is `store` and the run is a debug run.
1839
+ */
1840
+ function refuseStoreDebug(run, env) {
1841
+ if (run.debug === void 0 || env.MOKU_NATIVE_PROFILE !== "store") return;
1842
+ throw new Error("[game] build: MOKU_NATIVE_PROFILE is store: a store page must not carry the editor agent.\n Build the store app without --debug.");
1843
+ }
1844
+ /**
1845
+ * What a debug run builds its page with: the editor's page agent must resolve from the game, and
1846
+ * the engine the page bundles is fingerprinted.
1847
+ *
1848
+ * @param run - The flags of a debug run.
1849
+ * @param level - Its profile.
1850
+ * @param deps - The resolver and the clock.
1851
+ * @returns The debug page and the engine fingerprint.
1852
+ * @throws {Error} With the install line when the game cannot resolve `@moku-labs/editor`, and
1853
+ * when it cannot resolve `@moku-labs/game/hot`.
1854
+ */
1855
+ function debugPageOf(run, level, deps) {
1856
+ try {
1857
+ deps.resolve(DEBUG_AGENT, run.root);
1858
+ } catch (error) {
1859
+ throw new Error("[game] build --debug needs @moku-labs/editor in the game.\n Install it: bun add -d @moku-labs/editor.", { cause: error });
1860
+ }
1861
+ const engine = engineFingerprint(run.root, hotPluginOf(run.root, deps.resolve));
1862
+ return {
1863
+ page: {
1864
+ level,
1865
+ build: buildIdOf(level, deps.now()),
1866
+ engine: engine.id,
1867
+ editor: run.editor ?? "127.0.0.1:47600"
1868
+ },
1869
+ engine
1870
+ };
1871
+ }
1872
+ /**
1873
+ * The warning a debug build prints before its size line: the profile, the build id, the engine and
1874
+ * the editor address.
1875
+ *
1876
+ * @param run - The flags of the run.
1877
+ * @param debug - The debug page and the engine fingerprint.
1878
+ * @param debug.page - What the page was built with.
1879
+ * @param debug.engine - The engine it bundles.
1880
+ * @returns The line.
1881
+ */
1882
+ function debugWarning(run, debug) {
1883
+ const { page, engine } = debug;
1884
+ const control = page.level === "full" ? "every command, the state-changing ones too" : "read and cosmetic commands";
1885
+ const engineText = engineLine(engine, run.root).slice(7);
1886
+ return `build: a DEBUG page with the editor agent, control: ${control}. Build ${page.build}, engine ${engineText}, editor ${page.editor}. Never ship it to a store.`;
1887
+ }
1888
+ /**
1889
+ * Builds the page of a game: packs the assets, replaces the output folder with the bundled page,
1890
+ * refuses a release page that carries the editor's agent, and copies the pack beside it. A debug
1891
+ * run first checks that the game resolves the editor's agent and fingerprints the engine, and
1892
+ * prints one warning before the size line.
1198
1893
  *
1199
1894
  * @param run - The flags of the run.
1200
- * @param deps - The process seams: the asset scanner and the console.
1895
+ * @param deps - The process seams: the asset scanner, the resolver, the clock, the environment and
1896
+ * the console.
1201
1897
  * @returns The exit code: the pack's when it failed, else `0`.
1202
- * @throws {Error} When the game, the output folder or the bundle is refused.
1898
+ * @throws {Error} When the game, the output folder, the profile or the bundle is refused, a debug
1899
+ * run cannot resolve the editor, or a release page carries the editor's agent.
1203
1900
  */
1204
1901
  async function runBuild(run, deps) {
1902
+ refuseStoreDebug(run, deps.env);
1205
1903
  const settings = await loadGame(run.root);
1206
1904
  refuseOut(run.root, run.out);
1905
+ const debug = run.debug === void 0 ? void 0 : debugPageOf(run, run.debug, deps);
1207
1906
  const packed = await deps.assets(packArguments(run.root, settings, false));
1208
1907
  if (packed !== 0) return packed;
1209
1908
  rmSync(run.out, {
1210
1909
  recursive: true,
1211
1910
  force: true
1212
1911
  });
1213
- await inFolder(run.root, () => bundlePage(run, settings));
1912
+ await inFolder(run.root, () => bundlePage(run, settings, debug?.page));
1214
1913
  relinkIcons(run, settings);
1914
+ if (debug === void 0) refuseAgent(run.out);
1215
1915
  copyPack(run);
1216
1916
  const files = filesUnder(run.out);
1217
1917
  const bytes = files.map((file) => statSync(path.join(run.out, file)).size).reduce((sum, size) => sum + size, 0);
1918
+ if (debug !== void 0) deps.ui.warn(debugWarning(run, debug));
1218
1919
  deps.ui.info(`built "${run.out}": ${files.length} files, ${Math.round(bytes / BYTES_PER_KB)} KB.`);
1219
1920
  return 0;
1220
1921
  }
@@ -1438,17 +2139,28 @@ const ALL_OPTIONS = {
1438
2139
  multiple: true
1439
2140
  },
1440
2141
  "no-pixels": { type: "boolean" },
2142
+ "no-verify-frames": { type: "boolean" },
1441
2143
  pixels: { type: "boolean" },
1442
2144
  dir: { type: "string" },
1443
- webgl: { type: "boolean" }
2145
+ webgl: { type: "boolean" },
2146
+ debug: { type: "string" },
2147
+ editor: { type: "string" }
1444
2148
  };
1445
2149
  /** The flags of `visual` that `parseVisualArgv` reads, in the runner's words. */
1446
- const VISUAL_FLAGS = /^(?:update|only|no-pixels|pixels|dir|webgl)$/;
2150
+ const VISUAL_FLAGS = /^(?:update|only|no-pixels|pixels|dir|webgl|no-verify-frames)$/;
1447
2151
  /** The flags of one command besides the global ones. */
1448
2152
  const COMMAND_OPTIONS = {
1449
2153
  dev: ["port", "packed"],
1450
- build: ["out"],
1451
- native: ["simulator"],
2154
+ build: [
2155
+ "out",
2156
+ "debug",
2157
+ "editor"
2158
+ ],
2159
+ native: [
2160
+ "simulator",
2161
+ "debug",
2162
+ "editor"
2163
+ ],
1452
2164
  keys: ["check"],
1453
2165
  pack: ["no-cache"],
1454
2166
  visual: [
@@ -1459,7 +2171,8 @@ const COMMAND_OPTIONS = {
1459
2171
  "no-pixels",
1460
2172
  "pixels",
1461
2173
  "dir",
1462
- "webgl"
2174
+ "webgl",
2175
+ "no-verify-frames"
1463
2176
  ]
1464
2177
  };
1465
2178
  /** The native verbs, in the order the messages name them. */
@@ -1477,19 +2190,21 @@ const VISUAL_TESTS = path.join("tests", "visual", "index.ts");
1477
2190
  const DEFAULT_PORT = "3000";
1478
2191
  /** The highest port number. */
1479
2192
  const MAX_PORT = 65535;
2193
+ /** A host of `--editor`: a name or an IPv4 address, or an IPv6 address in brackets. */
2194
+ const EDITOR_HOST = /^(?:[\w.-]+|\[[\da-f:.]+\])$/i;
1480
2195
  /** The text of `moku-game help`. */
1481
2196
  const USAGE = [
1482
2197
  "moku-game <command> [options]",
1483
2198
  "",
1484
2199
  " dev [--port 3000] [--packed] serve the game with hot reload",
1485
- " build [--out dist/web] pack the assets and build the page",
1486
- " native build <target> [--simulator] build the native app (ios, macos, android)",
2200
+ " build [--out dist/web] [--debug[=full] [--editor host:port]] pack the assets and build the page; --debug adds the editor agent",
2201
+ " native build <target> [--simulator] [--debug[=full] [--editor host:port]] build the native app (ios, macos, android)",
1487
2202
  " native dev <target> run the native shell on the dev server",
1488
2203
  " native doctor | native clean check or remove the native project",
1489
2204
  " keys [--check] write generated/assets.ts and generated/manifest.json",
1490
2205
  " pack [--no-cache] pack the assets into dist/assets",
1491
2206
  " visual [--update] [--only <name>] run tests/visual/index.ts: headless, pixels on a Mac",
1492
- " [--no-pixels | --pixels] [--webgl] [--dir tests/visual/baselines]",
2207
+ " [--no-pixels | --pixels] [--webgl] [--no-verify-frames] [--dir tests/visual/baselines]",
1493
2208
  " [--tests tests/visual/index.ts] [--url <url>]",
1494
2209
  " help print this text",
1495
2210
  "",
@@ -1564,14 +2279,16 @@ function checkTokens(command, tokens) {
1564
2279
  }
1565
2280
  /**
1566
2281
  * Reads the command line into one command with its flags, the paths resolved against the
1567
- * working directory.
2282
+ * working directory. A bare `--debug` reads as `--debug=read`: a string flag without its value
2283
+ * would take the next word.
1568
2284
  *
1569
- * @param argv - The arguments after the bin.
2285
+ * @param given - The arguments after the bin.
1570
2286
  * @param cwd - The working directory.
1571
2287
  * @returns The command, `"help"`, or `undefined` when no command is named.
1572
2288
  * @throws {Error} When the command is unknown or a flag is refused.
1573
2289
  */
1574
- function parseCommand(argv, cwd) {
2290
+ function parseCommand(given, cwd) {
2291
+ const argv = given.map((word) => word === "--debug" ? "--debug=read" : word);
1575
2292
  const loose = parseLoose(argv);
1576
2293
  const word = loose.positionals[0];
1577
2294
  if (loose.values.help === true || word === "help") return "help";
@@ -1598,7 +2315,47 @@ function parseCommand(argv, cwd) {
1598
2315
  */
1599
2316
  function refuseExtra(parsed, count) {
1600
2317
  const extra = parsed.rest[count];
1601
- if (extra !== void 0) throw new Error(`[game] ${parsed.command}: unexpected argument "${extra}".`);
2318
+ if (extra === void 0) return;
2319
+ const advice = parsed.values.debug !== void 0 && (extra === "full" || extra === "read") ? ` Write --debug=${extra}.` : "";
2320
+ throw new Error(`[game] ${parsed.command}: unexpected argument "${extra}".${advice}`);
2321
+ }
2322
+ /**
2323
+ * Reads `--debug`: no flag is a release page, a bare flag or `read` the read profile, `full`
2324
+ * every command.
2325
+ *
2326
+ * @param value - The value of the flag, `read` for a bare `--debug`.
2327
+ * @param command - The command word, for the message.
2328
+ * @returns The profile, or `undefined` without the flag.
2329
+ * @throws {Error} When the value is another word.
2330
+ * @example
2331
+ * ```ts
2332
+ * debugLevelOf("full"); // "full"
2333
+ * ```
2334
+ */
2335
+ function debugLevelOf(value, command = "build") {
2336
+ if (value === void 0 || value === "read" || value === "full") return value;
2337
+ throw new Error(`[game] ${command}: --debug takes no value, "read" or "full", got "${value}".`);
2338
+ }
2339
+ /**
2340
+ * Reads `--editor`: the `host:port` the page agent of a debug page dials, 127.0.0.1:47600 when
2341
+ * left out.
2342
+ *
2343
+ * @param value - The value of the flag.
2344
+ * @param command - The command word, for the message.
2345
+ * @returns The address.
2346
+ * @throws {Error} When the value is not a host and a whole port from 1 to 65535.
2347
+ * @example
2348
+ * ```ts
2349
+ * editorOf("192.168.1.20:47600"); // "192.168.1.20:47600"
2350
+ * ```
2351
+ */
2352
+ function editorOf(value, command = "build") {
2353
+ if (value === void 0) return DEFAULT_EDITOR;
2354
+ const colon = value.lastIndexOf(":");
2355
+ const host = value.slice(0, Math.max(colon, 0));
2356
+ const port = value.slice(colon + 1);
2357
+ if (!(colon > 0 && EDITOR_HOST.test(host) && /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= MAX_PORT)) throw new Error(`[game] ${command}: --editor takes host:port, got "${value}".`);
2358
+ return value;
1602
2359
  }
1603
2360
  /**
1604
2361
  * Reads `--port`: an integer 0-65535, 3000 when left out.
@@ -1667,6 +2424,7 @@ async function runNativeVerb(parsed, deps) {
1667
2424
  if (word === void 0) throw new Error("[game] native: name a verb: build, dev, doctor, clean.");
1668
2425
  if (verb === void 0) throw new Error(`[game] native: no verb "${word}". Name one of build, dev, doctor, clean.`);
1669
2426
  refuseExtra(parsed, 2);
2427
+ const debug = nativeDebugOf(parsed, verb, target);
1670
2428
  const settings = await loadSettings(parsed.root);
1671
2429
  const where = {
1672
2430
  cwd: parsed.root,
@@ -1674,11 +2432,36 @@ async function runNativeVerb(parsed, deps) {
1674
2432
  };
1675
2433
  const options = {
1676
2434
  ...target === void 0 ? {} : { target },
1677
- ...verb === "build" && parsed.values.simulator === true ? { simulator: true } : {}
2435
+ ...verb === "build" && parsed.values.simulator === true ? { simulator: true } : {},
2436
+ ...debug
1678
2437
  };
2438
+ if (debug.debug !== void 0 && target === "android") deps.ui.warn("native: a DEBUG app with the editor agent, installed beside the store app. Never ship it to a store.");
1679
2439
  return await deps.native(verb, settings, where, options) ? 0 : 1;
1680
2440
  }
1681
2441
  /**
2442
+ * Reads `--debug` and `--editor` of `moku-game native`: they go with `native build android`
2443
+ * only in this release.
2444
+ *
2445
+ * @param parsed - The command line.
2446
+ * @param verb - The native verb.
2447
+ * @param target - The target the command line names, unchecked.
2448
+ * @returns The profile and the editor address as given, each only when given.
2449
+ * @throws {Error} When either goes with another verb, `--editor` comes without `--debug`, a value
2450
+ * is refused, or the target is not `android`.
2451
+ */
2452
+ function nativeDebugOf(parsed, verb, target) {
2453
+ const debug = debugLevelOf(parsed.values.debug, "native");
2454
+ const editor = parsed.values.editor;
2455
+ if (debug === void 0 && editor === void 0) return {};
2456
+ if (verb !== "build") throw new Error("[game] native: --debug goes with native build only.");
2457
+ if (debug === void 0) throw new Error("[game] native: --editor goes with --debug.");
2458
+ if (target !== void 0 && target !== "android") throw new Error(`[game] native: --debug builds Android only in this release (got ${target}).`);
2459
+ return editor === void 0 ? { debug } : {
2460
+ debug,
2461
+ editor: editorOf(editor, "native")
2462
+ };
2463
+ }
2464
+ /**
1682
2465
  * Runs `moku-game keys` or `pack` through the asset scanner, with the layers of `config.ts`.
1683
2466
  *
1684
2467
  * @param parsed - The command line.
@@ -1754,6 +2537,31 @@ function hop(argv, parsed, deps) {
1754
2537
  }).exited;
1755
2538
  }
1756
2539
  /**
2540
+ * Runs `moku-game build`: a release page, or with `--debug[=full]` a debug page whose agent dials
2541
+ * `--editor`.
2542
+ *
2543
+ * @param parsed - The command line.
2544
+ * @param deps - The process seams.
2545
+ * @returns The exit code of the build.
2546
+ * @throws {Error} When an argument, `--debug` or `--editor` is refused.
2547
+ */
2548
+ function runBuildCommand(parsed, deps) {
2549
+ refuseExtra(parsed, 0);
2550
+ const debug = debugLevelOf(parsed.values.debug);
2551
+ const editor = parsed.values.editor;
2552
+ if (debug === void 0 && editor !== void 0) throw new Error("[game] build: --editor goes with --debug.");
2553
+ const out = parsed.values.out ?? path.join(parsed.root, "dist", "web");
2554
+ return runBuild({
2555
+ root: parsed.root,
2556
+ out: path.resolve(deps.cwd, out),
2557
+ servePlugins: parsed.servePlugins,
2558
+ ...debug === void 0 ? {} : {
2559
+ debug,
2560
+ editor: editorOf(editor)
2561
+ }
2562
+ }, deps);
2563
+ }
2564
+ /**
1757
2565
  * Runs one checked command.
1758
2566
  *
1759
2567
  * @param argv - The arguments after the bin.
@@ -1768,15 +2576,7 @@ async function runParsed(argv, parsed, deps) {
1768
2576
  if (parsed.command !== "dev" && parsed.command !== "visual" && parsed.preload.length > 0 && deps.env.MOKU_GAME_CHILD !== "1") return hop(argv, parsed, deps);
1769
2577
  switch (parsed.command) {
1770
2578
  case "dev": return runServe(parsed, deps);
1771
- case "build": {
1772
- refuseExtra(parsed, 0);
1773
- const out = parsed.values.out ?? path.join(parsed.root, "dist", "web");
1774
- return runBuild({
1775
- root: parsed.root,
1776
- out: path.resolve(deps.cwd, out),
1777
- servePlugins: parsed.servePlugins
1778
- }, deps);
1779
- }
2579
+ case "build": return runBuildCommand(parsed, deps);
1780
2580
  case "native": return runNativeVerb(parsed, deps);
1781
2581
  case "keys":
1782
2582
  case "pack": return runScan(parsed, deps);
@@ -1825,7 +2625,8 @@ async function runCommand(argv, deps) {
1825
2625
  * keeps `generated/` fresh while a dev page is open. A file event is only a trigger. When the
1826
2626
  * events stop for a quiet period, a batch walks the game and reads the size and the time stamp of
1827
2627
  * 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
2628
+ * when they differ from the batch before. A folder that becomes or stops being a sub-feature scans
2629
+ * too: the keys and bundles of its files move between owners. After a scan it writes `.moku/assets-stamp.ts` from the
1829
2630
  * asset files: every file with its stamp, and the ones this batch found changed, so the dev page
1830
2631
  * reads only those again. One scan runs at a time, and a failed scan never stops the watch. A
1831
2632
  * batch that scanned is followed by one more walk: the platform reports only the first path of a
@@ -1884,6 +2685,22 @@ function readFolder(root, folder, walk) {
1884
2685
  }
1885
2686
  }
1886
2687
  /**
2688
+ * Adds the feature owners below the top level to the inputs: a folder that becomes or stops being
2689
+ * a sub-feature moves keys and bundles between owners without any asset or strings file changing.
2690
+ * The trailing `/` keeps the entry apart from every file. A name two folders share adds one entry
2691
+ * with the message instead of throwing: the scan reports the clash, and a rename moves the entry.
2692
+ *
2693
+ * @param root - The real game folder.
2694
+ * @param walk - The inputs so far.
2695
+ */
2696
+ function readOwners(root, walk) {
2697
+ try {
2698
+ for (const owner of findFeatureOwners(root)) if (owner.depth >= 1) walk.inputs.set(`${owner.dir}/`, `sub-feature of ${owner.parent}`);
2699
+ } catch (error) {
2700
+ walk.inputs.set("features/", `clash: ${error instanceof Error ? error.message : String(error)}`);
2701
+ }
2702
+ }
2703
+ /**
1887
2704
  * Walks a game folder for the inputs of the scan.
1888
2705
  *
1889
2706
  * @param root - The real game folder.
@@ -1895,6 +2712,7 @@ function walkInputs(root) {
1895
2712
  folders: []
1896
2713
  };
1897
2714
  readFolder(root, "", walk);
2715
+ readOwners(root, walk);
1898
2716
  return walk;
1899
2717
  }
1900
2718
  /**
@@ -2129,13 +2947,34 @@ function systemRowsOf(settings) {
2129
2947
  return names.map((name) => ({ name }));
2130
2948
  }
2131
2949
  /**
2950
+ * The web build command native runs: `moku-game build`, with the debug profile and the editor
2951
+ * address of a debug app.
2952
+ *
2953
+ * @param where - The game folder and the command that runs `moku-game`.
2954
+ * @param debug - The debug profile, or `undefined` for a store app.
2955
+ * @returns The command.
2956
+ * @example
2957
+ * ```ts
2958
+ * webBuildOf({ cwd: "/g", command: '"bun" "/g/bin/moku-game.mjs"' }, { level: "full", editor: "192.168.1.20:47600" }); // '"bun" "/g/bin/moku-game.mjs" build --debug=full --editor 192.168.1.20:47600'
2959
+ * ```
2960
+ */
2961
+ function webBuildOf(where, debug) {
2962
+ if (debug === void 0) return `${where.command} build`;
2963
+ const level = debug.level === "full" ? "--debug=full" : "--debug";
2964
+ const editor = debug.editor === void 0 ? "" : ` --editor ${debug.editor}`;
2965
+ return `${where.command} build ${level}${editor}`;
2966
+ }
2967
+ /**
2132
2968
  * Maps a game's resolved `config.ts` to the config of `@moku-labs/native`: the app from `native`
2133
2969
  * and `page`, the web build through `moku-game`, the system rows, the targets, and the Tauri
2134
- * project and output folders under the game.
2970
+ * project and output folders under the game. An Android app composes native's `shell` system
2971
+ * first, the one that sets `globalThis.__MOKU_NATIVE__`. A debug app builds its web page with the
2972
+ * same profile and `--editor`; its name and identifier stay the game's.
2135
2973
  *
2136
2974
  * @param settings - The resolved `config.ts` of the game.
2137
2975
  * @param where - The game folder and the command that runs `moku-game`.
2138
2976
  * @param target - The target of the verb, used when `config.ts` names no targets.
2977
+ * @param debug - The debug profile of a debug app; left out for a store app.
2139
2978
  * @returns The config for `createApp({ config })` of `@moku-labs/native`.
2140
2979
  * @throws {Error} When `config.ts` has no native section.
2141
2980
  * @example
@@ -2144,8 +2983,9 @@ function systemRowsOf(settings) {
2144
2983
  * nativeConfigOf(resolveConfig({ page: { title: "T" }, native: { name: "T", identifier: "com.x.t" } }), where, "ios").web.dist; // "/g/dist/web"
2145
2984
  * ```
2146
2985
  */
2147
- function nativeConfigOf(settings, where, target) {
2986
+ function nativeConfigOf(settings, where, target, debug) {
2148
2987
  const native = nativeSectionOf(settings);
2988
+ const rows = systemRowsOf(settings);
2149
2989
  const config = {
2150
2990
  app: {
2151
2991
  name: native.name,
@@ -2155,12 +2995,12 @@ function nativeConfigOf(settings, where, target) {
2155
2995
  },
2156
2996
  web: {
2157
2997
  cwd: where.cwd,
2158
- build: `${where.command} build`,
2998
+ build: webBuildOf(where, debug),
2159
2999
  devCommand: `${where.command} dev --port ${DEV_PORT}`,
2160
3000
  devUrl: `http://127.0.0.1:${DEV_PORT}`,
2161
3001
  dist: path.join(where.cwd, "dist/web")
2162
3002
  },
2163
- system: systemRowsOf(settings),
3003
+ system: target === "android" ? [{ name: "shell" }, ...rows.filter((row) => row.name !== "shell")] : rows,
2164
3004
  projectDir: path.join(where.cwd, ".moku/tauri"),
2165
3005
  outDir: path.join(where.cwd, "dist-native")
2166
3006
  };
@@ -2191,17 +3031,20 @@ function targetOf(known, verb, given) {
2191
3031
  * @param cli - The cli of the native app.
2192
3032
  * @param verb - The native verb.
2193
3033
  * @param target - The checked target.
2194
- * @param simulator - iOS: build the simulator slice.
3034
+ * @param options - The simulator flag, and the debug profile of a debug app.
2195
3035
  * @returns The boolean of `doctor`, true for the other verbs once they finish.
2196
3036
  */
2197
- async function runVerb(cli, verb, target, simulator) {
3037
+ async function runVerb(cli, verb, target, options) {
2198
3038
  switch (verb) {
2199
- case "build":
2200
- await cli.build({
3039
+ case "build": {
3040
+ const build = {
2201
3041
  target,
2202
- simulator
2203
- });
3042
+ simulator: options.simulator,
3043
+ ...options.debug === void 0 ? {} : { debug: true }
3044
+ };
3045
+ await cli.build(build);
2204
3046
  return true;
3047
+ }
2205
3048
  case "dev":
2206
3049
  await cli.dev({ target });
2207
3050
  return true;
@@ -2218,7 +3061,7 @@ async function runVerb(cli, verb, target, simulator) {
2218
3061
  * @param verb - The native verb: build, dev, doctor or clean.
2219
3062
  * @param settings - The resolved `config.ts` of the game.
2220
3063
  * @param where - The game folder and the command that runs `moku-game`.
2221
- * @param options - The target and the simulator flag.
3064
+ * @param options - The target, the simulator flag, and the debug profile of a debug app.
2222
3065
  * @returns True when the verb succeeded, false when it failed.
2223
3066
  * @throws {Error} When `config.ts` has no native section, `@moku-labs/native` is not installed, or
2224
3067
  * the target is unknown or missing.
@@ -2226,13 +3069,17 @@ async function runVerb(cli, verb, target, simulator) {
2226
3069
  async function runNative(verb, settings, where, options) {
2227
3070
  nativeSectionOf(settings);
2228
3071
  const native = await import("@moku-labs/native").catch((error) => {
2229
- throw new Error("[game] moku-game native needs @moku-labs/native.\n Install it: bun add -d @moku-labs/native@^0.3.2.", { cause: error });
3072
+ throw new Error("[game] moku-game native needs @moku-labs/native.\n Install it: bun add -d @moku-labs/native@^0.3.9.", { cause: error });
2230
3073
  });
2231
3074
  const target = targetOf(native.TARGETS, verb, options.target);
2232
- const app = native.createApp({ config: nativeConfigOf(settings, where, target) });
3075
+ const debug = options.debug === void 0 ? void 0 : {
3076
+ level: options.debug,
3077
+ editor: options.editor
3078
+ };
3079
+ const app = native.createApp({ config: nativeConfigOf(settings, where, target, debug) });
2233
3080
  await app.start();
2234
3081
  try {
2235
- return await runVerb(app.cli, verb, target, options.simulator);
3082
+ return await runVerb(app.cli, verb, target, options);
2236
3083
  } catch {
2237
3084
  return false;
2238
3085
  } finally {
@@ -2245,11 +3092,35 @@ async function runNative(verb, settings, where, options) {
2245
3092
  * @file The `@moku-labs/game/cli` door, node and bun only: the `moku-game` bin and the editor's
2246
3093
  * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, visual,
2247
3094
  * help) in this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
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
3095
+ * caller starts itself, as the editor does, `watchKeys` keeps the game's `generated/` fresh
3096
+ * while that page is open, and `watchEngine` tells when the installed engine changed under it. It imports no Pixi, and the root entry never imports it. The code
2250
3097
  * lives in `app/`; this file wires the real process into it.
2251
3098
  */
2252
3099
  /**
3100
+ * Watches a folder with `fs.watch`: a watched folder that disappears ends that watch, and the
3101
+ * process runs on.
3102
+ *
3103
+ * @param folder - The folder or file.
3104
+ * @param listener - Gets the event name.
3105
+ * @returns The watcher.
3106
+ */
3107
+ function watchFolder(folder, listener) {
3108
+ const watcher = watch(folder, (event) => listener(event));
3109
+ watcher.on("error", () => watcher.close());
3110
+ return watcher;
3111
+ }
3112
+ /**
3113
+ * Bun's resolver: the file a specifier gives from a folder.
3114
+ *
3115
+ * @param specifier - The specifier.
3116
+ * @param from - The folder.
3117
+ * @returns The absolute path.
3118
+ * @throws {Error} When it does not resolve.
3119
+ */
3120
+ function bunResolve(specifier, from) {
3121
+ return Bun.resolveSync(specifier, from);
3122
+ }
3123
+ /**
2253
3124
  * Runs the asset scanner's command line with the string tools of i18n: the scan behind
2254
3125
  * `moku-game keys`, `pack`, and the keys watch.
2255
3126
  *
@@ -2303,17 +3174,14 @@ function processDeps() {
2303
3174
  process.off(signal, handler);
2304
3175
  };
2305
3176
  },
2306
- watch: (folder, listener) => {
2307
- const watcher = watch(folder, (event) => listener(event));
2308
- watcher.on("error", () => watcher.close());
2309
- return watcher;
2310
- },
3177
+ watch: watchFolder,
2311
3178
  watchKeys: (root, settings) => watchKeysAt(root, settings, keysSeams(ui)),
2312
3179
  assets: (argv) => runScanner(argv, ui),
2313
3180
  native: runNative,
2314
- resolve: (specifier, from) => Bun.resolveSync(specifier, from),
3181
+ resolve: bunResolve,
2315
3182
  loadPage: (file) => import(file),
2316
- visual: () => import("./visual.mjs")
3183
+ visual: () => import("./visual.mjs"),
3184
+ now: () => Date.now()
2317
3185
  };
2318
3186
  }
2319
3187
  /**
@@ -2328,6 +3196,7 @@ function processDeps() {
2328
3196
  * // bin/moku-game.mjs in a game folder:
2329
3197
  * await runCli(["dev", "--port", "0"]); // 0 after Ctrl+C; stdout had "http://localhost:<port>/"
2330
3198
  * await runCli(["visual", "--no-pixels"]); // 0 when every checkpoint of tests/visual/index.ts is the same
3199
+ * await runCli(["build", "--debug"]); // 0: dist/web carries the editor agent; one warning names build debug-20261014T153012Z
2331
3200
  * await runCli(["help"]); // 0
2332
3201
  * ```
2333
3202
  */
@@ -2336,8 +3205,9 @@ async function runCli(argv) {
2336
3205
  }
2337
3206
  /**
2338
3207
  * Writes a game's dev page into `<root>/.moku/` for a server the caller starts: `index.html`,
2339
- * `dev.ts`, `main.ts` with the scenarios of `tests/scenarios/` (and, with agents, the agents and
2340
- * the game's `.dev` modules), and `bunfig.toml` with the engine's hot plugin. It also writes the
3208
+ * `script.ts`, `dev.ts` with the flags and the engine id, `main.ts` with the scenarios of
3209
+ * `tests/scenarios/` (and, with agents, the agents and the game's `.dev` modules), and
3210
+ * `bunfig.toml` with the engine's hot plugin. It also writes the
2341
3211
  * first `assets-stamp.ts`, the empty one, when the game has none: `main.ts` imports it, and
2342
3212
  * `watchKeys` rewrites it when an asset file changes. The page accepts the new stamp: new bytes
2343
3213
  * of a file swap in place, a new or removed file reloads the page. It starts no server and no
@@ -2345,15 +3215,16 @@ async function runCli(argv) {
2345
3215
  *
2346
3216
  * @param root - The game folder, relative to the cwd or absolute.
2347
3217
  * @param options - The agents, the preloads and the extra bundler plugins.
2348
- * @returns The absolute paths of the HTML and the bunfig.
3218
+ * @returns The absolute paths of the HTML and the bunfig, and the engine the page was written for.
2349
3219
  * @throws {Error} When the game has no `config.ts` or `index.ts`, a config value is refused, the
2350
3220
  * game does not resolve `@moku-labs/game/hot`, or a preload or plugin is not a file.
2351
3221
  * @example
2352
3222
  * ```ts
2353
3223
  * // The editor bin, started with --root games/timber:
2354
3224
  * const { preparePage } = await import(Bun.resolveSync("@moku-labs/game/cli", root));
2355
- * await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] });
2356
- * // { html: "<cwd>/games/timber/.moku/index.html", bunfig: "<cwd>/games/timber/.moku/bunfig.toml" }
3225
+ * const page = await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] });
3226
+ * // { html: "<cwd>/games/timber/.moku/index.html", bunfig: "<cwd>/games/timber/.moku/bunfig.toml", engine: { … } }
3227
+ * // page.engine.id: "0.14.12+9c41e0"
2357
3228
  * ```
2358
3229
  */
2359
3230
  async function preparePage(root, options = {}) {
@@ -2362,7 +3233,7 @@ async function preparePage(root, options = {}) {
2362
3233
  ...options.agents === void 0 ? {} : { agents: options.agents },
2363
3234
  preload: absolute(options.preload),
2364
3235
  servePlugins: absolute(options.servePlugins)
2365
- }, (specifier, from) => Bun.resolveSync(specifier, from));
3236
+ }, bunResolve);
2366
3237
  }
2367
3238
  /**
2368
3239
  * Keeps `generated/` of a game fresh while its dev page is open, for a server the caller runs:
@@ -2395,5 +3266,42 @@ async function watchKeys(root, options = {}) {
2395
3266
  const game = path.resolve(root);
2396
3267
  return watchKeysAt(game, await loadGame(game), keysSeams(createBrandConsole()), options);
2397
3268
  }
3269
+ /**
3270
+ * Watches the engine a game resolves, for a page server the caller runs: the editor calls it next
3271
+ * to `preparePage`, and raw `moku-game dev` runs the same watch itself. It takes the fingerprint
3272
+ * of the installed engine now (the id `preparePage(...).engine` has), watches the engine's
3273
+ * folders, its patch and the install root's `package.json` and lock, and after 300 ms of quiet
3274
+ * fingerprints again: a new version or dist calls `onChange` with `restart: true` (the page server
3275
+ * must restart, Bun caches nothing across a restart), a changed patch alone with `restart: false`
3276
+ * (the install did not happen yet). An engine run from its sources is not watched. Sync: it
3277
+ * scans nothing.
3278
+ *
3279
+ * @param root - The game folder, relative to the cwd or absolute.
3280
+ * @param options - Where the changes and the errors go.
3281
+ * @returns The running watch; `close()` stops it.
3282
+ * @throws {Error} When the game does not resolve `@moku-labs/game/hot`.
3283
+ * @example
3284
+ * ```ts
3285
+ * // The editor bin, next to preparePage, started with --root games/timber:
3286
+ * const { preparePage, watchEngine } = await import(Bun.resolveSync("@moku-labs/game/cli", root));
3287
+ * const page = await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] });
3288
+ * page.engine.id; // "0.14.12+9c41e0"
3289
+ * const engine = watchEngine("games/timber", { onChange: change => change.restart && restartPageServer() });
3290
+ * // after `bun patch --commit` in the game: onChange({ before: { id: "0.14.12+9c41e0", … }, after: { id: "0.14.12+1a2b3c.patch.3f2a7b", … }, restart: true })
3291
+ * engine.close(); // when the editor closes the game
3292
+ * ```
3293
+ */
3294
+ function watchEngine(root, options = {}) {
3295
+ const game = path.resolve(root);
3296
+ const start = engineFingerprint(game, hotPluginOf(game, bunResolve));
3297
+ const ui = createBrandConsole();
3298
+ const warn = (change) => {
3299
+ for (const line of engineChangeLines(change)) ui.warn(line);
3300
+ };
3301
+ return watchEngineAt(game, start, {
3302
+ watch: watchFolder,
3303
+ resolve: bunResolve
3304
+ }, options.onChange ?? warn, options.onError ?? ((message) => ui.warn(message)));
3305
+ }
2398
3306
  //#endregion
2399
- export { preparePage, runCli, watchKeys };
3307
+ export { preparePage, runCli, watchEngine, watchKeys };