@moku-labs/game 0.2.0 → 0.3.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 +2 -0
  2. package/dist/assets.d.mts +181 -22
  3. package/dist/assets.mjs +1186 -160
  4. package/dist/{commands-C0BljsM5.d.mts → commands-DYO22lzi.d.mts} +22 -5
  5. package/dist/control.d.mts +3 -3
  6. package/dist/control.mjs +3 -3
  7. package/dist/{define-CjP_4H0G.d.mts → define-BKWjWgyQ.d.mts} +1 -1
  8. package/dist/dev-CQYJPmcD.mjs +1183 -0
  9. package/dist/flatten-BkXuscAV.mjs +100 -0
  10. package/dist/index.d.mts +85 -31
  11. package/dist/index.mjs +3208 -490
  12. package/dist/{session-DmAxY6Ll.mjs → input-CK4nxBO1.mjs} +41 -12
  13. package/dist/inspect.d.mts +32 -9
  14. package/dist/inspect.mjs +2 -2
  15. package/dist/jsx-dev-runtime.d.mts +1 -1
  16. package/dist/jsx-dev-runtime.mjs +1 -1
  17. package/dist/jsx-runtime.d.mts +1 -1
  18. package/dist/jsx-runtime.mjs +1 -1
  19. package/dist/{run-tlcn1w7y.mjs → run-BfuMLahc.mjs} +489 -213
  20. package/dist/{runtime-Bn5TXNxP.d.mts → runtime-Bhu442L8.d.mts} +1 -1
  21. package/dist/{runtime-DRlwxkIv.mjs → runtime-CBZgWgzx.mjs} +1 -57
  22. package/dist/{sources-BooL2MsX.mjs → sources-C1uGjz4l.mjs} +198 -21
  23. package/dist/testing.d.mts +5 -5
  24. package/dist/testing.mjs +5 -4
  25. package/dist/{types-CxZy7Pi4.d.mts → types-BnWH-75b.d.mts} +95 -21
  26. package/dist/{types-CqkrEY06.d.mts → types-CVUHPvMB.d.mts} +5 -434
  27. package/dist/{types-DmhTHRVW.d.mts → types-DtmFVCHf.d.mts} +1 -1
  28. package/dist/{types-BZ1OLf24.d.mts → types-Dy1xB68L.d.mts} +2318 -1169
  29. package/dist/{types-X9jXjka3.d.mts → types-VErbiEXy.d.mts} +867 -198
  30. package/dist/{visual-NPrDkvUk.mjs → visual-DUvvPPCt.mjs} +51 -1
  31. package/llms.txt +5 -5
  32. package/package.json +5 -5
  33. package/dist/component-DGg5DqKK.mjs +0 -44
  34. package/dist/dev-CcwFrwok.mjs +0 -413
  35. package/dist/types-C47fKNcn.d.mts +0 -695
@@ -1,5 +1,5 @@
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";
1
+ import { f as graphHash, n as isDev, s as sheetAlone, t as controlRefused } from "./dev-CQYJPmcD.mjs";
2
+ import { a as isProjectionTarget, i as recordCheat, o as readTarget, r as isTainted, s as defineCommand, t as inputOrEmpty } from "./input-CK4nxBO1.mjs";
3
3
  //#region src/plugins/flow/headless.ts
4
4
  const noPayload = null;
5
5
  /** Seed of a repro that pins no rng state, the seed `saveOf` writes. */
@@ -219,37 +219,25 @@ const reducedMotionCommand = defineCommand({
219
219
  //#region src/plugins/input/control.ts
220
220
  /**
221
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.
222
+ * another, a trace through many views and a key press. Each goes through `app.input`, so the gate
223
+ * decides and the session stays clean. Dev builds only: every body starts with the inline dev
224
+ * guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker. The targets
225
+ * are read with `target.ts`; `readTarget` is re-exported here for the `game.tap` command of ui,
226
+ * which taps a view by the same projection key.
226
227
  */
227
228
  /**
228
- * Tells whether the JSON a command got names a view by its projection key.
229
+ * Reads the cells of a trace out of the JSON a command got.
229
230
  *
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`.
231
+ * @param value - The JSON: a list of `{ projection, key }`.
232
+ * @returns The targets in list order, fresh objects.
233
+ * @throws {Error} When the JSON is not a list, or an element is not a projection key.
246
234
  */
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\" }.");
235
+ function readPath(value) {
236
+ if (Array.isArray(value) && value.every((item) => isProjectionTarget(item))) return value.map((target) => ({
237
+ projection: target.projection,
238
+ key: target.key
239
+ }));
240
+ throw new Error("[game] The path is not a list of projection keys.\n Pass views like [{ projection: \"board.cells\", key: \"b3\" }, …].");
253
241
  }
254
242
  /**
255
243
  * Drags one view onto another, both by their projection keys. Answers what `input.drag`
@@ -277,6 +265,28 @@ const dragCommand = defineCommand({
277
265
  }
278
266
  });
279
267
  /**
268
+ * Traces through views by their projection keys, in list order. Answers what `input.trace`
269
+ * answers: whether the gate took the answer.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * // Spell the first two letters of a word on the grid.
274
+ * const path = [{ projection: "board.cells", key: "b3" }, { projection: "board.cells", key: "c3" }];
275
+ * (await run(app, commands.trace, { path })).value; // true
276
+ * ```
277
+ */
278
+ const traceCommand = defineCommand({
279
+ id: "game.trace",
280
+ title: "Trace",
281
+ input: { path: "json" },
282
+ effect: "route",
283
+ run: (app, { path }) => {
284
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
285
+ app.log.debug("moku:dev", { command: "game.trace" });
286
+ return app.input.trace(readPath(path));
287
+ }
288
+ });
289
+ /**
280
290
  * Presses a key without a keyboard, with or without Shift. Answers whether a listener handled
281
291
  * it.
282
292
  *
@@ -357,189 +367,6 @@ const resumeCommand = defineCommand({
357
367
  }
358
368
  });
359
369
  //#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
370
  //#region src/plugins/flow/json.ts
544
371
  /**
545
372
  * Tells whether a JSON value is an object.
@@ -740,6 +567,453 @@ function readRepro(value) {
740
567
  });
741
568
  }
742
569
  //#endregion
570
+ //#region src/plugins/renderer/control.ts
571
+ /**
572
+ * @file renderer plugin — the renderer commands of the `/control` door: take a picture of the
573
+ * canvas, with its options, and switch the debug drawing. Dev builds only: every body starts with
574
+ * the inline dev guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker.
575
+ */
576
+ /** The input fields of `game.capture`, in the order its `moku:dev` entry lists them. */
577
+ const CAPTURE_FIELDS = [
578
+ "legend",
579
+ "layers",
580
+ "sheet",
581
+ "diff"
582
+ ];
583
+ /** Drawn frames a restored bookmark gets to come to rest at its gate. */
584
+ const REST_FRAMES = 600;
585
+ /** Drawn frames after the rest, so the screen the gate opened is built and drawn. */
586
+ const AFTER_REST_FRAMES = 2;
587
+ /** The length of one frame at 60 fps: the step a paused clock takes per frame, as in `game.step`. */
588
+ const FRAME_MS$1 = 1e3 / 60;
589
+ /**
590
+ * Builds the error of an input field of `game.capture` with the wrong shape.
591
+ *
592
+ * @param field - The field.
593
+ * @param expected - What to pass instead.
594
+ * @returns The error, ready to throw.
595
+ * @example
596
+ * ```ts
597
+ * wrongShape("sheet", "{ frames, everyMs }, two numbers").message;
598
+ * // "[game] game.capture: sheet has the wrong shape.\n Pass { frames, everyMs }, two numbers."
599
+ * ```
600
+ */
601
+ function wrongShape(field, expected) {
602
+ return /* @__PURE__ */ new Error(`[game] game.capture: ${field} has the wrong shape.\n Pass ${expected}.`);
603
+ }
604
+ /**
605
+ * Reads the `layers` field: a JSON array of strings.
606
+ *
607
+ * @param value - The JSON the editor sent.
608
+ * @returns The layer names.
609
+ * @throws {Error} When it is not an array of strings.
610
+ * @example
611
+ * ```ts
612
+ * readLayers(["board", "hud"]); // ["board", "hud"]
613
+ * ```
614
+ */
615
+ function readLayers(value) {
616
+ if (Array.isArray(value) && value.every((name) => typeof name === "string")) return value;
617
+ throw wrongShape("layers", "an array of layer names, such as [\"board\", \"hud\"]");
618
+ }
619
+ /**
620
+ * Reads the `sheet` field: an object of two numbers, `frames` and `everyMs`. Their range is
621
+ * checked by `renderer.capture()`.
622
+ *
623
+ * @param value - The JSON the editor sent.
624
+ * @returns The sheet.
625
+ * @throws {Error} When it is not an object with two numbers.
626
+ * @example
627
+ * ```ts
628
+ * readSheet({ frames: 6, everyMs: 100 }); // { frames: 6, everyMs: 100 }
629
+ * ```
630
+ */
631
+ function readSheet(value) {
632
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
633
+ const { frames, everyMs } = value;
634
+ if (typeof frames === "number" && typeof everyMs === "number") return {
635
+ frames,
636
+ everyMs
637
+ };
638
+ }
639
+ throw wrongShape("sheet", "{ frames, everyMs }, two numbers");
640
+ }
641
+ /**
642
+ * Reads the `diff` field: a bookmark, the value `game.bookmark` answered.
643
+ *
644
+ * @param value - The JSON the editor sent.
645
+ * @returns The bookmark.
646
+ * @throws {Error} When it is not a bookmark.
647
+ */
648
+ function readDiff(value) {
649
+ try {
650
+ return readBookmark(value);
651
+ } catch {
652
+ throw wrongShape("diff", "a bookmark, the value of game.bookmark");
653
+ }
654
+ }
655
+ /**
656
+ * Reads the options `game.capture` passes to `renderer.capture()` as they are: only the fields
657
+ * given.
658
+ *
659
+ * @param input - The input of the command.
660
+ * @returns The options.
661
+ * @throws {Error} When `layers` or `sheet` has the wrong shape.
662
+ */
663
+ function readOptions(input) {
664
+ const options = {};
665
+ if (input.legend !== void 0) options.legend = input.legend;
666
+ if (input.layers !== void 0) options.layers = readLayers(input.layers);
667
+ if (input.sheet !== void 0) options.sheet = readSheet(input.sheet);
668
+ return options;
669
+ }
670
+ /**
671
+ * The input fields given: `legend` when true, the others when present.
672
+ *
673
+ * @param input - The input of the command.
674
+ * @returns Their names.
675
+ * @example
676
+ * ```ts
677
+ * givenFields({ legend: true, diff: { path: "home" } }); // ["legend", "diff"]
678
+ * ```
679
+ */
680
+ function givenFields(input) {
681
+ return CAPTURE_FIELDS.filter((field) => field === "legend" ? input.legend === true : input[field] !== void 0);
682
+ }
683
+ /**
684
+ * Tells whether the graph waits at a gate with no effect pending.
685
+ *
686
+ * @param state - The flow state.
687
+ * @returns True at rest.
688
+ * @example
689
+ * ```ts
690
+ * atRest({ running: true, path: "home", stack: [], pending: { gate: ["play"] }, mode: "live" }); // true
691
+ * ```
692
+ */
693
+ function atRest(state) {
694
+ return state.pending.gate !== void 0 && state.pending.fx === void 0;
695
+ }
696
+ /**
697
+ * Waits for the next drawn frame: the end of its `render` phase. A paused clock runs no frame, so
698
+ * the frame is stepped here instead, one `time.step(1000 / 60)`, as `game.step` does.
699
+ *
700
+ * @param app - The app whose frames are waited on.
701
+ * @returns Resolves after that frame.
702
+ */
703
+ function nextFrame(app) {
704
+ if (app.time.isPaused()) {
705
+ app.time.step(FRAME_MS$1);
706
+ return Promise.resolve();
707
+ }
708
+ return new Promise((resolve) => {
709
+ const off = app.time.onFrame("render", () => {
710
+ off();
711
+ resolve();
712
+ });
713
+ });
714
+ }
715
+ /**
716
+ * Waits on drawn frames until the graph rests at a gate: at most 600.
717
+ *
718
+ * @param app - The app.
719
+ * @returns True once it rests, false when it still does not after 600 drawn frames.
720
+ */
721
+ async function waitForRest(app) {
722
+ for (let frame = 0; frame < REST_FRAMES; frame += 1) {
723
+ if (atRest(app.flow.state())) return true;
724
+ await nextFrame(app);
725
+ }
726
+ return atRest(app.flow.state());
727
+ }
728
+ /**
729
+ * Restores a bookmark and waits until the graph rests at a gate, then two drawn frames more, so
730
+ * the screen of the gate is built and drawn.
731
+ *
732
+ * @param app - The app.
733
+ * @param bookmark - The bookmark to enter.
734
+ * @throws {Error} When the graph does not rest within 600 drawn frames.
735
+ */
736
+ async function restoreAtRest(app, bookmark) {
737
+ await app.flow.restore(bookmark);
738
+ if (!await waitForRest(app)) throw new Error("[game] game.capture diff: the bookmark did not come to rest in 600 frames.\n Capture a bookmark taken at a gate.");
739
+ for (let frame = 0; frame < AFTER_REST_FRAMES; frame += 1) await nextFrame(app);
740
+ }
741
+ /**
742
+ * Runs work in the state of a bookmark and comes back to where the game stood, also when the
743
+ * work or the restore fails.
744
+ *
745
+ * @param app - The app.
746
+ * @param bookmark - The bookmark to visit.
747
+ * @param work - What to do there.
748
+ * @returns What the work answered.
749
+ * @throws {Error} When a restore does not come to rest, and whatever the work throws.
750
+ */
751
+ async function atBookmark(app, bookmark, work) {
752
+ const before = app.flow.bookmark();
753
+ try {
754
+ await restoreAtRest(app, bookmark);
755
+ return await work();
756
+ } finally {
757
+ await restoreAtRest(app, before);
758
+ }
759
+ }
760
+ /**
761
+ * The diff of `game.capture`: the picture of the bookmark, taken in its state, and the picture of
762
+ * now drawn against it. The command journals itself first, since it replaces the state twice.
763
+ *
764
+ * @param app - The app.
765
+ * @param options - The options passed on: `layers` to both pictures, `legend` to the second.
766
+ * @param diff - The bookmark to compare with.
767
+ * @param raw - The bookmark as the editor sent it, for the journal.
768
+ * @returns The diff picture, or `undefined` while the renderer is inert.
769
+ * @throws {Error} When the graph is not at rest, and when a restore does not come to rest.
770
+ */
771
+ async function captureDiff(app, options, diff, raw) {
772
+ if (!app.renderer.host.ready()) return void 0;
773
+ if (!atRest(app.flow.state())) throw new Error("[game] game.capture diff needs the game at rest.\n Wait for the gate, then capture.");
774
+ recordCheat(app, "game.capture", { diff: raw }, app.time.snapshot().frame);
775
+ const layers = options.layers === void 0 ? {} : { layers: options.layers };
776
+ const theirs = await atBookmark(app, diff, () => app.renderer.capture(layers));
777
+ return theirs === void 0 ? void 0 : app.renderer.capture({
778
+ ...options,
779
+ against: theirs.png
780
+ });
781
+ }
782
+ /**
783
+ * A picture of the whole canvas taken at the end of the next drawn frame: `{ png }`, and
784
+ * `{ png, legend }` with `legend: true`. `legend`, `layers` and `sheet` go to `renderer.capture()`
785
+ * as they are. `diff`, a bookmark, is the one option the command runs itself: at rest, it journals
786
+ * itself as a raw write (`tainted` turns true), restores the bookmark and waits for its gate, takes
787
+ * that picture, restores where the game stood and waits again, and answers the pixel diff of now
788
+ * against then. While the game is paused it steps each frame it waits on, 1000/60 ms of game time.
789
+ * `undefined` while the renderer is inert, as in a headless test.
790
+ *
791
+ * @example
792
+ * ```ts
793
+ * // The editor attaches the numbered screen to a bug report.
794
+ * (await run(app, commands.capture, { legend: true })).value;
795
+ * // { png: "data:image/png;base64,…", legend: [{ n: 1, projection: "hud", key: "claim", rect: { x: 1140, y: 2310, w: 567, h: 164 } }] }
796
+ * // What changed since the start of the level: red where the pixels differ.
797
+ * (await run(app, commands.capture, { diff: levelStart })).value; // { png: "data:image/png;base64,…" }
798
+ * ```
799
+ */
800
+ const captureCommand = defineCommand({
801
+ id: "game.capture",
802
+ title: "Capture",
803
+ input: {
804
+ legend: "boolean?",
805
+ layers: "json?",
806
+ sheet: "json?",
807
+ diff: "json?"
808
+ },
809
+ effect: "read",
810
+ run: (app, input) => {
811
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
812
+ const given = givenFields(input);
813
+ app.log.debug("moku:dev", given.length === 0 ? { command: "game.capture" } : {
814
+ command: "game.capture",
815
+ options: given
816
+ });
817
+ const options = readOptions(input);
818
+ if (input.diff === void 0) return app.renderer.capture(options);
819
+ const diff = readDiff(input.diff);
820
+ if (options.sheet !== void 0) throw sheetAlone();
821
+ return captureDiff(app, options, diff, input.diff);
822
+ }
823
+ });
824
+ /**
825
+ * Switches the debug drawing: `nineSlice` outlines every nine-slice and the lines Pixi cuts its
826
+ * texture at. Answers the switches as they are afterwards.
827
+ *
828
+ * @example
829
+ * ```ts
830
+ * // Check where the settings popup cuts its parchment.
831
+ * (await run(app, commands.debug, { nineSlice: true })).value; // { nineSlice: true }
832
+ * ```
833
+ */
834
+ const debugCommand = defineCommand({
835
+ id: "game.debug",
836
+ title: "Debug drawing",
837
+ input: { nineSlice: "boolean" },
838
+ effect: "cosmetic",
839
+ run: (app, { nineSlice }) => {
840
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
841
+ app.log.debug("moku:dev", {
842
+ command: "game.debug",
843
+ nineSlice
844
+ });
845
+ app.renderer.sync.debug.nineSlice(nineSlice);
846
+ return app.renderer.sync.debug.state();
847
+ }
848
+ });
849
+ //#endregion
850
+ //#region src/plugins/time/control.ts
851
+ /**
852
+ * @file time plugin — the time commands of the `/control` door: step frames by hand and set the
853
+ * time scale. Dev builds only: every body starts with the inline dev guard, so a bundler `define`
854
+ * of `false` drops it, and logs the `moku:dev` marker.
855
+ */
856
+ /** The length of one frame at 60 fps, the step a frame count takes by default. */
857
+ const FRAME_MS = 1e3 / 60;
858
+ /**
859
+ * Refuses a frame count that is not a whole number of zero or more.
860
+ *
861
+ * @param frames - The frame count.
862
+ * @throws {Error} When it is negative, fractional or not finite.
863
+ */
864
+ function checkFrames(frames) {
865
+ if (Number.isInteger(frames) && frames >= 0) return;
866
+ throw new Error(`[game] game.step takes a whole number of frames.\n Pass 0 or a positive whole number, not ${String(frames)}.`);
867
+ }
868
+ /**
869
+ * Refuses a time scale that is not a finite number of zero or more. `setScale` clamps silently;
870
+ * the command refuses, so an editor slider cannot pass `NaN`.
871
+ *
872
+ * @param scale - The time scale.
873
+ * @throws {Error} When it is negative or not finite.
874
+ */
875
+ function checkScale(scale) {
876
+ if (Number.isFinite(scale) && scale >= 0) return;
877
+ throw new Error(`[game] game.timeScale takes a scale of 0 or more.\n Pass a finite number, not ${String(scale)}.`);
878
+ }
879
+ /**
880
+ * Runs frames by hand, one `time.step` each, also while the game is paused, and answers the time
881
+ * after them. A frame lasts 1000/60 ms unless `deltaMs` says otherwise.
882
+ *
883
+ * @example
884
+ * ```ts
885
+ * // The editor paused the game on a merge; step three frames to watch the item land.
886
+ * const ran = await run(app, commands.step, { frames: 3 });
887
+ * ran.value; // { delta: 16.666…, elapsed: 50, scale: 1, frame: 3, idle: false }
888
+ * ```
889
+ */
890
+ const stepCommand = defineCommand({
891
+ id: "game.step",
892
+ title: "Step frames",
893
+ input: {
894
+ frames: "number",
895
+ deltaMs: "number?"
896
+ },
897
+ effect: "cosmetic",
898
+ run: (app, { frames, deltaMs = FRAME_MS }) => {
899
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
900
+ app.log.debug("moku:dev", {
901
+ command: "game.step",
902
+ frames
903
+ });
904
+ checkFrames(frames);
905
+ for (let frame = 0; frame < frames; frame += 1) app.time.step(deltaMs);
906
+ return app.time.snapshot();
907
+ }
908
+ });
909
+ /**
910
+ * Sets the time scale and answers the time after the write. 0 freezes game time while the frames
911
+ * keep running; `game.step` still steps.
912
+ *
913
+ * @example
914
+ * ```ts
915
+ * // The editor slows a merge to a quarter speed to watch the item land.
916
+ * (await run(app, commands.timeScale, { scale: 0.25 })).value.scale; // 0.25
917
+ * ```
918
+ */
919
+ const timeScaleCommand = defineCommand({
920
+ id: "game.timeScale",
921
+ title: "Time scale",
922
+ input: { scale: "number" },
923
+ effect: "cosmetic",
924
+ run: (app, { scale }) => {
925
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
926
+ app.log.debug("moku:dev", {
927
+ command: "game.timeScale",
928
+ scale
929
+ });
930
+ checkScale(scale);
931
+ app.time.setScale(scale);
932
+ return app.time.snapshot();
933
+ }
934
+ });
935
+ //#endregion
936
+ //#region src/plugins/ui/control.ts
937
+ /**
938
+ * @file ui plugin — the two ui commands of the `/control` door: a tap on a keyed element or on a
939
+ * view, and the text typed into a field. The tap goes through `app.input`, so the gate decides;
940
+ * the text goes through `app.ui.fill`, which writes a component's local and answers nothing. The
941
+ * session stays clean. Dev builds only: each body starts with the inline dev guard, so a bundler
942
+ * `define` of `false` drops it, and logs the `moku:dev` marker.
943
+ */
944
+ /**
945
+ * Finds the entity of a keyed ui element on screen.
946
+ *
947
+ * @param ui - The ui API.
948
+ * @param key - The `key` prop of the element.
949
+ * @returns The entity.
950
+ * @throws {Error} When no element with the key is on screen.
951
+ */
952
+ function elementOf(ui, key) {
953
+ const entity = ui.find(key);
954
+ if (entity !== void 0) return entity;
955
+ throw new Error(`[game] No element with the key "${key}" is on screen.\n Read sources.ui for the keys on screen.`);
956
+ }
957
+ /**
958
+ * Taps a ui element by its key, or a view by its projection key: exactly one of the two. Answers
959
+ * what `input.tap` answers: whether the gate took the answer.
960
+ *
961
+ * @example
962
+ * ```ts
963
+ * // Home rests: tap the Play plank, then the first generator of the board.
964
+ * (await run(app, commands.tap, { key: "play" })).value; // true
965
+ * await run(app, commands.tap, { target: { projection: "board.generators", key: "g1" } });
966
+ * ```
967
+ */
968
+ const tapCommand = defineCommand({
969
+ id: "game.tap",
970
+ title: "Tap",
971
+ input: {
972
+ key: "string?",
973
+ target: "json?"
974
+ },
975
+ effect: "route",
976
+ run: (app, { key, target }) => {
977
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
978
+ app.log.debug("moku:dev", {
979
+ command: "game.tap",
980
+ key
981
+ });
982
+ if (key !== void 0 && target === void 0) return app.input.tap(elementOf(app.ui, key));
983
+ if (target !== void 0 && key === void 0) return app.input.tap(readTarget(target));
984
+ throw new Error("[game] game.tap takes a key or a target.\n Pass exactly one of { key } and { target }.");
985
+ }
986
+ });
987
+ /**
988
+ * Types into a text field by its key, the way `app.ui.fill` does: the field becomes the one being
989
+ * edited, the value cut to its `maxLength` is written into it and into the local of its
990
+ * component. Answers what `fill` answers: false for a key that is no live `input`.
991
+ *
992
+ * @example
993
+ * ```ts
994
+ * // The Rename popup rests: type the name, then submit it with the Enter key.
995
+ * (await run(app, commands.fill, { key: "nameField", value: "Alex" })).value; // true
996
+ * await run(app, commands.key, { key: "Enter" }); // the gate takes { intent: "save", payload: { name: "Alex" } }
997
+ * ```
998
+ */
999
+ const fillCommand = defineCommand({
1000
+ id: "game.fill",
1001
+ title: "Fill",
1002
+ input: {
1003
+ key: "string",
1004
+ value: "string"
1005
+ },
1006
+ effect: "route",
1007
+ run: (app, { key, value }) => {
1008
+ if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
1009
+ app.log.debug("moku:dev", {
1010
+ command: "game.fill",
1011
+ key
1012
+ });
1013
+ return app.ui.fill(key, value);
1014
+ }
1015
+ });
1016
+ //#endregion
743
1017
  //#region src/plugins/flow/control.ts
744
1018
  /**
745
1019
  * Enters a bookmark, or a repro's bookmark followed by its route.
@@ -896,7 +1170,9 @@ const commands = Object.freeze({
896
1170
  capture: captureCommand,
897
1171
  debug: debugCommand,
898
1172
  reducedMotion: reducedMotionCommand,
899
- fill: fillCommand
1173
+ fill: fillCommand,
1174
+ timeScale: timeScaleCommand,
1175
+ trace: traceCommand
900
1176
  });
901
1177
  //#endregion
902
1178
  //#region src/plugins/flow/doors/run.ts