@moku-labs/game 0.0.1 → 0.0.2

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.
@@ -0,0 +1,758 @@
1
+ import { n as reproBookmark } from "./headless-CvamCUcR.mjs";
2
+ import { a as defineCommand, i as inputOrEmpty, n as isTainted, r as recordCheat } from "./session-DmAxY6Ll.mjs";
3
+ //#region src/plugins/flow/doors/dev.ts
4
+ /**
5
+ * Reads the dev flag at call time. Undefined means off: production is the default.
6
+ *
7
+ * @returns True only when `__MOKU_GAME_DEV__` is exactly `true`.
8
+ * @example
9
+ * ```ts
10
+ * // The dev page set the flag in web/dev.ts before the engine started.
11
+ * globalThis.__MOKU_GAME_DEV__ = true;
12
+ * isDev(); // true
13
+ * ```
14
+ */
15
+ function isDev() {
16
+ return typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ === true;
17
+ }
18
+ /**
19
+ * Builds the error a control command throws outside a dev build.
20
+ *
21
+ * @returns The error, ready to throw.
22
+ * @example
23
+ * ```ts
24
+ * // A production build called a /control command.
25
+ * controlRefused().message; // "[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build."
26
+ * ```
27
+ */
28
+ function controlRefused() {
29
+ return /* @__PURE__ */ new Error("[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build.");
30
+ }
31
+ //#endregion
32
+ //#region src/plugins/anim/control.ts
33
+ /**
34
+ * @file anim plugin — the anim command of the `/control` door: switch reduced motion. Dev builds
35
+ * only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
36
+ * logs the `moku:dev` marker.
37
+ */
38
+ /**
39
+ * Switches reduced motion on or off, the way the player's "Less motion" setting does. Answers
40
+ * the switch as it is afterwards.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * // Check the popups of the settings screen with no motion at all.
45
+ * (await run(app, commands.reducedMotion, { on: true })).value; // true
46
+ * ```
47
+ */
48
+ const reducedMotionCommand = defineCommand({
49
+ id: "game.reducedMotion",
50
+ title: "Reduced motion",
51
+ input: { on: "boolean" },
52
+ effect: "cosmetic",
53
+ run: (app, { on }) => {
54
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
55
+ app.log.debug("moku:dev", {
56
+ command: "game.reducedMotion",
57
+ on
58
+ });
59
+ app.anim.setReducedMotion(on);
60
+ return app.anim.reducedMotion();
61
+ }
62
+ });
63
+ //#endregion
64
+ //#region src/plugins/input/control.ts
65
+ /**
66
+ * @file input plugin — the input commands of the `/control` door: a drag from one view onto
67
+ * another and a key press. Each goes through `app.input`, so the gate decides and the session
68
+ * stays clean. Dev builds only: every body starts with the inline dev guard, so a bundler
69
+ * `define` of `false` drops it, and logs the `moku:dev` marker. `readTarget` is shared with the
70
+ * `game.tap` command of ui, which taps a view by the same projection key.
71
+ */
72
+ /**
73
+ * Tells whether the JSON a command got names a view by its projection key.
74
+ *
75
+ * @param value - The JSON.
76
+ * @returns True for an object with a string `projection` and a string `key`.
77
+ * @example
78
+ * ```ts
79
+ * isProjectionTarget({ projection: "board.items", key: "i5" }); // true
80
+ * ```
81
+ */
82
+ function isProjectionTarget(value) {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.projection === "string" && typeof value.key === "string";
84
+ }
85
+ /**
86
+ * Reads the projection key of a view out of the JSON a command got.
87
+ *
88
+ * @param value - The JSON: `{ projection, key }`.
89
+ * @returns The target, a fresh object.
90
+ * @throws {Error} When the JSON is not an object with a string `projection` and a string `key`.
91
+ */
92
+ function readTarget(value) {
93
+ if (isProjectionTarget(value)) return {
94
+ projection: value.projection,
95
+ key: value.key
96
+ };
97
+ throw new Error("[game] The target is not a projection key.\n Pass a view like { projection: \"board.items\", key: \"i5\" }.");
98
+ }
99
+ /**
100
+ * Drags one view onto another, both by their projection keys. Answers what `input.drag`
101
+ * answers: whether the gate took the answer.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * // Merge two logs of level 1 on the board.
106
+ * const from = { projection: "board.items", key: "i5" };
107
+ * (await run(app, commands.drag, { from, to: { projection: "board.items", key: "i7" } })).value; // true
108
+ * ```
109
+ */
110
+ const dragCommand = defineCommand({
111
+ id: "game.drag",
112
+ title: "Drag",
113
+ input: {
114
+ from: "json",
115
+ to: "json"
116
+ },
117
+ effect: "route",
118
+ run: (app, { from, to }) => {
119
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
120
+ app.log.debug("moku:dev", { command: "game.drag" });
121
+ return app.input.drag(readTarget(from), readTarget(to));
122
+ }
123
+ });
124
+ /**
125
+ * Presses a key without a keyboard, with or without Shift. Answers whether a listener handled
126
+ * it.
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * // Close the settings popup the way Escape does.
131
+ * (await run(app, commands.key, { key: "Escape" })).value; // true
132
+ * (await run(app, commands.key, { key: "Tab", shift: true })).value; // true: the focus moved back
133
+ * ```
134
+ */
135
+ const keyCommand = defineCommand({
136
+ id: "game.key",
137
+ title: "Press a key",
138
+ input: {
139
+ key: "string",
140
+ shift: "boolean?"
141
+ },
142
+ effect: "route",
143
+ run: (app, { key, shift = false }) => {
144
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
145
+ app.log.debug("moku:dev", {
146
+ command: "game.key",
147
+ key
148
+ });
149
+ return app.input.pressKey(key, { shift });
150
+ }
151
+ });
152
+ //#endregion
153
+ //#region src/plugins/lifecycle/control.ts
154
+ /**
155
+ * @file lifecycle plugin — the lifecycle commands of the `/control` door: pause and resume the
156
+ * game for the editor, through the `devtools` pause reason, so another reason keeps its hold.
157
+ * Dev builds only: every body starts with the inline dev guard, so a bundler `define` of `false`
158
+ * drops it, and logs the `moku:dev` marker.
159
+ */
160
+ /**
161
+ * Pauses the game with the pause reason `devtools`. Answers whether the game is paused.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * // The editor's pause button: the world stands, and `game.step` moves it frame by frame.
166
+ * (await run(app, commands.pause)).value; // true
167
+ * app.lifecycle.reasons(); // ["devtools"]
168
+ * ```
169
+ */
170
+ const pauseCommand = defineCommand({
171
+ id: "game.pause",
172
+ title: "Pause",
173
+ input: {},
174
+ effect: "cosmetic",
175
+ run: (app) => {
176
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
177
+ app.log.debug("moku:dev", { command: "game.pause" });
178
+ app.lifecycle.push("devtools");
179
+ return app.lifecycle.isPaused();
180
+ }
181
+ });
182
+ /**
183
+ * Takes the pause reason `devtools` back. Answers whether the game is still paused: another
184
+ * reason, such as a hidden tab, keeps its hold.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * // The editor's play button while the tab is visible.
189
+ * (await run(app, commands.resume)).value; // false: the game runs again
190
+ * ```
191
+ */
192
+ const resumeCommand = defineCommand({
193
+ id: "game.resume",
194
+ title: "Resume",
195
+ input: {},
196
+ effect: "cosmetic",
197
+ run: (app) => {
198
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
199
+ app.log.debug("moku:dev", { command: "game.resume" });
200
+ app.lifecycle.pop("devtools");
201
+ return app.lifecycle.isPaused();
202
+ }
203
+ });
204
+ //#endregion
205
+ //#region src/plugins/renderer/control.ts
206
+ /**
207
+ * @file renderer plugin — the renderer commands of the `/control` door: take a picture of the
208
+ * canvas and switch the debug drawing. Dev builds only: every body starts with the inline dev
209
+ * guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker.
210
+ */
211
+ /**
212
+ * A PNG of the whole canvas taken at the end of the next drawn frame, as a data URL.
213
+ * `undefined` while the renderer is inert, as in a headless test.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * // The editor attaches the screen to a bug report.
218
+ * (await run(app, commands.capture)).value; // "data:image/png;base64,iVBORw0KGgo…"
219
+ * ```
220
+ */
221
+ const captureCommand = defineCommand({
222
+ id: "game.capture",
223
+ title: "Capture",
224
+ input: {},
225
+ effect: "read",
226
+ run: (app) => {
227
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
228
+ app.log.debug("moku:dev", { command: "game.capture" });
229
+ return app.renderer.capture();
230
+ }
231
+ });
232
+ /**
233
+ * Switches the debug drawing: `nineSlice` outlines every nine-slice and the lines Pixi cuts its
234
+ * texture at. Answers the switches as they are afterwards.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * // Check where the settings popup cuts its parchment.
239
+ * (await run(app, commands.debug, { nineSlice: true })).value; // { nineSlice: true }
240
+ * ```
241
+ */
242
+ const debugCommand = defineCommand({
243
+ id: "game.debug",
244
+ title: "Debug drawing",
245
+ input: { nineSlice: "boolean" },
246
+ effect: "cosmetic",
247
+ run: (app, { nineSlice }) => {
248
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
249
+ app.log.debug("moku:dev", {
250
+ command: "game.debug",
251
+ nineSlice
252
+ });
253
+ app.renderer.sync.debug.nineSlice(nineSlice);
254
+ return app.renderer.sync.debug.state();
255
+ }
256
+ });
257
+ //#endregion
258
+ //#region src/plugins/time/control.ts
259
+ /**
260
+ * @file time plugin — the time command of the `/control` door: step frames by hand. Dev builds
261
+ * only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
262
+ * logs the `moku:dev` marker.
263
+ */
264
+ /** The length of one frame at 60 fps, the step a frame count takes by default. */
265
+ const FRAME_MS = 1e3 / 60;
266
+ /**
267
+ * Refuses a frame count that is not a whole number of zero or more.
268
+ *
269
+ * @param frames - The frame count.
270
+ * @throws {Error} When it is negative, fractional or not finite.
271
+ */
272
+ function checkFrames(frames) {
273
+ if (Number.isInteger(frames) && frames >= 0) return;
274
+ throw new Error(`[game] game.step takes a whole number of frames.\n Pass 0 or a positive whole number, not ${String(frames)}.`);
275
+ }
276
+ /**
277
+ * Runs frames by hand, one `time.step` each, also while the game is paused, and answers the time
278
+ * after them. A frame lasts 1000/60 ms unless `deltaMs` says otherwise.
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * // The editor paused the game on a merge; step three frames to watch the item land.
283
+ * const ran = await run(app, commands.step, { frames: 3 });
284
+ * ran.value; // { delta: 16.666…, elapsed: 50, scale: 1, frame: 3, idle: false }
285
+ * ```
286
+ */
287
+ const stepCommand = defineCommand({
288
+ id: "game.step",
289
+ title: "Step frames",
290
+ input: {
291
+ frames: "number",
292
+ deltaMs: "number?"
293
+ },
294
+ effect: "cosmetic",
295
+ run: (app, { frames, deltaMs = FRAME_MS }) => {
296
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
297
+ app.log.debug("moku:dev", {
298
+ command: "game.step",
299
+ frames
300
+ });
301
+ checkFrames(frames);
302
+ for (let frame = 0; frame < frames; frame += 1) app.time.step(deltaMs);
303
+ return app.time.snapshot();
304
+ }
305
+ });
306
+ //#endregion
307
+ //#region src/plugins/ui/control.ts
308
+ /**
309
+ * @file ui plugin — the ui command of the `/control` door: a tap on a keyed element or on a
310
+ * view. It goes through `app.input`, so the gate decides and the session stays clean. Dev builds
311
+ * only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it,
312
+ * and logs the `moku:dev` marker.
313
+ */
314
+ /**
315
+ * Finds the entity of a keyed ui element on screen.
316
+ *
317
+ * @param ui - The ui API.
318
+ * @param key - The `key` prop of the element.
319
+ * @returns The entity.
320
+ * @throws {Error} When no element with the key is on screen.
321
+ */
322
+ function elementOf(ui, key) {
323
+ const entity = ui.find(key);
324
+ if (entity !== void 0) return entity;
325
+ throw new Error(`[game] No element with the key "${key}" is on screen.\n Read sources.ui for the keys on screen.`);
326
+ }
327
+ /**
328
+ * Taps a ui element by its key, or a view by its projection key: exactly one of the two. Answers
329
+ * what `input.tap` answers: whether the gate took the answer.
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * // Home rests: tap the Play plank, then the first generator of the board.
334
+ * (await run(app, commands.tap, { key: "play" })).value; // true
335
+ * await run(app, commands.tap, { target: { projection: "board.generators", key: "g1" } });
336
+ * ```
337
+ */
338
+ const tapCommand = defineCommand({
339
+ id: "game.tap",
340
+ title: "Tap",
341
+ input: {
342
+ key: "string?",
343
+ target: "json?"
344
+ },
345
+ effect: "route",
346
+ run: (app, { key, target }) => {
347
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
348
+ app.log.debug("moku:dev", {
349
+ command: "game.tap",
350
+ key
351
+ });
352
+ if (key !== void 0 && target === void 0) return app.input.tap(elementOf(app.ui, key));
353
+ if (target !== void 0 && key === void 0) return app.input.tap(readTarget(target));
354
+ throw new Error("[game] game.tap takes a key or a target.\n Pass exactly one of { key } and { target }.");
355
+ }
356
+ });
357
+ //#endregion
358
+ //#region src/plugins/flow/json.ts
359
+ /**
360
+ * Tells whether a JSON value is an object.
361
+ *
362
+ * @param value - The value.
363
+ * @returns True for an object that is neither null nor an array.
364
+ * @example
365
+ * ```ts
366
+ * isRecord({ at: "home" }); // true
367
+ * ```
368
+ */
369
+ function isRecord(value) {
370
+ return typeof value === "object" && value !== null && !Array.isArray(value);
371
+ }
372
+ /**
373
+ * Builds the error of a route that is not a list of steps.
374
+ *
375
+ * @returns The error, ready to throw.
376
+ * @example
377
+ * ```ts
378
+ * invalidRoute().message.startsWith("[game] The route is not a list of route steps."); // true
379
+ * ```
380
+ */
381
+ function invalidRoute() {
382
+ 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\" } }.");
383
+ }
384
+ /**
385
+ * Reads the result of a skipped sub-flow.
386
+ *
387
+ * @param result - The `result` field of a step.
388
+ * @returns The outcome and its payload, if any.
389
+ * @throws {Error} When the result has no outcome name.
390
+ * @example
391
+ * ```ts
392
+ * readResult({ outcome: "won", payload: { stars: 3 } }); // { outcome: "won", payload: { stars: 3 } }
393
+ * ```
394
+ */
395
+ function readResult(result) {
396
+ if (!isRecord(result) || typeof result.outcome !== "string") throw invalidRoute();
397
+ const { outcome, payload } = result;
398
+ return payload === void 0 ? { outcome } : {
399
+ outcome,
400
+ payload
401
+ };
402
+ }
403
+ /**
404
+ * Reads one step: an answer at a rest node, or a substituted sub-flow result.
405
+ *
406
+ * @param step - One element of the route.
407
+ * @returns The step.
408
+ * @throws {Error} When the step has no `at`, or neither an intent nor a result.
409
+ * @example
410
+ * ```ts
411
+ * readStep({ at: "home", intent: "play" }); // { at: "home", intent: "play" }
412
+ * ```
413
+ */
414
+ function readStep(step) {
415
+ if (!isRecord(step) || typeof step.at !== "string") throw invalidRoute();
416
+ const { at, intent, payload, result } = step;
417
+ if (typeof intent === "string") return payload === void 0 ? {
418
+ at,
419
+ intent
420
+ } : {
421
+ at,
422
+ intent,
423
+ payload
424
+ };
425
+ if (result !== void 0) return {
426
+ at,
427
+ result: readResult(result)
428
+ };
429
+ throw invalidRoute();
430
+ }
431
+ /**
432
+ * Reads a route out of JSON.
433
+ *
434
+ * @param value - The JSON the editor sent.
435
+ * @returns The route steps, in order.
436
+ * @throws {Error} When the value is not a list of steps.
437
+ * @example
438
+ * ```ts
439
+ * readRoute([{ at: "home", intent: "play" }]); // [{ at: "home", intent: "play" }]
440
+ * ```
441
+ */
442
+ function readRoute(value) {
443
+ if (!Array.isArray(value)) throw invalidRoute();
444
+ return value.map((step) => readStep(step));
445
+ }
446
+ /**
447
+ * Reads an rng state: a seed and a number per stream.
448
+ *
449
+ * @param value - The JSON value.
450
+ * @returns The rng state, or `undefined` when the value is not one.
451
+ * @example
452
+ * ```ts
453
+ * readRng({ seed: 42, streams: { chest: 3 } }); // { seed: 42, streams: { chest: 3 } }
454
+ * ```
455
+ */
456
+ function readRng(value) {
457
+ if (!isRecord(value) || typeof value.seed !== "number" || !isRecord(value.streams)) return;
458
+ const streams = {};
459
+ for (const [name, draws] of Object.entries(value.streams)) {
460
+ if (typeof draws !== "number") return void 0;
461
+ streams[name] = draws;
462
+ }
463
+ return {
464
+ seed: value.seed,
465
+ streams
466
+ };
467
+ }
468
+ /**
469
+ * Builds the error of a value that is not a bookmark.
470
+ *
471
+ * @returns The error, ready to throw.
472
+ * @example
473
+ * ```ts
474
+ * invalidBookmark().message.startsWith("[game] The bookmark is not"); // true
475
+ * ```
476
+ */
477
+ function invalidBookmark() {
478
+ return /* @__PURE__ */ new Error("[game] The bookmark is not a flow.bookmark() value.\n Pass the JSON that game.bookmark returned.");
479
+ }
480
+ /**
481
+ * Reads a bookmark out of JSON, the value `flow.bookmark()` made and the editor kept.
482
+ *
483
+ * @param value - The JSON the editor sent.
484
+ * @returns The bookmark.
485
+ * @throws {Error} When a field is missing or has the wrong type.
486
+ * @example
487
+ * ```ts
488
+ * readBookmark(JSON.parse(saved)).path; // "board/awaitIntent"
489
+ * ```
490
+ */
491
+ function readBookmark(value) {
492
+ if (!isRecord(value)) throw invalidBookmark();
493
+ const { path, input, player, session, graph } = value;
494
+ const rng = readRng(value.rng);
495
+ if (typeof path !== "string" || typeof graph !== "string" || !(input !== void 0 && player !== void 0 && session !== void 0) || rng === void 0) throw invalidBookmark();
496
+ return {
497
+ path,
498
+ input,
499
+ player,
500
+ session,
501
+ rng,
502
+ graph
503
+ };
504
+ }
505
+ /**
506
+ * Builds the error of a value that is not a repro.
507
+ *
508
+ * @returns The error, ready to throw.
509
+ * @example
510
+ * ```ts
511
+ * invalidRepro().message.startsWith("[game] The repro is not a Repro."); // true
512
+ * ```
513
+ */
514
+ function invalidRepro() {
515
+ return /* @__PURE__ */ new Error("[game] The repro is not a Repro.\n Pass { player, route } with an optional session, rng and checkpoint.");
516
+ }
517
+ /**
518
+ * Reads the optional fields of a repro onto it.
519
+ *
520
+ * @param value - The JSON object of the repro.
521
+ * @param repro - The repro with its player and route.
522
+ * @returns The repro with its session, rng and checkpoint.
523
+ * @throws {Error} When the rng or the checkpoint has the wrong type.
524
+ */
525
+ function readReproOptions(value, repro) {
526
+ const { session, checkpoint } = value;
527
+ if (session !== void 0) repro.session = session;
528
+ if (value.rng !== void 0) {
529
+ const rng = readRng(value.rng);
530
+ if (rng === void 0) throw invalidRepro();
531
+ repro.rng = rng;
532
+ }
533
+ if (checkpoint !== void 0) {
534
+ if (typeof checkpoint !== "string") throw invalidRepro();
535
+ repro.checkpoint = checkpoint;
536
+ }
537
+ return repro;
538
+ }
539
+ /**
540
+ * Reads a repro out of JSON: the `/testing` repro of a bug report.
541
+ *
542
+ * @param value - The JSON the editor sent.
543
+ * @returns The repro.
544
+ * @throws {Error} When the player is missing, a field has the wrong type or the route is broken.
545
+ * @example
546
+ * ```ts
547
+ * readRepro({ player: { coins: 7 }, checkpoint: "home", route: [] }).checkpoint; // "home"
548
+ * ```
549
+ */
550
+ function readRepro(value) {
551
+ if (!isRecord(value) || value.player === void 0) throw invalidRepro();
552
+ return readReproOptions(value, {
553
+ player: value.player,
554
+ route: readRoute(value.route)
555
+ });
556
+ }
557
+ //#endregion
558
+ //#region src/plugins/flow/control.ts
559
+ /**
560
+ * Enters a bookmark, or a repro's bookmark followed by its route.
561
+ *
562
+ * @param app - The app.
563
+ * @param input - Exactly one of a bookmark and a repro, as JSON.
564
+ * @param input.bookmark - A `flow.bookmark()` value.
565
+ * @param input.repro - A `/testing` repro.
566
+ * @returns The state the game rests in afterwards.
567
+ * @throws {Error} When both or neither are given, or the JSON is not of its shape.
568
+ */
569
+ async function restoreFrom(app, input) {
570
+ const { bookmark, repro } = input;
571
+ if (bookmark !== void 0 && repro === void 0) {
572
+ await app.flow.restore(readBookmark(bookmark));
573
+ return app.flow.state();
574
+ }
575
+ if (repro !== void 0 && bookmark === void 0) {
576
+ const parsed = readRepro(repro);
577
+ await app.flow.restore(reproBookmark(app, parsed));
578
+ return app.flow.walk(parsed.route);
579
+ }
580
+ throw new Error("[game] game.restore takes a bookmark or a repro.\n Pass exactly one of { bookmark } and { repro }.");
581
+ }
582
+ /**
583
+ * Answers the gate, the way a tap on a button does. Goes through the graph: the session stays
584
+ * clean.
585
+ *
586
+ * @example
587
+ * ```ts
588
+ * // The home screen rests: tap Play without touching the screen.
589
+ * const ran = await run(app, commands.answer, { intent: "play" });
590
+ * ran.value; // true: "home" waits for "play"
591
+ * ran.state.tainted; // false
592
+ * ```
593
+ */
594
+ const answerCommand = defineCommand({
595
+ id: "game.answer",
596
+ title: "Answer",
597
+ input: {
598
+ intent: "string",
599
+ payload: "json?"
600
+ },
601
+ effect: "route",
602
+ run: (app, { intent, payload }) => {
603
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
604
+ app.log.debug("moku:dev", {
605
+ command: "game.answer",
606
+ intent
607
+ });
608
+ return app.flow.gate.answer(payload === void 0 ? { intent } : {
609
+ intent,
610
+ payload
611
+ });
612
+ }
613
+ });
614
+ /**
615
+ * Walks a route in fast mode through the graph. The session stays clean.
616
+ *
617
+ * @example
618
+ * ```ts
619
+ * // Skip the menu and stand on the board.
620
+ * const ran = await run(app, commands.walk, { route: [{ at: "home", intent: "play" }] });
621
+ * ran.value.path; // "board/awaitIntent"
622
+ * ```
623
+ */
624
+ const walkCommand = defineCommand({
625
+ id: "game.walk",
626
+ title: "Walk a route",
627
+ input: { route: "json" },
628
+ effect: "route",
629
+ run: (app, { route }) => {
630
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
631
+ app.log.debug("moku:dev", { command: "game.walk" });
632
+ return app.flow.walk(readRoute(route));
633
+ }
634
+ });
635
+ /**
636
+ * Takes a bookmark of the rest point: the node plus the committed state, plain JSON.
637
+ *
638
+ * @example
639
+ * ```ts
640
+ * // The editor keeps the position before a risky test.
641
+ * const { value } = await run(app, commands.bookmark);
642
+ * value.path; // "board/awaitIntent"
643
+ * ```
644
+ */
645
+ const bookmarkCommand = defineCommand({
646
+ id: "game.bookmark",
647
+ title: "Bookmark",
648
+ input: {},
649
+ effect: "read",
650
+ run: (app) => {
651
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
652
+ app.log.debug("moku:dev", { command: "game.bookmark" });
653
+ return app.flow.bookmark();
654
+ }
655
+ });
656
+ /**
657
+ * Restores a bookmark, or a repro: its state at its checkpoint, then its route. Replaces the
658
+ * state outside the graph, so the session is tainted and the command journaled.
659
+ *
660
+ * @example
661
+ * ```ts
662
+ * // Load the bug report "the order stays after delivery".
663
+ * const repro = { player: { coins: 40 }, checkpoint: "home", route: [{ at: "home", intent: "play" }] };
664
+ * const ran = await run(app, commands.restore, { repro });
665
+ * ran.state; // { path: "board/awaitIntent", frame: 12, tainted: true }
666
+ * ```
667
+ */
668
+ const restoreCommand = defineCommand({
669
+ id: "game.restore",
670
+ title: "Restore",
671
+ input: {
672
+ bookmark: "json?",
673
+ repro: "json?"
674
+ },
675
+ effect: "raw",
676
+ run: (app, input) => {
677
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
678
+ app.log.debug("moku:dev", { command: "game.restore" });
679
+ return restoreFrom(app, input);
680
+ }
681
+ });
682
+ //#endregion
683
+ //#region src/plugins/flow/doors/commands.ts
684
+ /**
685
+ * @file flow/doors — the base commands: every command of the engine, keyed by a short name. Each
686
+ * descriptor lives with the plugin that owns its data; this module only gathers them. Imported
687
+ * by the door `src/control.ts` only, so no plugin reaches it and no import cycle forms.
688
+ */
689
+ /**
690
+ * Every command of the engine, keyed by its short name: what an editor lists as buttons and MCP
691
+ * tools. Frozen. Each runs in dev builds only.
692
+ *
693
+ * @example
694
+ * ```ts
695
+ * // An e2e script taps Play, and the editor registers every command.
696
+ * (await run(app, commands.tap, { key: "play" })).state.path; // "board/awaitIntent"
697
+ * for (const command of Object.values(commands)) registry.add(command);
698
+ * ```
699
+ */
700
+ const commands = Object.freeze({
701
+ answer: answerCommand,
702
+ tap: tapCommand,
703
+ drag: dragCommand,
704
+ key: keyCommand,
705
+ walk: walkCommand,
706
+ bookmark: bookmarkCommand,
707
+ restore: restoreCommand,
708
+ step: stepCommand,
709
+ pause: pauseCommand,
710
+ resume: resumeCommand,
711
+ capture: captureCommand,
712
+ debug: debugCommand,
713
+ reducedMotion: reducedMotionCommand
714
+ });
715
+ //#endregion
716
+ //#region src/plugins/flow/doors/run.ts
717
+ /**
718
+ * Reads where the game stands after a command.
719
+ *
720
+ * @param app - The app.
721
+ * @returns The graph path, the frame and the taint.
722
+ */
723
+ function envelopeOf(app) {
724
+ return {
725
+ path: app.flow.state().path,
726
+ frame: app.time.snapshot().frame,
727
+ tainted: isTainted(app)
728
+ };
729
+ }
730
+ /**
731
+ * Runs a command in a dev build. A `cheat` or `raw` command taints the session and is journaled
732
+ * before it runs, so a failing one still counts.
733
+ *
734
+ * @param app - The app the command acts on.
735
+ * @param command - The command.
736
+ * @param input - Its input; left out when every field of its schema is optional.
737
+ * @returns The command's value and the envelope read after it.
738
+ * @throws {Error} Outside a dev build, and whatever the command throws.
739
+ * @example
740
+ * ```ts
741
+ * // An e2e script: load the bug report, then tap Play.
742
+ * await run(app, commands.restore, { repro }); // state: { path: "home", frame: 12, tainted: true }
743
+ * const ran = await run(app, commands.answer, { intent: "play" });
744
+ * ran.value; // true
745
+ * ran.state; // { path: "board/awaitIntent", frame: 14, tainted: true }
746
+ * ```
747
+ */
748
+ async function run(app, command, ...input) {
749
+ if (!isDev()) throw controlRefused();
750
+ const given = inputOrEmpty(input[0]);
751
+ if (command.effect === "cheat" || command.effect === "raw") recordCheat(app, command.id, given, app.time.snapshot().frame);
752
+ return {
753
+ value: await command.run(app, given),
754
+ state: envelopeOf(app)
755
+ };
756
+ }
757
+ //#endregion
758
+ export { commands, controlRefused, defineCommand, run };