@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.
- package/README.md +81 -22
- package/bin/moku-game.mjs +4 -0
- package/dist/app/page.d.mts +41 -0
- package/dist/app/page.mjs +376 -0
- package/dist/app/system.d.mts +129 -0
- package/dist/app/system.mjs +307 -0
- package/dist/app.d.mts +37 -0
- package/dist/app.mjs +235 -0
- package/dist/assets-BHwTLVta.mjs +7391 -0
- package/dist/assets.d.mts +1 -1
- package/dist/assets.mjs +1 -7390
- package/dist/cli.d.mts +70 -0
- package/dist/cli.mjs +1650 -0
- package/dist/{commands-DuwCxK5t.d.mts → commands-C4QlqijJ.d.mts} +5 -5
- package/dist/config-CdARrrhU.mjs +81 -0
- package/dist/control.d.mts +11 -10
- package/dist/control.mjs +3 -3
- package/dist/{define-B6pHpdBi.d.mts → define-CCc0ozKe.d.mts} +2 -2
- package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
- package/dist/drafts-BawpSaVp.mjs +156 -0
- package/dist/fake-MpzwZhIZ.mjs +87 -0
- package/dist/{headless-SCQXJxQf.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
- package/dist/hot.d.mts +4 -6
- package/dist/hot.mjs +8 -9
- package/dist/{index-BO9kZzY3.d.mts → index-xxSxKJJW.d.mts} +17 -17
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +1 -1
- package/dist/inspect-la_KkjCS.mjs +1 -0
- package/dist/inspect.d.mts +7 -7
- package/dist/inspect.mjs +3 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/lint.mjs +10 -6
- package/dist/memory-CWlqhS7l.mjs +136 -0
- package/dist/memory-DwDhfFWK.d.mts +50 -0
- package/dist/project.mjs +3 -3
- package/dist/{run-CofJEdYx.mjs → run-wYty_Lwo.mjs} +2 -2
- package/dist/{runtime-CdNJ0UBm.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
- package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
- package/dist/{sources-DNnzyCYf.mjs → sources-Duzj6lhV.mjs} +2 -2
- package/dist/{src-536arXjV.mjs → src-DSl1eNB1.mjs} +14 -292
- package/dist/testing.d.mts +4 -50
- package/dist/testing.mjs +3 -87
- package/dist/{types-VZVWwqCg.d.mts → types-BM4shh8K.d.mts} +259 -259
- package/dist/{types-xXfyk56Q.d.mts → types-BUwDHKXm.d.mts} +1377 -1377
- package/dist/{types-CQueODzo.d.mts → types-CefgYzFt.d.mts} +2 -2
- package/dist/{types-DUMoYPYe.d.mts → types-DAdwEJMp.d.mts} +2 -2
- package/dist/{types-B0v5VVa3.d.mts → types-ZQLYXo5X.d.mts} +304 -304
- package/dist/types-hQgI_wrq.d.mts +544 -0
- package/dist/visual.d.mts +6 -6
- package/dist/visual.mjs +5 -5
- package/llms.txt +119 -43
- 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
|
|
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
|
-
//
|
|
99
|
-
import {
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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) |
|
|
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:
|
|
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.
|
|
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
|
|
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,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 };
|