@moku-labs/game 0.9.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 (53) hide show
  1. package/README.md +81 -22
  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 +1 -1
  11. package/dist/assets.mjs +1 -7390
  12. package/dist/cli.d.mts +70 -0
  13. package/dist/cli.mjs +1650 -0
  14. package/dist/{commands-DuwCxK5t.d.mts → commands-C4QlqijJ.d.mts} +5 -5
  15. package/dist/config-CdARrrhU.mjs +81 -0
  16. package/dist/control.d.mts +11 -10
  17. package/dist/control.mjs +3 -3
  18. package/dist/{define-B6pHpdBi.d.mts → define-CCc0ozKe.d.mts} +2 -2
  19. package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
  20. package/dist/drafts-BawpSaVp.mjs +156 -0
  21. package/dist/fake-MpzwZhIZ.mjs +87 -0
  22. package/dist/{headless-SCQXJxQf.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
  23. package/dist/hot.d.mts +4 -6
  24. package/dist/hot.mjs +8 -9
  25. package/dist/{index-BO9kZzY3.d.mts → index-xxSxKJJW.d.mts} +17 -17
  26. package/dist/index.d.mts +5 -5
  27. package/dist/index.mjs +1 -1
  28. package/dist/inspect-la_KkjCS.mjs +1 -0
  29. package/dist/inspect.d.mts +7 -7
  30. package/dist/inspect.mjs +3 -2
  31. package/dist/jsx-dev-runtime.d.mts +1 -1
  32. package/dist/jsx-runtime.d.mts +1 -1
  33. package/dist/lint.mjs +10 -6
  34. package/dist/memory-CWlqhS7l.mjs +136 -0
  35. package/dist/memory-DwDhfFWK.d.mts +50 -0
  36. package/dist/project.mjs +3 -3
  37. package/dist/{run-CofJEdYx.mjs → run-wYty_Lwo.mjs} +2 -2
  38. package/dist/{runtime-CdNJ0UBm.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
  39. package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
  40. package/dist/{sources-DNnzyCYf.mjs → sources-Duzj6lhV.mjs} +2 -2
  41. package/dist/{src-536arXjV.mjs → src-DSl1eNB1.mjs} +14 -292
  42. package/dist/testing.d.mts +4 -50
  43. package/dist/testing.mjs +3 -87
  44. package/dist/{types-VZVWwqCg.d.mts → types-BM4shh8K.d.mts} +259 -259
  45. package/dist/{types-xXfyk56Q.d.mts → types-BUwDHKXm.d.mts} +1377 -1377
  46. package/dist/{types-CQueODzo.d.mts → types-CefgYzFt.d.mts} +2 -2
  47. package/dist/{types-DUMoYPYe.d.mts → types-DAdwEJMp.d.mts} +2 -2
  48. package/dist/{types-B0v5VVa3.d.mts → types-ZQLYXo5X.d.mts} +304 -304
  49. package/dist/types-hQgI_wrq.d.mts +544 -0
  50. package/dist/visual.d.mts +6 -6
  51. package/dist/visual.mjs +5 -5
  52. package/llms.txt +119 -43
  53. package/package.json +30 -2
package/README.md CHANGED
@@ -15,7 +15,7 @@ You write small nodes and edge tables. The engine runs them, saves on the edges
15
15
 
16
16
  <br/>
17
17
 
18
- [Why](#why) · [Status](#status) · [Install](#install) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Plugins](#plugins) · [Docs](#docs)
18
+ [Why](#why) · [Status](#status) · [Install](#install) · [Quick start](#quick-start) · [Game shell](#game-shell) · [How it works](#how-it-works) · [Plugins](#plugins) · [Docs](#docs)
19
19
 
20
20
  ---
21
21
 
@@ -55,9 +55,11 @@ bun add @moku-labs/game pixi.js
55
55
  > [!IMPORTANT]
56
56
  > Bun only. ESM only. There is no CJS build. `pixi.js` `^8.0.0` is a peer dependency. Screens are `.tsx`, so a game sets `"jsx": "react-jsx"` and `"jsxImportSource": "@moku-labs/game"`.
57
57
 
58
+ A game in a native shell adds the optional peers: `bun add @moku-labs/system` for the system plugins and the store save, `bun add -d @moku-labs/native` for the native build. A web-only game installs neither.
59
+
58
60
  ## Quick start
59
61
 
60
- A dice game in three files: one rest node, one working node, one flow.
62
+ A dice game in a folder: one rest node, one working node, one flow, then the game and its page as data.
61
63
 
62
64
  ```ts
63
65
  // state.ts: bind the helpers to the types of the game
@@ -94,30 +96,81 @@ export const mainFlow = defineFlow("main", {
94
96
  });
95
97
  ```
96
98
 
99
+ A game in two files: `index.ts` is the game as one data object, `config.ts` the page as plain data.
100
+
97
101
  ```ts
98
- // game.test.ts: play it with no screen
99
- import { createApp } from "@moku-labs/game";
100
- import { createHeadless } from "@moku-labs/game/testing";
102
+ // index.ts: the game as one data object
103
+ import { defineGameApp } from "@moku-labs/game/app";
101
104
  import { mainFlow } from "./flow";
102
105
 
103
- const app = createApp({
104
- pluginConfigs: {
105
- model: { initialPlayer: { coins: 0 }, initialSession: { rolls: 0 }, seed: 42 },
106
- flow: { mainFlow, safeNode: "home" }
107
- },
108
- onStart: ctx => {
109
- ctx.flow.run().catch((error: unknown) => ctx.log.error("game: the graph failed", { error }));
110
- }
106
+ export default defineGameApp({
107
+ flow: mainFlow,
108
+ safeNode: "home",
109
+ player: { coins: 0 },
110
+ session: { rolls: 0 }
111
111
  });
112
+ ```
113
+
114
+ ```ts
115
+ // config.ts: the page, as plain data
116
+ import type { GameConfig } from "@moku-labs/game/app";
117
+
118
+ export default { page: { title: "Dice" } } satisfies GameConfig;
119
+ ```
120
+
121
+ Tests make the app with `game.headless()` or `game.screen()`, not started, on a fake clock and an in-memory save:
122
+
123
+ ```ts
124
+ // game.test.ts: play it with no screen
125
+ import { createHeadless } from "@moku-labs/game/testing";
126
+ import game from "./index";
112
127
 
113
- const game = await createHeadless(app);
114
- await game.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
128
+ const { app } = game.headless();
129
+ const run = await createHeadless(app);
130
+
131
+ await run.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
115
132
  app.model.store.snapshot().session; // { rolls: 2 }
116
133
  ```
117
134
 
118
- The full version, with a reset node and a vitest file, is in [docs/quick-start.md](./docs/quick-start.md). A bigger game, the merge game, lives in [moku-labs/demos](https://github.com/moku-labs/demos) with its tests.
135
+ `bunx moku-game dev` serves it with hot reload on `http://localhost:3000/`. The full version, with a reset node and a vitest file, is in [docs/quick-start.md](./docs/quick-start.md). A bigger game, the merge game, lives in [moku-labs/demos](https://github.com/moku-labs/demos) with its tests.
136
+
137
+ A game with a screen gets two more things from the package. The body font `fonts/font-body.*` (Pangolin, SIL OFL 1.1) is the default of the built-in text style `body` once copied into `features/ui/assets/`. `moku-game keys` writes the typed asset keys, the manifest and the strings. Both are in [docs/quick-start.md](./docs/quick-start.md#a-screen-the-body-font-and-the-asset-keys).
138
+
139
+ ## Game shell
140
+
141
+ The engine bin `moku-game` serves, builds and packs a game folder. A game writes no page, no server, no bridge and no `bunfig.toml`.
142
+
143
+ | Path | Holds |
144
+ |---|---|
145
+ | `index.ts` | `export default defineGameApp({ ... })` |
146
+ | `config.ts` | `page`, `native`, `system`, `save`, `assets`: plain data that `satisfies GameConfig` |
147
+ | `core/`, `shared/`, `features/`, `plugins/` | The kit, the shared layer, the features, the game's plugins |
148
+ | `tests/` | The tests. `tests/scenarios/<name>.ts` are the saves of `?player=<name>` on the dev page |
149
+ | `.moku/` | What `moku-game` writes. Add `.moku/` to `.gitignore` |
150
+
151
+ ```json
152
+ {
153
+ "scripts": {
154
+ "dev": "moku-game dev",
155
+ "build": "moku-game build",
156
+ "native:ios-sim": "moku-game native build ios --simulator"
157
+ }
158
+ }
159
+ ```
160
+
161
+ | Command | Does |
162
+ |---|---|
163
+ | `moku-game dev [--port 3000] [--packed]` | Serves the game with hot reload. Prints the bound URL, also for `--port 0` |
164
+ | `moku-game build [--out dist/web]` | Packs the assets and builds the production page |
165
+ | `moku-game native build <target> [--simulator]` | Builds the native app: `ios`, `macos`, `android` |
166
+ | `moku-game native dev <target>` | Runs the native shell on the dev server |
167
+ | `moku-game native doctor`, `native clean` | Checks or removes the native project |
168
+ | `moku-game keys [--check]` | Writes `generated/assets.ts`, the strings and `manifest.json` |
169
+ | `moku-game pack [--no-cache]` | Packs the assets into `dist/assets` |
170
+
171
+ Every command takes `--root <dir>`, `--preload <path>` and `--serve-plugin <path>`.
119
172
 
120
- A game with a screen gets two more things from the package. The body font `fonts/font-body.*` (Pangolin, SIL OFL 1.1) is the default of the built-in text style `body` once copied into `src/features/ui/assets/`. The bin `moku-game-assets` writes the typed asset keys, the manifest and the strings. It runs under bun; with node only, run `bun node_modules/@moku-labs/game/dist/assets.mjs`. Both are in [docs/quick-start.md](./docs/quick-start.md#a-screen-the-body-font-and-the-asset-keys).
173
+ `config.ts` names the system plugins the page wires, `system: ["lifecycle", "back", "haptics", "keepAwake"]`, and the save, `save: "memory" | "local" | "store"`. A `system` list and the `"store"` save need the optional peer `@moku-labs/system`; `moku-game native` needs `@moku-labs/native`. The game imports neither. The whole shell is in [docs/shell.md](./docs/shell.md).
121
174
 
122
175
  ## How it works
123
176
 
@@ -188,12 +241,16 @@ createApp({ plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin] });
188
241
  | Import | Runs in | For |
189
242
  |---|---|---|
190
243
  | `@moku-labs/game` | anywhere | The engine: plugins, helpers, components |
244
+ | `@moku-labs/game/app` | anywhere | `defineGameApp`, `startMoment` and the types of `index.ts` and `config.ts`. See [The game shell](./docs/shell.md) |
245
+ | `@moku-labs/game/app/page` | the browser | `startPage`, the page the generated `.moku/main.ts` calls |
246
+ | `@moku-labs/game/app/system` | the browser, the native shell | The system shell over the optional peer `@moku-labs/system` |
247
+ | `@moku-labs/game/cli` | Node and Bun | `runCli`, the bin `moku-game`; `preparePage`, the dev page for the editor |
191
248
  | `@moku-labs/game/testing` | anywhere | Headless and isolated games, the fake clock, the in-memory save |
192
249
  | `@moku-labs/game/visual` | Node and Bun | Visual tests: baselines of state, describe and pixels |
193
250
  | `@moku-labs/game/assets` | Node and Bun | Asset keys, the manifest, strings, the pack |
194
251
  | `@moku-labs/game/inspect` | anywhere | Read a running game |
195
252
  | `@moku-labs/game/control` | dev builds | Drive a running game |
196
- | `@moku-labs/game/hot` | the Bun dev server | The `bunfig.toml` plugin that hot swaps views. See [Hot swap](./docs/hot-swap.md) |
253
+ | `@moku-labs/game/hot` | the Bun dev server | The Bun plugin that hot swaps views. `moku-game dev` lists it. See [Hot swap](./docs/hot-swap.md) |
197
254
  | `@moku-labs/game/lint` | oxlint | The game lint rules, as an oxlint JS plugin |
198
255
  | `@moku-labs/game/project` | Node and Bun | Where every engine id of a game is defined, line fresh at the call. The bin `moku-game-index`. See [Project index](./docs/project-index.md) |
199
256
  | `@moku-labs/game/jsx-runtime`, `/jsx-dev-runtime` | anywhere | The JSX runtime. A game never imports it by hand. |
@@ -228,6 +285,7 @@ Each rule takes `{ "files": [...], "ignores": [...] }`. `layer-imports`, `featur
228
285
  | Page | What is inside |
229
286
  |---|---|
230
287
  | [Quick start](./docs/quick-start.md) | The full dice game, step by step |
288
+ | [The game shell](./docs/shell.md) | `defineGameApp`, `config.ts`, the page, the save, system and native, the `moku-game` commands |
231
289
  | [Plugins](./docs/plugins.md) | The dependency graph, the plugin table, every root export |
232
290
  | [Interface in JSX](./docs/jsx.md) | Tags, components, styles, popups |
233
291
  | [Doors for the editor](./docs/doors.md) | `/inspect` and `/control`, base sources and commands |
@@ -236,7 +294,7 @@ Each rule takes `{ "files": [...], "ignores": [...] }`. `layer-imports`, `featur
236
294
  | [Configuration](./docs/configuration.md) | Every config field and its default |
237
295
  | [Lint for games](./docs/lint.md) | The `moku-game` oxlint rules, their options and defaults |
238
296
  | [Project index](./docs/project-index.md) | `openProject`, the key scheme, `find` and `watch`, the `moku-game-index` bin |
239
- | [Testing](./docs/testing.md) | Headless, isolated and visual tests, all scripts, test layout, lint rules |
297
+ | [Testing](./docs/testing.md) | `game.headless()` and `game.screen()`, headless, isolated and visual tests, all scripts, test layout, lint rules |
240
298
  | [`llms.txt`](./llms.txt) | The engine in one page, for an AI that writes a game |
241
299
 
242
300
  Each plugin has its own README, linked in the table above. The kernel is specified in the [Moku Core specification](https://github.com/moku-labs/core/tree/main/specification).
@@ -250,7 +308,8 @@ bun run lint # biome check . && eslint .
250
308
  bun run test # vitest, unit and integration
251
309
  bun run test:coverage # with the 90% threshold
252
310
  bun run validate # publint and attw
253
- bun run mini:pack # pack the mini game of tests/fixtures
311
+ bun run mini:dev # moku-game dev of the mini game of tests/fixtures
312
+ bun run mini:pack # moku-game pack of the mini game
254
313
  bun run mini:visual # visual tests of the mini game
255
314
  bun run release # moku-release
256
315
  ```
@@ -259,7 +318,7 @@ Every script is in [docs/testing.md](./docs/testing.md#scripts).
259
318
 
260
319
  ### Hot swap
261
320
 
262
- A save of a view file swaps it in the running page, with the same state. One line in the game's `bunfig.toml`:
321
+ A save of a view file swaps it in the running page, with the same state. `moku-game dev` lists the plugin in the bunfig it writes, so a game sets nothing. A game with a server of its own lists it in its `bunfig.toml`:
263
322
 
264
323
  ```toml
265
324
  [serve.static]
@@ -272,7 +331,7 @@ A save of a logic file reloads the page. What swaps and what reloads: [docs/hot-
272
331
 
273
332
  - **Node `>= 24`** and **Bun `>= 1.3.14`**. Use `bun` only.
274
333
  - **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
275
- - **`pixi.js` `^8.0.0`** as a peer dependency. `playwright-core` for visual tests, `sharp` for the asset pack and `typescript` for the project index are optional peers. `yoga-layout` is loaded lazily by `ui`. The string compiler uses `@formatjs/icu-messageformat-parser` at build time only.
334
+ - **`pixi.js` `^8.0.0`** as a peer dependency. `playwright-core` for visual tests, `sharp` for the asset pack, `typescript` for the project index, `@moku-labs/system` for the system shell and `@moku-labs/native` for the native build are optional peers. `yoga-layout` is loaded lazily by `ui`. The string compiler uses `@formatjs/icu-messageformat-parser` at build time only.
276
335
  - **[`@moku-labs/core`](https://github.com/moku-labs/core)** is the kernel. **[`@moku-labs/common`](https://github.com/moku-labs/common)** brings `log` and `env`.
277
336
 
278
337
  ## License
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { runCli } from "../dist/cli.mjs";
3
+
4
+ process.exitCode = await runCli(process.argv.slice(2));
@@ -0,0 +1,41 @@
1
+ import { h as StartedPage, r as GameConfig, t as AnyGameApp, u as PageOptions } from "../types-hQgI_wrq.mjs";
2
+
3
+ //#region src/app/page.d.ts
4
+ /**
5
+ * Runs a game on its page: resolves `config.ts`, picks the start (a `?player=` scenario runs on a
6
+ * fresh memory save, never on the stored one), builds the system shell when the page has one,
7
+ * picks the save, makes the screen app on `#game` over the device clock and the manifest next to
8
+ * the page, sets `globalThis.game`, `system` and `doors`, follows reduced motion, starts the shell,
9
+ * then the app, and runs the graph. A graph that stops is logged, never thrown. In dev it then
10
+ * starts the agents. Every problem goes to the app's log.
11
+ *
12
+ * @param game - The default export of the game's `index.ts`.
13
+ * @param config - The default export of the game's `config.ts`.
14
+ * @param options - What the CLI found: scenarios, the shell factory, agents, `.dev` modules.
15
+ * @returns The running screen app, and the system app for `globalThis.system`.
16
+ * @throws {Error} When `config.ts` holds a value TypeScript would refuse, or the save is `"store"`
17
+ * and the page has no system shell.
18
+ * @example
19
+ * ```ts
20
+ * // Written by moku-game dev. Do not edit.
21
+ * import "./dev.ts";
22
+ * import { startPage } from "@moku-labs/game/app/page";
23
+ * import { systemShell } from "@moku-labs/game/app/system";
24
+ * import game from "../index.ts";
25
+ * import config from "../config.ts";
26
+ * import scenario0 from "../tests/scenarios/empty.ts";
27
+ * import scenario1 from "../tests/scenarios/full.ts";
28
+ * import * as devModule0 from "../features/board/board.dev.ts";
29
+ * import agent0 from "@moku-labs/editor/agent/page";
30
+ *
31
+ * await startPage(game, config, {
32
+ * scenarios: { "empty": scenario0, "full": scenario1 },
33
+ * system: systemShell,
34
+ * agents: [agent0],
35
+ * devModules: [devModule0]
36
+ * });
37
+ * ```
38
+ */
39
+ declare function startPage<Game extends AnyGameApp>(game: Game, config: GameConfig, options?: PageOptions): Promise<StartedPage<Game>>;
40
+ //#endregion
41
+ export { startPage };
@@ -0,0 +1,376 @@
1
+ import { r as systemSource, t as memory } from "../memory-CWlqhS7l.mjs";
2
+ import { t as applyTo } from "../drafts-BawpSaVp.mjs";
3
+ import { n as read, r as watch, t as sources } from "../sources-Duzj6lhV.mjs";
4
+ import { t as resolveConfig } from "../config-CdARrrhU.mjs";
5
+ //#region src/app/save.ts
6
+ /**
7
+ * @file The saves of the page: `localSave` keeps the player in `localStorage`, `pageSave` picks
8
+ * the save `config.save` names. The store save is the system shell's (`storeSave` of
9
+ * `./system`): this file imports no system code, so a web-only page bundles none. Not exported
10
+ * from any entry: the page is the one caller.
11
+ */
12
+ /**
13
+ * Tells whether a JSON value is an object, not an array or `null`.
14
+ *
15
+ * @param value - A JSON value.
16
+ * @returns True for a JSON object.
17
+ * @example
18
+ * ```ts
19
+ * isDocument([1, 2]); // false
20
+ * ```
21
+ */
22
+ function isDocument(value) {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+ /**
26
+ * Tells whether a parsed JSON value is a save: a document under `state` and a number under
27
+ * `version`.
28
+ *
29
+ * @param value - The parsed text.
30
+ * @returns True for a save.
31
+ * @example
32
+ * ```ts
33
+ * isStored({ state: {}, version: "1" }); // false
34
+ * ```
35
+ */
36
+ function isStored(value) {
37
+ return isDocument(value) && isDocument(value.state) && typeof value.version === "number";
38
+ }
39
+ /**
40
+ * Reads the stored text as a save: the document and its schema version.
41
+ *
42
+ * @param text - What `localStorage` holds under the key.
43
+ * @returns The save, or `undefined` when the text is not JSON or not a save.
44
+ * @example
45
+ * ```ts
46
+ * parseStored('{ "state": {}, "version": 1 }'); // { state: {}, version: 1 }
47
+ * ```
48
+ */
49
+ function parseStored(text) {
50
+ try {
51
+ const value = JSON.parse(text);
52
+ return isStored(value) ? {
53
+ state: value.state,
54
+ version: value.version
55
+ } : void 0;
56
+ } catch {
57
+ return;
58
+ }
59
+ }
60
+ /**
61
+ * Says what a failed write threw, without a closing period: the sentence adds its own.
62
+ *
63
+ * @param thrown - What `setItem` threw.
64
+ * @returns The message.
65
+ * @example
66
+ * ```ts
67
+ * reasonOf(new Error("The quota has been exceeded.")); // "The quota has been exceeded"
68
+ * ```
69
+ */
70
+ function reasonOf(thrown) {
71
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
72
+ return message.endsWith(".") ? message.slice(0, -1) : message;
73
+ }
74
+ /**
75
+ * Tries `localStorage` once: a write and a removal of a probe key.
76
+ *
77
+ * @param key - The key of the save.
78
+ * @returns The storage, or `undefined` when the page has none or it refuses.
79
+ */
80
+ function probeStorage(key) {
81
+ try {
82
+ const storage = globalThis.localStorage;
83
+ storage.setItem(`${key}:probe`, "1");
84
+ storage.removeItem(`${key}:probe`);
85
+ return storage;
86
+ } catch {
87
+ return;
88
+ }
89
+ }
90
+ /**
91
+ * Makes the save that keeps the player in `localStorage` under one key. Each commit applies its
92
+ * patches to the held document and writes the whole value at once: the value is small and the
93
+ * write is synchronous, so nothing is pending and `flush` has nothing to do. A page with no
94
+ * `localStorage`, or one that refuses a write (private mode, site data off), gets a memory save
95
+ * and a report.
96
+ *
97
+ * @param key - The key of the save, `<namespace>:save`.
98
+ * @param report - Where a problem goes: the app's warn log once the app exists.
99
+ * @returns The save provider.
100
+ */
101
+ function localSave(key, report) {
102
+ const storage = probeStorage(key);
103
+ if (storage === void 0) {
104
+ report("[game] localStorage is not available, so the save lives in memory.\n Progress is lost when the page closes.");
105
+ return memory();
106
+ }
107
+ const held = { document: void 0 };
108
+ /**
109
+ * Applies one commit to the held document and writes the whole value.
110
+ *
111
+ * @param patches - The patches of the commit.
112
+ * @param version - The schema version this build writes.
113
+ * @returns The sentence of a failed write, or `undefined` when the value is written.
114
+ */
115
+ const write = (patches, version) => {
116
+ const document = applyTo(held.document ?? {}, patches);
117
+ const value = {
118
+ state: document,
119
+ version
120
+ };
121
+ held.document = document;
122
+ try {
123
+ storage.setItem(key, JSON.stringify(value));
124
+ return;
125
+ } catch (error) {
126
+ return `[game] The local save was not written: ${reasonOf(error)}.\n Free browser storage; progress since the last write is lost on close.`;
127
+ }
128
+ };
129
+ return {
130
+ load: async () => {
131
+ const text = storage.getItem(key);
132
+ if (text === null) return null;
133
+ const stored = parseStored(text);
134
+ if (stored === void 0) throw new Error(`[game] The local save is not JSON.\n Clear the site data or the key "${key}".`);
135
+ held.document = stored.state;
136
+ return stored;
137
+ },
138
+ commit: (patches, version) => {
139
+ const failure = write(patches, version);
140
+ if (failure !== void 0) report(failure);
141
+ },
142
+ commitDurable: async (patches, _txId, version) => {
143
+ const failure = write(patches, version);
144
+ if (failure !== void 0) throw new Error(failure);
145
+ },
146
+ flush: () => Promise.resolve()
147
+ };
148
+ }
149
+ /**
150
+ * Picks the save `config.save` names: a fresh memory save, the local save under
151
+ * `<namespace>:save`, or the system shell's store save.
152
+ *
153
+ * @param kind - The save kind of the resolved config.
154
+ * @param where - The key prefix, the shell and the reporter.
155
+ * @returns The save provider of the page.
156
+ * @throws {Error} When the kind is `"store"` and the page has no shell with a store save.
157
+ */
158
+ function pageSave(kind, where) {
159
+ switch (kind) {
160
+ case "memory": return memory();
161
+ case "local": return localSave(`${where.namespace}:save`, where.report);
162
+ case "store":
163
+ if (where.shell?.save === void 0) throw new Error("[game] config.save is \"store\", but the page has no system shell.\n Run moku-game dev or build again: it passes the shell when config.ts names save \"store\".");
164
+ return where.shell.save;
165
+ }
166
+ }
167
+ //#endregion
168
+ //#region src/app/page.ts
169
+ /**
170
+ * @file The `@moku-labs/game/app/page` entry: `startPage`, the page of a game. The generated
171
+ * `.moku/main.ts` calls it with the game, its config and what the CLI found: scenarios, the system
172
+ * shell, the dev agents and the `.dev` modules. It reaches the shell only through
173
+ * `options.system`, never by an import, so a web-only page bundles no system code. Its dev parts
174
+ * (the write door `/control`, the agents, the audio journal) sit behind the inline dev guard, so a
175
+ * production build drops them.
176
+ */
177
+ /** How many started sounds the audio journal of the dev page keeps. */
178
+ const DEV_AUDIO_JOURNAL = 200;
179
+ /**
180
+ * Makes the reporter of the page: a problem found before the app exists (the scenario, the shell)
181
+ * waits for the app's log.
182
+ *
183
+ * @returns The reporter.
184
+ */
185
+ function lateReporter() {
186
+ const kept = [];
187
+ const sink = { log: void 0 };
188
+ return {
189
+ report: (problem) => {
190
+ if (sink.log === void 0) kept.push(problem);
191
+ else sink.log.warn(problem);
192
+ },
193
+ attach: (log) => {
194
+ sink.log = log;
195
+ for (const problem of kept.splice(0)) log.warn(problem);
196
+ }
197
+ };
198
+ }
199
+ /**
200
+ * Turns anything thrown into an `Error` for the log.
201
+ *
202
+ * @param thrown - What was thrown or rejected.
203
+ * @returns The error itself, or an error with its text.
204
+ * @example
205
+ * ```ts
206
+ * toError("no bridge").message; // "no bridge"
207
+ * ```
208
+ */
209
+ function toError(thrown) {
210
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
211
+ }
212
+ /**
213
+ * Picks the start of the page from `?player=`: the named scenario's player and session, at the
214
+ * device time. An unknown name is reported, and the page starts from the starting player.
215
+ *
216
+ * @param name - The value of `?player=`, or `null` without it.
217
+ * @param scenarios - The scenarios of the page, by name.
218
+ * @param now - The device time.
219
+ * @param report - Where the unknown name goes.
220
+ * @returns The player and session of the scenario, or `undefined`.
221
+ */
222
+ function scenarioStart(name, scenarios, now, report) {
223
+ if (name === null) return void 0;
224
+ const scenario = scenarios[name];
225
+ if (scenario !== void 0) return scenario(now);
226
+ report(`[game] No scenario "${name}".\n Known: ${Object.keys(scenarios).join(", ") || "none"}. The page starts from the starting player.`);
227
+ }
228
+ /**
229
+ * Makes the screen app of the page: the canvas on `#game`, the manifest next to the page, the
230
+ * shell's platform, the save, the device clock, and the scenario's start when one is named.
231
+ *
232
+ * @param game - The game.
233
+ * @param runtime - What the app runs on.
234
+ * @returns The app, not started.
235
+ */
236
+ function screenOf(game, runtime) {
237
+ const { settings, query, shell, start } = runtime;
238
+ return game.screen({
239
+ manifest: new URL("manifest.json", location.href).href,
240
+ renderer: {
241
+ mount: "#game",
242
+ ...query.get("renderer") === "webgl" ? { preference: "webgl" } : {}
243
+ },
244
+ ...shell === void 0 ? {} : { platform: shell.platform },
245
+ keepAwake: settings.system.includes("keepAwake"),
246
+ provider: runtime.provider,
247
+ ...start === void 0 ? {} : { player: start.player },
248
+ ...start?.session === void 0 ? {} : { session: start.session },
249
+ clock: runtime.clock,
250
+ audio: typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ ? { journal: DEV_AUDIO_JOURNAL } : {}
251
+ }).app;
252
+ }
253
+ /**
254
+ * Sets the three handles of the page on `globalThis`: `game` for e2e, `system` for the shell,
255
+ * `doors` for the editor. The write door `/control` is loaded in dev only.
256
+ *
257
+ * @param app - The screen app.
258
+ * @param shell - The system shell, or `undefined`.
259
+ * @returns Resolves when the handles are set.
260
+ */
261
+ async function setHandles(app, shell) {
262
+ Reflect.set(globalThis, "game", app);
263
+ Reflect.set(globalThis, "system", shell?.handle);
264
+ const control = typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ ? await import("../control.mjs") : void 0;
265
+ Reflect.set(globalThis, "doors", {
266
+ read,
267
+ watch,
268
+ sources,
269
+ ...control === void 0 ? {} : {
270
+ run: control.run,
271
+ commands: control.commands
272
+ }
273
+ });
274
+ }
275
+ /**
276
+ * Follows the system setting of reduced motion, also when the player changes it while the page
277
+ * runs.
278
+ *
279
+ * @param app - The screen app.
280
+ */
281
+ function followReducedMotion(app) {
282
+ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
283
+ app.anim.setReducedMotion(reducedMotion.matches);
284
+ reducedMotion.addEventListener("change", (event) => app.anim.setReducedMotion(event.matches));
285
+ }
286
+ /**
287
+ * Starts the dev agents, in order, after the app: each gets the app, the page title and the
288
+ * game's `.dev` modules. A throwing agent is logged and the page keeps running.
289
+ *
290
+ * @param app - The running app.
291
+ * @param settings - The resolved config.
292
+ * @param options - The agents and the dev modules.
293
+ * @returns Resolves when every agent started or failed.
294
+ */
295
+ async function startAgents(app, settings, options) {
296
+ for (const agent of options.agents ?? []) try {
297
+ await agent({
298
+ app,
299
+ name: settings.page.title,
300
+ modules: options.devModules ?? []
301
+ });
302
+ } catch (error) {
303
+ app.log.error("[game] A page agent failed to start.", void 0, toError(error));
304
+ }
305
+ }
306
+ /**
307
+ * Runs a game on its page: resolves `config.ts`, picks the start (a `?player=` scenario runs on a
308
+ * fresh memory save, never on the stored one), builds the system shell when the page has one,
309
+ * picks the save, makes the screen app on `#game` over the device clock and the manifest next to
310
+ * the page, sets `globalThis.game`, `system` and `doors`, follows reduced motion, starts the shell,
311
+ * then the app, and runs the graph. A graph that stops is logged, never thrown. In dev it then
312
+ * starts the agents. Every problem goes to the app's log.
313
+ *
314
+ * @param game - The default export of the game's `index.ts`.
315
+ * @param config - The default export of the game's `config.ts`.
316
+ * @param options - What the CLI found: scenarios, the shell factory, agents, `.dev` modules.
317
+ * @returns The running screen app, and the system app for `globalThis.system`.
318
+ * @throws {Error} When `config.ts` holds a value TypeScript would refuse, or the save is `"store"`
319
+ * and the page has no system shell.
320
+ * @example
321
+ * ```ts
322
+ * // Written by moku-game dev. Do not edit.
323
+ * import "./dev.ts";
324
+ * import { startPage } from "@moku-labs/game/app/page";
325
+ * import { systemShell } from "@moku-labs/game/app/system";
326
+ * import game from "../index.ts";
327
+ * import config from "../config.ts";
328
+ * import scenario0 from "../tests/scenarios/empty.ts";
329
+ * import scenario1 from "../tests/scenarios/full.ts";
330
+ * import * as devModule0 from "../features/board/board.dev.ts";
331
+ * import agent0 from "@moku-labs/editor/agent/page";
332
+ *
333
+ * await startPage(game, config, {
334
+ * scenarios: { "empty": scenario0, "full": scenario1 },
335
+ * system: systemShell,
336
+ * agents: [agent0],
337
+ * devModules: [devModule0]
338
+ * });
339
+ * ```
340
+ */
341
+ async function startPage(game, config, options = {}) {
342
+ const settings = resolveConfig(config);
343
+ const query = new URLSearchParams(location.search);
344
+ const { report, attach } = lateReporter();
345
+ const clock = systemSource();
346
+ const start = scenarioStart(query.get("player"), options.scenarios ?? {}, clock.now(), report);
347
+ const shell = options.system === void 0 ? void 0 : await options.system(settings, report);
348
+ const namespace = settings.native?.identifier ?? "moku-game";
349
+ const app = screenOf(game, {
350
+ settings,
351
+ query,
352
+ clock,
353
+ shell,
354
+ provider: start === void 0 ? pageSave(settings.save, {
355
+ namespace,
356
+ shell,
357
+ report
358
+ }) : memory(),
359
+ start
360
+ });
361
+ attach(app.log);
362
+ await setHandles(app, shell);
363
+ followReducedMotion(app);
364
+ await shell?.start();
365
+ await app.start();
366
+ app.flow.run().catch((error) => {
367
+ app.log.error("[game] The graph stopped.", void 0, toError(error));
368
+ });
369
+ if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) await startAgents(app, settings, options);
370
+ return {
371
+ app,
372
+ system: shell?.handle
373
+ };
374
+ }
375
+ //#endregion
376
+ export { startPage };