@moku-labs/game 0.4.5 → 0.5.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.
@@ -1,9 +1,9 @@
1
- import { At as JournalEntry, H as EntitySnapshot, Ot as FlowGraph, R as ComponentSchema, U as FrameDiff, Ut as Api, f as Usage, gt as Position, j as Explained, k as Api$1, m as Api$2, t as Api$3, tn as Snapshot, w as RenderStats, x as Under } from "./types-Dy1xB68L.mjs";
2
- import { a as InputOf, c as Source, d as HeadlessApp, i as InputArguments, l as WatchApp, o as InputSchema, t as CheatEntry, u as WatchInput } from "./types-DtmFVCHf.mjs";
3
- import { c as UiNode } from "./types-VErbiEXy.mjs";
4
- import { G as UiApi, a as SoundEntry, t as AudioApi } from "./types-C-4f2jD_.mjs";
5
- import { n as defineSource } from "./define-BKWjWgyQ.mjs";
6
- import { n as EffectsApi, r as EffectsStats } from "./types-w646dMeA.mjs";
1
+ import { At as JournalEntry, H as EntitySnapshot, Ot as FlowGraph, R as ComponentSchema, U as FrameDiff, Ut as Api, f as Usage, gt as Position, j as Explained, k as Api$1, m as Api$2, t as Api$3, tn as Snapshot, w as RenderStats, x as Under } from "./types-BMEn0tSC.mjs";
2
+ import { a as InputOf, c as Source, d as HeadlessApp, i as InputArguments, l as WatchApp, o as InputSchema, t as CheatEntry, u as WatchInput } from "./types-CYYKjvLn.mjs";
3
+ import { c as UiNode } from "./types-BaUUcIaN.mjs";
4
+ import { a as SoundEntry, t as AudioApi, v as UiApi } from "./types-CXyipKhE.mjs";
5
+ import { n as defineSource } from "./define-BbgXv4cy.mjs";
6
+ import { n as EffectsApi, r as EffectsStats } from "./types-ClF8say0.mjs";
7
7
 
8
8
  //#region src/plugins/flow/doors/read.d.ts
9
9
  /**
@@ -1,2 +1,2 @@
1
- import { a as jsxs, i as jsxDEV, n as JSX, r as jsx, t as Fragment } from "./runtime-Bhu442L8.mjs";
1
+ import { a as jsxs, i as jsxDEV, n as JSX, r as jsx, t as Fragment } from "./runtime-HTlmRb81.mjs";
2
2
  export { Fragment, type JSX, jsx, jsxDEV, jsxs };
@@ -1,2 +1,2 @@
1
- import { a as jsxs, n as JSX, r as jsx, t as Fragment } from "./runtime-Bhu442L8.mjs";
1
+ import { a as jsxs, n as JSX, r as jsx, t as Fragment } from "./runtime-HTlmRb81.mjs";
2
2
  export { Fragment, type JSX, jsx, jsxs };
@@ -0,0 +1,79 @@
1
+ //#region src/lint.d.ts
2
+ /** The options every rule takes: which files it checks. A key given replaces the rule's default. */
3
+ type GameLintOptions = {
4
+ /** Globs of the files the rule checks, relative to the directory oxlint runs in. */files?: string[]; /** Globs of the files the rule skips, even when `files` matches them. */
5
+ ignores?: string[];
6
+ };
7
+ /** The part of an ESTree node the rules read. oxlint and ESLint hand over full nodes. */
8
+ type GameLintNode = {
9
+ readonly type: string;
10
+ readonly name?: string;
11
+ readonly value?: unknown;
12
+ readonly kind?: string;
13
+ readonly computed?: boolean;
14
+ readonly importKind?: string;
15
+ readonly exportKind?: string;
16
+ readonly source?: GameLintNode | null;
17
+ readonly body?: readonly GameLintNode[];
18
+ readonly declarations?: readonly GameLintNode[];
19
+ readonly declaration?: GameLintNode | null;
20
+ readonly init?: GameLintNode | null;
21
+ readonly callee?: GameLintNode;
22
+ readonly arguments?: readonly GameLintNode[];
23
+ readonly object?: GameLintNode;
24
+ readonly property?: GameLintNode;
25
+ };
26
+ /** The part of the rule context the rules read: where the file is, the options, the report. */
27
+ type GameLintContext = {
28
+ readonly cwd: string;
29
+ readonly filename: string;
30
+ readonly options: readonly unknown[];
31
+ report(descriptor: {
32
+ node: GameLintNode;
33
+ message: string;
34
+ }): void;
35
+ };
36
+ /** A node visitor per ESTree node type. */
37
+ type GameLintListeners = Record<string, (node: GameLintNode) => void>;
38
+ /** One rule in the ESLint rule shape oxlint `jsPlugins` load. */
39
+ type GameLintRule = {
40
+ readonly meta: {
41
+ readonly type: "problem";
42
+ readonly docs: {
43
+ readonly description: string;
44
+ };
45
+ readonly schema: readonly unknown[];
46
+ };
47
+ create(context: GameLintContext): GameLintListeners;
48
+ };
49
+ /** The plugin object: `moku-game` and its six rules. */
50
+ type GameLintPlugin = {
51
+ readonly meta: {
52
+ readonly name: "moku-game";
53
+ };
54
+ readonly rules: Readonly<Record<GameLintRuleName, GameLintRule>>;
55
+ };
56
+ /** The rule names, without the `moku-game/` prefix. */
57
+ type GameLintRuleName = "lazy-imports" | "native-imports" | "dev-imports" | "no-module-state" | "determinism" | "rules-siblings";
58
+ /**
59
+ * Turn a glob into an anchored regular expression: `**`, `*`, `?` and one level of `{a,b}`.
60
+ *
61
+ * @param glob - A POSIX glob relative to the lint root.
62
+ * @returns The expression a relative POSIX path is tested against.
63
+ * @example
64
+ * ```ts
65
+ * globToRegExp("{nodes,rules}/*.ts").test("rules/merge.ts"); // true
66
+ * ```
67
+ */
68
+ declare function globToRegExp(glob: string): RegExp;
69
+ /**
70
+ * The `moku-game` oxlint plugin. A game loads it by package name and turns its rules on.
71
+ *
72
+ * @example
73
+ * ```json
74
+ * { "jsPlugins": ["@moku-labs/game/lint"], "rules": { "moku-game/lazy-imports": "error" } }
75
+ * ```
76
+ */
77
+ declare const plugin: GameLintPlugin;
78
+ //#endregion
79
+ export { GameLintContext, GameLintListeners, GameLintNode, GameLintOptions, GameLintPlugin, GameLintRule, GameLintRuleName, plugin as default, globToRegExp };
package/dist/lint.mjs ADDED
@@ -0,0 +1,360 @@
1
+ import path from "node:path";
2
+ //#region src/lint.ts
3
+ /**
4
+ * @file The lint door of the engine (subpath `./lint`): an oxlint JS plugin named `moku-game` with
5
+ * the rules a game carries. A game loads it by package name in `.oxlintrc.json`
6
+ * (`"jsPlugins": ["@moku-labs/game/lint"]`) and turns the rules on as `moku-game/<rule>`. The rules
7
+ * use the ESLint rule API, so ESLint 9 loads the same object. Every rule takes
8
+ * `[{ files, ignores }]`: globs relative to the directory oxlint runs in; a key given replaces the
9
+ * default. Only `node:path` is imported; the file is erasable TypeScript, so Node loads it unbuilt.
10
+ */
11
+ /** Tests and test helpers: no rule checks them by default. */
12
+ const TESTS = [
13
+ "tests/**",
14
+ "**/__tests__/**",
15
+ "**/*.{test,spec}.{ts,tsx}"
16
+ ];
17
+ /** Dev modules: a `.dev` module is wired into the editor only. */
18
+ const DEV_MODULES = ["**/*.dev.{ts,tsx}"];
19
+ /** The logic of a game: state, tables, nodes, flows, rules, features. `**` so `src/` matches too. */
20
+ const LOGIC = [
21
+ "**/state.ts",
22
+ "**/tables.ts",
23
+ "**/{nodes,flows,rules,features}/**"
24
+ ];
25
+ /** The options schema every rule shares; oxlint refuses options a rule has no schema for. */
26
+ const SCHEMA = [{
27
+ type: "object",
28
+ properties: {
29
+ files: {
30
+ type: "array",
31
+ items: { type: "string" }
32
+ },
33
+ ignores: {
34
+ type: "array",
35
+ items: { type: "string" }
36
+ }
37
+ },
38
+ additionalProperties: false
39
+ }];
40
+ /** Bases of a timer call that still reach the global timer. */
41
+ const GLOBAL_OBJECT = /^(?:globalThis|window|self)$/;
42
+ /** The global timers of L3. */
43
+ const TIMER = /^set(?:Timeout|Interval)$/;
44
+ /** The forbidden member reads of L3: object, property and the fix. */
45
+ const CLOCK_READS = [
46
+ {
47
+ object: "Math",
48
+ property: "random",
49
+ message: "L3: use an rng stream: rng.stream(id)."
50
+ },
51
+ {
52
+ object: "Date",
53
+ property: "now",
54
+ message: "L3: use `now` from the node context."
55
+ },
56
+ {
57
+ object: "performance",
58
+ property: "now",
59
+ message: "L3: use `now` from the node context."
60
+ }
61
+ ];
62
+ /** The collections a module may not hold at module scope (L5). */
63
+ const COLLECTION = /^(?:Map|Set|WeakMap|WeakSet)$/;
64
+ /**
65
+ * Turn a glob into an anchored regular expression: `**`, `*`, `?` and one level of `{a,b}`.
66
+ *
67
+ * @param glob - A POSIX glob relative to the lint root.
68
+ * @returns The expression a relative POSIX path is tested against.
69
+ * @example
70
+ * ```ts
71
+ * globToRegExp("{nodes,rules}/*.ts").test("rules/merge.ts"); // true
72
+ * ```
73
+ */
74
+ function globToRegExp(glob) {
75
+ let source = "";
76
+ let index = 0;
77
+ while (index < glob.length) {
78
+ const char = glob.charAt(index);
79
+ if (glob.startsWith("**/", index)) {
80
+ source += "(?:.*/)?";
81
+ index += 3;
82
+ } else if (glob.startsWith("**", index)) {
83
+ source += ".*";
84
+ index += 2;
85
+ } else {
86
+ source += globChar(char);
87
+ index += 1;
88
+ }
89
+ }
90
+ return new RegExp(`^${source}$`);
91
+ }
92
+ /**
93
+ * The expression for one glob character other than `**`.
94
+ *
95
+ * @param char - The character.
96
+ * @returns Its regular-expression source.
97
+ */
98
+ function globChar(char) {
99
+ switch (char) {
100
+ case "*": return "[^/]*";
101
+ case "?": return "[^/]";
102
+ case "{": return "(?:";
103
+ case "}": return ")";
104
+ case ",": return "|";
105
+ default: return char.replaceAll(/[$()+.[\\\]^|]/g, String.raw`\$&`);
106
+ }
107
+ }
108
+ /**
109
+ * Whether the rule checks the file the context lints: `files` match and no `ignores` match.
110
+ *
111
+ * @param context - The rule context.
112
+ * @param defaults - The rule's default globs.
113
+ * @returns True when the rule applies.
114
+ */
115
+ function inScope(context, defaults) {
116
+ const [given] = context.options;
117
+ const files = given?.files ?? defaults.files;
118
+ const ignores = given?.ignores ?? defaults.ignores;
119
+ const file = path.relative(context.cwd, context.filename).split(path.sep).join("/");
120
+ const matches = (globs) => globs.some((glob) => globToRegExp(glob).test(file));
121
+ return matches(files) && !matches(ignores);
122
+ }
123
+ /**
124
+ * The specifier of an import node, when it is a string literal.
125
+ *
126
+ * @param node - An import, export-from or `import()` node.
127
+ * @returns The specifier, or undefined for a computed `import()` or a local export.
128
+ */
129
+ function specifierOf(node) {
130
+ const value = node.source?.type === "Literal" ? node.source.value : void 0;
131
+ return typeof value === "string" ? value : void 0;
132
+ }
133
+ /**
134
+ * Whether a static import or export carries types only: `import type` or `export type`.
135
+ * `import { type A }` keeps a side-effect import under `verbatimModuleSyntax`, so it counts as a value.
136
+ *
137
+ * @param node - A static import or export node.
138
+ * @returns True for a type-only statement.
139
+ */
140
+ function isTypeOnly(node) {
141
+ return node.importKind === "type" || node.exportKind === "type";
142
+ }
143
+ /**
144
+ * Build a rule that reports imports of forbidden specifiers.
145
+ *
146
+ * @param description - What the rule enforces.
147
+ * @param defaults - The rule's default globs.
148
+ * @param checks - The forbidden patterns and their messages.
149
+ * @param lazy - True for L2: `import()` and type-only statements pass.
150
+ * @returns The rule.
151
+ */
152
+ function importRule(description, defaults, checks, lazy = false) {
153
+ return {
154
+ meta: {
155
+ type: "problem",
156
+ docs: { description },
157
+ schema: SCHEMA
158
+ },
159
+ /**
160
+ * Visit the file when the rule's globs match it.
161
+ *
162
+ * @param context - The rule context.
163
+ * @returns The node visitors, none for a file out of scope.
164
+ */
165
+ create(context) {
166
+ if (!inScope(context, defaults)) return {};
167
+ const check = (node) => {
168
+ const name = specifierOf(node);
169
+ const hit = name === void 0 ? void 0 : checks.find((item) => item.pattern.test(name));
170
+ if (hit === void 0) return;
171
+ if (lazy && isTypeOnly(node)) return;
172
+ context.report({
173
+ node,
174
+ message: hit.message
175
+ });
176
+ };
177
+ const listeners = {
178
+ ImportDeclaration: check,
179
+ ExportAllDeclaration: check,
180
+ ExportNamedDeclaration: check
181
+ };
182
+ if (!lazy) listeners.ImportExpression = check;
183
+ return listeners;
184
+ }
185
+ };
186
+ }
187
+ /**
188
+ * The name of a member's property: `a.b` and `a["b"]`.
189
+ *
190
+ * @param node - A MemberExpression.
191
+ * @returns The property name, or undefined for a computed non-literal key.
192
+ */
193
+ function propertyName(node) {
194
+ const key = node.property;
195
+ if (key === void 0) return void 0;
196
+ if (!node.computed) return key.name;
197
+ return typeof key.value === "string" ? key.value : void 0;
198
+ }
199
+ /**
200
+ * Report the module-scope state of one top-level statement (L5).
201
+ *
202
+ * @param context - The rule context.
203
+ * @param statement - A statement of the Program body.
204
+ */
205
+ function reportModuleState(context, statement) {
206
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
207
+ if (declaration?.type !== "VariableDeclaration") return;
208
+ if (declaration.kind === "let" || declaration.kind === "var") {
209
+ context.report({
210
+ node: declaration,
211
+ message: "L5: no module-scope state. State lives in player and session."
212
+ });
213
+ return;
214
+ }
215
+ for (const declarator of declaration.declarations ?? []) {
216
+ const init = declarator.init;
217
+ if (init?.type === "NewExpression" && COLLECTION.test(init.callee?.name ?? "")) context.report({
218
+ node: init,
219
+ message: "L5: no module-scope collections. Create them inside a function."
220
+ });
221
+ }
222
+ }
223
+ /**
224
+ * Whether a call's callee reaches a global timer: `setTimeout(...)`, `globalThis.setInterval(...)`.
225
+ *
226
+ * @param callee - The callee of a CallExpression.
227
+ * @returns True for a timer.
228
+ */
229
+ function isTimer(callee) {
230
+ if (callee?.type === "Identifier") return TIMER.test(callee.name ?? "");
231
+ if (callee?.type !== "MemberExpression") return false;
232
+ return GLOBAL_OBJECT.test(callee.object?.name ?? "") && TIMER.test(propertyName(callee) ?? "");
233
+ }
234
+ /** L2: static value imports of the packages the engine loads lazily. */
235
+ const lazyImports = importRule("No static import of pixi.js or yoga-layout; import() and import type pass.", {
236
+ files: ["**"],
237
+ ignores: TESTS
238
+ }, [{
239
+ pattern: /^pixi\.js(?:\/|$)/,
240
+ message: "L2: the renderer loads Pixi lazily. Draw with the engine's tags, use import() or import type."
241
+ }, {
242
+ pattern: /^yoga-layout(?:\/|$)/,
243
+ message: "L2: the ui plugin loads Yoga lazily. Use import() or import type."
244
+ }], true);
245
+ /** L13: native packages stay out of the engine-facing layers. */
246
+ const nativeImports = importRule("No @moku-labs/system, @moku-labs/native or @tauri-apps/* in the engine-facing layers.", {
247
+ files: [
248
+ ...LOGIC,
249
+ "**/kit.ts",
250
+ "**/game.ts"
251
+ ],
252
+ ignores: [...DEV_MODULES, ...TESTS]
253
+ }, [{
254
+ pattern: /^(?:@moku-labs\/(?:system|native)(?:\/|$)|@tauri-apps\/)/,
255
+ message: "L13: only platform-bridge.ts, native.ts and web/ import a native package. Pass a PlatformProvider."
256
+ }]);
257
+ /** Dev only: the editor and the control door stay in dev files. */
258
+ const developmentImports = importRule("@moku-labs/editor and @moku-labs/game/control only in dev files and tests.", {
259
+ files: ["**"],
260
+ ignores: [
261
+ "web/main.ts",
262
+ "web/dev*.ts",
263
+ "web/editor*.ts",
264
+ ...DEV_MODULES,
265
+ ...TESTS
266
+ ]
267
+ }, [{
268
+ pattern: /^@moku-labs\/(?:editor(?:\/|$)|game\/control$)/,
269
+ message: "Dev only: web/main.ts, web/dev*.ts, web/editor*.ts, .dev modules and tests import the editor and /control."
270
+ }]);
271
+ /** L4: a rule imports only its siblings. */
272
+ const rulesSiblings = importRule("A file under rules/ imports only its siblings.", {
273
+ files: ["**/rules/**"],
274
+ ignores: TESTS
275
+ }, [{
276
+ pattern: /^(?!\.\/)/,
277
+ message: "L4: a rule imports only its siblings in rules/: (state, input, tables) => result."
278
+ }]);
279
+ /**
280
+ * The `moku-game` oxlint plugin. A game loads it by package name and turns its rules on.
281
+ *
282
+ * @example
283
+ * ```json
284
+ * { "jsPlugins": ["@moku-labs/game/lint"], "rules": { "moku-game/lazy-imports": "error" } }
285
+ * ```
286
+ */
287
+ const plugin = {
288
+ meta: { name: "moku-game" },
289
+ rules: {
290
+ "lazy-imports": lazyImports,
291
+ "native-imports": nativeImports,
292
+ "dev-imports": developmentImports,
293
+ "no-module-state": {
294
+ meta: {
295
+ type: "problem",
296
+ docs: { description: "No module-scope let or var and no module-scope Map, Set, WeakMap, WeakSet." },
297
+ schema: SCHEMA
298
+ },
299
+ /**
300
+ * Visit the file when the rule's globs match it.
301
+ *
302
+ * @param context - The rule context.
303
+ * @returns The node visitors, none for a file out of scope.
304
+ */
305
+ create(context) {
306
+ if (!inScope(context, {
307
+ files: ["**"],
308
+ ignores: TESTS
309
+ })) return {};
310
+ return { Program: (program) => {
311
+ for (const statement of program.body ?? []) reportModuleState(context, statement);
312
+ } };
313
+ }
314
+ },
315
+ determinism: {
316
+ meta: {
317
+ type: "problem",
318
+ docs: { description: "No Math.random, Date.now, performance.now, new Date(), setTimeout or setInterval in the logic." },
319
+ schema: SCHEMA
320
+ },
321
+ /**
322
+ * Visit the file when the rule's globs match it.
323
+ *
324
+ * @param context - The rule context.
325
+ * @returns The node visitors, none for a file out of scope.
326
+ */
327
+ create(context) {
328
+ if (!inScope(context, {
329
+ files: LOGIC,
330
+ ignores: [...DEV_MODULES, ...TESTS]
331
+ })) return {};
332
+ return {
333
+ MemberExpression: (node) => {
334
+ const property = propertyName(node);
335
+ const read = CLOCK_READS.find((item) => item.object === node.object?.name && item.property === property);
336
+ if (read !== void 0) context.report({
337
+ node,
338
+ message: read.message
339
+ });
340
+ },
341
+ NewExpression: (node) => {
342
+ if (node.callee?.name === "Date" && (node.arguments ?? []).length === 0) context.report({
343
+ node,
344
+ message: "L3: use `now` from the node context."
345
+ });
346
+ },
347
+ CallExpression: (node) => {
348
+ if (isTimer(node.callee)) context.report({
349
+ node,
350
+ message: "L3: store the moment in player, await fx(schedule(moment))."
351
+ });
352
+ }
353
+ };
354
+ }
355
+ },
356
+ "rules-siblings": rulesSiblings
357
+ }
358
+ };
359
+ //#endregion
360
+ export { plugin as default, globToRegExp };
@@ -525,7 +525,8 @@ function invalidBookmark() {
525
525
  return /* @__PURE__ */ new Error("[game] The bookmark is not a flow.bookmark() value.\n Pass the JSON that game.bookmark returned.");
526
526
  }
527
527
  /**
528
- * Reads a bookmark out of JSON, the value `flow.bookmark()` made and the editor kept.
528
+ * Reads a bookmark out of JSON, the value `flow.bookmark()` made and the editor kept. The
529
+ * `scene` the `game.bookmark` door added is kept when present.
529
530
  *
530
531
  * @param value - The JSON the editor sent.
531
532
  * @returns The bookmark.
@@ -539,8 +540,9 @@ function readBookmark(value) {
539
540
  if (!isRecord(value)) throw invalidBookmark();
540
541
  const { path, input, player, session, graph } = value;
541
542
  const rng = readRng(value.rng);
542
- if (typeof path !== "string" || typeof graph !== "string" || !(input !== void 0 && player !== void 0 && session !== void 0) || rng === void 0) throw invalidBookmark();
543
- return {
543
+ if (!(typeof path === "string" && typeof graph === "string") || !(input !== void 0 && player !== void 0 && session !== void 0) || rng === void 0) throw invalidBookmark();
544
+ const { scene } = value;
545
+ if (scene === void 0) return {
544
546
  path,
545
547
  input,
546
548
  player,
@@ -548,6 +550,16 @@ function readBookmark(value) {
548
550
  rng,
549
551
  graph
550
552
  };
553
+ if (typeof scene !== "string") throw invalidBookmark();
554
+ return {
555
+ path,
556
+ input,
557
+ player,
558
+ session,
559
+ rng,
560
+ graph,
561
+ scene
562
+ };
551
563
  }
552
564
  /**
553
565
  * Builds the error of a value that is not a repro.
@@ -1051,9 +1063,10 @@ const fillCommand = defineCommand({
1051
1063
  //#endregion
1052
1064
  //#region src/plugins/flow/control.ts
1053
1065
  /**
1054
- * Enters a bookmark, or a repro's bookmark followed by its route.
1066
+ * Enters a bookmark, or a repro's bookmark followed by its route. A bookmark's `scene` is handed
1067
+ * to `scenes.expect` first, so a rest node without a scene of its own mounts it.
1055
1068
  *
1056
- * @param app - The app.
1069
+ * @param app - The app; with `scenes`, the bookmark's scene is expected before the restore.
1057
1070
  * @param input - Exactly one of a bookmark and a repro, as JSON.
1058
1071
  * @param input.bookmark - A `flow.bookmark()` value.
1059
1072
  * @param input.repro - A `/testing` repro.
@@ -1063,7 +1076,9 @@ const fillCommand = defineCommand({
1063
1076
  async function restoreFrom(app, input) {
1064
1077
  const { bookmark, repro } = input;
1065
1078
  if (bookmark !== void 0 && repro === void 0) {
1066
- await app.flow.restore(readBookmark(bookmark));
1079
+ const read = readBookmark(bookmark);
1080
+ if (read.scene !== void 0) app.scenes?.expect(read.scene);
1081
+ await app.flow.restore(read);
1067
1082
  return app.flow.state();
1068
1083
  }
1069
1084
  if (repro !== void 0 && bookmark === void 0) {
@@ -1127,13 +1142,15 @@ const walkCommand = defineCommand({
1127
1142
  }
1128
1143
  });
1129
1144
  /**
1130
- * Takes a bookmark of the rest point: the node plus the committed state, plain JSON.
1145
+ * Takes a bookmark of the rest point: the node plus the committed state, plain JSON. An app with
1146
+ * the screen plugins adds the mounted `scene`, so a restore in a fresh page builds it again.
1131
1147
  *
1132
1148
  * @example
1133
1149
  * ```ts
1134
- * // The editor keeps the position before a risky test.
1150
+ * // The editor keeps the position while the Settings popup stands over Home.
1135
1151
  * const { value } = await run(app, commands.bookmark);
1136
- * value.path; // "board/awaitIntent"
1152
+ * value.path; // "settings/open"
1153
+ * value.scene; // "home"
1137
1154
  * ```
1138
1155
  */
1139
1156
  const bookmarkCommand = defineCommand({
@@ -1144,12 +1161,18 @@ const bookmarkCommand = defineCommand({
1144
1161
  run: (app) => {
1145
1162
  if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
1146
1163
  app.log.debug("moku:dev", { command: "game.bookmark" });
1147
- return app.flow.bookmark();
1164
+ const bookmark = app.flow.bookmark();
1165
+ const scene = app.scenes?.current();
1166
+ return scene === void 0 ? bookmark : {
1167
+ ...bookmark,
1168
+ scene
1169
+ };
1148
1170
  }
1149
1171
  });
1150
1172
  /**
1151
1173
  * Restores a bookmark, or a repro: its state at its checkpoint, then its route. Replaces the
1152
- * state outside the graph, so the session is tainted and the command journaled.
1174
+ * state outside the graph, so the session is tainted and the command journaled. A bookmark's
1175
+ * `scene` goes to `scenes.expect` first: a bookmark at a popup comes back with the scene under it.
1153
1176
  *
1154
1177
  * @example
1155
1178
  * ```ts
@@ -1157,6 +1180,12 @@ const bookmarkCommand = defineCommand({
1157
1180
  * const repro = { player: { coins: 40 }, checkpoint: "home", route: [{ at: "home", intent: "play" }] };
1158
1181
  * const ran = await run(app, commands.restore, { repro });
1159
1182
  * ran.state; // { path: "board/awaitIntent", frame: 12, tainted: true }
1183
+ *
1184
+ * // A fresh page restores the bookmark game.bookmark took at the Settings popup over Home.
1185
+ * bookmark.scene; // "home"
1186
+ * await run(app, commands.restore, { bookmark });
1187
+ * await app.flow.walk([]); // the scene stage runs after the restore resolved
1188
+ * app.scenes.current(); // "home"
1160
1189
  * ```
1161
1190
  */
1162
1191
  const restoreCommand = defineCommand({
@@ -1,4 +1,4 @@
1
- import { a as JsxChild, m as UiIntrinsicElements, t as DescriptionNode } from "./types-VErbiEXy.mjs";
1
+ import { a as JsxChild, m as UiIntrinsicElements, t as DescriptionNode } from "./types-BaUUcIaN.mjs";
2
2
 
3
3
  //#region src/plugins/ui/jsx/runtime.d.ts
4
4
  /** What the compiler passes as props: the children plus whatever the tag declared. */
@@ -1,8 +1,8 @@
1
- import { $t as SaveDoc, Kt as Json, Qt as ProviderCall, Ut as Api$2, Zt as PlayerStateProvider, an as Api$3, k as Api$1, m as Api, pn as FakeClock } from "./types-Dy1xB68L.mjs";
2
- import { _ as stepFrames, a as InputOf, d as HeadlessApp, f as HeadlessGame, g as runRepro, h as createHeadless, m as ReproResult, p as Repro, r as ControlApp } from "./types-DtmFVCHf.mjs";
3
- import { H as InputApi } from "./types-VErbiEXy.mjs";
4
- import { G as UiApi, g as AnimApi } from "./types-C-4f2jD_.mjs";
5
- import { t as commands } from "./commands-C83eQ5B9.mjs";
1
+ import { $t as SaveDoc, Kt as Json, Qt as ProviderCall, Ut as Api$2, Zt as PlayerStateProvider, an as Api$3, k as Api$1, m as Api, pn as FakeClock } from "./types-BMEn0tSC.mjs";
2
+ import { _ as stepFrames, a as InputOf, d as HeadlessApp, f as HeadlessGame, g as runRepro, h as createHeadless, m as ReproResult, p as Repro, r as ControlApp } from "./types-CYYKjvLn.mjs";
3
+ import { H as InputApi } from "./types-BaUUcIaN.mjs";
4
+ import { b as AnimApi, v as UiApi } from "./types-CXyipKhE.mjs";
5
+ import { t as commands } from "./commands-mzE9PX0H.mjs";
6
6
 
7
7
  //#region src/plugins/clock/fake.d.ts
8
8
  /**
package/dist/testing.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { n as saveOf, t as memory } from "./memory-CvgdnsQO.mjs";
2
- import { a as stepFrames, i as runRepro, n as commands, r as createHeadless, t as run } from "./run-D_R0oJ6g.mjs";
2
+ import { a as stepFrames, i as runRepro, n as commands, r as createHeadless, t as run } from "./run-Bcyg32M5.mjs";
3
3
  import { n as read, t as sources } from "./sources-DVWJ8nfr.mjs";
4
4
  import { createBrandConsole } from "@moku-labs/common/cli";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -629,7 +629,7 @@ function addressesOf(keys) {
629
629
  * ```
630
630
  */
631
631
  function parentOf(parent) {
632
- if (typeof parent !== "object" || parent === null || Array.isArray(parent)) return void 0;
632
+ if (!(typeof parent === "object" && parent !== null && !Array.isArray(parent))) return void 0;
633
633
  return typeof parent.entity === "number" ? parent.entity : void 0;
634
634
  }
635
635
  /**
@@ -663,6 +663,20 @@ function stateOf(app) {
663
663
  };
664
664
  }
665
665
  /**
666
+ * The ui tree without the words of its texts, at every depth.
667
+ *
668
+ * @param node - A node of the ui source.
669
+ * @returns The node and its children, none with `content`.
670
+ */
671
+ function withoutContent(node) {
672
+ const copy = {
673
+ ...node,
674
+ children: node.children.map((child) => withoutContent(child))
675
+ };
676
+ delete copy.content;
677
+ return copy;
678
+ }
679
+ /**
666
680
  * Reads the screen a checkpoint saves: the ui tree, and every entity a projection names as
667
681
  * `{ projection, key, components }`, sorted by projection then key. Ui parts, rings and unkeyed
668
682
  * views appear through the tree only, or not at all.
@@ -675,13 +689,14 @@ function describeOf(app) {
675
689
  const views = [];
676
690
  for (const entity of read(app, sources.entities)) {
677
691
  const address = addresses.get(entity.id);
678
- if (address !== void 0) views.push({
692
+ if (address === void 0) continue;
693
+ views.push({
679
694
  ...address,
680
695
  components: withParentAddress(entity.components, addresses)
681
696
  });
682
697
  }
683
698
  return {
684
- ui: read(app, sources.ui),
699
+ ui: withoutContent(read(app, sources.ui)),
685
700
  views: views.toSorted(byAddress)
686
701
  };
687
702
  }