@moku-labs/game 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +88 -26
  2. package/bin/moku-game.mjs +4 -0
  3. package/dist/app/page.d.mts +41 -0
  4. package/dist/app/page.mjs +376 -0
  5. package/dist/app/system.d.mts +129 -0
  6. package/dist/app/system.mjs +307 -0
  7. package/dist/app.d.mts +37 -0
  8. package/dist/app.mjs +235 -0
  9. package/dist/assets-BHwTLVta.mjs +7391 -0
  10. package/dist/assets.d.mts +97 -33
  11. package/dist/assets.mjs +1 -7150
  12. package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
  13. package/dist/cli.d.mts +70 -0
  14. package/dist/cli.mjs +1650 -0
  15. package/dist/{commands-C1kIfOG5.d.mts → commands-C4QlqijJ.d.mts} +5 -6
  16. package/dist/config-CdARrrhU.mjs +81 -0
  17. package/dist/control.d.mts +11 -10
  18. package/dist/control.mjs +3 -3
  19. package/dist/{define-BPpZcpxW.d.mts → define-CCc0ozKe.d.mts} +2 -2
  20. package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
  21. package/dist/{memory-CvgdnsQO.mjs → drafts-BawpSaVp.mjs} +1 -104
  22. package/dist/fake-MpzwZhIZ.mjs +87 -0
  23. package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
  24. package/dist/{headless-q98SwADR.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
  25. package/dist/hot.d.mts +8 -8
  26. package/dist/hot.mjs +15 -14
  27. package/dist/index-xxSxKJJW.d.mts +1611 -0
  28. package/dist/index.d.mts +6 -1597
  29. package/dist/index.mjs +4 -34343
  30. package/dist/inspect-la_KkjCS.mjs +1 -0
  31. package/dist/inspect.d.mts +7 -8
  32. package/dist/inspect.mjs +3 -2
  33. package/dist/jsx-dev-runtime.d.mts +1 -1
  34. package/dist/jsx-runtime.d.mts +1 -1
  35. package/dist/lint.d.mts +55 -4
  36. package/dist/lint.mjs +999 -42
  37. package/dist/memory-CWlqhS7l.mjs +136 -0
  38. package/dist/memory-DwDhfFWK.d.mts +50 -0
  39. package/dist/project.d.mts +29 -13
  40. package/dist/project.mjs +1120 -631
  41. package/dist/{run-CUPSFM85.mjs → run-wYty_Lwo.mjs} +4 -4
  42. package/dist/{runtime-CvDZZg1S.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
  43. package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
  44. package/dist/{sources-BBUd15vQ.mjs → sources-Duzj6lhV.mjs} +3 -3
  45. package/dist/src-DSl1eNB1.mjs +34366 -0
  46. package/dist/testing.d.mts +115 -34
  47. package/dist/testing.mjs +175 -70
  48. package/dist/{types-DlgsfOmW.d.mts → types-BM4shh8K.d.mts} +300 -263
  49. package/dist/{types-DgdMLZnV.d.mts → types-BUwDHKXm.d.mts} +1372 -1373
  50. package/dist/{types-DF5kkW-C.d.mts → types-CefgYzFt.d.mts} +4 -5
  51. package/dist/{types-CDSKa0vl.d.mts → types-DAdwEJMp.d.mts} +2 -2
  52. package/dist/{types-BWzCSRM2.d.mts → types-ZQLYXo5X.d.mts} +2983 -20
  53. package/dist/types-hQgI_wrq.d.mts +544 -0
  54. package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
  55. package/dist/visual.d.mts +10 -11
  56. package/dist/visual.mjs +5 -5
  57. package/llms.txt +170 -57
  58. package/package.json +30 -2
  59. package/dist/registry-DlpRCibU.mjs +0 -384
  60. package/dist/types-ERb2Y9U5.d.mts +0 -2967
package/dist/cli.mjs ADDED
@@ -0,0 +1,1650 @@
1
+ import { t as resolveConfig } from "./config-CdARrrhU.mjs";
2
+ import { a as runCli$1, i as compileStrings, n as importStrings, t as exportStrings } from "./assets-BHwTLVta.mjs";
3
+ import { constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { createBrandConsole } from "@moku-labs/common/cli";
6
+ import { parseArgs } from "node:util";
7
+ import { pathToFileURL } from "node:url";
8
+ //#region src/app/generate.ts
9
+ /** The first line of a generated file a person may open. */
10
+ const WRITTEN = "// Written by moku-game dev. Do not edit.";
11
+ /** A colour or a language tag: it lands in a `<style>` and in attributes, so nothing else passes. */
12
+ const SAFE_VALUE = /^[#\w(),.%\s-]+$/;
13
+ /** The files of `tests/scenarios/` that are not scenarios. */
14
+ const NOT_SCENARIO = /\.(?:test|spec|d)\.ts$|^index\.ts$/;
15
+ /**
16
+ * Escapes the five characters HTML reads as markup.
17
+ *
18
+ * @param text - The text.
19
+ * @returns The text, safe in an element and in a quoted attribute.
20
+ * @example
21
+ * ```ts
22
+ * escapeHtml(`Tom & "Jerry"`); // "Tom &amp; &quot;Jerry&quot;"
23
+ * ```
24
+ */
25
+ function escapeHtml(text) {
26
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
27
+ }
28
+ /**
29
+ * Checks a value of the page that lands in the style and in attributes.
30
+ *
31
+ * @param value - The background or the lang.
32
+ * @param problem - The error when the value has another character.
33
+ * @returns The value.
34
+ * @throws {Error} The problem, when the value is not a plain colour or tag.
35
+ * @example
36
+ * ```ts
37
+ * safeValue("#10161d", "not a colour"); // "#10161d"
38
+ * ```
39
+ */
40
+ function safeValue(value, problem) {
41
+ if (!SAFE_VALUE.test(value)) throw new Error(problem);
42
+ return value;
43
+ }
44
+ /**
45
+ * The link of one icon: a path inside the game folder, never above it.
46
+ *
47
+ * @param name - The icon field.
48
+ * @param file - The path the config gives, relative to the game folder.
49
+ * @returns The path with `/` separators.
50
+ * @throws {Error} When the path is absolute or climbs out of the game folder.
51
+ * @example
52
+ * ```ts
53
+ * iconPath("favicon", "assets/icon.png"); // "assets/icon.png"
54
+ * ```
55
+ */
56
+ function iconPath(name, file) {
57
+ const slashed = file.replaceAll("\\", "/");
58
+ if (slashed.startsWith("/") || /^[a-z]:/i.test(slashed) || slashed.split("/").includes("..") || slashed === "") throw new Error(`[game] moku-game: page.icons.${name} "${file}" is not a file in the game.`);
59
+ return slashed;
60
+ }
61
+ /**
62
+ * The `<link>` tags of the icons the page sets, in a fixed order.
63
+ *
64
+ * @param icons - The icons of the page.
65
+ * @param toRoot - The path from the page to the game folder.
66
+ * @returns One line per icon set.
67
+ * @example
68
+ * ```ts
69
+ * iconLinks({ favicon: "icon.png" }, "../"); // [' <link rel="icon" href="../icon.png" />']
70
+ * ```
71
+ */
72
+ function iconLinks(icons, toRoot) {
73
+ return [["favicon", "icon"], ["appleTouch", "apple-touch-icon"]].flatMap(([name, relation]) => {
74
+ const file = icons[name];
75
+ if (file === void 0) return [];
76
+ return [` <link rel="${relation}" href="${escapeHtml(toRoot + iconPath(name, file))}" />`];
77
+ });
78
+ }
79
+ /**
80
+ * The page a game runs in: the title, the language, the background (also the theme colour), the
81
+ * icons as links (never `url()` in the style: `Bun.build` does not rewrite it), the `head` tags
82
+ * verbatim after the style, `<div id="game">` and the module script.
83
+ *
84
+ * @param page - The page of the resolved `config.ts`.
85
+ * @param links - The path to the game folder and the script.
86
+ * @returns The HTML.
87
+ * @throws {Error} When the background is not a CSS colour, the lang not a language tag, or an
88
+ * icon path leaves the game folder.
89
+ * @example
90
+ * ```ts
91
+ * pageHtml(resolveConfig({ page: { title: "mini-game" } }).page, { toRoot: "../", script: "./main.ts" }).includes("<title>mini-game</title>"); // true
92
+ * ```
93
+ */
94
+ function pageHtml(page, links) {
95
+ const background = safeValue(page.background, `[game] moku-game: page.background "${page.background}" is not a CSS color.`);
96
+ const lang = safeValue(page.lang, `[game] moku-game: page.lang "${page.lang}" is not a language tag.`);
97
+ const style = `html, body { margin: 0; height: 100%; background: ${background}; } #game { width: 100%; height: 100%; touch-action: none; }`;
98
+ return [
99
+ "<!doctype html>",
100
+ `<html lang="${lang}">`,
101
+ " <head>",
102
+ " <meta charset=\"utf-8\" />",
103
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />",
104
+ ` <meta name="theme-color" content="${background}" />`,
105
+ ` <title>${escapeHtml(page.title)}</title>`,
106
+ ...iconLinks(page.icons, links.toRoot),
107
+ ` <style>${style}</style>`,
108
+ ...page.head.map((tag) => ` ${tag}`),
109
+ " </head>",
110
+ " <body>",
111
+ " <div id=\"game\"></div>",
112
+ ` <script type="module" src="${escapeHtml(links.script)}"><\/script>`,
113
+ " </body>",
114
+ "</html>",
115
+ ""
116
+ ].join("\n");
117
+ }
118
+ /**
119
+ * The scenario files of `tests/scenarios/`, sorted: every `.ts` file but tests, declarations and
120
+ * `index.ts`.
121
+ *
122
+ * @param files - The file names of the folder.
123
+ * @returns The scenario file names, sorted.
124
+ * @example
125
+ * ```ts
126
+ * scenarioFiles(["ready.ts", "ready.test.ts", "index.ts", "empty.ts"]); // ["empty.ts", "ready.ts"]
127
+ * ```
128
+ */
129
+ function scenarioFiles(files) {
130
+ return files.filter((file) => file.endsWith(".ts") && !NOT_SCENARIO.test(file)).toSorted();
131
+ }
132
+ /**
133
+ * Whether the page needs the system shell: a system plugin is named, or the save is the store.
134
+ *
135
+ * @param settings - The resolved `config.ts`.
136
+ * @returns True when `main.ts` imports `systemShell`.
137
+ * @example
138
+ * ```ts
139
+ * needsShell(resolveConfig({ page: { title: "T" }, save: "store" })); // true
140
+ * ```
141
+ */
142
+ function needsShell(settings) {
143
+ return settings.system.length > 0 || settings.save === "store";
144
+ }
145
+ /**
146
+ * The imports of `main.ts` every page has: the page entry, the shell when needed, the game and its
147
+ * config.
148
+ *
149
+ * @param settings - The resolved `config.ts`.
150
+ * @param toRoot - The path from `main.ts` to the game folder.
151
+ * @returns The import lines.
152
+ * @example
153
+ * ```ts
154
+ * pageImports(resolveConfig({ page: { title: "T" } }), "../")[1]; // 'import game from "../index.ts";'
155
+ * ```
156
+ */
157
+ function pageImports(settings, toRoot) {
158
+ return [
159
+ "import { startPage } from \"@moku-labs/game/app/page\";",
160
+ ...needsShell(settings) ? ["import { systemShell } from \"@moku-labs/game/app/system\";"] : [],
161
+ `import game from "${toRoot}index.ts";`,
162
+ `import config from "${toRoot}config.ts";`
163
+ ];
164
+ }
165
+ /**
166
+ * The names of numbered bindings, as a list.
167
+ *
168
+ * @param prefix - The name before the number.
169
+ * @param count - How many.
170
+ * @returns The names, comma separated.
171
+ * @example
172
+ * ```ts
173
+ * numbered("agent", 2); // "agent0, agent1"
174
+ * ```
175
+ */
176
+ function numbered(prefix, count) {
177
+ return Array.from({ length: count }, (_, index) => prefix + String(index)).join(", ");
178
+ }
179
+ /**
180
+ * One import line of a generated file. The specifier is written by `JSON.stringify`: a file name
181
+ * may hold a quote or a backslash.
182
+ *
183
+ * @param binding - What the line imports, such as `scenario0` or `* as devModule0`.
184
+ * @param specifier - The module.
185
+ * @returns The line.
186
+ * @example
187
+ * ```ts
188
+ * importLine("* as devModule0", "../features/board/board.dev.ts"); // 'import * as devModule0 from "../features/board/board.dev.ts";'
189
+ * ```
190
+ */
191
+ function importLine(binding, specifier) {
192
+ return `import ${binding} from ${JSON.stringify(specifier)};`;
193
+ }
194
+ /**
195
+ * The `main.ts` of the dev page: the dev flag first, then the page entry, the game, its config,
196
+ * one import per scenario, and, with agents, one per `.dev` module and one per agent. It calls
197
+ * `startPage` with the scenarios by file stem, the shell when the game needs it, the agents and
198
+ * the `.dev` modules.
199
+ *
200
+ * @param settings - The resolved `config.ts`.
201
+ * @param sources - The scenario files, the `.dev` modules and the agents.
202
+ * @returns The text of `.moku/main.ts`.
203
+ * @example
204
+ * ```ts
205
+ * devMain(resolveConfig({ page: { title: "T" } }), { scenarios: ["ready.ts"], devModules: [] }).includes('scenarios: { "ready": scenario0 }'); // true
206
+ * ```
207
+ */
208
+ function devMain(settings, sources) {
209
+ const scenarios = scenarioFiles(sources.scenarios);
210
+ const agents = sources.agents ?? [];
211
+ const devModules = agents.length === 0 ? [] : sources.devModules.toSorted();
212
+ const keys = scenarios.map((file, index) => `${JSON.stringify(file.slice(0, -3))}: scenario${index}`);
213
+ const options = [
214
+ ` scenarios: ${keys.length === 0 ? "{}" : `{ ${keys.join(", ")} }`}`,
215
+ ...needsShell(settings) ? [" system: systemShell"] : [],
216
+ ...agents.length === 0 ? [] : [` agents: [${numbered("agent", agents.length)}]`],
217
+ ...devModules.length === 0 ? [] : [` devModules: [${numbered("devModule", devModules.length)}]`]
218
+ ];
219
+ return [
220
+ WRITTEN,
221
+ "import \"./dev.ts\";",
222
+ ...pageImports(settings, "../"),
223
+ ...scenarios.map((file, index) => importLine(`scenario${index}`, `../tests/scenarios/${file}`)),
224
+ ...devModules.map((file, index) => importLine(`* as devModule${index}`, `../${file}`)),
225
+ ...agents.map((agent, index) => importLine(`agent${index}`, agent)),
226
+ "",
227
+ "await startPage(game, config, {",
228
+ options.join(",\n"),
229
+ "});",
230
+ ""
231
+ ].join("\n");
232
+ }
233
+ /**
234
+ * The `main.ts` of the production page: the page entry, the game and its config, and the shell
235
+ * when the game needs it. No dev flag, no scenario, no agent, no `.dev` module, no door: none of
236
+ * them ships.
237
+ *
238
+ * @param settings - The resolved `config.ts`.
239
+ * @returns The text of the in-memory `.moku/build/main.ts`.
240
+ * @example
241
+ * ```ts
242
+ * buildMain(resolveConfig({ page: { title: "T" } })).endsWith("await startPage(game, config);\n"); // true
243
+ * ```
244
+ */
245
+ function buildMain(settings) {
246
+ const call = needsShell(settings) ? "await startPage(game, config, { system: systemShell });" : "await startPage(game, config);";
247
+ return [
248
+ ...pageImports(settings, "../../"),
249
+ "",
250
+ call,
251
+ ""
252
+ ].join("\n");
253
+ }
254
+ /**
255
+ * The `dev.ts` module the dev `main.ts` imports first: it sets the dev flag on `globalThis` for
256
+ * code that reads the global. The bunfig `define` already makes the bare flag true in the bundle.
257
+ *
258
+ * @returns The text of `.moku/dev.ts`.
259
+ * @example
260
+ * ```ts
261
+ * devFlag().endsWith("globalThis.__MOKU_GAME_DEV__ = true;\n"); // true
262
+ * ```
263
+ */
264
+ function devFlag() {
265
+ return `${WRITTEN}\nglobalThis.__MOKU_GAME_DEV__ = true;\n`;
266
+ }
267
+ /**
268
+ * A TOML array of strings. `JSON.stringify` of a path is a valid TOML basic string, also for a
269
+ * Windows path.
270
+ *
271
+ * @param paths - The paths.
272
+ * @returns The array text.
273
+ * @example
274
+ * ```ts
275
+ * tomlList(["/a.ts", "C:\\b.ts"]); // '["/a.ts", "C:\\\\b.ts"]'
276
+ * ```
277
+ */
278
+ function tomlList(paths) {
279
+ return `[${paths.map((file) => JSON.stringify(file)).join(", ")}]`;
280
+ }
281
+ /**
282
+ * The bunfig of the dev server: the preloads when given, the hot plugin first among the bundler
283
+ * plugins of the page, and the dev flag defined true.
284
+ *
285
+ * @param paths - The absolute paths of the hot plugin, the extra plugins and the preloads.
286
+ * @returns The text of `.moku/bunfig.toml`.
287
+ * @example
288
+ * ```ts
289
+ * bunfigText({ hotPlugin: "/g/node_modules/@moku-labs/game/dist/hot.mjs", servePlugins: [], preload: [] }).includes('plugins = ["/g/node_modules/@moku-labs/game/dist/hot.mjs"]'); // true
290
+ * ```
291
+ */
292
+ function bunfigText(paths) {
293
+ return [
294
+ `# ${WRITTEN.slice(3)}`,
295
+ ...paths.preload.length === 0 ? [] : [`preload = ${tomlList(paths.preload)}`],
296
+ "",
297
+ "[serve.static]",
298
+ `plugins = ${tomlList([paths.hotPlugin, ...paths.servePlugins])}`,
299
+ "define = { \"__MOKU_GAME_DEV__\" = \"true\" }",
300
+ ""
301
+ ].join("\n");
302
+ }
303
+ //#endregion
304
+ //#region src/app/serve.ts
305
+ /**
306
+ * @file `moku-game dev`: the game folder read and checked, the dev page written into
307
+ * `<game>/.moku/`, the parent that re-runs the bin under the generated bunfig, and the child that
308
+ * serves the page with `Bun.serve`. The hot plugin comes from the engine the game resolves, so
309
+ * the game writes no bunfig, no HTML and no dev file. Node and Bun only: the bin bundles it.
310
+ */
311
+ /** The signals the dev parent hands to its child. */
312
+ const FORWARDED = [
313
+ "SIGINT",
314
+ "SIGTERM",
315
+ "SIGHUP"
316
+ ];
317
+ /** The signals that stop the dev child. */
318
+ const STOPPING = ["SIGINT", "SIGTERM"];
319
+ /** How often the dev child checks that its parent still runs. */
320
+ const PARENT_CHECK_MS = 1e3;
321
+ /** The folders a `.dev.ts` module is never looked for in. */
322
+ const NO_DEV_MODULES = /^(?:node_modules|dist|generated|tests|__tests__)$/;
323
+ /** A `.gitignore` line that keeps `.moku/` of its own folder out. */
324
+ const OWN_MOKU_LINE = /^\/?\.moku\/?$/;
325
+ /** A `.gitignore` line that keeps every `.moku/` below its folder out. */
326
+ const ANY_MOKU_LINE = /^(?:\*\*\/)?\.moku\/?$/;
327
+ /**
328
+ * Tells whether a path is a file.
329
+ *
330
+ * @param file - The path.
331
+ * @returns True for an existing file.
332
+ */
333
+ function isFile(file) {
334
+ return statSync(file, { throwIfNoEntry: false })?.isFile() === true;
335
+ }
336
+ /**
337
+ * Tells whether a path is a folder.
338
+ *
339
+ * @param folder - The path.
340
+ * @returns True for an existing folder.
341
+ */
342
+ function isFolder(folder) {
343
+ return statSync(folder, { throwIfNoEntry: false })?.isDirectory() === true;
344
+ }
345
+ /**
346
+ * Checks that a game folder has its `config.ts`.
347
+ *
348
+ * @param root - The game folder, absolute.
349
+ * @returns The path of `config.ts`.
350
+ * @throws {Error} When the game has no `config.ts`.
351
+ */
352
+ function configFileOf(root) {
353
+ const file = path.join(root, "config.ts");
354
+ if (!isFile(file)) throw new Error(`[game] moku-game: no config.ts in "${root}".\n A game keeps its page, native, system, save and assets data there.`);
355
+ return file;
356
+ }
357
+ /**
358
+ * Reads a game's `config.ts` and fills its defaults.
359
+ *
360
+ * @param root - The game folder, absolute.
361
+ * @returns The resolved config.
362
+ * @throws {Error} When the game has no `config.ts`, it has no default export, or a value is one
363
+ * TypeScript would refuse.
364
+ */
365
+ async function loadSettings(root) {
366
+ const file = configFileOf(root);
367
+ const loaded = await import(pathToFileURL(file).href);
368
+ if (loaded.default === void 0) throw new Error(`[game] moku-game: config.ts in "${root}" has no default export.\n End it with: export default { page: { title: "…" } } satisfies GameConfig.`);
369
+ return resolveConfig(loaded.default);
370
+ }
371
+ /**
372
+ * Checks that every icon the page names is a file of the game.
373
+ *
374
+ * @param root - The game folder.
375
+ * @param page - The resolved page.
376
+ * @throws {Error} When an icon is not a file in the game.
377
+ */
378
+ function checkIcons(root, page) {
379
+ for (const [name, file] of Object.entries(page.icons)) if (file !== void 0 && !isFile(path.join(root, file))) throw new Error(`[game] moku-game: page.icons.${name} "${file}" is not a file in the game.`);
380
+ }
381
+ /**
382
+ * Reads and checks a game folder for a page: `config.ts` and `index.ts` exist, the config
383
+ * resolves and its icons are files of the game.
384
+ *
385
+ * @param root - The game folder, absolute.
386
+ * @returns The resolved config.
387
+ * @throws {Error} When `config.ts` or `index.ts` is missing, a config value is refused, or an icon
388
+ * is not a file in the game.
389
+ */
390
+ async function loadGame(root) {
391
+ configFileOf(root);
392
+ if (!isFile(path.join(root, "index.ts"))) throw new Error(`[game] moku-game: no index.ts in "${root}".\n It default-exports defineGameApp({ ... }).`);
393
+ const settings = await loadSettings(root);
394
+ checkIcons(root, settings.page);
395
+ return settings;
396
+ }
397
+ /**
398
+ * Checks that every file a flag names exists.
399
+ *
400
+ * @param flag - The flag, for the message.
401
+ * @param files - The absolute paths.
402
+ * @throws {Error} When a path is not a file.
403
+ */
404
+ function checkFiles(flag, files) {
405
+ const missing = files.find((file) => !isFile(file));
406
+ if (missing !== void 0) throw new Error(`[game] moku-game: ${flag} "${missing}" is not a file.`);
407
+ }
408
+ /**
409
+ * Finds the hot plugin of the engine the game resolves: the installed `dist/hot.mjs`, or
410
+ * `src/hot.ts` through the tsconfig `paths` of the engine repo.
411
+ *
412
+ * @param root - The game folder.
413
+ * @param resolve - Bun's resolver.
414
+ * @returns The absolute path of the plugin.
415
+ * @throws {Error} When the game cannot resolve `@moku-labs/game/hot`.
416
+ */
417
+ function hotPluginOf(root, resolve) {
418
+ try {
419
+ return resolve("@moku-labs/game/hot", root);
420
+ } catch (error) {
421
+ throw new Error(`[game] dev: "@moku-labs/game/hot" does not resolve from "${root}".\n Install @moku-labs/game in the game: bun add @moku-labs/game.`, { cause: error });
422
+ }
423
+ }
424
+ /**
425
+ * Lists the files of the game's `tests/scenarios/` folder.
426
+ *
427
+ * @param root - The game folder.
428
+ * @returns The file names, or none without the folder.
429
+ */
430
+ function scenarioNamesOf(root) {
431
+ const folder = path.join(root, "tests", "scenarios");
432
+ if (!isFolder(folder)) return [];
433
+ return readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name);
434
+ }
435
+ /**
436
+ * Tells whether the search for `.dev.ts` modules skips a folder: a dot folder, or a folder of
437
+ * tools and tests.
438
+ *
439
+ * @param name - The folder name.
440
+ * @returns True for a skipped folder.
441
+ * @example
442
+ * ```ts
443
+ * isSkippedFolder("node_modules"); // true
444
+ * ```
445
+ */
446
+ function isSkippedFolder(name) {
447
+ return name.startsWith(".") || NO_DEV_MODULES.test(name);
448
+ }
449
+ /**
450
+ * Lists the `.dev.ts` modules under a folder of the game, outside the dot folders and the folders
451
+ * of tools and tests.
452
+ *
453
+ * @param root - The game folder.
454
+ * @param relative - The folder under the game, `/` separated, `""` for the game itself.
455
+ * @returns The modules, relative to the game, `/` separated.
456
+ */
457
+ function devModulesOf(root, relative = "") {
458
+ return readdirSync(path.join(root, relative), { withFileTypes: true }).flatMap((entry) => {
459
+ const inGame = relative === "" ? entry.name : `${relative}/${entry.name}`;
460
+ if (entry.isDirectory() && isSkippedFolder(entry.name)) return [];
461
+ if (entry.isDirectory()) return devModulesOf(root, inGame);
462
+ return entry.isFile() && entry.name.endsWith(".dev.ts") ? [inGame] : [];
463
+ });
464
+ }
465
+ /**
466
+ * Writes a file only when its text changed, so a second run wakes no watcher.
467
+ *
468
+ * @param file - The absolute path.
469
+ * @param text - The text.
470
+ */
471
+ function writeIfChanged(file, text) {
472
+ if (existsSync(file) && readFileSync(file, "utf8") === text) return;
473
+ mkdirSync(path.dirname(file), { recursive: true });
474
+ writeFileSync(file, text);
475
+ }
476
+ /**
477
+ * The text of the dev `main.ts` for the game as it is on disk now.
478
+ *
479
+ * @param root - The game folder.
480
+ * @param settings - The resolved config.
481
+ * @param agents - The agent modules, when the page has any.
482
+ * @returns The text.
483
+ */
484
+ function mainOf(root, settings, agents) {
485
+ const scenarios = scenarioNamesOf(root);
486
+ return agents === void 0 || agents.length === 0 ? devMain(settings, {
487
+ scenarios,
488
+ devModules: []
489
+ }) : devMain(settings, {
490
+ scenarios,
491
+ devModules: devModulesOf(root),
492
+ agents
493
+ });
494
+ }
495
+ /**
496
+ * Writes the dev page of a checked game into `<game>/.moku/`: `index.html`, `dev.ts`, `main.ts`
497
+ * and `bunfig.toml`. Every text is made before the first write, so a refused value writes
498
+ * nothing.
499
+ *
500
+ * @param root - The game folder, absolute.
501
+ * @param settings - The resolved config.
502
+ * @param options - The agents, the preloads and the extra plugins, absolute.
503
+ * @param resolve - Bun's resolver, to find the hot plugin.
504
+ * @returns The paths of the HTML and the bunfig.
505
+ * @throws {Error} When the hot plugin does not resolve, a preload or plugin is not a file, or a
506
+ * page value is refused.
507
+ */
508
+ function writePage(root, settings, options, resolve) {
509
+ const preload = options.preload ?? [];
510
+ const servePlugins = options.servePlugins ?? [];
511
+ checkFiles("--preload", preload);
512
+ checkFiles("--serve-plugin", servePlugins);
513
+ const folder = path.join(root, ".moku");
514
+ const html = path.join(folder, "index.html");
515
+ const bunfig = path.join(folder, "bunfig.toml");
516
+ const texts = [
517
+ [html, pageHtml(settings.page, {
518
+ toRoot: "../",
519
+ script: "./main.ts"
520
+ })],
521
+ [path.join(folder, "dev.ts"), devFlag()],
522
+ [path.join(folder, "main.ts"), mainOf(root, settings, options.agents)],
523
+ [bunfig, bunfigText({
524
+ hotPlugin: hotPluginOf(root, resolve),
525
+ servePlugins,
526
+ preload
527
+ })]
528
+ ];
529
+ for (const [file, text] of texts) writeIfChanged(file, text);
530
+ return {
531
+ html,
532
+ bunfig
533
+ };
534
+ }
535
+ /**
536
+ * Reads and checks a game, then writes its dev page into `<game>/.moku/`. The editor's step and
537
+ * the dev parent's step 3; it starts no server and no watcher.
538
+ *
539
+ * @param root - The game folder, absolute.
540
+ * @param options - The agents, the preloads and the extra plugins, absolute.
541
+ * @param resolve - Bun's resolver, to find the hot plugin.
542
+ * @returns The paths of the HTML and the bunfig.
543
+ * @throws {Error} The errors of `moku-game dev` before its server starts.
544
+ */
545
+ async function preparePageAt(root, options, resolve) {
546
+ return writePage(root, await loadGame(root), options, resolve);
547
+ }
548
+ /**
549
+ * Tells whether a `.gitignore` of the folder or of a folder above it, up to the repository, keeps
550
+ * the game's `.moku/` out of git.
551
+ *
552
+ * @param folder - The folder to read.
553
+ * @param own - True for the game folder itself, where `/.moku` counts too.
554
+ * @returns True when a line ignores `.moku/`.
555
+ */
556
+ function ignoresMoku(folder, own = true) {
557
+ const file = path.join(folder, ".gitignore");
558
+ const lines = isFile(file) ? readFileSync(file, "utf8").split(/\r?\n/u) : [];
559
+ const pattern = own ? OWN_MOKU_LINE : ANY_MOKU_LINE;
560
+ if (lines.some((line) => pattern.test(line.trim()))) return true;
561
+ const parent = path.dirname(folder);
562
+ if (existsSync(path.join(folder, ".git")) || parent === folder) return false;
563
+ return ignoresMoku(parent, false);
564
+ }
565
+ /**
566
+ * Watches `tests/scenarios/` while the dev server runs: a file added or removed rewrites `main.ts`,
567
+ * and Bun reloads the page with the new registry. An edit of a scenario needs nothing: Bun
568
+ * watches the import itself.
569
+ *
570
+ * @param root - The game folder.
571
+ * @param settings - The resolved config.
572
+ * @param deps - The watcher and the console.
573
+ * @returns The watcher, or `undefined` when the game has no scenario folder.
574
+ */
575
+ function watchScenarios(root, settings, deps) {
576
+ const folder = path.join(root, "tests", "scenarios");
577
+ const main = path.join(root, ".moku", "main.ts");
578
+ if (!isFolder(folder)) return void 0;
579
+ return deps.watch(folder, (event) => {
580
+ if (event !== "rename") return;
581
+ try {
582
+ writeIfChanged(main, mainOf(root, settings));
583
+ } catch (error) {
584
+ const reason = error instanceof Error ? error.message : String(error);
585
+ deps.ui.warn(`[game] dev: main.ts was not rewritten: ${reason}.`);
586
+ }
587
+ });
588
+ }
589
+ /**
590
+ * The command that runs the bin again as the dev child: the Bun under the page's bunfig, then
591
+ * `dev` with the game, the port and `--packed`.
592
+ *
593
+ * @param run - The flags of the run.
594
+ * @param page - The written page, for its bunfig.
595
+ * @param deps - The Bun and the bin.
596
+ * @returns The words of the command.
597
+ */
598
+ function devChildCommand(run, page, deps) {
599
+ return [
600
+ deps.execPath,
601
+ `--config=${page.bunfig}`,
602
+ ...deps.self,
603
+ "dev",
604
+ "--root",
605
+ run.root,
606
+ "--port",
607
+ String(run.port),
608
+ ...run.packed ? ["--packed"] : []
609
+ ];
610
+ }
611
+ /**
612
+ * The dev parent: checks the game, writes the page, re-runs the bin under the generated bunfig
613
+ * with the game as its working directory, and watches the scenarios while the child runs. It
614
+ * hands Ctrl+C and the other stop signals to the child and answers the child's exit code.
615
+ *
616
+ * @param run - The flags of the run.
617
+ * @param deps - The process seams.
618
+ * @returns The exit code of the child.
619
+ * @throws {Error} When the game, a flag or the page is refused, or the child does not start.
620
+ */
621
+ async function serveParent(run, deps) {
622
+ const settings = await loadGame(run.root);
623
+ const hasPack = isFile(path.join(run.root, "dist", "assets", "manifest.json"));
624
+ const hasManifest = isFile(path.join(run.root, "manifest.json"));
625
+ 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.`);
626
+ if (!run.packed && !hasManifest) deps.ui.warn(`[game] dev: no manifest.json in "${run.root}".\n Run "moku-game keys" first.`);
627
+ const page = writePage(run.root, settings, run, deps.resolve);
628
+ if (!ignoresMoku(run.root)) deps.ui.warn("[game] dev: add \".moku/\" to .gitignore. moku-game writes its dev page there.");
629
+ const child = deps.spawn(devChildCommand(run, page, deps), {
630
+ cwd: run.root,
631
+ env: {
632
+ ...deps.env,
633
+ MOKU_GAME_CHILD: "1"
634
+ },
635
+ stdio: [
636
+ "inherit",
637
+ "inherit",
638
+ "inherit"
639
+ ],
640
+ detached: true
641
+ });
642
+ const removers = FORWARDED.map((signal) => deps.onSignal(signal, () => child.kill(signal)));
643
+ let watcher;
644
+ try {
645
+ watcher = watchScenarios(run.root, settings, deps);
646
+ return await child.exited;
647
+ } finally {
648
+ watcher?.close();
649
+ for (const remove of removers) remove();
650
+ }
651
+ }
652
+ /**
653
+ * The path of a request under the served folder: decoded, so the `%7B` braces of a nine-slice
654
+ * name work, and refused when it could leave the folder or reach a hidden or installed file.
655
+ *
656
+ * @param base - The served folder.
657
+ * @param pathname - The path of the request URL.
658
+ * @returns The absolute file path, or `undefined` for a refused path.
659
+ * @example
660
+ * ```ts
661
+ * staticPath("/g", "/features/ui/assets/%7Bbutton%7D.png"); // "/g/features/ui/assets/{button}.png"
662
+ * ```
663
+ */
664
+ function staticPath(base, pathname) {
665
+ const decoded = safeDecode(pathname);
666
+ if (decoded === void 0 || decoded.includes("\0") || decoded.includes("\\")) return void 0;
667
+ const segments = decoded.split("/").filter((segment) => segment !== "");
668
+ return segments.some((segment) => segment.startsWith(".") || segment === "node_modules") ? void 0 : path.join(base, ...segments);
669
+ }
670
+ /**
671
+ * Decodes a percent-encoded path.
672
+ *
673
+ * @param pathname - The encoded path.
674
+ * @returns The decoded path, or `undefined` when an escape is broken.
675
+ * @example
676
+ * ```ts
677
+ * safeDecode("/a%E0"); // undefined
678
+ * ```
679
+ */
680
+ function safeDecode(pathname) {
681
+ try {
682
+ return decodeURIComponent(pathname);
683
+ } catch {
684
+ return;
685
+ }
686
+ }
687
+ /**
688
+ * Answers a request with a file, or a 404 for a missing or empty one.
689
+ *
690
+ * @param file - The absolute path, or `undefined` for a refused path.
691
+ * @returns The response.
692
+ */
693
+ function fileResponse(file) {
694
+ const found = file === void 0 ? void 0 : statSync(file, { throwIfNoEntry: false });
695
+ return file !== void 0 && found?.isFile() === true && found.size > 0 ? new Response(Bun.file(file)) : new Response("Not found", { status: 404 });
696
+ }
697
+ /**
698
+ * Starts the dev server on `127.0.0.1` only, never on every interface: the page on `/`, the
699
+ * manifest of the served folder on `/manifest.json`, and its files as static files.
700
+ *
701
+ * @param run - The flags of the run.
702
+ * @param page - The page bundle the child imported.
703
+ * @returns The server.
704
+ * @throws {Error} When the port is in use.
705
+ */
706
+ function serveOn(run, page) {
707
+ const base = run.packed ? path.join(run.root, "dist", "assets") : run.root;
708
+ try {
709
+ return Bun.serve({
710
+ hostname: "127.0.0.1",
711
+ port: run.port,
712
+ development: true,
713
+ routes: {
714
+ "/": page,
715
+ "/manifest.json": () => fileResponse(path.join(base, "manifest.json"))
716
+ },
717
+ fetch: (request) => fileResponse(staticPath(base, new URL(request.url).pathname))
718
+ });
719
+ } catch (error) {
720
+ 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 });
721
+ throw error;
722
+ }
723
+ }
724
+ /**
725
+ * Waits until the dev child should stop: Ctrl+C, a stop signal, or a parent that died (its
726
+ * parent id changed). Then stops the server.
727
+ *
728
+ * @param server - The running server.
729
+ * @param deps - The signal seam.
730
+ * @returns Resolves to the exit code `0` once the server stopped.
731
+ */
732
+ function untilStopped(server, deps) {
733
+ const parent = process.ppid;
734
+ return new Promise((resolve) => {
735
+ const removers = [];
736
+ const timer = setInterval(() => {
737
+ if (process.ppid !== parent) stop();
738
+ }, PARENT_CHECK_MS);
739
+ const stop = () => {
740
+ clearInterval(timer);
741
+ for (const remove of removers.splice(0)) remove();
742
+ resolve(server.stop(true).then(() => 0));
743
+ };
744
+ for (const signal of STOPPING) removers.push(deps.onSignal(signal, stop));
745
+ });
746
+ }
747
+ /**
748
+ * The dev child, under the generated bunfig in the game folder: imports the written page, serves
749
+ * it, prints the bound URL on its own plain line, and runs until it is stopped.
750
+ *
751
+ * @param run - The flags of the run.
752
+ * @param deps - The process seams.
753
+ * @returns The exit code `0` once stopped.
754
+ * @throws {Error} When the port is in use.
755
+ */
756
+ async function serveChild(run, deps) {
757
+ const server = serveOn(run, (await deps.loadPage(path.join(run.root, ".moku", "index.html"))).default);
758
+ const assets = run.packed ? "packed build" : "raw assets";
759
+ deps.ui.info(`${path.basename(run.root)}: dev server, ${assets}. Ctrl+C stops it.`);
760
+ deps.ui.line(server.url.href);
761
+ return untilStopped(server, deps);
762
+ }
763
+ //#endregion
764
+ //#region src/app/build.ts
765
+ /**
766
+ * @file `moku-game build`: the assets packed with the config's layers, then the production page
767
+ * bundled by `Bun.build` in the game folder, with its HTML and `main.ts` in memory under a virtual
768
+ * `<game>/.moku/build/` that is never written, then the packed assets copied beside the page. The
769
+ * output works from any http sub-path and from the Tauri protocol: every link starts with `./`,
770
+ * and the page reads `manifest.json` next to itself. Node and Bun only: the bin bundles it.
771
+ */
772
+ /** The link prefix of the in-memory page, two folders under the game. */
773
+ const TO_ROOT = "../../";
774
+ /** Bytes in a kilobyte, for the size line of a run. */
775
+ const BYTES_PER_KB = 1024;
776
+ /**
777
+ * The scan flags of a game: its root, its key module and one `--layer` per config layer.
778
+ *
779
+ * @param root - The game folder.
780
+ * @param settings - The resolved config.
781
+ * @returns The flags.
782
+ * @example
783
+ * ```ts
784
+ * scanArguments("/g", resolveConfig({ page: { title: "T" }, assets: { layers: { shared: "ui" } } })).slice(4); // ["--layer", "shared=ui"]
785
+ * ```
786
+ */
787
+ function scanArguments(root, settings) {
788
+ const layers = Object.entries(settings.assets.layers).flatMap(([folder, name]) => ["--layer", `${folder}=${name}`]);
789
+ return [
790
+ "--root",
791
+ root,
792
+ "--keys",
793
+ path.join(root, "generated", "assets.ts"),
794
+ ...layers
795
+ ];
796
+ }
797
+ /**
798
+ * Lists the folders right inside a folder.
799
+ *
800
+ * @param folder - The folder.
801
+ * @returns Their absolute paths; none when the folder is missing.
802
+ */
803
+ function foldersIn(folder) {
804
+ if (statSync(folder, { throwIfNoEntry: false })?.isDirectory() !== true) return [];
805
+ return readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(folder, entry.name));
806
+ }
807
+ /**
808
+ * Tells whether a game has an English string file to derive the pseudo-locale from: a
809
+ * `strings/en.json` in a feature folder or in a layer folder of the config.
810
+ *
811
+ * @param root - The game folder.
812
+ * @param settings - The resolved config, for its layer folders.
813
+ * @returns True when at least one such file exists.
814
+ */
815
+ function hasEnglishStrings(root, settings) {
816
+ const featureFolders = foldersIn(path.join(root, "features"));
817
+ const layerFolders = Object.keys(settings.assets.layers).map((folder) => path.join(root, folder));
818
+ return [...featureFolders, ...layerFolders].some((folder) => statSync(path.join(folder, "strings", "en.json"), { throwIfNoEntry: false })?.isFile() === true);
819
+ }
820
+ /**
821
+ * The flags of the asset scan for `moku-game keys`: the dev manifest beside the game, the
822
+ * pseudo-locale when the game has an English string file, and `--check` for a check run.
823
+ *
824
+ * @param root - The game folder.
825
+ * @param settings - The resolved config.
826
+ * @param check - True to check instead of write.
827
+ * @returns The flags for the scanner's command line.
828
+ * @example
829
+ * ```ts
830
+ * keysArguments("/g", resolveConfig({ page: { title: "T" } }), true).slice(4); // ["--manifest", "/g/manifest.json", "--check"]: no strings
831
+ * keysArguments("/g", resolveConfig({ page: { title: "T" } }), false).at(-1); // "--pseudo" once /g/features/home/strings/en.json exists
832
+ * ```
833
+ */
834
+ function keysArguments(root, settings, check) {
835
+ return [
836
+ ...scanArguments(root, settings),
837
+ "--manifest",
838
+ path.join(root, "manifest.json"),
839
+ ...hasEnglishStrings(root, settings) ? ["--pseudo"] : [],
840
+ ...check ? ["--check"] : []
841
+ ];
842
+ }
843
+ /**
844
+ * The flags of the asset scan for `moku-game pack` and the first step of `build`: the pack into
845
+ * `<game>/dist/assets`, and `--no-cache` for a cold run.
846
+ *
847
+ * @param root - The game folder.
848
+ * @param settings - The resolved config.
849
+ * @param noCache - True to pack without the cache.
850
+ * @returns The flags for the scanner's command line.
851
+ * @example
852
+ * ```ts
853
+ * packArguments("/g", resolveConfig({ page: { title: "T" } }), false).slice(4); // ["--pack", "/g/dist/assets"]
854
+ * ```
855
+ */
856
+ function packArguments(root, settings, noCache) {
857
+ return [
858
+ ...scanArguments(root, settings),
859
+ "--pack",
860
+ path.join(root, "dist", "assets"),
861
+ ...noCache ? ["--no-cache"] : []
862
+ ];
863
+ }
864
+ /**
865
+ * Tells whether a folder is another folder or lies inside it.
866
+ *
867
+ * @param folder - The folder that may sit inside.
868
+ * @param outer - The outer folder.
869
+ * @returns True when `folder` is `outer` or lies under it.
870
+ * @example
871
+ * ```ts
872
+ * isInside("/g", "/g/dist/web"); // false
873
+ * ```
874
+ */
875
+ function isInside(folder, outer) {
876
+ const relative = path.relative(outer, folder);
877
+ const climbs = relative === ".." || relative.startsWith(`..${path.sep}`);
878
+ return relative === "" || !climbs && !path.isAbsolute(relative);
879
+ }
880
+ /**
881
+ * Refuses an output folder whose removal would take the game or its packed assets with it. Inside
882
+ * the game, only a folder under `dist` is the build's.
883
+ *
884
+ * @param root - The game folder.
885
+ * @param out - The output folder.
886
+ * @throws {Error} When the folder is the game, holds it, is, holds or lies in the pack, or lies in
887
+ * the game outside `dist`.
888
+ */
889
+ function refuseOut(root, out) {
890
+ const pack = path.join(root, "dist", "assets");
891
+ const replacesGame = isInside(root, out);
892
+ const touchesPack = isInside(out, pack) || isInside(pack, out);
893
+ const outsideBuildFolder = isInside(out, root) && !isInside(out, path.join(root, "dist"));
894
+ if (replacesGame || touchesPack || outsideBuildFolder) throw new Error(`[game] build: --out "${out}" would replace the game or its packed assets. Name another folder.`);
895
+ }
896
+ /**
897
+ * Tells whether a default export is a Bun plugin.
898
+ *
899
+ * @param value - The default export.
900
+ * @returns True for an object with a name and a `setup` function.
901
+ */
902
+ function isBunPlugin(value) {
903
+ return typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" && "setup" in value && typeof value.setup === "function";
904
+ }
905
+ /**
906
+ * Imports the `--serve-plugin` files and takes their default exports.
907
+ *
908
+ * @param files - The absolute paths.
909
+ * @returns The plugins, in order.
910
+ * @throws {Error} When a file default-exports no Bun plugin.
911
+ */
912
+ async function loadPlugins(files) {
913
+ return Promise.all(files.map(async (file) => {
914
+ const loaded = await import(pathToFileURL(file).href);
915
+ if (!isBunPlugin(loaded.default)) throw new Error(`[game] build: --serve-plugin "${file}" default-exports no Bun plugin.\n End it with: export default { name, setup(build) { … } }.`);
916
+ return loaded.default;
917
+ }));
918
+ }
919
+ /**
920
+ * Bundles the production page into the output folder: the HTML and `main.ts` in memory, every
921
+ * link `./`, the dev flag defined false, no hot plugin.
922
+ *
923
+ * @param run - The flags of the run.
924
+ * @param settings - The resolved config.
925
+ * @returns Resolves when the page is written.
926
+ * @throws {Error} With one Bun log per line when the page did not bundle.
927
+ */
928
+ async function bundlePage(run, settings) {
929
+ const folder = path.join(run.root, ".moku", "build");
930
+ const html = path.join(folder, "index.html");
931
+ const main = path.join(folder, "main.ts");
932
+ const result = await Bun.build({
933
+ entrypoints: [html],
934
+ files: {
935
+ [html]: pageHtml(settings.page, {
936
+ toRoot: TO_ROOT,
937
+ script: "./main.ts"
938
+ }),
939
+ [main]: buildMain(settings)
940
+ },
941
+ outdir: run.out,
942
+ publicPath: "./",
943
+ minify: true,
944
+ target: "browser",
945
+ define: { __MOKU_GAME_DEV__: "false" },
946
+ naming: {
947
+ entry: "[name].[ext]",
948
+ chunk: "page/[name]-[hash].[ext]",
949
+ asset: "page/[name]-[hash].[ext]"
950
+ },
951
+ plugins: await loadPlugins(run.servePlugins),
952
+ throw: false
953
+ });
954
+ if (!result.success) throw new Error(["[game] build: the page did not bundle.", ...result.logs.map(String)].join("\n"));
955
+ }
956
+ /**
957
+ * The fallback for icons Bun left as links into the game: copies each icon beside the page and
958
+ * links it as `./<name>`.
959
+ *
960
+ * @param run - The flags of the run.
961
+ * @param settings - The resolved config.
962
+ */
963
+ function relinkIcons(run, settings) {
964
+ const file = path.join(run.out, "index.html");
965
+ let html = readFileSync(file, "utf8");
966
+ if (!html.includes(`"${TO_ROOT}`)) return;
967
+ for (const icon of Object.values(settings.page.icons)) {
968
+ const linked = icon?.replaceAll("\\", "/");
969
+ if (!(linked !== void 0 && html.includes(`"${TO_ROOT}${linked}"`))) continue;
970
+ const name = path.basename(linked);
971
+ copyFileSync(path.join(run.root, linked), path.join(run.out, name));
972
+ html = html.replaceAll(`"${TO_ROOT}${linked}"`, `"./${name}"`);
973
+ }
974
+ writeFileSync(file, html);
975
+ }
976
+ /**
977
+ * Lists the files under a folder.
978
+ *
979
+ * @param folder - The folder.
980
+ * @param relative - The folder under it to list, `/` separated.
981
+ * @returns The files, relative to `folder`, `/` separated; none when it is missing.
982
+ */
983
+ function filesUnder(folder, relative = "") {
984
+ const here = path.join(folder, relative);
985
+ if (statSync(here, { throwIfNoEntry: false })?.isDirectory() !== true) return [];
986
+ return readdirSync(here, { withFileTypes: true }).flatMap((entry) => {
987
+ const inner = relative === "" ? entry.name : `${relative}/${entry.name}`;
988
+ return entry.isDirectory() ? filesUnder(folder, inner) : [inner];
989
+ });
990
+ }
991
+ /**
992
+ * Copies the packed assets beside the page, `manifest.json` next to `index.html`. File by file:
993
+ * the output folder and its page folders exist already, and a packed file never replaces a page
994
+ * file.
995
+ *
996
+ * @param run - The flags of the run.
997
+ * @throws {Error} When a packed file has the path of a page file.
998
+ */
999
+ function copyPack(run) {
1000
+ const pack = path.join(run.root, "dist", "assets");
1001
+ const page = new Set(filesUnder(run.out));
1002
+ const packed = filesUnder(pack);
1003
+ const clash = packed.find((file) => page.has(file));
1004
+ if (clash !== void 0) throw new Error(`[game] build: "${clash}" is both a page file and a packed asset.`);
1005
+ for (const file of packed) {
1006
+ const target = path.join(run.out, file);
1007
+ mkdirSync(path.dirname(target), { recursive: true });
1008
+ copyFileSync(path.join(pack, file), target, constants.COPYFILE_EXCL);
1009
+ }
1010
+ }
1011
+ /**
1012
+ * Runs a step with a folder as the working directory, then returns to the one before.
1013
+ *
1014
+ * @param folder - The folder the step runs in.
1015
+ * @param step - The step.
1016
+ * @returns What the step resolves to.
1017
+ */
1018
+ async function inFolder(folder, step) {
1019
+ const before = process.cwd();
1020
+ process.chdir(folder);
1021
+ try {
1022
+ return await step();
1023
+ } finally {
1024
+ process.chdir(before);
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Builds the production page of a game: packs the assets, replaces the output folder with the
1029
+ * bundled page, and copies the pack beside it.
1030
+ *
1031
+ * @param run - The flags of the run.
1032
+ * @param deps - The process seams: the asset scanner and the console.
1033
+ * @returns The exit code: the pack's when it failed, else `0`.
1034
+ * @throws {Error} When the game, the output folder or the bundle is refused.
1035
+ */
1036
+ async function runBuild(run, deps) {
1037
+ const settings = await loadGame(run.root);
1038
+ refuseOut(run.root, run.out);
1039
+ const packed = await deps.assets(packArguments(run.root, settings, false));
1040
+ if (packed !== 0) return packed;
1041
+ rmSync(run.out, {
1042
+ recursive: true,
1043
+ force: true
1044
+ });
1045
+ await inFolder(run.root, () => bundlePage(run, settings));
1046
+ relinkIcons(run, settings);
1047
+ copyPack(run);
1048
+ const files = filesUnder(run.out);
1049
+ const bytes = files.map((file) => statSync(path.join(run.out, file)).size).reduce((sum, size) => sum + size, 0);
1050
+ deps.ui.info(`built "${run.out}": ${files.length} files, ${Math.round(bytes / BYTES_PER_KB)} KB.`);
1051
+ return 0;
1052
+ }
1053
+ //#endregion
1054
+ //#region src/app/cli.ts
1055
+ /**
1056
+ * @file The command line of `moku-game`: the arguments read per command with `node:util`
1057
+ * `parseArgs`, then one of `dev`, `build`, `native <verb>`, `keys`, `pack` or `help`. Every seam
1058
+ * of the process (the console, the environment, spawning, signals, the watcher, the asset
1059
+ * scanner, native, Bun's resolver) comes in as `deps`, so a test runs a command with stubs. It
1060
+ * returns the exit code and never exits itself. A plain function, on no API.
1061
+ */
1062
+ /** The flags every command takes. */
1063
+ const GLOBAL_OPTIONS = [
1064
+ "root",
1065
+ "preload",
1066
+ "serve-plugin",
1067
+ "help"
1068
+ ];
1069
+ /** Every flag of the line, as `parseArgs` reads it. */
1070
+ const ALL_OPTIONS = {
1071
+ root: { type: "string" },
1072
+ preload: {
1073
+ type: "string",
1074
+ multiple: true
1075
+ },
1076
+ "serve-plugin": {
1077
+ type: "string",
1078
+ multiple: true
1079
+ },
1080
+ help: {
1081
+ type: "boolean",
1082
+ short: "h"
1083
+ },
1084
+ port: { type: "string" },
1085
+ packed: { type: "boolean" },
1086
+ out: { type: "string" },
1087
+ simulator: { type: "boolean" },
1088
+ check: { type: "boolean" },
1089
+ "no-cache": { type: "boolean" }
1090
+ };
1091
+ /** The flags of one command besides the global ones. */
1092
+ const COMMAND_OPTIONS = {
1093
+ dev: ["port", "packed"],
1094
+ build: ["out"],
1095
+ native: ["simulator"],
1096
+ keys: ["check"],
1097
+ pack: ["no-cache"]
1098
+ };
1099
+ /** The native verbs, in the order the messages name them. */
1100
+ const NATIVE_VERBS = [
1101
+ "build",
1102
+ "dev",
1103
+ "doctor",
1104
+ "clean"
1105
+ ];
1106
+ /** The port of `moku-game dev` without `--port`. */
1107
+ const DEFAULT_PORT = "3000";
1108
+ /** The highest port number. */
1109
+ const MAX_PORT = 65535;
1110
+ /** The text of `moku-game help`. */
1111
+ const USAGE = [
1112
+ "moku-game <command> [options]",
1113
+ "",
1114
+ " dev [--port 3000] [--packed] serve the game with hot reload",
1115
+ " build [--out dist/web] pack the assets and build the page",
1116
+ " native build <target> [--simulator] build the native app (ios, macos, android)",
1117
+ " native dev <target> run the native shell on the dev server",
1118
+ " native doctor | native clean check or remove the native project",
1119
+ " keys [--check] write generated/assets.ts and manifest.json",
1120
+ " pack [--no-cache] pack the assets into dist/assets",
1121
+ " help print this text",
1122
+ "",
1123
+ "Every command: --root <dir> (default .), --preload <path>, --serve-plugin <path>."
1124
+ ];
1125
+ /**
1126
+ * Tells whether a word is a command of the line that runs something.
1127
+ *
1128
+ * @param word - The first positional argument.
1129
+ * @returns True for `dev`, `build`, `native`, `keys` and `pack`.
1130
+ */
1131
+ function isCommand(word) {
1132
+ return Object.hasOwn(COMMAND_OPTIONS, word);
1133
+ }
1134
+ /**
1135
+ * Reads the message of anything that was thrown.
1136
+ *
1137
+ * @param failure - What the `catch` caught.
1138
+ * @returns The message.
1139
+ */
1140
+ function messageOf(failure) {
1141
+ return failure instanceof Error ? failure.message : String(failure);
1142
+ }
1143
+ /**
1144
+ * Reads the arguments without refusing anything, to find the command and the raw flags.
1145
+ *
1146
+ * @param argv - The arguments after the bin.
1147
+ * @returns The positionals, the values and the tokens.
1148
+ */
1149
+ function parseLoose(argv) {
1150
+ return parseArgs({
1151
+ args: [...argv],
1152
+ options: ALL_OPTIONS,
1153
+ strict: false,
1154
+ allowPositionals: true,
1155
+ tokens: true
1156
+ });
1157
+ }
1158
+ /**
1159
+ * Reads the arguments of a command whose flags were checked.
1160
+ *
1161
+ * @param argv - The arguments after the bin.
1162
+ * @returns The values and the positionals.
1163
+ */
1164
+ function parseStrict(argv) {
1165
+ return parseArgs({
1166
+ args: [...argv],
1167
+ options: ALL_OPTIONS,
1168
+ strict: true,
1169
+ allowPositionals: true
1170
+ });
1171
+ }
1172
+ /**
1173
+ * Refuses a flag the command does not take, a flag without its value, and a value given to a
1174
+ * switch.
1175
+ *
1176
+ * @param command - The command.
1177
+ * @param tokens - The tokens of the loose read.
1178
+ * @throws {Error} Naming the command and the flag.
1179
+ */
1180
+ function checkTokens(command, tokens) {
1181
+ const names = /* @__PURE__ */ new Set([...GLOBAL_OPTIONS, ...COMMAND_OPTIONS[command]]);
1182
+ const allowed = new Map(Object.entries(ALL_OPTIONS).filter(([name]) => names.has(name)).map(([name, option]) => [name, option.type]));
1183
+ for (const token of tokens) {
1184
+ if (token.kind !== "option") continue;
1185
+ const type = allowed.get(token.name);
1186
+ if (type === void 0) throw new Error(`[game] ${command}: unknown option "${token.rawName}".`);
1187
+ const missing = token.value === void 0 || token.inlineValue !== true && token.value.startsWith("-");
1188
+ if (type === "string" && missing) throw new Error(`[game] ${command}: "${token.rawName}" needs a value.`);
1189
+ if (type === "boolean" && token.inlineValue === true) throw new Error(`[game] ${command}: "${token.rawName}" takes no value.`);
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Reads the command line into one command with its flags, the paths resolved against the
1194
+ * working directory.
1195
+ *
1196
+ * @param argv - The arguments after the bin.
1197
+ * @param cwd - The working directory.
1198
+ * @returns The command, `"help"`, or `undefined` when no command is named.
1199
+ * @throws {Error} When the command is unknown or a flag is refused.
1200
+ */
1201
+ function parseCommand(argv, cwd) {
1202
+ const loose = parseLoose(argv);
1203
+ const word = loose.positionals[0];
1204
+ if (loose.values.help === true || word === "help") return "help";
1205
+ if (word === void 0) return void 0;
1206
+ if (!isCommand(word)) throw new Error(`[game] moku-game: no command "${word}". Name one of dev, build, native, keys, pack, help.`);
1207
+ checkTokens(word, loose.tokens);
1208
+ const { values, positionals } = parseStrict(argv);
1209
+ const resolveAll = (files) => (files ?? []).map((file) => path.resolve(cwd, file));
1210
+ return {
1211
+ command: word,
1212
+ rest: positionals.slice(1),
1213
+ root: path.resolve(cwd, values.root ?? "."),
1214
+ preload: resolveAll(values.preload),
1215
+ servePlugins: resolveAll(values["serve-plugin"]),
1216
+ values
1217
+ };
1218
+ }
1219
+ /**
1220
+ * Refuses arguments after the command word beyond the ones it takes.
1221
+ *
1222
+ * @param parsed - The command line.
1223
+ * @param count - How many positional arguments the command takes.
1224
+ * @throws {Error} Naming the first extra argument.
1225
+ */
1226
+ function refuseExtra(parsed, count) {
1227
+ const extra = parsed.rest[count];
1228
+ if (extra !== void 0) throw new Error(`[game] ${parsed.command}: unexpected argument "${extra}".`);
1229
+ }
1230
+ /**
1231
+ * Reads `--port`: an integer 0-65535, 3000 when left out.
1232
+ *
1233
+ * @param given - The value of the flag.
1234
+ * @returns The port.
1235
+ * @throws {Error} When the value is not a port.
1236
+ * @example
1237
+ * ```ts
1238
+ * portOf(undefined); // 3000
1239
+ * ```
1240
+ */
1241
+ function portOf(given) {
1242
+ const text = given ?? DEFAULT_PORT;
1243
+ const port = Number(text);
1244
+ if (!(/^\d+$/.test(text) && port <= MAX_PORT)) throw new Error(`[game] dev: --port must be an integer 0-65535, got "${text}".`);
1245
+ return port;
1246
+ }
1247
+ /**
1248
+ * Runs `moku-game dev`: the parent, or the server child the parent started.
1249
+ *
1250
+ * @param parsed - The command line.
1251
+ * @param deps - The process seams.
1252
+ * @returns The exit code.
1253
+ */
1254
+ function runServe(parsed, deps) {
1255
+ refuseExtra(parsed, 0);
1256
+ const run = {
1257
+ root: parsed.root,
1258
+ port: portOf(parsed.values.port),
1259
+ packed: parsed.values.packed === true,
1260
+ preload: parsed.preload,
1261
+ servePlugins: parsed.servePlugins
1262
+ };
1263
+ return deps.env.MOKU_GAME_CHILD === "1" ? serveChild(run, deps) : serveParent(run, deps);
1264
+ }
1265
+ /**
1266
+ * The command that runs `moku-game` again for native's web build and dev server, with the runner
1267
+ * flags, every word quoted.
1268
+ *
1269
+ * @param parsed - The command line.
1270
+ * @param deps - The Bun and the bin.
1271
+ * @returns The command text.
1272
+ */
1273
+ function selfCommand(parsed, deps) {
1274
+ const runnerFlags = [...parsed.preload.flatMap((file) => ["--preload", file]), ...parsed.servePlugins.flatMap((file) => ["--serve-plugin", file])];
1275
+ return [
1276
+ deps.execPath,
1277
+ ...deps.self,
1278
+ "--root",
1279
+ parsed.root,
1280
+ ...runnerFlags
1281
+ ].map((word) => JSON.stringify(word)).join(" ");
1282
+ }
1283
+ /**
1284
+ * Runs `moku-game native <verb> [<target>]` through native.
1285
+ *
1286
+ * @param parsed - The command line.
1287
+ * @param deps - The process seams.
1288
+ * @returns `0` when the verb succeeded, `1` when native reported a failure.
1289
+ * @throws {Error} When the verb is missing or unknown, or native refuses the config or target.
1290
+ */
1291
+ async function runNativeVerb(parsed, deps) {
1292
+ const [word, target] = parsed.rest;
1293
+ const verb = NATIVE_VERBS.find((name) => name === word);
1294
+ if (word === void 0) throw new Error("[game] native: name a verb: build, dev, doctor, clean.");
1295
+ if (verb === void 0) throw new Error(`[game] native: no verb "${word}". Name one of build, dev, doctor, clean.`);
1296
+ refuseExtra(parsed, 2);
1297
+ const settings = await loadSettings(parsed.root);
1298
+ const where = {
1299
+ cwd: parsed.root,
1300
+ command: selfCommand(parsed, deps)
1301
+ };
1302
+ const options = {
1303
+ ...target === void 0 ? {} : { target },
1304
+ ...verb === "build" && parsed.values.simulator === true ? { simulator: true } : {}
1305
+ };
1306
+ return await deps.native(verb, settings, where, options) ? 0 : 1;
1307
+ }
1308
+ /**
1309
+ * Runs `moku-game keys` or `pack` through the asset scanner, with the layers of `config.ts`.
1310
+ *
1311
+ * @param parsed - The command line.
1312
+ * @param deps - The process seams.
1313
+ * @returns The scanner's exit code.
1314
+ */
1315
+ async function runScan(parsed, deps) {
1316
+ refuseExtra(parsed, 0);
1317
+ const settings = await loadSettings(parsed.root);
1318
+ const argv = parsed.command === "keys" ? keysArguments(parsed.root, settings, parsed.values.check === true) : packArguments(parsed.root, settings, parsed.values["no-cache"] === true);
1319
+ return deps.assets(argv);
1320
+ }
1321
+ /**
1322
+ * Runs the bin once more with every `--preload` as a Bun preload, so the preload runs before the
1323
+ * engine loads. The re-run is marked, so it does not hop again.
1324
+ *
1325
+ * @param argv - The arguments after the bin.
1326
+ * @param parsed - The command line.
1327
+ * @param deps - The process seams.
1328
+ * @returns The exit code of the re-run.
1329
+ */
1330
+ function hop(argv, parsed, deps) {
1331
+ return deps.spawn([
1332
+ deps.execPath,
1333
+ ...parsed.preload.map((file) => `--preload=${file}`),
1334
+ ...deps.self,
1335
+ ...argv
1336
+ ], {
1337
+ env: {
1338
+ ...deps.env,
1339
+ MOKU_GAME_CHILD: "1"
1340
+ },
1341
+ stdio: [
1342
+ "inherit",
1343
+ "inherit",
1344
+ "inherit"
1345
+ ]
1346
+ }).exited;
1347
+ }
1348
+ /**
1349
+ * Runs one checked command.
1350
+ *
1351
+ * @param argv - The arguments after the bin.
1352
+ * @param parsed - The command line.
1353
+ * @param deps - The process seams.
1354
+ * @returns The exit code.
1355
+ */
1356
+ async function runParsed(argv, parsed, deps) {
1357
+ const usesPlugins = parsed.command !== "keys" && parsed.command !== "pack";
1358
+ checkFiles("--preload", parsed.preload);
1359
+ if (usesPlugins) checkFiles("--serve-plugin", parsed.servePlugins);
1360
+ if (parsed.command !== "dev" && parsed.preload.length > 0 && deps.env.MOKU_GAME_CHILD !== "1") return hop(argv, parsed, deps);
1361
+ switch (parsed.command) {
1362
+ case "dev": return runServe(parsed, deps);
1363
+ case "build": {
1364
+ refuseExtra(parsed, 0);
1365
+ const out = parsed.values.out ?? path.join(parsed.root, "dist", "web");
1366
+ return runBuild({
1367
+ root: parsed.root,
1368
+ out: path.resolve(deps.cwd, out),
1369
+ servePlugins: parsed.servePlugins
1370
+ }, deps);
1371
+ }
1372
+ case "native": return runNativeVerb(parsed, deps);
1373
+ case "keys":
1374
+ case "pack": return runScan(parsed, deps);
1375
+ }
1376
+ }
1377
+ /**
1378
+ * Prints the usage text on stdout.
1379
+ *
1380
+ * @param ui - The console.
1381
+ */
1382
+ function printUsage(ui) {
1383
+ for (const line of USAGE) ui.line(line);
1384
+ }
1385
+ /**
1386
+ * Runs one `moku-game` command line: reads the command and its flags, then runs `dev`, `build`,
1387
+ * `native <verb>`, `keys`, `pack` or `help`. Every failure is printed through the console as a
1388
+ * `[game] …` message; nothing exits the process.
1389
+ *
1390
+ * @param argv - The arguments after the bin.
1391
+ * @param deps - The process seams.
1392
+ * @returns The exit code: `0` on success, the child's code for `dev`, else `1` or the scanner's.
1393
+ */
1394
+ async function runCommand(argv, deps) {
1395
+ try {
1396
+ const parsed = parseCommand(argv, deps.cwd);
1397
+ if (parsed === "help") {
1398
+ printUsage(deps.ui);
1399
+ return 0;
1400
+ }
1401
+ if (parsed === void 0) {
1402
+ printUsage(deps.ui);
1403
+ deps.ui.error("[game] moku-game: name a command: dev, build, native, keys, pack, help.");
1404
+ return 1;
1405
+ }
1406
+ return await runParsed(argv, parsed, deps);
1407
+ } catch (error) {
1408
+ deps.ui.error(messageOf(error));
1409
+ return 1;
1410
+ }
1411
+ }
1412
+ //#endregion
1413
+ //#region src/app/native.ts
1414
+ /**
1415
+ * @file The native verbs of `moku-game`: a game's `config.ts` as the config of `@moku-labs/native`,
1416
+ * and one verb run over it. Node only, no package export: the bin bundles it. The only file of the
1417
+ * engine that imports `@moku-labs/native`, and only with `import()`, so a web-only game never
1418
+ * loads it.
1419
+ */
1420
+ /** The port of the dev server Tauri's shell starts and loads. */
1421
+ const DEV_PORT = 5173;
1422
+ /**
1423
+ * The native section of a resolved config.
1424
+ *
1425
+ * @param settings - The resolved `config.ts` of the game.
1426
+ * @returns The native section.
1427
+ * @throws {Error} When `config.ts` has no native section.
1428
+ */
1429
+ function nativeSectionOf(settings) {
1430
+ if (settings.native === void 0) throw new Error("[game] config.ts has no native section.\n Add native: { name, identifier } to config.ts.");
1431
+ return settings.native;
1432
+ }
1433
+ /**
1434
+ * The system rows native packages: one per system plugin that needs a Tauri capability. The
1435
+ * webview's own events and wake lock serve `lifecycle` and `keepAwake`, so they need no row. A
1436
+ * `"store"` save needs the store row even when `system` leaves it out.
1437
+ *
1438
+ * @param settings - The resolved `config.ts` of the game.
1439
+ * @returns The rows, in the order of `config.system`.
1440
+ */
1441
+ function systemRowsOf(settings) {
1442
+ const names = settings.system.filter((name) => name !== "lifecycle" && name !== "keepAwake");
1443
+ if (settings.save === "store" && !names.includes("store")) names.push("store");
1444
+ return names.map((name) => ({ name }));
1445
+ }
1446
+ /**
1447
+ * Maps a game's resolved `config.ts` to the config of `@moku-labs/native`: the app from `native`
1448
+ * and `page`, the web build through `moku-game`, the system rows, the targets, and the Tauri
1449
+ * project and output folders under the game.
1450
+ *
1451
+ * @param settings - The resolved `config.ts` of the game.
1452
+ * @param where - The game folder and the command that runs `moku-game`.
1453
+ * @param target - The target of the verb, used when `config.ts` names no targets.
1454
+ * @returns The config for `createApp({ config })` of `@moku-labs/native`.
1455
+ * @throws {Error} When `config.ts` has no native section.
1456
+ * @example
1457
+ * ```ts
1458
+ * const where = { cwd: "/g", command: '"/usr/local/bin/bun" "/g/node_modules/@moku-labs/game/bin/moku-game.mjs" "--root" "/g"' };
1459
+ * nativeConfigOf(resolveConfig({ page: { title: "T" }, native: { name: "T", identifier: "com.x.t" } }), where, "ios").web.dist; // "/g/dist/web"
1460
+ * ```
1461
+ */
1462
+ function nativeConfigOf(settings, where, target) {
1463
+ const native = nativeSectionOf(settings);
1464
+ const config = {
1465
+ app: {
1466
+ name: native.name,
1467
+ identifier: native.identifier,
1468
+ orientation: settings.page.orientation,
1469
+ backgroundColor: settings.page.background
1470
+ },
1471
+ web: {
1472
+ cwd: where.cwd,
1473
+ build: `${where.command} build`,
1474
+ devCommand: `${where.command} dev --port ${DEV_PORT}`,
1475
+ devUrl: `http://127.0.0.1:${DEV_PORT}`,
1476
+ dist: path.join(where.cwd, "dist/web")
1477
+ },
1478
+ system: systemRowsOf(settings),
1479
+ projectDir: path.join(where.cwd, ".moku/tauri"),
1480
+ outDir: path.join(where.cwd, "dist-native")
1481
+ };
1482
+ const targets = native.targets ?? (target === void 0 ? void 0 : [target]);
1483
+ if (native.icon !== void 0) config.app.icon = native.icon;
1484
+ if (targets !== void 0) config.targets = targets;
1485
+ return config;
1486
+ }
1487
+ /**
1488
+ * Checks the target of a verb against the targets native knows. `build` and `dev` need one.
1489
+ *
1490
+ * @param known - The `TARGETS` of `@moku-labs/native`.
1491
+ * @param verb - The native verb.
1492
+ * @param given - The target the command line names, unchecked.
1493
+ * @returns The target, or `undefined` for a `doctor` or `clean` over every target.
1494
+ * @throws {Error} When the target is unknown, or `build` or `dev` names none.
1495
+ */
1496
+ function targetOf(known, verb, given) {
1497
+ const target = known.find((name) => name === given);
1498
+ if (given !== void 0 && target === void 0) throw new Error(`[game] No native target "${given}".\n Name one of ${known.join(", ")}.`);
1499
+ if (target === void 0 && verb === "build") throw new Error("[game] moku-game native build needs a target.\n Run: moku-game native build ios --simulator.");
1500
+ if (target === void 0 && verb === "dev") throw new Error("[game] moku-game native dev needs a target.\n Run: moku-game native dev ios.");
1501
+ return target;
1502
+ }
1503
+ /**
1504
+ * Runs one verb on the cli of a started native app.
1505
+ *
1506
+ * @param cli - The cli of the native app.
1507
+ * @param verb - The native verb.
1508
+ * @param target - The checked target.
1509
+ * @param simulator - iOS: build the simulator slice.
1510
+ * @returns The boolean of `doctor`, true for the other verbs once they finish.
1511
+ */
1512
+ async function runVerb(cli, verb, target, simulator) {
1513
+ switch (verb) {
1514
+ case "build":
1515
+ await cli.build({
1516
+ target,
1517
+ simulator
1518
+ });
1519
+ return true;
1520
+ case "dev":
1521
+ await cli.dev({ target });
1522
+ return true;
1523
+ case "doctor": return cli.doctor({ target });
1524
+ case "clean":
1525
+ await cli.clean({ target });
1526
+ return true;
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Runs one native verb through `@moku-labs/native` over the game's config. Native's cli prints
1531
+ * the progress and the failure itself.
1532
+ *
1533
+ * @param verb - The native verb: build, dev, doctor or clean.
1534
+ * @param settings - The resolved `config.ts` of the game.
1535
+ * @param where - The game folder and the command that runs `moku-game`.
1536
+ * @param options - The target and the simulator flag.
1537
+ * @returns True when the verb succeeded, false when it failed.
1538
+ * @throws {Error} When `config.ts` has no native section, `@moku-labs/native` is not installed, or
1539
+ * the target is unknown or missing.
1540
+ */
1541
+ async function runNative(verb, settings, where, options) {
1542
+ nativeSectionOf(settings);
1543
+ const native = await import("@moku-labs/native").catch((error) => {
1544
+ 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 });
1545
+ });
1546
+ const target = targetOf(native.TARGETS, verb, options.target);
1547
+ const app = native.createApp({ config: nativeConfigOf(settings, where, target) });
1548
+ await app.start();
1549
+ try {
1550
+ return await runVerb(app.cli, verb, target, options.simulator);
1551
+ } catch {
1552
+ return false;
1553
+ } finally {
1554
+ await app.stop();
1555
+ }
1556
+ }
1557
+ //#endregion
1558
+ //#region src/cli.ts
1559
+ /**
1560
+ * @file The `@moku-labs/game/cli` door, node and bun only: the `moku-game` bin and the editor's
1561
+ * seam. `runCli` runs one command line of `moku-game` (dev, build, native, keys, pack, help) in
1562
+ * this process; `preparePage` writes a game's dev page into `<game>/.moku/` for a server the
1563
+ * caller starts itself, as the editor does. It imports no Pixi, and the root entry never imports
1564
+ * it. The code lives in `app/`; this file wires the real process into it.
1565
+ */
1566
+ /**
1567
+ * The real process behind the command line: the branded console, the environment the bin
1568
+ * started with, Bun's spawn and resolver, the signals, `fs.watch`, the asset scanner with the
1569
+ * string tools, and native.
1570
+ *
1571
+ * @returns The seams of this process.
1572
+ */
1573
+ function processDeps() {
1574
+ const env = { ...process.env };
1575
+ const ui = createBrandConsole();
1576
+ Reflect.deleteProperty(process.env, "MOKU_GAME_CHILD");
1577
+ return {
1578
+ ui,
1579
+ env,
1580
+ cwd: process.cwd(),
1581
+ execPath: process.execPath,
1582
+ self: [Bun.main],
1583
+ spawn: (command, options) => Bun.spawn(command, options),
1584
+ onSignal: (signal, handler) => {
1585
+ process.on(signal, handler);
1586
+ return () => {
1587
+ process.off(signal, handler);
1588
+ };
1589
+ },
1590
+ watch: (folder, listener) => {
1591
+ const watcher = watch(folder, (event) => listener(event));
1592
+ watcher.on("error", () => watcher.close());
1593
+ return watcher;
1594
+ },
1595
+ assets: (argv) => runCli$1(argv, {
1596
+ compile: compileStrings,
1597
+ exportStrings,
1598
+ importStrings
1599
+ }, ui),
1600
+ native: runNative,
1601
+ resolve: (specifier, from) => Bun.resolveSync(specifier, from),
1602
+ loadPage: (file) => import(file)
1603
+ };
1604
+ }
1605
+ /**
1606
+ * Runs one `moku-game` command line in this process: `dev`, `build`, `native <verb>`, `keys`,
1607
+ * `pack` or `help`, with `--root`, `--preload` and `--serve-plugin` before or after the command
1608
+ * word. Every line goes through the branded console; nothing exits the process.
1609
+ *
1610
+ * @param argv - The arguments after the bin.
1611
+ * @returns The exit code.
1612
+ * @example
1613
+ * ```ts
1614
+ * // bin/moku-game.mjs in a game folder:
1615
+ * await runCli(["dev", "--port", "0"]); // 0 after Ctrl+C; stdout had "http://localhost:<port>/"
1616
+ * await runCli(["help"]); // 0
1617
+ * ```
1618
+ */
1619
+ async function runCli(argv) {
1620
+ return runCommand(argv, processDeps());
1621
+ }
1622
+ /**
1623
+ * Writes a game's dev page into `<root>/.moku/` for a server the caller starts: `index.html`,
1624
+ * `dev.ts`, `main.ts` with the scenarios of `tests/scenarios/` (and, with agents, the agents and
1625
+ * the game's `.dev` modules), and `bunfig.toml` with the engine's hot plugin. It starts no server
1626
+ * and no watcher, and throws the `[game]` errors of `moku-game dev`.
1627
+ *
1628
+ * @param root - The game folder, relative to the cwd or absolute.
1629
+ * @param options - The agents, the preloads and the extra bundler plugins.
1630
+ * @returns The absolute paths of the HTML and the bunfig.
1631
+ * @throws {Error} When the game has no `config.ts` or `index.ts`, a config value is refused, the
1632
+ * game does not resolve `@moku-labs/game/hot`, or a preload or plugin is not a file.
1633
+ * @example
1634
+ * ```ts
1635
+ * // The editor bin, started with --root games/timber:
1636
+ * const { preparePage } = await import(Bun.resolveSync("@moku-labs/game/cli", root));
1637
+ * await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] });
1638
+ * // { html: "<cwd>/games/timber/.moku/index.html", bunfig: "<cwd>/games/timber/.moku/bunfig.toml" }
1639
+ * ```
1640
+ */
1641
+ async function preparePage(root, options = {}) {
1642
+ const absolute = (files) => (files ?? []).map((file) => path.resolve(file));
1643
+ return preparePageAt(path.resolve(root), {
1644
+ ...options.agents === void 0 ? {} : { agents: options.agents },
1645
+ preload: absolute(options.preload),
1646
+ servePlugins: absolute(options.servePlugins)
1647
+ }, (specifier, from) => Bun.resolveSync(specifier, from));
1648
+ }
1649
+ //#endregion
1650
+ export { preparePage, runCli };