@moku-labs/game 0.0.2 → 0.1.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 (35) hide show
  1. package/README.md +107 -31
  2. package/dist/assets.d.mts +58 -6
  3. package/dist/assets.mjs +1630 -234
  4. package/dist/commands-C0BljsM5.d.mts +99 -0
  5. package/dist/control.d.mts +3 -92
  6. package/dist/control.mjs +3 -757
  7. package/dist/define-CjP_4H0G.d.mts +51 -0
  8. package/dist/{registry-DlpRCibU.mjs → dev-CcwFrwok.mjs} +30 -1
  9. package/dist/index.d.mts +362 -303
  10. package/dist/index.mjs +12717 -6654
  11. package/dist/inspect.d.mts +10 -8
  12. package/dist/inspect.mjs +2 -518
  13. package/dist/jsx-dev-runtime.d.mts +1 -1
  14. package/dist/jsx-runtime.d.mts +1 -1
  15. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
  16. package/dist/run-tlcn1w7y.mjs +944 -0
  17. package/dist/{runtime-DiOTkDZz.d.mts → runtime-Bn5TXNxP.d.mts} +1 -1
  18. package/dist/sources-BooL2MsX.mjs +545 -0
  19. package/dist/testing.d.mts +328 -3
  20. package/dist/testing.mjs +2126 -2
  21. package/dist/types-BZ1OLf24.d.mts +5999 -0
  22. package/dist/{types-DWILGrPn.d.mts → types-C47fKNcn.d.mts} +93 -20
  23. package/dist/types-CqkrEY06.d.mts +1734 -0
  24. package/dist/types-CxZy7Pi4.d.mts +1210 -0
  25. package/dist/{define-sFoO3y6X.d.mts → types-DmhTHRVW.d.mts} +180 -51
  26. package/dist/{types-yg_ywtT-.d.mts → types-X9jXjka3.d.mts} +230 -31
  27. package/dist/{visual-BDUHSRvf.mjs → visual-NPrDkvUk.mjs} +19 -13
  28. package/package.json +22 -2
  29. package/dist/headless-CvamCUcR.mjs +0 -186
  30. package/dist/headless-KaSWcd0s.d.mts +0 -181
  31. package/dist/types-BxkNNYul.d.mts +0 -2840
  32. package/dist/types-CTPS9GBu.d.mts +0 -742
  33. package/dist/types-DD-QrG_z.d.mts +0 -588
  34. package/dist/types-DYLnSgMI.d.mts +0 -570
  35. package/dist/types-JNc_UQBo.d.mts +0 -2896
@@ -0,0 +1,944 @@
1
+ import { n as isDev, s as graphHash, t as controlRefused } from "./dev-CcwFrwok.mjs";
2
+ import { a as defineCommand, i as inputOrEmpty, n as isTainted, r as recordCheat } from "./session-DmAxY6Ll.mjs";
3
+ //#region src/plugins/flow/headless.ts
4
+ const noPayload = null;
5
+ /** Seed of a repro that pins no rng state, the seed `saveOf` writes. */
6
+ const defaultSeed = 1;
7
+ /**
8
+ * Turns whatever `run()` rejected with into an error that can be thrown again.
9
+ *
10
+ * @param value - The rejection value.
11
+ * @returns The value itself when it is an error, a wrapped one otherwise.
12
+ * @example
13
+ * ```ts
14
+ * asError("offline").message; // "[game] The headless run failed.\n offline."
15
+ * ```
16
+ */
17
+ function asError(value) {
18
+ if (value instanceof Error) return value;
19
+ return /* @__PURE__ */ new Error(`[game] The headless run failed.\n ${String(value)}.`);
20
+ }
21
+ /**
22
+ * Starts the one loop unless the app's own `onStart` already did, and keeps a fatal error until
23
+ * someone asks for it. `run()` rejects only on a fatal error, and nobody awaits it here.
24
+ *
25
+ * @param app - The started app.
26
+ * @returns The holder of the fatal error.
27
+ */
28
+ function startLoop(app) {
29
+ const fatal = { error: void 0 };
30
+ if (app.flow.state().running) return fatal;
31
+ app.flow.run().catch((error) => {
32
+ fatal.error = error;
33
+ });
34
+ return fatal;
35
+ }
36
+ /**
37
+ * Waits until the loop rests for the first time, or ends. An empty route walks nowhere: it only
38
+ * waits for the gate the loop opens at the node it enters, so a start that is a chain of transit
39
+ * nodes is played out before anything reads the game.
40
+ *
41
+ * @param app - The started app whose loop is running.
42
+ * @param fatal - Holder of the fatal error of `run()`.
43
+ * @returns Resolves once the loop rests at its first rest node.
44
+ * @throws {Error} When the loop failed fatally instead of reaching a rest node.
45
+ */
46
+ async function settleAtFirstRest(app, fatal) {
47
+ await app.flow.walk([]).catch((error) => {
48
+ throw asError(fatal.error ?? error);
49
+ });
50
+ if (fatal.error !== void 0) throw asError(fatal.error);
51
+ }
52
+ /**
53
+ * Starts an app headless: sets flow mode `"fast"`, awaits `app.start()`, starts `flow.run()`
54
+ * unless the app's own `onStart` already did, and waits until the loop rests for the first time.
55
+ * `game.state().path` therefore names the first rest node of the graph as soon as the call
56
+ * resolves. A fatal error of the loop rejects this call, and a later one is re-thrown by `walk`
57
+ * and by `stop`, so a headless test never loses it.
58
+ *
59
+ * @param app - An app that is not started yet.
60
+ * @returns The game: walk it, answer it, read it, stop it.
61
+ * @throws {Error} When the loop failed fatally before it reached its first rest node.
62
+ * @example
63
+ * ```ts
64
+ * // A test starts the merge game without a screen. The call resolves at the first rest node.
65
+ * const game = await createHeadless(app);
66
+ *
67
+ * game.state().path; // "home": the transit node "boot" was played out
68
+ * game.state().mode; // "fast"
69
+ * await game.stop();
70
+ * ```
71
+ */
72
+ async function createHeadless(app) {
73
+ app.flow.setMode("fast");
74
+ await app.start();
75
+ const fatal = startLoop(app);
76
+ await settleAtFirstRest(app, fatal);
77
+ return {
78
+ walk: async (route) => {
79
+ const state = await app.flow.walk(route).catch((error) => {
80
+ throw asError(fatal.error ?? error);
81
+ });
82
+ if (fatal.error !== void 0) throw asError(fatal.error);
83
+ return state;
84
+ },
85
+ answer: (answer) => app.flow.gate.answer(answer),
86
+ state: () => app.flow.state(),
87
+ history: () => app.flow.history(),
88
+ stop: async () => {
89
+ await app.stop();
90
+ if (fatal.error !== void 0) throw asError(fatal.error);
91
+ }
92
+ };
93
+ }
94
+ /**
95
+ * Reads the rest node a repro starts at: its checkpoint, or the start of the main flow.
96
+ *
97
+ * @param app - The started app.
98
+ * @param repro - Starting state, optional checkpoint and the route.
99
+ * @returns The path of the node the repro enters.
100
+ * @throws {Error} When the graph has no main flow to start from.
101
+ */
102
+ function reproPath(app, repro) {
103
+ if (repro.checkpoint !== void 0) return repro.checkpoint;
104
+ const graph = app.flow.describe();
105
+ const start = graph.flows[graph.main]?.start;
106
+ if (start === void 0) throw new Error(`[game] runRepro() found no flow "${graph.main}" to start from.\n Name the checkpoint the repro was taken at.`);
107
+ return start;
108
+ }
109
+ /**
110
+ * Builds the bookmark a repro is entered with: its state, its checkpoint and the hash of the
111
+ * graph that runs now, so a checkpoint is accepted and any other rest node is checked. The route
112
+ * is not part of it: the caller walks it after the restore.
113
+ *
114
+ * @param app - The started app.
115
+ * @param repro - Starting state, optional checkpoint and the route.
116
+ * @returns The bookmark to restore.
117
+ * @throws {Error} When the repro names no checkpoint and the graph has no main flow.
118
+ * @example
119
+ * ```ts
120
+ * // The game.restore command loads a bug report taken at the "home" checkpoint.
121
+ * const bookmark = reproBookmark(app, { player: { coins: 7 }, checkpoint: "home", route: [] });
122
+ * bookmark.path; // "home"
123
+ * bookmark.rng; // { seed: 1, streams: {} }: the repro pinned no rng
124
+ * await app.flow.restore(bookmark);
125
+ * ```
126
+ */
127
+ function reproBookmark(app, repro) {
128
+ return {
129
+ path: reproPath(app, repro),
130
+ input: noPayload,
131
+ player: repro.player,
132
+ session: repro.session ?? {},
133
+ rng: repro.rng ?? {
134
+ seed: defaultSeed,
135
+ streams: {}
136
+ },
137
+ graph: graphHash(app.flow.describe())
138
+ };
139
+ }
140
+ /**
141
+ * Plays a repro: starts the app headless, restores the bookmark built from its state and
142
+ * checkpoint, then walks its route. The app keeps running, so the caller can read more than the
143
+ * result and stops it itself.
144
+ *
145
+ * @param app - An app that is not started yet.
146
+ * @param repro - Starting state, optional checkpoint and the route.
147
+ * @returns The path the run ended at, and the committed state.
148
+ * @example
149
+ * ```ts
150
+ * // A bug report on the dice game: "reset keeps my coins". Replay it from the "home" checkpoint.
151
+ * const reset = { at: "home", intent: "reset" };
152
+ * const result = await runRepro(app, { player: { coins: 7 }, checkpoint: "home", route: [reset] });
153
+ *
154
+ * result.player; // { coins: 0 }; result.path is ["home"]
155
+ * await app.stop(); // the app keeps running after the run
156
+ * ```
157
+ */
158
+ async function runRepro(app, repro) {
159
+ const game = await createHeadless(app);
160
+ await app.flow.restore(reproBookmark(app, repro));
161
+ const state = await game.walk(repro.route);
162
+ const ended = app.flow.bookmark();
163
+ return {
164
+ path: state.stack.map((frame) => frame.node),
165
+ player: ended.player,
166
+ session: ended.session
167
+ };
168
+ }
169
+ /**
170
+ * Steps the frame loop by hand: `count` calls of `app.time.step(deltaMs)`. A headless game has no
171
+ * frame source, so this is the only thing that moves the frame phases.
172
+ *
173
+ * @param app - A started app.
174
+ * @param count - Number of frames.
175
+ * @param deltaMs - Milliseconds per frame.
176
+ * @example
177
+ * ```ts
178
+ * // A headless game has no frame source: the test moves the six frame phases by hand.
179
+ * stepFrames(app, 2, 16);
180
+ * app.time.snapshot(); // frame: 2, delta: 16, elapsed: 32
181
+ * ```
182
+ */
183
+ function stepFrames(app, count, deltaMs) {
184
+ for (let frame = 0; frame < count; frame += 1) app.time.step(deltaMs);
185
+ }
186
+ //#endregion
187
+ //#region src/plugins/anim/control.ts
188
+ /**
189
+ * @file anim plugin — the anim command of the `/control` door: switch reduced motion. Dev builds
190
+ * only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
191
+ * logs the `moku:dev` marker.
192
+ */
193
+ /**
194
+ * Switches reduced motion on or off, the way the player's "Less motion" setting does. Answers
195
+ * the switch as it is afterwards.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * // Check the popups of the settings screen with no motion at all.
200
+ * (await run(app, commands.reducedMotion, { on: true })).value; // true
201
+ * ```
202
+ */
203
+ const reducedMotionCommand = defineCommand({
204
+ id: "game.reducedMotion",
205
+ title: "Reduced motion",
206
+ input: { on: "boolean" },
207
+ effect: "cosmetic",
208
+ run: (app, { on }) => {
209
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
210
+ app.log.debug("moku:dev", {
211
+ command: "game.reducedMotion",
212
+ on
213
+ });
214
+ app.anim.setReducedMotion(on);
215
+ return app.anim.reducedMotion();
216
+ }
217
+ });
218
+ //#endregion
219
+ //#region src/plugins/input/control.ts
220
+ /**
221
+ * @file input plugin — the input commands of the `/control` door: a drag from one view onto
222
+ * another and a key press. Each goes through `app.input`, so the gate decides and the session
223
+ * stays clean. Dev builds only: every body starts with the inline dev guard, so a bundler
224
+ * `define` of `false` drops it, and logs the `moku:dev` marker. `readTarget` is shared with the
225
+ * `game.tap` command of ui, which taps a view by the same projection key.
226
+ */
227
+ /**
228
+ * Tells whether the JSON a command got names a view by its projection key.
229
+ *
230
+ * @param value - The JSON.
231
+ * @returns True for an object with a string `projection` and a string `key`.
232
+ * @example
233
+ * ```ts
234
+ * isProjectionTarget({ projection: "board.items", key: "i5" }); // true
235
+ * ```
236
+ */
237
+ function isProjectionTarget(value) {
238
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.projection === "string" && typeof value.key === "string";
239
+ }
240
+ /**
241
+ * Reads the projection key of a view out of the JSON a command got.
242
+ *
243
+ * @param value - The JSON: `{ projection, key }`.
244
+ * @returns The target, a fresh object.
245
+ * @throws {Error} When the JSON is not an object with a string `projection` and a string `key`.
246
+ */
247
+ function readTarget(value) {
248
+ if (isProjectionTarget(value)) return {
249
+ projection: value.projection,
250
+ key: value.key
251
+ };
252
+ throw new Error("[game] The target is not a projection key.\n Pass a view like { projection: \"board.items\", key: \"i5\" }.");
253
+ }
254
+ /**
255
+ * Drags one view onto another, both by their projection keys. Answers what `input.drag`
256
+ * answers: whether the gate took the answer.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * // Merge two logs of level 1 on the board.
261
+ * const from = { projection: "board.items", key: "i5" };
262
+ * (await run(app, commands.drag, { from, to: { projection: "board.items", key: "i7" } })).value; // true
263
+ * ```
264
+ */
265
+ const dragCommand = defineCommand({
266
+ id: "game.drag",
267
+ title: "Drag",
268
+ input: {
269
+ from: "json",
270
+ to: "json"
271
+ },
272
+ effect: "route",
273
+ run: (app, { from, to }) => {
274
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
275
+ app.log.debug("moku:dev", { command: "game.drag" });
276
+ return app.input.drag(readTarget(from), readTarget(to));
277
+ }
278
+ });
279
+ /**
280
+ * Presses a key without a keyboard, with or without Shift. Answers whether a listener handled
281
+ * it.
282
+ *
283
+ * @example
284
+ * ```ts
285
+ * // Close the settings popup the way Escape does.
286
+ * (await run(app, commands.key, { key: "Escape" })).value; // true
287
+ * (await run(app, commands.key, { key: "Tab", shift: true })).value; // true: the focus moved back
288
+ * ```
289
+ */
290
+ const keyCommand = defineCommand({
291
+ id: "game.key",
292
+ title: "Press a key",
293
+ input: {
294
+ key: "string",
295
+ shift: "boolean?"
296
+ },
297
+ effect: "route",
298
+ run: (app, { key, shift = false }) => {
299
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
300
+ app.log.debug("moku:dev", {
301
+ command: "game.key",
302
+ key
303
+ });
304
+ return app.input.pressKey(key, { shift });
305
+ }
306
+ });
307
+ //#endregion
308
+ //#region src/plugins/lifecycle/control.ts
309
+ /**
310
+ * @file lifecycle plugin — the lifecycle commands of the `/control` door: pause and resume the
311
+ * game for the editor, through the `devtools` pause reason, so another reason keeps its hold.
312
+ * Dev builds only: every body starts with the inline dev guard, so a bundler `define` of `false`
313
+ * drops it, and logs the `moku:dev` marker.
314
+ */
315
+ /**
316
+ * Pauses the game with the pause reason `devtools`. Answers whether the game is paused.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * // The editor's pause button: the world stands, and `game.step` moves it frame by frame.
321
+ * (await run(app, commands.pause)).value; // true
322
+ * app.lifecycle.reasons(); // ["devtools"]
323
+ * ```
324
+ */
325
+ const pauseCommand = defineCommand({
326
+ id: "game.pause",
327
+ title: "Pause",
328
+ input: {},
329
+ effect: "cosmetic",
330
+ run: (app) => {
331
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
332
+ app.log.debug("moku:dev", { command: "game.pause" });
333
+ app.lifecycle.push("devtools");
334
+ return app.lifecycle.isPaused();
335
+ }
336
+ });
337
+ /**
338
+ * Takes the pause reason `devtools` back. Answers whether the game is still paused: another
339
+ * reason, such as a hidden tab, keeps its hold.
340
+ *
341
+ * @example
342
+ * ```ts
343
+ * // The editor's play button while the tab is visible.
344
+ * (await run(app, commands.resume)).value; // false: the game runs again
345
+ * ```
346
+ */
347
+ const resumeCommand = defineCommand({
348
+ id: "game.resume",
349
+ title: "Resume",
350
+ input: {},
351
+ effect: "cosmetic",
352
+ run: (app) => {
353
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
354
+ app.log.debug("moku:dev", { command: "game.resume" });
355
+ app.lifecycle.pop("devtools");
356
+ return app.lifecycle.isPaused();
357
+ }
358
+ });
359
+ //#endregion
360
+ //#region src/plugins/renderer/control.ts
361
+ /**
362
+ * @file renderer plugin — the renderer commands of the `/control` door: take a picture of the
363
+ * canvas and switch the debug drawing. Dev builds only: every body starts with the inline dev
364
+ * guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker.
365
+ */
366
+ /**
367
+ * A PNG of the whole canvas taken at the end of the next drawn frame, as a data URL.
368
+ * `undefined` while the renderer is inert, as in a headless test.
369
+ *
370
+ * @example
371
+ * ```ts
372
+ * // The editor attaches the screen to a bug report.
373
+ * (await run(app, commands.capture)).value; // "data:image/png;base64,iVBORw0KGgo…"
374
+ * ```
375
+ */
376
+ const captureCommand = defineCommand({
377
+ id: "game.capture",
378
+ title: "Capture",
379
+ input: {},
380
+ effect: "read",
381
+ run: (app) => {
382
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
383
+ app.log.debug("moku:dev", { command: "game.capture" });
384
+ return app.renderer.capture();
385
+ }
386
+ });
387
+ /**
388
+ * Switches the debug drawing: `nineSlice` outlines every nine-slice and the lines Pixi cuts its
389
+ * texture at. Answers the switches as they are afterwards.
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * // Check where the settings popup cuts its parchment.
394
+ * (await run(app, commands.debug, { nineSlice: true })).value; // { nineSlice: true }
395
+ * ```
396
+ */
397
+ const debugCommand = defineCommand({
398
+ id: "game.debug",
399
+ title: "Debug drawing",
400
+ input: { nineSlice: "boolean" },
401
+ effect: "cosmetic",
402
+ run: (app, { nineSlice }) => {
403
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
404
+ app.log.debug("moku:dev", {
405
+ command: "game.debug",
406
+ nineSlice
407
+ });
408
+ app.renderer.sync.debug.nineSlice(nineSlice);
409
+ return app.renderer.sync.debug.state();
410
+ }
411
+ });
412
+ //#endregion
413
+ //#region src/plugins/time/control.ts
414
+ /**
415
+ * @file time plugin — the time command of the `/control` door: step frames by hand. Dev builds
416
+ * only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
417
+ * logs the `moku:dev` marker.
418
+ */
419
+ /** The length of one frame at 60 fps, the step a frame count takes by default. */
420
+ const FRAME_MS = 1e3 / 60;
421
+ /**
422
+ * Refuses a frame count that is not a whole number of zero or more.
423
+ *
424
+ * @param frames - The frame count.
425
+ * @throws {Error} When it is negative, fractional or not finite.
426
+ */
427
+ function checkFrames(frames) {
428
+ if (Number.isInteger(frames) && frames >= 0) return;
429
+ throw new Error(`[game] game.step takes a whole number of frames.\n Pass 0 or a positive whole number, not ${String(frames)}.`);
430
+ }
431
+ /**
432
+ * Runs frames by hand, one `time.step` each, also while the game is paused, and answers the time
433
+ * after them. A frame lasts 1000/60 ms unless `deltaMs` says otherwise.
434
+ *
435
+ * @example
436
+ * ```ts
437
+ * // The editor paused the game on a merge; step three frames to watch the item land.
438
+ * const ran = await run(app, commands.step, { frames: 3 });
439
+ * ran.value; // { delta: 16.666…, elapsed: 50, scale: 1, frame: 3, idle: false }
440
+ * ```
441
+ */
442
+ const stepCommand = defineCommand({
443
+ id: "game.step",
444
+ title: "Step frames",
445
+ input: {
446
+ frames: "number",
447
+ deltaMs: "number?"
448
+ },
449
+ effect: "cosmetic",
450
+ run: (app, { frames, deltaMs = FRAME_MS }) => {
451
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
452
+ app.log.debug("moku:dev", {
453
+ command: "game.step",
454
+ frames
455
+ });
456
+ checkFrames(frames);
457
+ for (let frame = 0; frame < frames; frame += 1) app.time.step(deltaMs);
458
+ return app.time.snapshot();
459
+ }
460
+ });
461
+ //#endregion
462
+ //#region src/plugins/ui/control.ts
463
+ /**
464
+ * @file ui plugin — the two ui commands of the `/control` door: a tap on a keyed element or on a
465
+ * view, and the text typed into a field. The tap goes through `app.input`, so the gate decides;
466
+ * the text goes through `app.ui.fill`, which writes a component's local and answers nothing. The
467
+ * session stays clean. Dev builds only: each body starts with the inline dev guard, so a bundler
468
+ * `define` of `false` drops it, and logs the `moku:dev` marker.
469
+ */
470
+ /**
471
+ * Finds the entity of a keyed ui element on screen.
472
+ *
473
+ * @param ui - The ui API.
474
+ * @param key - The `key` prop of the element.
475
+ * @returns The entity.
476
+ * @throws {Error} When no element with the key is on screen.
477
+ */
478
+ function elementOf(ui, key) {
479
+ const entity = ui.find(key);
480
+ if (entity !== void 0) return entity;
481
+ throw new Error(`[game] No element with the key "${key}" is on screen.\n Read sources.ui for the keys on screen.`);
482
+ }
483
+ /**
484
+ * Taps a ui element by its key, or a view by its projection key: exactly one of the two. Answers
485
+ * what `input.tap` answers: whether the gate took the answer.
486
+ *
487
+ * @example
488
+ * ```ts
489
+ * // Home rests: tap the Play plank, then the first generator of the board.
490
+ * (await run(app, commands.tap, { key: "play" })).value; // true
491
+ * await run(app, commands.tap, { target: { projection: "board.generators", key: "g1" } });
492
+ * ```
493
+ */
494
+ const tapCommand = defineCommand({
495
+ id: "game.tap",
496
+ title: "Tap",
497
+ input: {
498
+ key: "string?",
499
+ target: "json?"
500
+ },
501
+ effect: "route",
502
+ run: (app, { key, target }) => {
503
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
504
+ app.log.debug("moku:dev", {
505
+ command: "game.tap",
506
+ key
507
+ });
508
+ if (key !== void 0 && target === void 0) return app.input.tap(elementOf(app.ui, key));
509
+ if (target !== void 0 && key === void 0) return app.input.tap(readTarget(target));
510
+ throw new Error("[game] game.tap takes a key or a target.\n Pass exactly one of { key } and { target }.");
511
+ }
512
+ });
513
+ /**
514
+ * Types into a text field by its key, the way `app.ui.fill` does: the field becomes the one being
515
+ * edited, the value cut to its `maxLength` is written into it and into the local of its
516
+ * component. Answers what `fill` answers: false for a key that is no live `input`.
517
+ *
518
+ * @example
519
+ * ```ts
520
+ * // The Rename popup rests: type the name, then submit it with the Enter key.
521
+ * (await run(app, commands.fill, { key: "nameField", value: "Alex" })).value; // true
522
+ * await run(app, commands.key, { key: "Enter" }); // the gate takes { intent: "save", payload: { name: "Alex" } }
523
+ * ```
524
+ */
525
+ const fillCommand = defineCommand({
526
+ id: "game.fill",
527
+ title: "Fill",
528
+ input: {
529
+ key: "string",
530
+ value: "string"
531
+ },
532
+ effect: "route",
533
+ run: (app, { key, value }) => {
534
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
535
+ app.log.debug("moku:dev", {
536
+ command: "game.fill",
537
+ key
538
+ });
539
+ return app.ui.fill(key, value);
540
+ }
541
+ });
542
+ //#endregion
543
+ //#region src/plugins/flow/json.ts
544
+ /**
545
+ * Tells whether a JSON value is an object.
546
+ *
547
+ * @param value - The value.
548
+ * @returns True for an object that is neither null nor an array.
549
+ * @example
550
+ * ```ts
551
+ * isRecord({ at: "home" }); // true
552
+ * ```
553
+ */
554
+ function isRecord(value) {
555
+ return typeof value === "object" && value !== null && !Array.isArray(value);
556
+ }
557
+ /**
558
+ * Builds the error of a route that is not a list of steps.
559
+ *
560
+ * @returns The error, ready to throw.
561
+ * @example
562
+ * ```ts
563
+ * invalidRoute().message.startsWith("[game] The route is not a list of route steps."); // true
564
+ * ```
565
+ */
566
+ function invalidRoute() {
567
+ return /* @__PURE__ */ new Error("[game] The route is not a list of route steps.\n Pass steps like { at: \"home\", intent: \"play\" } or { at: \"board\", result: { outcome: \"left\" } }.");
568
+ }
569
+ /**
570
+ * Reads the result of a skipped sub-flow.
571
+ *
572
+ * @param result - The `result` field of a step.
573
+ * @returns The outcome and its payload, if any.
574
+ * @throws {Error} When the result has no outcome name.
575
+ * @example
576
+ * ```ts
577
+ * readResult({ outcome: "won", payload: { stars: 3 } }); // { outcome: "won", payload: { stars: 3 } }
578
+ * ```
579
+ */
580
+ function readResult(result) {
581
+ if (!isRecord(result) || typeof result.outcome !== "string") throw invalidRoute();
582
+ const { outcome, payload } = result;
583
+ return payload === void 0 ? { outcome } : {
584
+ outcome,
585
+ payload
586
+ };
587
+ }
588
+ /**
589
+ * Reads one step: an answer at a rest node, or a substituted sub-flow result.
590
+ *
591
+ * @param step - One element of the route.
592
+ * @returns The step.
593
+ * @throws {Error} When the step has no `at`, or neither an intent nor a result.
594
+ * @example
595
+ * ```ts
596
+ * readStep({ at: "home", intent: "play" }); // { at: "home", intent: "play" }
597
+ * ```
598
+ */
599
+ function readStep(step) {
600
+ if (!isRecord(step) || typeof step.at !== "string") throw invalidRoute();
601
+ const { at, intent, payload, result } = step;
602
+ if (typeof intent === "string") return payload === void 0 ? {
603
+ at,
604
+ intent
605
+ } : {
606
+ at,
607
+ intent,
608
+ payload
609
+ };
610
+ if (result !== void 0) return {
611
+ at,
612
+ result: readResult(result)
613
+ };
614
+ throw invalidRoute();
615
+ }
616
+ /**
617
+ * Reads a route out of JSON.
618
+ *
619
+ * @param value - The JSON the editor sent.
620
+ * @returns The route steps, in order.
621
+ * @throws {Error} When the value is not a list of steps.
622
+ * @example
623
+ * ```ts
624
+ * readRoute([{ at: "home", intent: "play" }]); // [{ at: "home", intent: "play" }]
625
+ * ```
626
+ */
627
+ function readRoute(value) {
628
+ if (!Array.isArray(value)) throw invalidRoute();
629
+ return value.map((step) => readStep(step));
630
+ }
631
+ /**
632
+ * Reads an rng state: a seed and a number per stream.
633
+ *
634
+ * @param value - The JSON value.
635
+ * @returns The rng state, or `undefined` when the value is not one.
636
+ * @example
637
+ * ```ts
638
+ * readRng({ seed: 42, streams: { chest: 3 } }); // { seed: 42, streams: { chest: 3 } }
639
+ * ```
640
+ */
641
+ function readRng(value) {
642
+ if (!isRecord(value) || typeof value.seed !== "number" || !isRecord(value.streams)) return;
643
+ const streams = {};
644
+ for (const [name, draws] of Object.entries(value.streams)) {
645
+ if (typeof draws !== "number") return void 0;
646
+ streams[name] = draws;
647
+ }
648
+ return {
649
+ seed: value.seed,
650
+ streams
651
+ };
652
+ }
653
+ /**
654
+ * Builds the error of a value that is not a bookmark.
655
+ *
656
+ * @returns The error, ready to throw.
657
+ * @example
658
+ * ```ts
659
+ * invalidBookmark().message.startsWith("[game] The bookmark is not"); // true
660
+ * ```
661
+ */
662
+ function invalidBookmark() {
663
+ return /* @__PURE__ */ new Error("[game] The bookmark is not a flow.bookmark() value.\n Pass the JSON that game.bookmark returned.");
664
+ }
665
+ /**
666
+ * Reads a bookmark out of JSON, the value `flow.bookmark()` made and the editor kept.
667
+ *
668
+ * @param value - The JSON the editor sent.
669
+ * @returns The bookmark.
670
+ * @throws {Error} When a field is missing or has the wrong type.
671
+ * @example
672
+ * ```ts
673
+ * readBookmark(JSON.parse(saved)).path; // "board/awaitIntent"
674
+ * ```
675
+ */
676
+ function readBookmark(value) {
677
+ if (!isRecord(value)) throw invalidBookmark();
678
+ const { path, input, player, session, graph } = value;
679
+ const rng = readRng(value.rng);
680
+ if (typeof path !== "string" || typeof graph !== "string" || !(input !== void 0 && player !== void 0 && session !== void 0) || rng === void 0) throw invalidBookmark();
681
+ return {
682
+ path,
683
+ input,
684
+ player,
685
+ session,
686
+ rng,
687
+ graph
688
+ };
689
+ }
690
+ /**
691
+ * Builds the error of a value that is not a repro.
692
+ *
693
+ * @returns The error, ready to throw.
694
+ * @example
695
+ * ```ts
696
+ * invalidRepro().message.startsWith("[game] The repro is not a Repro."); // true
697
+ * ```
698
+ */
699
+ function invalidRepro() {
700
+ return /* @__PURE__ */ new Error("[game] The repro is not a Repro.\n Pass { player, route } with an optional session, rng and checkpoint.");
701
+ }
702
+ /**
703
+ * Reads the optional fields of a repro onto it.
704
+ *
705
+ * @param value - The JSON object of the repro.
706
+ * @param repro - The repro with its player and route.
707
+ * @returns The repro with its session, rng and checkpoint.
708
+ * @throws {Error} When the rng or the checkpoint has the wrong type.
709
+ */
710
+ function readReproOptions(value, repro) {
711
+ const { session, checkpoint } = value;
712
+ if (session !== void 0) repro.session = session;
713
+ if (value.rng !== void 0) {
714
+ const rng = readRng(value.rng);
715
+ if (rng === void 0) throw invalidRepro();
716
+ repro.rng = rng;
717
+ }
718
+ if (checkpoint !== void 0) {
719
+ if (typeof checkpoint !== "string") throw invalidRepro();
720
+ repro.checkpoint = checkpoint;
721
+ }
722
+ return repro;
723
+ }
724
+ /**
725
+ * Reads a repro out of JSON: the `/testing` repro of a bug report.
726
+ *
727
+ * @param value - The JSON the editor sent.
728
+ * @returns The repro.
729
+ * @throws {Error} When the player is missing, a field has the wrong type or the route is broken.
730
+ * @example
731
+ * ```ts
732
+ * readRepro({ player: { coins: 7 }, checkpoint: "home", route: [] }).checkpoint; // "home"
733
+ * ```
734
+ */
735
+ function readRepro(value) {
736
+ if (!isRecord(value) || value.player === void 0) throw invalidRepro();
737
+ return readReproOptions(value, {
738
+ player: value.player,
739
+ route: readRoute(value.route)
740
+ });
741
+ }
742
+ //#endregion
743
+ //#region src/plugins/flow/control.ts
744
+ /**
745
+ * Enters a bookmark, or a repro's bookmark followed by its route.
746
+ *
747
+ * @param app - The app.
748
+ * @param input - Exactly one of a bookmark and a repro, as JSON.
749
+ * @param input.bookmark - A `flow.bookmark()` value.
750
+ * @param input.repro - A `/testing` repro.
751
+ * @returns The state the game rests in afterwards.
752
+ * @throws {Error} When both or neither are given, or the JSON is not of its shape.
753
+ */
754
+ async function restoreFrom(app, input) {
755
+ const { bookmark, repro } = input;
756
+ if (bookmark !== void 0 && repro === void 0) {
757
+ await app.flow.restore(readBookmark(bookmark));
758
+ return app.flow.state();
759
+ }
760
+ if (repro !== void 0 && bookmark === void 0) {
761
+ const parsed = readRepro(repro);
762
+ await app.flow.restore(reproBookmark(app, parsed));
763
+ return app.flow.walk(parsed.route);
764
+ }
765
+ throw new Error("[game] game.restore takes a bookmark or a repro.\n Pass exactly one of { bookmark } and { repro }.");
766
+ }
767
+ /**
768
+ * Answers the gate, the way a tap on a button does. Goes through the graph: the session stays
769
+ * clean.
770
+ *
771
+ * @example
772
+ * ```ts
773
+ * // The home screen rests: tap Play without touching the screen.
774
+ * const ran = await run(app, commands.answer, { intent: "play" });
775
+ * ran.value; // true: "home" waits for "play"
776
+ * ran.state.tainted; // false
777
+ * ```
778
+ */
779
+ const answerCommand = defineCommand({
780
+ id: "game.answer",
781
+ title: "Answer",
782
+ input: {
783
+ intent: "string",
784
+ payload: "json?"
785
+ },
786
+ effect: "route",
787
+ run: (app, { intent, payload }) => {
788
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
789
+ app.log.debug("moku:dev", {
790
+ command: "game.answer",
791
+ intent
792
+ });
793
+ return app.flow.gate.answer(payload === void 0 ? { intent } : {
794
+ intent,
795
+ payload
796
+ });
797
+ }
798
+ });
799
+ /**
800
+ * Walks a route in fast mode through the graph. The session stays clean.
801
+ *
802
+ * @example
803
+ * ```ts
804
+ * // Skip the menu and stand on the board.
805
+ * const ran = await run(app, commands.walk, { route: [{ at: "home", intent: "play" }] });
806
+ * ran.value.path; // "board/awaitIntent"
807
+ * ```
808
+ */
809
+ const walkCommand = defineCommand({
810
+ id: "game.walk",
811
+ title: "Walk a route",
812
+ input: { route: "json" },
813
+ effect: "route",
814
+ run: (app, { route }) => {
815
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
816
+ app.log.debug("moku:dev", { command: "game.walk" });
817
+ return app.flow.walk(readRoute(route));
818
+ }
819
+ });
820
+ /**
821
+ * Takes a bookmark of the rest point: the node plus the committed state, plain JSON.
822
+ *
823
+ * @example
824
+ * ```ts
825
+ * // The editor keeps the position before a risky test.
826
+ * const { value } = await run(app, commands.bookmark);
827
+ * value.path; // "board/awaitIntent"
828
+ * ```
829
+ */
830
+ const bookmarkCommand = defineCommand({
831
+ id: "game.bookmark",
832
+ title: "Bookmark",
833
+ input: {},
834
+ effect: "read",
835
+ run: (app) => {
836
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
837
+ app.log.debug("moku:dev", { command: "game.bookmark" });
838
+ return app.flow.bookmark();
839
+ }
840
+ });
841
+ /**
842
+ * Restores a bookmark, or a repro: its state at its checkpoint, then its route. Replaces the
843
+ * state outside the graph, so the session is tainted and the command journaled.
844
+ *
845
+ * @example
846
+ * ```ts
847
+ * // Load the bug report "the order stays after delivery".
848
+ * const repro = { player: { coins: 40 }, checkpoint: "home", route: [{ at: "home", intent: "play" }] };
849
+ * const ran = await run(app, commands.restore, { repro });
850
+ * ran.state; // { path: "board/awaitIntent", frame: 12, tainted: true }
851
+ * ```
852
+ */
853
+ const restoreCommand = defineCommand({
854
+ id: "game.restore",
855
+ title: "Restore",
856
+ input: {
857
+ bookmark: "json?",
858
+ repro: "json?"
859
+ },
860
+ effect: "raw",
861
+ run: (app, input) => {
862
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
863
+ app.log.debug("moku:dev", { command: "game.restore" });
864
+ return restoreFrom(app, input);
865
+ }
866
+ });
867
+ //#endregion
868
+ //#region src/plugins/flow/doors/commands.ts
869
+ /**
870
+ * @file flow/doors — the base commands: every command of the engine, keyed by a short name. Each
871
+ * descriptor lives with the plugin that owns its data; this module only gathers them. Imported
872
+ * by the door `src/control.ts` only, so no plugin reaches it and no import cycle forms.
873
+ */
874
+ /**
875
+ * Every command of the engine, keyed by its short name: what an editor lists as buttons and MCP
876
+ * tools. Frozen. Each runs in dev builds only.
877
+ *
878
+ * @example
879
+ * ```ts
880
+ * // An e2e script taps Play, and the editor registers every command.
881
+ * (await run(app, commands.tap, { key: "play" })).state.path; // "board/awaitIntent"
882
+ * for (const command of Object.values(commands)) registry.add(command);
883
+ * ```
884
+ */
885
+ const commands = Object.freeze({
886
+ answer: answerCommand,
887
+ tap: tapCommand,
888
+ drag: dragCommand,
889
+ key: keyCommand,
890
+ walk: walkCommand,
891
+ bookmark: bookmarkCommand,
892
+ restore: restoreCommand,
893
+ step: stepCommand,
894
+ pause: pauseCommand,
895
+ resume: resumeCommand,
896
+ capture: captureCommand,
897
+ debug: debugCommand,
898
+ reducedMotion: reducedMotionCommand,
899
+ fill: fillCommand
900
+ });
901
+ //#endregion
902
+ //#region src/plugins/flow/doors/run.ts
903
+ /**
904
+ * Reads where the game stands after a command.
905
+ *
906
+ * @param app - The app.
907
+ * @returns The graph path, the frame and the taint.
908
+ */
909
+ function envelopeOf(app) {
910
+ return {
911
+ path: app.flow.state().path,
912
+ frame: app.time.snapshot().frame,
913
+ tainted: isTainted(app)
914
+ };
915
+ }
916
+ /**
917
+ * Runs a command in a dev build. A `cheat` or `raw` command taints the session and is journaled
918
+ * before it runs, so a failing one still counts.
919
+ *
920
+ * @param app - The app the command acts on.
921
+ * @param command - The command.
922
+ * @param input - Its input; left out when every field of its schema is optional.
923
+ * @returns The command's value and the envelope read after it.
924
+ * @throws {Error} Outside a dev build, and whatever the command throws.
925
+ * @example
926
+ * ```ts
927
+ * // An e2e script: load the bug report, then tap Play.
928
+ * await run(app, commands.restore, { repro }); // state: { path: "home", frame: 12, tainted: true }
929
+ * const ran = await run(app, commands.answer, { intent: "play" });
930
+ * ran.value; // true
931
+ * ran.state; // { path: "board/awaitIntent", frame: 14, tainted: true }
932
+ * ```
933
+ */
934
+ async function run(app, command, ...input) {
935
+ if (!isDev()) throw controlRefused();
936
+ const given = inputOrEmpty(input[0]);
937
+ if (command.effect === "cheat" || command.effect === "raw") recordCheat(app, command.id, given, app.time.snapshot().frame);
938
+ return {
939
+ value: await command.run(app, given),
940
+ state: envelopeOf(app)
941
+ };
942
+ }
943
+ //#endregion
944
+ export { stepFrames as a, runRepro as i, commands as n, createHeadless as r, run as t };