@moku-labs/game 0.7.0 → 0.8.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 (34) hide show
  1. package/README.md +5 -4
  2. package/dist/assets.d.mts +1 -1
  3. package/dist/{capture-fpd83Mi5.mjs → capture-BX5Gfoty.mjs} +1 -384
  4. package/dist/{commands-mzE9PX0H.d.mts → commands-C1kIfOG5.d.mts} +6 -4
  5. package/dist/control.d.mts +4 -3
  6. package/dist/control.mjs +2 -2
  7. package/dist/{define-BbgXv4cy.d.mts → define-BPpZcpxW.d.mts} +4 -3
  8. package/dist/headless-CvamCUcR.mjs +186 -0
  9. package/dist/headless-q98SwADR.d.mts +181 -0
  10. package/dist/index.d.mts +23 -22
  11. package/dist/index.mjs +17 -6
  12. package/dist/{input-CK4nxBO1.mjs → input-z_35Jwfn.mjs} +2 -2
  13. package/dist/inspect.d.mts +8 -6
  14. package/dist/inspect.mjs +2 -2
  15. package/dist/jsx-dev-runtime.d.mts +1 -1
  16. package/dist/jsx-runtime.d.mts +1 -1
  17. package/dist/project.d.mts +28 -28
  18. package/dist/project.mjs +7 -7
  19. package/dist/registry-DlpRCibU.mjs +384 -0
  20. package/dist/{run-Bcyg32M5.mjs → run-CUPSFM85.mjs} +4 -187
  21. package/dist/{runtime-HTlmRb81.d.mts → runtime-CvDZZg1S.d.mts} +1 -1
  22. package/dist/{sources-DVWJ8nfr.mjs → sources-BBUd15vQ.mjs} +1 -1
  23. package/dist/testing.d.mts +3 -328
  24. package/dist/testing.mjs +2 -2142
  25. package/dist/{types-BMEn0tSC.d.mts → types-BWzCSRM2.d.mts} +12 -2974
  26. package/dist/{types-CYYKjvLn.d.mts → types-CDSKa0vl.d.mts} +5 -182
  27. package/dist/{types-ClF8say0.d.mts → types-DF5kkW-C.d.mts} +5 -4
  28. package/dist/{types-BaUUcIaN.d.mts → types-DgdMLZnV.d.mts} +8 -7
  29. package/dist/{types-CXyipKhE.d.mts → types-DlgsfOmW.d.mts} +10 -9
  30. package/dist/types-ERb2Y9U5.d.mts +2967 -0
  31. package/dist/visual.d.mts +331 -0
  32. package/dist/visual.mjs +2142 -0
  33. package/llms.txt +6 -5
  34. package/package.json +7 -8
@@ -13,8 +13,8 @@
13
13
  *
14
14
  * @example
15
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 };
16
+ * // The editor opens the mini game with the default manifest and a slower debounce.
17
+ * const options: ProjectOptions = { root: "tests/fixtures/mini-game", debounceMs: 120 };
18
18
  * ```
19
19
  */
20
20
  type ProjectOptions = {
@@ -29,7 +29,7 @@ type ProjectOptions = {
29
29
  *
30
30
  * @example
31
31
  * ```ts
32
- * const kind: JsxKind = "idProp"; // id="settingsBoard" on <Signboard>
32
+ * const kind: JsxKind = "idProp"; // id="infoPanel" on <Panel>
33
33
  * ```
34
34
  */
35
35
  type JsxKind = "literal" | "template" | "idProp" | "ident";
@@ -39,8 +39,8 @@ type JsxKind = "literal" | "template" | "idProp" | "ident";
39
39
  *
40
40
  * @example
41
41
  * ```ts
42
- * // node:board/merge, defined by the const `merge` of nodes/merge.ts.
43
- * const anchor: Anchor = { path: "nodes/merge.ts", binding: "merge" };
42
+ * // node:infoPopup/count, defined by the const `count` of nodes/count.ts.
43
+ * const anchor: Anchor = { path: "nodes/count.ts", binding: "count" };
44
44
  * ```
45
45
  */
46
46
  type Anchor = {
@@ -66,8 +66,8 @@ type Anchor = {
66
66
  *
67
67
  * @example
68
68
  * ```ts
69
- * index.symbols["flow:board"]; // { def: [{ path: "flows/board.ts", binding: "boardFlow" }] }
70
- * index.files["nodes/merge.ts"]?.state; // "ok"
69
+ * index.symbols["flow:infoPopup"]; // { def: [{ path: "flows/info.ts", binding: "infoFlow" }] }
70
+ * index.files["nodes/count.ts"]?.state; // "ok"
71
71
  * ```
72
72
  */
73
73
  type ProjectIndex = {
@@ -99,9 +99,9 @@ type ProjectIndex = {
99
99
  *
100
100
  * @example
101
101
  * ```ts
102
- * // An agent moved nodes/toast.ts to nodes/board/toast.ts and fixed the import of the board flow.
103
- * change.files; // ["flows/board.ts", "nodes/board/toast.ts", "nodes/toast.ts"]
104
- * change.moved; // [{ key: "node:board/toast", from: "nodes/toast.ts", to: "nodes/board/toast.ts" }]
102
+ * // An agent moved nodes/count.ts to nodes/info/count.ts and fixed the import of the info flow.
103
+ * change.files; // ["flows/info.ts", "nodes/count.ts", "nodes/info/count.ts"]
104
+ * change.moved; // [{ key: "node:infoPopup/count", from: "nodes/count.ts", to: "nodes/info/count.ts" }]
105
105
  * change.removed; // []
106
106
  * ```
107
107
  */
@@ -123,9 +123,9 @@ type ProjectChange = {
123
123
  *
124
124
  * @example
125
125
  * ```ts
126
- * // The editor opens the Home scene of the fixture game.
126
+ * // The editor opens the Home scene of the mini game.
127
127
  * const [found] = await project.find("scene:home");
128
- * [found?.path, found?.line, found?.range]; // ["features/home/scene.ts", 8, [8, 1, 13, 4]]
128
+ * [found?.path, found?.line, found?.range]; // ["features/home/scene.ts", 8, [8, 1, 12, 4]]
129
129
  * ```
130
130
  */
131
131
  type Found = Anchor & {
@@ -145,7 +145,7 @@ type ProjectApi = {
145
145
  * @example
146
146
  * ```ts
147
147
  * // The editor checks a node file before it opens it.
148
- * project.index.files["nodes/merge.ts"]?.state; // "ok"
148
+ * project.index.files["nodes/count.ts"]?.state; // "ok"
149
149
  * ```
150
150
  */
151
151
  readonly index: ProjectIndex;
@@ -157,14 +157,14 @@ type ProjectApi = {
157
157
  * with a literal prop of the same name on their component (both the pattern and the prop come
158
158
  * back), then `*` patterns. An unknown key answers `[]`.
159
159
  *
160
- * @param key - An engine id: `node:board/merge`, `flow:board`, `textStyle:ui.title`, `jsx:card2Picture`.
160
+ * @param key - An engine id: `node:infoPopup/count`, `flow:main`, `textStyle:ui.counter`, `jsx:infoPanelSpark`.
161
161
  * @returns One entry per place, in the order above; `broken` when the file does not parse now.
162
162
  * @example
163
163
  * ```ts
164
- * // The editor jumps to the node the flow view clicked, and to the X of the settings board.
165
- * (await project.find("node:board/merge"))[0]?.line; // 17, in "nodes/merge.ts"
166
- * (await project.find("jsx:settingsBoardClose")).map(found => `${found.path}:${found.line}`);
167
- * // ["features/ui/kit.tsx:844", "features/settings/settings.tsx:290"]
164
+ * // The editor jumps to the node the flow view clicked, and to the spark of the info panel.
165
+ * (await project.find("node:infoPopup/count"))[0]?.line; // 7, in "nodes/count.ts"
166
+ * (await project.find("jsx:infoPanelSpark")).map(found => `${found.path}:${found.line}`);
167
+ * // ["features/info/popup.tsx:65", "features/info/popup.tsx:79"]
168
168
  * ```
169
169
  */
170
170
  find(key: string): Promise<Found[]>;
@@ -198,9 +198,9 @@ type ProjectApi = {
198
198
  * @throws {Error} When the path leaves the root, by `..` or by a symlink.
199
199
  * @example
200
200
  * ```ts
201
- * // The editor saved nodes/merge.ts through its own file layer.
202
- * const index = await project.changed("nodes/merge.ts");
203
- * index.files["nodes/merge.ts"]?.state; // "ok"
201
+ * // The editor saved nodes/count.ts through its own file layer.
202
+ * const index = await project.changed("nodes/count.ts");
203
+ * index.files["nodes/count.ts"]?.state; // "ok"
204
204
  * ```
205
205
  */
206
206
  changed(path: string): Promise<ProjectIndex>;
@@ -247,9 +247,9 @@ type IndexUi = {
247
247
  * `--check` that found a broken file or a conflict; else `0`.
248
248
  * @example
249
249
  * ```ts
250
- * // An agent asks where a node of the fixture game lives.
251
- * await runCli(["--root", "tests/integration/merge-game", "where", "node:board/merge"]);
252
- * // 0, and the console printed "nodes/merge.ts:17"
250
+ * // An agent asks where a node of the mini game lives.
251
+ * await runCli(["--root", "tests/fixtures/mini-game", "where", "node:infoPopup/count"]);
252
+ * // 0, and the console printed "nodes/count.ts:7"
253
253
  * ```
254
254
  */
255
255
  declare function runCli(argv: readonly string[], ui?: IndexUi): Promise<number>;
@@ -266,10 +266,10 @@ declare function runCli(argv: readonly string[], ui?: IndexUi): Promise<number>;
266
266
  * installed: `[game] The project index needs the "typescript" package.`
267
267
  * @example
268
268
  * ```ts
269
- * // The editor opens the fixture game and answers where a node and a text style live.
270
- * const project = await openProject({ root: "tests/integration/merge-game" });
271
- * Object.keys(project.index.files).length; // 111
272
- * (await project.find("textStyle:ui.title"))[0]?.line; // 39, in "features/ui/styles.ts"
269
+ * // The editor opens the mini game and answers where a node and a text style live.
270
+ * const project = await openProject({ root: "tests/fixtures/mini-game" });
271
+ * Object.keys(project.index.files).length; // 21
272
+ * (await project.find("textStyle:ui.counter"))[0]?.line; // 16, in "features/home/styles.ts"
273
273
  * project.close();
274
274
  * ```
275
275
  */
package/dist/project.mjs CHANGED
@@ -2862,10 +2862,10 @@ const DEFAULT_DEBOUNCE_MS = 75;
2862
2862
  * installed: `[game] The project index needs the "typescript" package.`
2863
2863
  * @example
2864
2864
  * ```ts
2865
- * // The editor opens the fixture game and answers where a node and a text style live.
2866
- * const project = await openProject({ root: "tests/integration/merge-game" });
2867
- * Object.keys(project.index.files).length; // 111
2868
- * (await project.find("textStyle:ui.title"))[0]?.line; // 39, in "features/ui/styles.ts"
2865
+ * // The editor opens the mini game and answers where a node and a text style live.
2866
+ * const project = await openProject({ root: "tests/fixtures/mini-game" });
2867
+ * Object.keys(project.index.files).length; // 21
2868
+ * (await project.find("textStyle:ui.counter"))[0]?.line; // 16, in "features/home/styles.ts"
2869
2869
  * project.close();
2870
2870
  * ```
2871
2871
  */
@@ -3075,9 +3075,9 @@ async function runCommand(project, options, ui) {
3075
3075
  * `--check` that found a broken file or a conflict; else `0`.
3076
3076
  * @example
3077
3077
  * ```ts
3078
- * // An agent asks where a node of the fixture game lives.
3079
- * await runCli(["--root", "tests/integration/merge-game", "where", "node:board/merge"]);
3080
- * // 0, and the console printed "nodes/merge.ts:17"
3078
+ * // An agent asks where a node of the mini game lives.
3079
+ * await runCli(["--root", "tests/fixtures/mini-game", "where", "node:infoPopup/count"]);
3080
+ * // 0, and the console printed "nodes/count.ts:7"
3081
3081
  * ```
3082
3082
  */
3083
3083
  async function runCli(argv, ui = createBrandConsole()) {
@@ -0,0 +1,384 @@
1
+ //#region src/plugins/flow/runner/journal.ts
2
+ /** FNV-1a 32-bit offset basis. */
3
+ const OFFSET_BASIS = 2166136261;
4
+ /** FNV-1a 32-bit prime. */
5
+ const PRIME = 16777619;
6
+ /** Separates the parts of a hashed edge, so "a|b" and "a" + "b" never collide. */
7
+ const SEPARATOR = "\0";
8
+ /**
9
+ * Hashes text with FNV-1a, 32 bit. Pure and deterministic: the same text always gives the same
10
+ * eight hexadecimal characters, in every runtime.
11
+ *
12
+ * @param text - The text to hash.
13
+ * @returns Eight lowercase hexadecimal characters.
14
+ * @example
15
+ * ```ts
16
+ * hashText("board/merge"); // "53ceca70"
17
+ * ```
18
+ */
19
+ function hashText(text) {
20
+ let hash = OFFSET_BASIS;
21
+ for (const character of text) hash = Math.imul(hash ^ (character.codePointAt(0) ?? 0), PRIME);
22
+ return (hash >>> 0).toString(16).padStart(8, "0");
23
+ }
24
+ /**
25
+ * Hashes one edge, so a replay against edited code fails loudly in dev.
26
+ *
27
+ * @param path - Path of the node that produced the outcome.
28
+ * @param outcome - Outcome name.
29
+ * @param next - Rendered edge target.
30
+ * @returns The digest of the three parts together.
31
+ * @example
32
+ * ```ts
33
+ * entryHash("board/merge", "done", "board/awaitIntent"); // "e881176b"
34
+ * ```
35
+ */
36
+ function entryHash(path, outcome, next) {
37
+ return hashText([
38
+ path,
39
+ outcome,
40
+ next
41
+ ].join(SEPARATOR));
42
+ }
43
+ /**
44
+ * Appends an entry with the next index and its hash, and drops the oldest entries above the limit.
45
+ * The index keeps counting while entries are dropped, so two entries never share a number.
46
+ *
47
+ * @param state - Runner state that holds the journal.
48
+ * @param entry - The taken edge without `index` and `hash`.
49
+ * @param limit - Journal entries kept between checkpoints.
50
+ * @returns The stored entry.
51
+ */
52
+ function pushEntry(state, entry, limit) {
53
+ const stored = {
54
+ ...entry,
55
+ index: state.journalIndex,
56
+ hash: entryHash(entry.path, entry.outcome, entry.next)
57
+ };
58
+ state.journalIndex += 1;
59
+ state.journal.push(stored);
60
+ if (state.journal.length > limit) state.journal.splice(0, state.journal.length - limit);
61
+ return stored;
62
+ }
63
+ /**
64
+ * Clears the journal at a checkpoint. The running index is kept, and so is the array itself: a
65
+ * reader that holds `history()` sees the compaction.
66
+ *
67
+ * @param state - Runner state that holds the journal.
68
+ */
69
+ function compact(state) {
70
+ state.journal.length = 0;
71
+ }
72
+ //#endregion
73
+ //#region src/plugins/flow/runner/registry.ts
74
+ /**
75
+ * Creates the empty flow map. It lives in its own function because the plugin's lint rule L5
76
+ * refuses a collection built inside an exported declaration.
77
+ *
78
+ * @returns An empty map of flow id to flow.
79
+ * @example
80
+ * ```ts
81
+ * emptyFlows().size; // 0
82
+ * ```
83
+ */
84
+ function emptyFlows() {
85
+ return /* @__PURE__ */ new Map();
86
+ }
87
+ /**
88
+ * Adds a flow and every flow it reaches by reference to the map. The first flow under an id wins,
89
+ * so a collision is visible to `validateGraph` instead of silently replacing a flow.
90
+ *
91
+ * @param flow - The flow to add.
92
+ * @param flows - The map being filled.
93
+ */
94
+ function addFlow(flow, flows) {
95
+ if (flows.has(flow.id)) return;
96
+ flows.set(flow.id, flow);
97
+ for (const entry of Object.values(flow.nodes)) if (entry.kind === "flow") addFlow(entry, flows);
98
+ }
99
+ /**
100
+ * Collects every flow reachable from the main flow by reference, plus the registered extras.
101
+ * The main flow is the first entry: `describeGraph` reads it as the entry point of the graph.
102
+ *
103
+ * @param main - The top-level flow.
104
+ * @param extra - Flows added with `register`, not reachable by reference.
105
+ * @returns Flow id to flow, main first.
106
+ * @example
107
+ * ```ts
108
+ * [...collectFlows(mainFlow, []).keys()]; // ["main", "board"]: the main flow first
109
+ * ```
110
+ */
111
+ function collectFlows(main, extra) {
112
+ const flows = emptyFlows();
113
+ addFlow(main, flows);
114
+ for (const flow of extra) addFlow(flow, flows);
115
+ return flows;
116
+ }
117
+ /**
118
+ * Renders a position as a path: the node of every frame, outermost first.
119
+ *
120
+ * @param stack - One frame per nesting level.
121
+ * @returns The path, for example `"board/awaitIntent"`; empty without a position.
122
+ * @example
123
+ * ```ts
124
+ * const board = { flow: "main", node: "board", input: null };
125
+ * framePath([board, { flow: "board", node: "merge", input: null }]); // "board/merge"
126
+ * ```
127
+ */
128
+ function framePath(stack) {
129
+ return stack.map((frame) => frame.node).join("/");
130
+ }
131
+ /**
132
+ * Picks the flow a path continues in after one entry: the sub-flow itself, or, behind a slot, the
133
+ * first contribution in order that has the next segment as a node. A path keeps node names only,
134
+ * so two contributions of one slot with the same node name cannot be told apart; `validateGraph`
135
+ * warns about that.
136
+ *
137
+ * @param entry - The entry the path just passed.
138
+ * @param next - The next segment of the path.
139
+ * @param contributionsOf - Reads the contributions of a slot, in order.
140
+ * @returns The flow to go on in, or `undefined` when the path ends here.
141
+ */
142
+ function flowBehind(entry, next, contributionsOf) {
143
+ if (entry.kind === "flow") return entry;
144
+ if (entry.kind !== "slot") return void 0;
145
+ return contributionsOf(entry.name).find((item) => Object.hasOwn(item.flow.nodes, next))?.flow;
146
+ }
147
+ /**
148
+ * Reads no contributions: the default of `findNode` for a caller that has no features API.
149
+ *
150
+ * @returns An empty list.
151
+ * @example
152
+ * ```ts
153
+ * noContributions(); // []
154
+ * ```
155
+ */
156
+ function noContributions() {
157
+ return [];
158
+ }
159
+ /**
160
+ * Finds the node, sub-flow or slot a path names, starting at the main flow. A path descends one
161
+ * sub-flow per segment, and through a slot into the contribution that has the next segment; a
162
+ * segment after a plain node finds nothing.
163
+ *
164
+ * @param main - The top-level flow.
165
+ * @param path - A path as `framePath` renders it.
166
+ * @param contributionsOf - Reads the contributions of a slot, in order. Without it a path ends at
167
+ * a slot.
168
+ * @returns Where the path leads, or `undefined` when no such node exists.
169
+ * @example
170
+ * ```ts
171
+ * findNode(mainFlow, "board/awaitIntent")?.trail;
172
+ * // [{ flow: "main", node: "board" }, { flow: "board", node: "awaitIntent" }]
173
+ * findNode(mainFlow, "afterOrder/show", () => [{ flow: rewardFlow }])?.trail;
174
+ * // [{ flow: "main", node: "afterOrder" }, { flow: "rewardPopup", node: "show" }]
175
+ * ```
176
+ */
177
+ function findNode(main, path, contributionsOf = noContributions) {
178
+ const names = path.split("/").filter(Boolean);
179
+ const trail = [];
180
+ let flow = main;
181
+ for (const [step, name] of names.entries()) {
182
+ const entry = flow.nodes[name];
183
+ if (!entry) return void 0;
184
+ trail.push({
185
+ flow: flow.id,
186
+ node: name
187
+ });
188
+ if (step === names.length - 1) return {
189
+ flow,
190
+ name,
191
+ entry,
192
+ trail
193
+ };
194
+ const behind = flowBehind(entry, names[step + 1] ?? "", contributionsOf);
195
+ if (behind === void 0) return void 0;
196
+ flow = behind;
197
+ }
198
+ }
199
+ /**
200
+ * Renders an edge target as a string: a node name, `"exit:win"` or `"map:node"`.
201
+ *
202
+ * @param target - The target as the edge table holds it.
203
+ * @returns The rendered target.
204
+ * @example
205
+ * ```ts
206
+ * renderTarget("home"); // "home"
207
+ * renderTarget({ kind: "exit", outcome: "win" }); // "exit:win"
208
+ * ```
209
+ */
210
+ function renderTarget(target) {
211
+ if (typeof target === "string") return target;
212
+ return target.kind === "exit" ? `exit:${target.outcome}` : `map:${target.target}`;
213
+ }
214
+ /**
215
+ * Renders the edge table of one flow.
216
+ *
217
+ * @param flow - The flow to render.
218
+ * @returns Node name to outcome name to rendered target.
219
+ * @example
220
+ * ```ts
221
+ * renderEdges(boardFlow).awaitIntent?.leave; // "exit:left"
222
+ * ```
223
+ */
224
+ function renderEdges(flow) {
225
+ const edges = {};
226
+ for (const [name, row] of Object.entries(flow.edges)) {
227
+ const rendered = {};
228
+ for (const [outcome, target] of Object.entries(row)) rendered[outcome] = renderTarget(target);
229
+ edges[name] = rendered;
230
+ }
231
+ return edges;
232
+ }
233
+ /**
234
+ * Maps every node and flow a feature brought to the name of that feature.
235
+ *
236
+ * @param features - Features API.
237
+ * @returns Node or flow object to feature name.
238
+ */
239
+ function collectOwners(features) {
240
+ const owners = /* @__PURE__ */ new Map();
241
+ for (const { name, description } of features.all()) {
242
+ for (const node of description.nodes ?? []) owners.set(node, name);
243
+ for (const flow of description.flows ?? []) owners.set(flow, name);
244
+ }
245
+ return owners;
246
+ }
247
+ /**
248
+ * Describes one entry of a flow: its flags, its scene, its outcome names, and the slot, sub-flow
249
+ * or owning feature when it has one.
250
+ *
251
+ * @param flow - The flow that holds the entry.
252
+ * @param name - Name of the entry inside that flow.
253
+ * @param entry - The node, sub-flow or slot.
254
+ * @param owner - Name of the feature that brought it, when a feature did.
255
+ * @returns The entry as JSON.
256
+ * @example
257
+ * ```ts
258
+ * describeNode(mainFlow, "home", home, undefined).rest; // true
259
+ * describeNode(mainFlow, "board", boardFlow, "board").subFlow; // "board"
260
+ * ```
261
+ */
262
+ function describeNode(flow, name, entry, owner) {
263
+ const node = entry.kind === "node" ? entry : void 0;
264
+ return {
265
+ flow: flow.id,
266
+ node: name,
267
+ rest: node?.rest ?? false,
268
+ over: node?.over ?? false,
269
+ checkpoint: node?.checkpoint ?? false,
270
+ barrier: node?.barrier ?? false,
271
+ outcomes: Object.keys(entry.outcomes),
272
+ ...node?.scene === void 0 ? {} : { scene: node.scene },
273
+ ...entry.kind === "slot" ? { slot: entry.name } : {},
274
+ ...entry.kind === "flow" ? { subFlow: entry.id } : {},
275
+ ...owner === void 0 ? {} : { owner }
276
+ };
277
+ }
278
+ /**
279
+ * Describes every entry of one flow.
280
+ *
281
+ * @param flow - The flow to describe.
282
+ * @param owners - Node or flow object to feature name.
283
+ * @returns Node name to description.
284
+ * @example
285
+ * ```ts
286
+ * describeNodes(boardFlow, new Map()).merge?.outcomes; // ["done", "rejected"]
287
+ * ```
288
+ */
289
+ function describeNodes(flow, owners) {
290
+ const nodes = {};
291
+ for (const [name, entry] of Object.entries(flow.nodes)) nodes[name] = describeNode(flow, name, entry, owners.get(entry) ?? owners.get(flow));
292
+ return nodes;
293
+ }
294
+ /**
295
+ * Collects the slot names of every flow of the graph. `describe()` and `validate` both read the
296
+ * contributions of exactly these slots.
297
+ *
298
+ * @param flows - Every collected flow by id.
299
+ * @returns The slot names, in the order the graph declares them, each once.
300
+ * @example
301
+ * ```ts
302
+ * slotNames(collectFlows(mainFlow, [])); // ["afterOrder"]
303
+ * ```
304
+ */
305
+ function slotNames(flows) {
306
+ const names = [];
307
+ for (const flow of flows.values()) for (const entry of Object.values(flow.nodes)) if (entry.kind === "slot" && !names.includes(entry.name)) names.push(entry.name);
308
+ return names;
309
+ }
310
+ /**
311
+ * Renders the whole graph as JSON without running the game: nodes, flags, outcomes, edges,
312
+ * slots and who contributed. Edge targets become `"node"`, `"exit:win"`, `"map:node"`.
313
+ *
314
+ * @param flows - Every collected flow by id, the main flow first, as `collectFlows` returns them.
315
+ * @param features - Features API: owners and slot contributions.
316
+ * @returns The graph as plain JSON.
317
+ */
318
+ function describeGraph(flows, features) {
319
+ const owners = collectOwners(features);
320
+ const described = {};
321
+ const slots = {};
322
+ for (const flow of flows.values()) described[flow.id] = {
323
+ nodes: describeNodes(flow, owners),
324
+ start: flow.start,
325
+ edges: renderEdges(flow)
326
+ };
327
+ for (const name of slotNames(flows)) slots[name] = features.contributions(name).map(({ feature, flow, order }) => ({
328
+ feature,
329
+ flow: flow.id,
330
+ order
331
+ }));
332
+ return {
333
+ main: [...flows.keys()][0] ?? "",
334
+ flows: described,
335
+ slots
336
+ };
337
+ }
338
+ /**
339
+ * Compares two object keys without a locale, so the rendering is the same everywhere.
340
+ *
341
+ * @param first - First key.
342
+ * @param second - Second key.
343
+ * @returns A negative number, zero or a positive number.
344
+ * @example
345
+ * ```ts
346
+ * compareKeys("edges", "nodes"); // -1
347
+ * ```
348
+ */
349
+ function compareKeys(first, second) {
350
+ if (first === second) return 0;
351
+ return first < second ? -1 : 1;
352
+ }
353
+ /**
354
+ * Renders a JSON value with its object keys sorted, so two equal graphs written in a different
355
+ * order hash the same.
356
+ *
357
+ * @param value - Any JSON value.
358
+ * @returns The value as text.
359
+ * @example
360
+ * ```ts
361
+ * stableJson({ b: 1, a: 2 }); // '{"a":2,"b":1}'
362
+ * ```
363
+ */
364
+ function stableJson(value) {
365
+ if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
366
+ if (typeof value !== "object" || !value) return JSON.stringify(value) ?? "";
367
+ return `{${Object.entries(value).filter(([, item]) => item !== void 0).toSorted(([first], [second]) => compareKeys(first, second)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
368
+ }
369
+ /**
370
+ * Hashes a graph description. A bookmark of a plain rest node is accepted only while this hash
371
+ * is unchanged.
372
+ *
373
+ * @param graph - Result of `describeGraph`.
374
+ * @returns Eight lowercase hexadecimal characters.
375
+ * @example
376
+ * ```ts
377
+ * graphHash({ main: "main", flows: {}, slots: {} }); // "cfb16c03"
378
+ * ```
379
+ */
380
+ function graphHash(graph) {
381
+ return hashText(stableJson(graph));
382
+ }
383
+ //#endregion
384
+ export { graphHash as a, pushEntry as c, framePath as i, describeGraph as n, slotNames as o, findNode as r, compact as s, collectFlows as t };