@moku-labs/game 0.5.0 → 0.6.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 CHANGED
@@ -194,6 +194,7 @@ createApp({ plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin] });
194
194
  | `@moku-labs/game/control` | dev builds | Drive a running game |
195
195
  | `@moku-labs/game/hot` | the Bun dev server | The `bunfig.toml` plugin that hot swaps views. See [Hot swap](./docs/hot-swap.md) |
196
196
  | `@moku-labs/game/lint` | oxlint | The game lint rules, as an oxlint JS plugin |
197
+ | `@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) |
197
198
  | `@moku-labs/game/jsx-runtime`, `/jsx-dev-runtime` | anywhere | The JSX runtime. A game never imports it by hand. |
198
199
 
199
200
  ### Lint for games
@@ -229,6 +230,7 @@ Each rule takes `{ "files": [...], "ignores": [...] }`. The defaults follow the
229
230
  | [Events](./docs/events.md) | Every event and its payload |
230
231
  | [Configuration](./docs/configuration.md) | Every config field and its default |
231
232
  | [Lint for games](./docs/lint.md) | The `moku-game` oxlint rules, their options and defaults |
233
+ | [Project index](./docs/project-index.md) | `openProject`, the key scheme, `find` and `watch`, the `moku-game-index` bin |
232
234
  | [Testing](./docs/testing.md) | Headless and visual tests, all scripts, test layout, lint rules |
233
235
  | [`llms.txt`](./llms.txt) | The engine in one page, for an AI that writes a game |
234
236
 
@@ -265,7 +267,7 @@ A save of a logic file reloads the page. What swaps and what reloads: [docs/hot-
265
267
 
266
268
  - **Node `>= 24`** and **Bun `>= 1.3.14`**. Use `bun` only.
267
269
  - **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
268
- - **`pixi.js` `^8.0.0`** as a peer dependency. `playwright-core` for visual tests and `sharp` for the asset pack are optional peers. `yoga-layout` is loaded lazily by `ui`. The string compiler uses `@formatjs/icu-messageformat-parser` at build time only.
270
+ - **`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.
269
271
  - **[`@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`.
270
272
 
271
273
  ## License
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { runCli } from "../dist/project.mjs";
3
+
4
+ process.exitCode = await runCli(process.argv.slice(2));
@@ -0,0 +1,267 @@
1
+ //#region src/project/types.d.ts
2
+ /**
3
+ * @file project — the public types of the project index: the options of `openProject`, the index
4
+ * it holds, the anchors of a key, the fresh lines `find` answers and the handle itself. The index
5
+ * maps every engine id of a game (flows, nodes, features, scenes, projections, emitters, text
6
+ * styles, styles and JSX keys) to anchors: a path plus a binding, a key or a component. It never
7
+ * holds a line number; `find` reads the line from the file as it is on disk at the call. No type
8
+ * here names a `typescript` type, so the shipped declarations need no TypeScript.
9
+ */
10
+ /**
11
+ * What `openProject` reads. Paths are root-relative POSIX; the root itself is resolved against the
12
+ * working directory once and never inferred.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * // The editor opens the fixture game with the default manifest and a slower debounce.
17
+ * const options: ProjectOptions = { root: "tests/integration/merge-game", debounceMs: 120 };
18
+ * ```
19
+ */
20
+ type ProjectOptions = {
21
+ /** The folder of the game. A path that is not a directory rejects `openProject`. */root: string; /** The asset manifest, root-relative. Default `manifest.json`, the scanner's default. */
22
+ manifest?: string; /** The quiet period, in milliseconds, that closes a batch of file events. Default 75. */
23
+ debounceMs?: number;
24
+ };
25
+ /**
26
+ * How a JSX key is written: a string literal `key="hudRow"`, a template `` key={`${id}Picture`} ``,
27
+ * any other expression `key={id}`, or a literal `id=` prop on a component (`id="settingsBoard"`).
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const kind: JsxKind = "idProp"; // id="settingsBoard" on <Signboard>
32
+ * ```
33
+ */
34
+ type JsxKind = "literal" | "template" | "idProp" | "ident";
35
+ /**
36
+ * Where a key is defined or used, without a line: the file and what in it holds the key. `find`
37
+ * turns an anchor into a line on the file as it is now.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * // node:board/merge, defined by the const `merge` of nodes/merge.ts.
42
+ * const anchor: Anchor = { path: "nodes/merge.ts", binding: "merge" };
43
+ * ```
44
+ */
45
+ type Anchor = {
46
+ /** The file, root-relative POSIX. */path: string; /** The const, function or export the definition is bound to. */
47
+ binding?: string; /** The property or attribute key: a text style key, the `name` of a projection, a JSX key. */
48
+ key?: string; /** JSX: the component an `id=` prop sits on, or the component an `{id}` pattern is written in. */
49
+ component?: string; /** JSX only: how the key is written. */
50
+ kind?: JsxKind; /** JSX templates and identifiers: the literal head of the pattern, `card` for `card*Picture`. */
51
+ stem?: string;
52
+ };
53
+ /**
54
+ * The index of one game at one moment: every key with its anchors, every indexed file with its
55
+ * hash, and what could not be read without guessing. Computed on demand, never written to disk.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * index.symbols["flow:board"]; // { def: [{ path: "flows/board.ts", binding: "boardFlow" }] }
60
+ * index.files["nodes/merge.ts"]?.state; // "ok"
61
+ * ```
62
+ */
63
+ type ProjectIndex = {
64
+ /** The shape of this object. A change of shape bumps it. */schemaVersion: 1; /** sha1 hex over the sorted lines `${path}\0${hash}\n` of every indexed file. */
65
+ revision: string; /** The asset manifest, root-relative, when that file exists. */
66
+ manifest?: string;
67
+ /**
68
+ * Every key: where it is defined (`def`), where its binding is used (`uses`, nodes and styles,
69
+ * one level), and `conflict` when a key other than `jsx:` is defined twice.
70
+ */
71
+ symbols: Record<string, {
72
+ def: Anchor[];
73
+ uses?: Anchor[];
74
+ conflict?: true;
75
+ }>; /** Every indexed file: the sha1 of its bytes, and `broken` with the first parse error. */
76
+ files: Record<string, {
77
+ hash: string;
78
+ state: "ok" | "broken";
79
+ error?: string;
80
+ }>; /** What a definer or a JSX key holds that cannot be read without running the game. */
81
+ unresolved: {
82
+ path: string;
83
+ reason: string;
84
+ }[];
85
+ };
86
+ /**
87
+ * What one watch batch changed: the new revision, the files whose bytes changed, the keys that
88
+ * left one file for another, and the keys gone from every file.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * // An agent moved nodes/toast.ts to nodes/board/toast.ts and fixed the import of the board flow.
93
+ * change.files; // ["flows/board.ts", "nodes/board/toast.ts", "nodes/toast.ts"]
94
+ * change.moved; // [{ key: "node:board/toast", from: "nodes/toast.ts", to: "nodes/board/toast.ts" }]
95
+ * change.removed; // []
96
+ * ```
97
+ */
98
+ type ProjectChange = {
99
+ /** The revision of the index after the batch. */revision: string; /** The root-relative paths whose bytes changed, appeared or vanished, sorted. */
100
+ files: string[]; /** A key that left one file and appeared in another in the same batch. */
101
+ moved: {
102
+ key: string;
103
+ from: string;
104
+ to: string;
105
+ }[]; /** Keys gone from every file. */
106
+ removed: string[];
107
+ };
108
+ /**
109
+ * One answer of `find`: the anchor with its line read from the file now. Lines and columns are
110
+ * 1-based; the end column is exclusive. A binding answers its declaration statement, a text style
111
+ * key its property, a JSX key the line of its attribute with the whole element as the range.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * // The editor opens the Home scene of the fixture game.
116
+ * const [found] = await project.find("scene:home");
117
+ * [found?.path, found?.line, found?.range]; // ["features/home/scene.ts", 8, [8, 1, 13, 4]]
118
+ * ```
119
+ */
120
+ type Found = Anchor & {
121
+ /** The line, 1-based. */line: number; /** Start line, start column, end line, end column; 1-based, the end column exclusive. */
122
+ range: [startLine: number, startColumn: number, endLine: number, endColumn: number]; /** sha1 hex of the bytes the line was read from. */
123
+ hash: string; /** The file does not parse now; the line comes from its last good parse. */
124
+ broken?: true;
125
+ };
126
+ /**
127
+ * The handle `openProject` returns: one index of one game root, kept fresh by one watcher, and
128
+ * the lines of a key read from disk at the call. Close it when the game closes.
129
+ */
130
+ type ProjectApi = {
131
+ /**
132
+ * The current index. A watch batch or `changed` replaces it with a new frozen object.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * // The editor checks a node file before it opens it.
137
+ * project.index.files["nodes/merge.ts"]?.state; // "ok"
138
+ * ```
139
+ */
140
+ readonly index: ProjectIndex;
141
+ /**
142
+ * Answers where a key is defined, with lines read from the file on disk now. A file whose bytes
143
+ * changed since the index was built is parsed again for the answer; the index itself stays as
144
+ * it is until the next watch batch. A JSX key the game reports at run time also finds the
145
+ * patterns it was built from: exact keys first, then `{id}` patterns filled with a literal
146
+ * `id=` prop (both the pattern and the prop come back), then `*` patterns. An unknown key
147
+ * answers `[]`.
148
+ *
149
+ * @param key - An engine id: `node:board/merge`, `flow:board`, `textStyle:ui.title`, `jsx:card2Picture`.
150
+ * @returns One entry per place, in the order above; `broken` when the file does not parse now.
151
+ * @example
152
+ * ```ts
153
+ * // The editor jumps to the node the flow view clicked, and to the X of the settings board.
154
+ * (await project.find("node:board/merge"))[0]?.line; // 17, in "nodes/merge.ts"
155
+ * (await project.find("jsx:settingsBoardClose")).map(found => `${found.path}:${found.line}`);
156
+ * // ["features/ui/kit.tsx:844", "features/settings/settings.tsx:301"]
157
+ * ```
158
+ */
159
+ find(key: string): Promise<Found[]>;
160
+ /**
161
+ * Calls back once per batch of file changes that changed the index, with the new index and
162
+ * what changed. Events only start a batch: when the quiet period ends, the root is walked and
163
+ * every file whose size or time stamp moved is hashed; a save with the same bytes changes
164
+ * nothing and calls nobody. The handle also walks once when watching starts and every two
165
+ * seconds after, for what the platform watcher missed. Every caller shares the one watcher of
166
+ * the handle. An error thrown by a callback is dropped, so the other callers still hear the
167
+ * batch.
168
+ *
169
+ * @param onIndex - Gets the new index and the change of the batch.
170
+ * @returns Removes this caller; the watcher stops with the last one.
171
+ * @throws {Error} When the handle is closed.
172
+ * @example
173
+ * ```ts
174
+ * // The editor refreshes its flow pages when an agent saves a game file.
175
+ * const stop = project.watch((index, change) => pages.refresh(index.revision, change.moved));
176
+ * stop(); // this caller hears no more batches
177
+ * ```
178
+ */
179
+ watch(onIndex: (index: ProjectIndex, change: ProjectChange) => void): () => void;
180
+ /**
181
+ * Re-indexes one root-relative path now, for a caller that watches files itself. A deleted
182
+ * file drops its entries; a path that is not a `.ts` or `.tsx` source of the index changes
183
+ * nothing. The watch callers are not called.
184
+ *
185
+ * @param path - The root-relative POSIX path of the file.
186
+ * @returns The index after the file was read.
187
+ * @throws {Error} When the path leaves the root, by `..` or by a symlink.
188
+ * @example
189
+ * ```ts
190
+ * // The editor saved nodes/merge.ts through its own file layer.
191
+ * const index = await project.changed("nodes/merge.ts");
192
+ * index.files["nodes/merge.ts"]?.state; // "ok"
193
+ * ```
194
+ */
195
+ changed(path: string): Promise<ProjectIndex>;
196
+ /**
197
+ * Stops the watcher and drops every watch caller. `find` and `changed` keep working.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * // The editor closes the game.
202
+ * project.close(); // no more batches; project.watch(...) now throws
203
+ * ```
204
+ */
205
+ close(): void;
206
+ };
207
+ /**
208
+ * The part of the branded console the `moku-game-index` command line writes through. A
209
+ * `BrandConsole` of `@moku-labs/common/cli` fits it; a test passes a recorder.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * // A test records what the command printed.
214
+ * const lines: string[] = [];
215
+ * const ui: IndexUi = {
216
+ * line: text => lines.push(text),
217
+ * info: text => lines.push(text),
218
+ * error: text => lines.push(text)
219
+ * };
220
+ * ```
221
+ */
222
+ type IndexUi = {
223
+ /** Writes one line as it is: a `where` answer, the JSON of `--json`. */line(text: string): void; /** Writes one neutral line of the `--check` report. */
224
+ info(message: string): void; /** Writes one error line: a broken file, a conflict, an unknown key, a bad flag. */
225
+ error(message: string): void;
226
+ };
227
+ //#endregion
228
+ //#region src/project/cli.d.ts
229
+ /**
230
+ * Runs `moku-game-index`: opens the project of `--root` and runs one command. Nothing here calls
231
+ * `process.exit`; the caller does.
232
+ *
233
+ * @param argv - The arguments after the script name.
234
+ * @param ui - Where the lines go. The branded console by default.
235
+ * @returns The exit code: `1` for a bad flag, a root that cannot be opened, an unknown key, or a
236
+ * `--check` that found a broken file or a conflict; else `0`.
237
+ * @example
238
+ * ```ts
239
+ * // An agent asks where a node of the fixture game lives.
240
+ * await runCli(["--root", "tests/integration/merge-game", "where", "node:board/merge"]);
241
+ * // 0, and the console printed "nodes/merge.ts:17"
242
+ * ```
243
+ */
244
+ declare function runCli(argv: readonly string[], ui?: IndexUi): Promise<number>;
245
+ //#endregion
246
+ //#region src/project/open.d.ts
247
+ /**
248
+ * Opens the project index of a game: checks the root (it exists, it is a directory, its real
249
+ * path), loads TypeScript, parses every source file and builds the index. Nothing is written to
250
+ * disk and nothing is inferred from the working directory but the root itself.
251
+ *
252
+ * @param options - The game root, the manifest path and the quiet period of `watch`.
253
+ * @returns The handle; close it when the game closes.
254
+ * @throws {Error} When the root is missing or not a directory, or the `typescript` package is not
255
+ * installed: `[game] The project index needs the "typescript" package.`
256
+ * @example
257
+ * ```ts
258
+ * // The editor opens the fixture game and answers where a node and a text style live.
259
+ * const project = await openProject({ root: "tests/integration/merge-game" });
260
+ * Object.keys(project.index.files).length; // 111
261
+ * (await project.find("textStyle:ui.title"))[0]?.line; // 39, in "features/ui/styles.ts"
262
+ * project.close();
263
+ * ```
264
+ */
265
+ declare function openProject(options: ProjectOptions): Promise<ProjectApi>;
266
+ //#endregion
267
+ export { type Anchor, type Found, type IndexUi, type JsxKind, type ProjectApi, type ProjectChange, type ProjectIndex, type ProjectOptions, openProject, runCli };