@moku-labs/game 0.13.1 → 0.13.3

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 (42) hide show
  1. package/dist/app/page.d.mts +1 -1
  2. package/dist/app/system.d.mts +3 -3
  3. package/dist/app.d.mts +2 -2
  4. package/dist/app.mjs +1 -1
  5. package/dist/assets.d.mts +1 -1
  6. package/dist/assets.mjs +1 -1
  7. package/dist/{capture-fpd83Mi5.mjs → capture-D7XvoHdH.mjs} +78 -1
  8. package/dist/cli.d.mts +7 -5
  9. package/dist/cli.mjs +74 -36
  10. package/dist/{commands-DvFr8Uwp.d.mts → commands-DRhJ_LIH.d.mts} +5 -5
  11. package/dist/control.d.mts +4 -4
  12. package/dist/control.mjs +1 -1
  13. package/dist/{define-CCc0ozKe.d.mts → define-Ckp70_GR.d.mts} +2 -2
  14. package/dist/{headless-BsNdJs9W.mjs → headless-BA6ffzqD.mjs} +30 -9
  15. package/dist/{headless-G4nuZ-xz.d.mts → headless-CYgYzro0.d.mts} +1 -1
  16. package/dist/hot.d.mts +3 -3
  17. package/dist/hot.mjs +11 -9
  18. package/dist/{index-rnOYAriI.d.mts → index-CjuObHhF.d.mts} +12 -4
  19. package/dist/index.d.mts +5 -5
  20. package/dist/index.mjs +1 -1
  21. package/dist/inspect.d.mts +7 -7
  22. package/dist/jsx-dev-runtime.d.mts +1 -1
  23. package/dist/jsx-runtime.d.mts +1 -1
  24. package/dist/{memory-DwDhfFWK.d.mts → memory-DKo9rRWW.d.mts} +1 -1
  25. package/dist/project.mjs +1 -1
  26. package/dist/{run-wYty_Lwo.mjs → run-BtyrJjmW.mjs} +130 -40
  27. package/dist/{runtime-B276Gg0s.d.mts → runtime-iTxTcukZ.d.mts} +1 -1
  28. package/dist/{src-SmT-XxVf.mjs → src-DlyYl_9X.mjs} +1414 -277
  29. package/dist/testing.d.mts +4 -4
  30. package/dist/testing.mjs +2 -2
  31. package/dist/{types-ZQLYXo5X.d.mts → types-BSMmnpFm.d.mts} +138 -29
  32. package/dist/{types-Bcdo5-2w.d.mts → types-B_Bp2tWK.d.mts} +7 -3
  33. package/dist/{types-DAdwEJMp.d.mts → types-Bonifz5M.d.mts} +2 -2
  34. package/dist/{types-DLghaGEy.d.mts → types-DkO3s9YX.d.mts} +4 -4
  35. package/dist/{types-BrTpz9oL.d.mts → types-Dm5Oh5bH.d.mts} +19 -6
  36. package/dist/{types-D_2G6Q68.d.mts → types-dJMOXXL3.d.mts} +21 -13
  37. package/dist/visual.d.mts +6 -6
  38. package/dist/visual.mjs +1 -1
  39. package/llms.txt +10 -7
  40. package/package.json +4 -2
  41. package/dist/{assets-DYiKxPBX.mjs → assets-BrKSg1O6.mjs} +1 -1
  42. package/dist/{watch-CB8IWDBi.mjs → watch-CWI_on4T.mjs} +1 -1
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { r as systemSource, t as memory } from "./memory-CWlqhS7l.mjs";
3
3
  import { a as finishDrafts, i as enableDraftPatches, n as deepFreeze, o as openDrafts, r as dropDrafts } from "./drafts-BawpSaVp.mjs";
4
- import { c as findNode, d as slotNames, f as compact, i as servePaused, l as framePath, n as captureFrame, o as collectFlows, p as pushEntry, r as serveCaptures, s as describeGraph, t as cancelCaptures, u as graphHash } from "./capture-fpd83Mi5.mjs";
4
+ import { d as graphHash, f as isRestCheckpoint, h as pushEntry, i as servePaused, l as findNode, m as compact, n as captureFrame, o as collectFlows, p as slotNames, r as serveCaptures, s as describeGraph, t as cancelCaptures, u as framePath } from "./capture-D7XvoHdH.mjs";
5
5
  import { A as system, C as Exiting, D as component, E as Tree, T as Order, _ as Parent, a as visualOf$1, b as Transform, c as Text, g as NineSlice, h as Display, j as tag, k as resource, m as textFor, n as fitScaleOf, o as visualRectOf, p as readStyle, r as samePose, s as Countdown, t as CONTENT, u as builtInStyles, v as Shape, w as Layer, x as componentsFor, y as Sprite } from "./visual-dMW8iqAE.mjs";
6
6
  import { n as isDev } from "./dev-Cc50Dk7H.mjs";
7
7
  import { i as isComponentDefinition, n as flatten, r as defineComponent } from "./flatten-BkXuscAV.mjs";
@@ -1933,19 +1933,6 @@ function connectFlow(ctx) {
1933
1933
  registerSchedule(flowCtx, fx);
1934
1934
  }
1935
1935
  //#endregion
1936
- //#region src/plugins/flow/runner/loop-types.ts
1937
- /** The stages of entering a node, in the order they run. */
1938
- const stages = ["load", "scene"];
1939
- /**
1940
- * Does nothing. It stands in for an unsubscribe function until the real one arrives.
1941
- *
1942
- * @example
1943
- * ```ts
1944
- * const watch = { off: noop };
1945
- * ```
1946
- */
1947
- function noop() {}
1948
- //#endregion
1949
1936
  //#region src/plugins/flow/runner/validate.ts
1950
1937
  /** Nodes of one flow above which `validate` warns. A flow holds 7 to 15 nodes. */
1951
1938
  const MAX_NODES = 15;
@@ -2400,6 +2387,16 @@ function collectGraph(ctx, features) {
2400
2387
  return graph.flows;
2401
2388
  }
2402
2389
  /**
2390
+ * Renders the whole graph as JSON. It reads the flows as data, so it works before `run()`.
2391
+ *
2392
+ * @param ctx - Domain context of the flow plugin.
2393
+ * @param features - Features API: owners and slot contributions.
2394
+ * @returns The graph.
2395
+ */
2396
+ function graphOf(ctx, features) {
2397
+ return describeGraph(collectGraph(ctx, features), features);
2398
+ }
2399
+ /**
2403
2400
  * Validates the collected graph. Warnings are logged, problems stop `run()` in one error.
2404
2401
  *
2405
2402
  * @param ctx - Domain context of the flow plugin.
@@ -2412,6 +2409,169 @@ function checkGraph(ctx, features) {
2412
2409
  if (report.problems.length > 0) throw new Error(report.problems.join("\n"));
2413
2410
  }
2414
2411
  //#endregion
2412
+ //#region src/plugins/flow/runner/bookmark.ts
2413
+ /**
2414
+ * Reads the main flow of the config.
2415
+ *
2416
+ * @param ctx - Domain context of the flow plugin.
2417
+ * @param method - Name of the API method that needs it, for the message.
2418
+ * @returns The top-level flow.
2419
+ * @throws {Error} When no main flow was configured.
2420
+ */
2421
+ function mainFlow(ctx, method) {
2422
+ const main = ctx.config.mainFlow;
2423
+ if (main === void 0) throw new Error(`[game] flow.${method}() needs a main flow.\n Pass it as pluginConfigs.flow.mainFlow.`);
2424
+ return main;
2425
+ }
2426
+ /**
2427
+ * Names a checkpoint of the graph, for the message of a refused bookmark. Only a rest node is
2428
+ * one: a transit node that carries the flag is no place a restore may be sent to.
2429
+ *
2430
+ * @param ctx - Domain context of the flow plugin.
2431
+ * @param modules - Injected sibling APIs.
2432
+ * @returns The path of the first rest checkpoint, or the start of the main flow.
2433
+ */
2434
+ function someCheckpoint(ctx, modules) {
2435
+ const main = mainFlow(ctx, "restore");
2436
+ for (const flow of collectGraph(ctx, modules.features).values()) for (const [name, entry] of Object.entries(flow.nodes)) if (isRestCheckpoint(entry)) return flow.id === main.id ? name : `${flow.id}/${name}`;
2437
+ return main.start;
2438
+ }
2439
+ /**
2440
+ * Looks up the node at a path. A sub-flow and a slot are no nodes: nothing runs at them.
2441
+ *
2442
+ * @param ctx - Domain context of the flow plugin.
2443
+ * @param modules - Injected sibling APIs.
2444
+ * @param path - Path of a bookmark, or of its rest point.
2445
+ * @returns The node there, a rest node or a transit node, or `undefined` when the path names none.
2446
+ */
2447
+ function nodeAt(ctx, modules, path) {
2448
+ const entry = findNode(mainFlow(ctx, "restore"), path, modules.features.contributions)?.entry;
2449
+ return entry?.kind === "node" ? entry : void 0;
2450
+ }
2451
+ /**
2452
+ * Finds the node a bookmark names.
2453
+ *
2454
+ * @param ctx - Domain context of the flow plugin.
2455
+ * @param modules - Injected sibling APIs.
2456
+ * @param bookmark - The bookmark to enter.
2457
+ * @returns The node at the bookmark's path, a rest node or a transit node.
2458
+ * @throws {Error} When the path names no node of the graph.
2459
+ */
2460
+ function bookmarkNode(ctx, modules, bookmark) {
2461
+ const node = nodeAt(ctx, modules, bookmark.path);
2462
+ if (node === void 0) throw new Error(`[game] The bookmark "${bookmark.path}" is not a node of the graph.\n Restore a rest node, for example the checkpoint "${someCheckpoint(ctx, modules)}".`);
2463
+ return node;
2464
+ }
2465
+ /**
2466
+ * Tells whether the graph is the one a bookmark was made for. The answer costs a description and
2467
+ * a hash of the whole graph, so one acceptance works it out once: see `askedOnce`.
2468
+ *
2469
+ * @param ctx - Domain context of the flow plugin.
2470
+ * @param modules - Injected sibling APIs.
2471
+ * @param bookmark - The bookmark to compare with.
2472
+ * @returns True while the hash of `describe()` is the bookmark's.
2473
+ */
2474
+ function sameGraph(ctx, modules, bookmark) {
2475
+ return graphHash(graphOf(ctx, modules.features)) === bookmark.graph;
2476
+ }
2477
+ /**
2478
+ * Makes a question that is worked out at most once: at the first ask, and never when nobody asks.
2479
+ *
2480
+ * @param ask - Works the answer out.
2481
+ * @returns The question. Every ask after the first gets the first answer.
2482
+ * @example
2483
+ * ```ts
2484
+ * const same = askedOnce(() => true);
2485
+ * same() && same(); // true, and the answer was worked out once
2486
+ * ```
2487
+ */
2488
+ function askedOnce(ask) {
2489
+ const answer = { value: void 0 };
2490
+ return () => {
2491
+ answer.value ??= ask();
2492
+ return answer.value;
2493
+ };
2494
+ }
2495
+ /**
2496
+ * Checks that a bookmark may be entered: a rest checkpoint always, any other node only while the
2497
+ * graph is the one the bookmark was made for. A transit node is never taken for a checkpoint,
2498
+ * whatever its flag says: it is a place inside a transition, not a place a save may stand on.
2499
+ *
2500
+ * @param ctx - Domain context of the flow plugin.
2501
+ * @param modules - Injected sibling APIs.
2502
+ * @param bookmark - The bookmark to enter.
2503
+ * @param unchanged - Asks whether the graph is the bookmark's own. Not asked for a checkpoint.
2504
+ * @returns The node the bookmark names.
2505
+ * @throws {Error} When the path is no node, or the graph changed since the bookmark.
2506
+ */
2507
+ function checkBookmark(ctx, modules, bookmark, unchanged) {
2508
+ const node = bookmarkNode(ctx, modules, bookmark);
2509
+ if (isRestCheckpoint(node)) return node;
2510
+ if (unchanged()) return node;
2511
+ throw new Error(`[game] The bookmark "${bookmark.path}" was made for another graph.\n Restore the checkpoint "${someCheckpoint(ctx, modules)}" instead.`);
2512
+ }
2513
+ /**
2514
+ * Builds the bookmark of the rest point before a transit node, for a bookmark that carries `rest`
2515
+ * and was made for another graph. That is one whose path names a transit node while the hash
2516
+ * differs, and one whose path names no node any more: a renamed or removed node is a changed
2517
+ * graph too. Only the position changes: the state stays the bookmark's.
2518
+ *
2519
+ * @param ctx - Domain context of the flow plugin.
2520
+ * @param modules - Injected sibling APIs.
2521
+ * @param bookmark - The bookmark to enter.
2522
+ * @param unchanged - Asks whether the graph is the bookmark's own. Asked for a transit node only.
2523
+ * @returns The bookmark to enter instead, or `undefined` when the bookmark stands for itself.
2524
+ */
2525
+ function restFallback(ctx, modules, bookmark, unchanged) {
2526
+ const rest = bookmark.rest;
2527
+ if (rest === void 0) return void 0;
2528
+ const node = nodeAt(ctx, modules, bookmark.path);
2529
+ if (node !== void 0 && (node.rest || unchanged())) return void 0;
2530
+ return {
2531
+ ...bookmark,
2532
+ path: rest.path,
2533
+ input: rest.input
2534
+ };
2535
+ }
2536
+ /**
2537
+ * Decides what the loop enters for a bookmark: the bookmark itself, or the rest point before it
2538
+ * when the graph changed under a bookmark that has one. Either one passes the same check, so the
2539
+ * fallback is entered only when it is a checkpoint, and the log says that it was taken.
2540
+ *
2541
+ * @param ctx - Domain context of the flow plugin.
2542
+ * @param modules - Injected sibling APIs.
2543
+ * @param bookmark - The bookmark to enter.
2544
+ * @returns The bookmark the loop enters, and whether its node is a rest node.
2545
+ * @throws {Error} When neither the bookmark nor its rest point may be entered.
2546
+ */
2547
+ function acceptBookmark(ctx, modules, bookmark) {
2548
+ const unchanged = askedOnce(() => sameGraph(ctx, modules, bookmark));
2549
+ const fallback = restFallback(ctx, modules, bookmark, unchanged);
2550
+ const entered = fallback ?? bookmark;
2551
+ const node = checkBookmark(ctx, modules, entered, unchanged);
2552
+ if (fallback !== void 0) ctx.log.info("flow:restore-fell-back", {
2553
+ from: bookmark.path,
2554
+ to: fallback.path
2555
+ });
2556
+ return {
2557
+ bookmark: entered,
2558
+ rest: node.rest
2559
+ };
2560
+ }
2561
+ //#endregion
2562
+ //#region src/plugins/flow/runner/loop-types.ts
2563
+ /** The stages of entering a node, in the order they run. */
2564
+ const stages = ["load", "scene"];
2565
+ /**
2566
+ * Does nothing. It stands in for an unsubscribe function until the real one arrives.
2567
+ *
2568
+ * @example
2569
+ * ```ts
2570
+ * const watch = { off: noop };
2571
+ * ```
2572
+ */
2573
+ function noop() {}
2574
+ //#endregion
2415
2575
  //#region src/plugins/flow/runner/position.ts
2416
2576
  /**
2417
2577
  * Resolves what the deepest frame points at.
@@ -3075,8 +3235,27 @@ function guideAllow(payload) {
3075
3235
  };
3076
3236
  }
3077
3237
  /**
3238
+ * Refuses an effect a body starts after its node was aborted. The rejection counts as handled
3239
+ * from the start: a body that does not await the effect leaves no unhandled rejection behind,
3240
+ * and a body that awaits it still gets the reason.
3241
+ *
3242
+ * @param signal - The aborted signal of the node.
3243
+ * @returns A promise rejected with the abort reason.
3244
+ * @example
3245
+ * ```ts
3246
+ * refuseEffect(AbortSignal.abort("restore")); // a promise rejected with "restore"
3247
+ * ```
3248
+ */
3249
+ function refuseEffect(signal) {
3250
+ const refused = Promise.reject(abortReason(signal));
3251
+ refused.catch(noop);
3252
+ return refused;
3253
+ }
3254
+ /**
3078
3255
  * Builds the `fx` of a node context: awaited effects by call, cosmetic hints by `emit`. A `guide`
3079
- * narrows the gate while it runs; the runner lifts the narrow when the node is left.
3256
+ * narrows the gate while it runs; the runner lifts the narrow when the node is left. A body that
3257
+ * was aborted keeps running, since the loop only stops waiting for it, so its `fx` starts nothing
3258
+ * any more: the gate and the narrow belong to the node the loop entered after it.
3080
3259
  *
3081
3260
  * @param modules - Injected sibling APIs.
3082
3261
  * @param step - The node run in progress.
@@ -3084,12 +3263,13 @@ function guideAllow(payload) {
3084
3263
  */
3085
3264
  function nodeFx(modules, step) {
3086
3265
  /**
3087
- * Awaits one effect.
3266
+ * Awaits one effect. After the abort of the node it rejects with the abort reason instead.
3088
3267
  *
3089
3268
  * @param descriptor - The descriptor the node awaits.
3090
3269
  * @returns The answer, the handler's value, or `undefined`.
3091
3270
  */
3092
3271
  const run = (descriptor) => {
3272
+ if (step.signal.aborted) return refuseEffect(step.signal);
3093
3273
  if (descriptor.kind === "guide") {
3094
3274
  const allow = guideAllow(descriptor.payload);
3095
3275
  if (allow !== void 0) modules.gate.narrow(allow);
@@ -3599,19 +3779,23 @@ function enterSlot(ctx, modules, location, slot, safe) {
3599
3779
  return "continue";
3600
3780
  }
3601
3781
  /**
3602
- * Enters the node of a bookmark: the state is replaced, the rest point is marked and the position
3603
- * becomes the bookmark's path.
3782
+ * Enters the node of a bookmark, a rest node or a transit node: the state is replaced, the rest
3783
+ * point is marked and the position becomes the bookmark's path. The mark is made for a transit
3784
+ * node too. A restore leaves the whole document waiting for the provider, and a rollback of the
3785
+ * node would drop it unsent; and the frames of the transit node are what a failure of it must
3786
+ * retry, since the restored state belongs to them. `flow:rest` and the rest seam fire once for
3787
+ * every node entered here; only a rest node is announced as a checkpoint.
3604
3788
  *
3605
3789
  * @param ctx - Domain context of the flow plugin.
3606
3790
  * @param modules - Injected sibling APIs; the features API resolves a path through a slot.
3607
3791
  * @param bookmark - The bookmark to enter.
3608
3792
  * @throws {Error} When the graph has no node at the bookmark's path.
3609
3793
  */
3610
- function enterBookmark$1(ctx, modules, bookmark) {
3794
+ function enterBookmark(ctx, modules, bookmark) {
3611
3795
  const state = ctx.state.runner;
3612
3796
  const location = findNode(requireMainFlow(ctx), bookmark.path, modules.features.contributions);
3613
3797
  loopSeam(state).restoring = void 0;
3614
- if (location === void 0) throw new Error(`[game] The bookmark names no node "${bookmark.path}".\n Bookmark a rest node of the running graph.`);
3798
+ if (location === void 0) throw new Error(`[game] The bookmark names no node "${bookmark.path}".\n Bookmark a node of the running graph.`);
3615
3799
  ctx.deps.model.store.restore({
3616
3800
  player: bookmark.player,
3617
3801
  session: bookmark.session,
@@ -3622,10 +3806,9 @@ function enterBookmark$1(ctx, modules, bookmark) {
3622
3806
  state.slotAfter = void 0;
3623
3807
  state.restFrame = [...state.stack];
3624
3808
  state.failures = 0;
3625
- const entry = location.entry;
3626
3809
  ctx.emit("flow:rest", {
3627
3810
  path: bookmark.path,
3628
- checkpoint: entry.kind === "node" && entry.checkpoint
3811
+ checkpoint: isRestCheckpoint(location.entry)
3629
3812
  });
3630
3813
  notifyRest(state, bookmark.path);
3631
3814
  }
@@ -3643,7 +3826,7 @@ async function advance(ctx, modules, safe) {
3643
3826
  if (stopped(state)) return "stop";
3644
3827
  const restoring = state.seam?.restoring;
3645
3828
  if (restoring !== void 0) {
3646
- enterBookmark$1(ctx, modules, restoring);
3829
+ enterBookmark(ctx, modules, restoring);
3647
3830
  return "continue";
3648
3831
  }
3649
3832
  const location = locateFrames(ctx, state.stack);
@@ -3685,12 +3868,15 @@ async function runLoop(ctx, modules) {
3685
3868
  }
3686
3869
  /**
3687
3870
  * Restores a bookmark through the running loop: the active node is aborted with reason
3688
- * `"restore"`, its transaction is discarded, the state is replaced and the node is entered.
3871
+ * `"restore"`, its transaction is discarded, the state is replaced and the node is entered. An
3872
+ * edge that was already committed still arrives first, and the rest point it reaches is not the
3873
+ * bookmark's: the promise waits for the turn in which the loop takes the bookmark.
3689
3874
  *
3690
3875
  * @param ctx - Domain context of the flow plugin.
3691
3876
  * @param _modules - Injected sibling APIs.
3692
3877
  * @param bookmark - The bookmark to enter.
3693
- * @returns A promise that resolves once the loop rests at the bookmark's node.
3878
+ * @returns A promise that resolves once the loop entered the bookmark's node, before that node
3879
+ * ran: the rest seam fired.
3694
3880
  * @throws {Error} When the loop is not running.
3695
3881
  */
3696
3882
  function restorePosition(ctx, _modules, bookmark) {
@@ -3702,6 +3888,7 @@ function restorePosition(ctx, _modules, bookmark) {
3702
3888
  * Resolves once, when the loop rests at the restored node.
3703
3889
  */
3704
3890
  const listener = () => {
3891
+ if (seam.restoring !== void 0) return;
3705
3892
  const index = seam.rest.indexOf(listener);
3706
3893
  if (index !== -1) seam.rest.splice(index, 1);
3707
3894
  resolve();
@@ -3722,7 +3909,7 @@ function restorePosition(ctx, _modules, bookmark) {
3722
3909
  * @param view - The last view.
3723
3910
  * @returns True while nothing the state shows moved.
3724
3911
  */
3725
- function isCurrent(ctx, view) {
3912
+ function isCurrent$1(ctx, view) {
3726
3913
  const runner = ctx.state.runner;
3727
3914
  return view.running === (runner.running !== void 0) && view.depth === runner.stack.length && view.top === runner.stack.at(-1) && view.open === ctx.state.gate.open && view.mode === ctx.state.fx.mode && view.journalIndex === runner.journalIndex;
3728
3915
  }
@@ -3763,7 +3950,7 @@ function viewOf(ctx) {
3763
3950
  function readState(ctx) {
3764
3951
  const runner = ctx.state.runner;
3765
3952
  const view = runner.view;
3766
- if (view !== void 0 && isCurrent(ctx, view)) return view.state;
3953
+ if (view !== void 0 && isCurrent$1(ctx, view)) return view.state;
3767
3954
  const next = viewOf(ctx);
3768
3955
  runner.view = next;
3769
3956
  return next.state;
@@ -3871,24 +4058,37 @@ function watchEnd(running) {
3871
4058
  return end;
3872
4059
  }
3873
4060
  /**
3874
- * Waits for the next gate the loop opens. The listener takes itself off when it fires, so a walk
3875
- * that ended meanwhile leaves nothing behind.
4061
+ * Watches for the next gate the loop opens. The listener leaves the seam when a gate wakes it. A
4062
+ * wait that something else ended calls `off()`: the gate that would take the listener off may
4063
+ * never come.
3876
4064
  *
3877
4065
  * @param seam - The seam of the running loop.
3878
- * @returns A promise that resolves when the loop opens a gate.
4066
+ * @returns The promise of the next open gate, and the way to stop watching.
3879
4067
  */
3880
4068
  function nextGateOpen(seam) {
3881
- return new Promise((resolve) => {
3882
- /**
3883
- * Wakes the walk once the loop opened the gate.
3884
- */
3885
- const listener = () => {
3886
- const index = seam.gateOpen.indexOf(listener);
3887
- if (index !== -1) seam.gateOpen.splice(index, 1);
3888
- resolve();
3889
- };
3890
- seam.gateOpen.push(listener);
4069
+ const watch = { wake: void 0 };
4070
+ /**
4071
+ * Takes the listener off the seam. It may be gone already.
4072
+ */
4073
+ const off = () => {
4074
+ const index = seam.gateOpen.indexOf(listener);
4075
+ if (index !== -1) seam.gateOpen.splice(index, 1);
4076
+ };
4077
+ /**
4078
+ * Wakes the waiting side once the loop opened a gate, and leaves.
4079
+ */
4080
+ function listener() {
4081
+ off();
4082
+ watch.wake?.();
4083
+ }
4084
+ const promise = new Promise((resolve) => {
4085
+ watch.wake = resolve;
3891
4086
  });
4087
+ seam.gateOpen.push(listener);
4088
+ return {
4089
+ promise,
4090
+ off
4091
+ };
3892
4092
  }
3893
4093
  /**
3894
4094
  * Waits until the loop opens its gate. Between the edge it commits and the gate of the rest node
@@ -3902,7 +4102,40 @@ function nextGateOpen(seam) {
3902
4102
  async function reachGate(ctx, seam, ended) {
3903
4103
  if (ctx.state.gate.open !== void 0) return;
3904
4104
  if (ended.done) return;
3905
- await Promise.race([nextGateOpen(seam), ended.settled]);
4105
+ const opened = nextGateOpen(seam);
4106
+ try {
4107
+ await Promise.race([opened.promise, ended.settled]);
4108
+ } finally {
4109
+ opened.off();
4110
+ }
4111
+ }
4112
+ /**
4113
+ * Waits until a transit node the loop was sent into shows what it waits for: the gate it opens,
4114
+ * or, when it opens none, the next rest point or the end of the loop. `restore` waits here after
4115
+ * it entered a transit bookmark, so its caller reads a state with the open gate in it. There is no
4116
+ * timeout: a node that opens no gate, never rests and never ends keeps the wait pending, as its
4117
+ * body would. Call it only after the loop entered the node: a gate opened before that belongs to
4118
+ * the node the restore aborted.
4119
+ *
4120
+ * @param ctx - Domain context of the flow plugin.
4121
+ * @returns A promise that resolves at the open gate, at a rest point or at the end of the loop.
4122
+ */
4123
+ async function reachGateOrRest(ctx) {
4124
+ const running = ctx.state.runner.running;
4125
+ if (running === void 0 || ctx.state.gate.open !== void 0) return;
4126
+ const seam = loopSeam(ctx.state.runner);
4127
+ const opened = nextGateOpen(seam);
4128
+ const rested = watchRest(seam);
4129
+ try {
4130
+ await Promise.race([
4131
+ opened.promise,
4132
+ rested.next(),
4133
+ watchEnd(running).settled
4134
+ ]);
4135
+ } finally {
4136
+ opened.off();
4137
+ rested.off();
4138
+ }
3906
4139
  }
3907
4140
  /**
3908
4141
  * The error of a step the loop never reached, naming where it stands instead.
@@ -4020,14 +4253,17 @@ async function playRoute(ctx, modules, route, running) {
4020
4253
  /**
4021
4254
  * Walks a route through the running loop: restores `from` when given, switches to fast mode,
4022
4255
  * waits until the loop rests at each step's `at` and answers through the gate, and substitutes
4023
- * the result of every sub-flow node the route skips. The mode of the caller is put back
4024
- * afterwards, also when a step rejects, and a substitution the route never reached is disarmed:
4025
- * it must not skip a sub-flow in the live play that follows.
4256
+ * the result of every sub-flow node the route skips. A rest node `from` names is entered before
4257
+ * the switch: the stages of that node see the mode of the caller. A transit node is entered
4258
+ * after the switch, so it never starts live and never shows its popup. The mode of the caller
4259
+ * is put back afterwards, also when the bookmark is refused or a step rejects, and a
4260
+ * substitution the route never reached is disarmed: it must not skip a sub-flow in the live play
4261
+ * that follows.
4026
4262
  *
4027
4263
  * @param ctx - Domain context of the flow plugin.
4028
4264
  * @param modules - Injected sibling APIs: features, fx, gate, inbox.
4029
4265
  * @param route - The player's answers and substituted results, in order.
4030
- * @param start - Bookmark to enter first and the checked restore that enters it.
4266
+ * @param start - Bookmark to enter first, the restore that enters it and the kind of its node.
4031
4267
  * @returns The state the walk ended in.
4032
4268
  * @throws {Error} Before `run()` was called, when the bookmark is refused, and when a step's `at`
4033
4269
  * is never reached.
@@ -4035,10 +4271,13 @@ async function playRoute(ctx, modules, route, running) {
4035
4271
  async function walkRoute(ctx, modules, route, start) {
4036
4272
  const running = ctx.state.runner.running;
4037
4273
  if (running === void 0) throw new Error("[game] flow.walk() needs a running graph.\n Call flow.run() from the createApp onStart callback first.");
4038
- if (start !== void 0) await start.restore(start.from);
4274
+ const entersBeforeSwitch = start !== void 0 && !start.transit;
4275
+ const entersAfterSwitch = start !== void 0 && !entersBeforeSwitch;
4276
+ if (entersBeforeSwitch) await start.restore(start.from);
4039
4277
  const previous = ctx.state.fx.mode;
4040
4278
  modules.fx.setMode("fast");
4041
4279
  try {
4280
+ if (entersAfterSwitch) await start.restore(start.from);
4042
4281
  await playRoute(ctx, modules, route, running);
4043
4282
  } finally {
4044
4283
  loopSeam(ctx.state.runner).substitutions.clear();
@@ -4050,69 +4289,71 @@ async function walkRoute(ctx, modules, route, start) {
4050
4289
  //#region src/plugins/flow/runner/api.ts
4051
4290
  const noPayload = null;
4052
4291
  /**
4053
- * Reads the main flow of the config.
4292
+ * Tells whether a frame points at a rest node.
4054
4293
  *
4055
4294
  * @param ctx - Domain context of the flow plugin.
4056
- * @param method - Name of the API method that needs it, for the message.
4057
- * @returns The top-level flow.
4058
- * @throws {Error} When no main flow was configured.
4295
+ * @param frame - One level of a position.
4296
+ * @returns True when the entry of that frame is a node with `rest`.
4059
4297
  */
4060
- function mainFlow(ctx, method) {
4061
- const main = ctx.config.mainFlow;
4062
- if (main === void 0) throw new Error(`[game] flow.${method}() needs a main flow.\n Pass it as pluginConfigs.flow.mainFlow.`);
4063
- return main;
4298
+ function restsAt(ctx, frame) {
4299
+ const entry = ctx.state.runner.flows.get(frame.flow)?.nodes[frame.node];
4300
+ return entry !== void 0 && entry.kind === "node" && entry.rest;
4064
4301
  }
4065
4302
  /**
4066
- * Renders the whole graph as JSON. It reads the flows as data, so it works before `run()`.
4303
+ * Tells whether the graph stands on a rest node. `setMode` is legal there and before `run()`.
4067
4304
  *
4068
4305
  * @param ctx - Domain context of the flow plugin.
4069
- * @param modules - Injected sibling APIs.
4070
- * @returns The graph.
4306
+ * @returns True while the position is empty or points at a rest node.
4071
4307
  */
4072
- function graphOf(ctx, modules) {
4073
- return describeGraph(collectGraph(ctx, modules.features), modules.features);
4308
+ function resting(ctx) {
4309
+ const frame = ctx.state.runner.stack.at(-1);
4310
+ return frame === void 0 || restsAt(ctx, frame);
4074
4311
  }
4075
4312
  /**
4076
- * Names a checkpoint of the graph, for the message of a refused bookmark.
4313
+ * Tells whether a transit node waits at the gate: an effect that takes answers is open inside
4314
+ * it. A rest node with its own gate open rests, so it does not count.
4077
4315
  *
4078
4316
  * @param ctx - Domain context of the flow plugin.
4079
- * @param modules - Injected sibling APIs.
4080
- * @returns The path of the first checkpoint, or the start of the main flow.
4317
+ * @returns True while the gate is open and the position is no rest node.
4081
4318
  */
4082
- function someCheckpoint(ctx, modules) {
4083
- const main = mainFlow(ctx, "restore");
4084
- for (const flow of collectGraph(ctx, modules.features).values()) for (const [name, entry] of Object.entries(flow.nodes)) if (entry.kind === "node" && entry.checkpoint) return flow.id === main.id ? name : `${flow.id}/${name}`;
4085
- return main.start;
4319
+ function waitsInTransit(ctx) {
4320
+ return ctx.state.gate.open !== void 0 && !resting(ctx);
4086
4321
  }
4087
4322
  /**
4088
- * Checks that a bookmark may be entered: a checkpoint always, any other rest node only while the
4089
- * graph is the one the bookmark was made for.
4323
+ * Reads the rest point before the transit node the graph stands in. The rest frame names a
4324
+ * transit node when the graph started at one or a restore entered one: no rest point came
4325
+ * before it then.
4090
4326
  *
4091
4327
  * @param ctx - Domain context of the flow plugin.
4092
- * @param modules - Injected sibling APIs.
4093
- * @param bookmark - The bookmark to enter.
4094
- * @throws {Error} When the path is no rest node, or the graph changed since the bookmark.
4328
+ * @returns The last rest node and its input, or `undefined` when the rest frame names none.
4095
4329
  */
4096
- function checkBookmark(ctx, modules, bookmark) {
4097
- const entry = findNode(mainFlow(ctx, "restore"), bookmark.path, modules.features.contributions)?.entry;
4098
- if (entry === void 0 || entry.kind !== "node" || !entry.rest) throw new Error(`[game] The bookmark "${bookmark.path}" is not a rest node of the graph.\n Restore a rest node, for example the checkpoint "${someCheckpoint(ctx, modules)}".`);
4099
- if (entry.checkpoint) return;
4100
- if (graphHash(graphOf(ctx, modules)) === bookmark.graph) return;
4101
- throw new Error(`[game] The bookmark "${bookmark.path}" was made for another graph.\n Restore the checkpoint "${someCheckpoint(ctx, modules)}" instead.`);
4330
+ function restBefore(ctx) {
4331
+ const frames = ctx.state.runner.restFrame ?? [];
4332
+ const frame = frames.at(-1);
4333
+ if (frame === void 0 || !restsAt(ctx, frame)) return void 0;
4334
+ return {
4335
+ path: framePath(frames),
4336
+ input: frame.input
4337
+ };
4102
4338
  }
4103
4339
  /**
4104
- * Makes a bookmark of the rest point the graph stands on.
4340
+ * Makes a bookmark of where the graph stands. While a transit node waits at the gate for the
4341
+ * answer of an effect it is that node, with the rest point before it; in every other moment it is
4342
+ * the last rest point. The state is the committed one in both cases: for a waiting transit node
4343
+ * that is the state it was entered with, since its own writes are still in the open transaction.
4105
4344
  *
4106
4345
  * @param ctx - Domain context of the flow plugin.
4107
4346
  * @param modules - Injected sibling APIs.
4108
- * @returns A rest node plus a state that really existed there.
4347
+ * @returns A node plus a state that really existed there.
4109
4348
  * @throws {Error} When the graph has no position yet.
4110
4349
  */
4111
4350
  function makeBookmark(ctx, modules) {
4112
4351
  const state = ctx.state.runner;
4113
- const frames = state.restFrame ?? state.stack;
4352
+ const waiting = waitsInTransit(ctx);
4353
+ const frames = waiting ? state.stack : state.restFrame ?? state.stack;
4114
4354
  if (frames.length === 0) throw new Error("[game] flow.bookmark() needs a position.\n Call it while the graph rests at a node.");
4115
4355
  const snapshot = ctx.deps.model.store.snapshot();
4356
+ const rest = waiting ? restBefore(ctx) : void 0;
4116
4357
  return {
4117
4358
  path: framePath(frames),
4118
4359
  input: frames.at(-1)?.input ?? noPayload,
@@ -4122,35 +4363,68 @@ function makeBookmark(ctx, modules) {
4122
4363
  seed: snapshot.rng.seed,
4123
4364
  streams: { ...snapshot.rng.streams }
4124
4365
  },
4125
- graph: graphHash(graphOf(ctx, modules))
4366
+ graph: graphHash(graphOf(ctx, modules.features)),
4367
+ ...rest === void 0 ? {} : { rest }
4126
4368
  };
4127
4369
  }
4128
4370
  /**
4129
- * Enters a bookmark the way the plugin's `restore` does: the bookmark is checked first, then the
4130
- * running loop is sent to its node. `walk({ from })` enters through here as well, so both ways
4131
- * into a position refuse the same bookmarks.
4371
+ * Sends the running loop to a bookmark that passed the check, the short way a walk needs: the
4372
+ * promise resolves as soon as the loop stands at the node, before the node ran. It stays an
4373
+ * `async` function of its own: the microtask turn it adds lets the loop start the stages of a
4374
+ * rest node before the walk goes on and switches to fast mode.
4132
4375
  *
4133
4376
  * @param ctx - Domain context of the flow plugin.
4134
4377
  * @param modules - Injected sibling APIs.
4135
- * @param bookmark - The bookmark to enter.
4136
- * @returns A promise that resolves once the loop rests at the bookmark's node.
4137
- * @throws {Error} When the bookmark names no rest node of this graph.
4378
+ * @param bookmark - The accepted bookmark.
4379
+ * @returns A promise that resolves once the loop entered the node.
4138
4380
  */
4139
- async function enterBookmark(ctx, modules, bookmark) {
4140
- checkBookmark(ctx, modules, bookmark);
4381
+ async function enterAccepted(ctx, modules, bookmark) {
4141
4382
  await restorePosition(ctx, modules, bookmark);
4142
4383
  }
4143
4384
  /**
4144
- * Tells whether the graph stands on a rest node. `setMode` is legal there and before `run()`.
4385
+ * Walks a route from a bookmark. The bookmark passes the check `restore` runs, so both ways into
4386
+ * a position refuse the same bookmarks and take the same fallback. The walk is told which kind of
4387
+ * node it enters: a rest node is entered before the switch to fast mode, a transit node after it.
4145
4388
  *
4146
4389
  * @param ctx - Domain context of the flow plugin.
4147
- * @returns True while the position is empty or points at a rest node.
4390
+ * @param modules - Injected sibling APIs.
4391
+ * @param route - The player's answers and substituted results, in order.
4392
+ * @param from - The bookmark to enter first.
4393
+ * @returns The state the walk ended in.
4394
+ * @throws {Error} When the bookmark may not be entered, and when a step's `at` is never reached.
4148
4395
  */
4149
- function resting(ctx) {
4150
- const frame = ctx.state.runner.stack.at(-1);
4151
- if (frame === void 0) return true;
4152
- const entry = ctx.state.runner.flows.get(frame.flow)?.nodes[frame.node];
4153
- return entry !== void 0 && entry.kind === "node" && entry.rest;
4396
+ async function walkFrom(ctx, modules, route, from) {
4397
+ const accepted = acceptBookmark(ctx, modules, from);
4398
+ return walkRoute(ctx, modules, route, {
4399
+ /**
4400
+ * Enters the accepted bookmark. The walk never waits for a gate here: it answers the gates
4401
+ * of its own route.
4402
+ *
4403
+ * @param bookmark - The bookmark to enter.
4404
+ * @returns A promise that resolves once the loop entered its node.
4405
+ */
4406
+ restore: (bookmark) => enterAccepted(ctx, modules, bookmark),
4407
+ from: accepted.bookmark,
4408
+ transit: !accepted.rest
4409
+ });
4410
+ }
4411
+ /**
4412
+ * Restores a bookmark, the way the plugin's `restore` does. A rest node is entered as `walk`
4413
+ * enters it. A transit node shows nothing until it ran up to its gate, so for one the promise
4414
+ * waits on: for the gate the node opens, or the rest point or the end of the loop it reaches
4415
+ * without one. The wait starts only after the loop entered the node, so a gate the aborted old
4416
+ * node still opened is not taken for the new one.
4417
+ *
4418
+ * @param ctx - Domain context of the flow plugin.
4419
+ * @param modules - Injected sibling APIs.
4420
+ * @param bookmark - The bookmark to enter.
4421
+ * @returns A promise that resolves once a rest node is entered, or a transit node waits.
4422
+ * @throws {Error} When the bookmark may not be entered, and before `run()`.
4423
+ */
4424
+ async function restoreBookmark(ctx, modules, bookmark) {
4425
+ const accepted = acceptBookmark(ctx, modules, bookmark);
4426
+ await restorePosition(ctx, modules, accepted.bookmark);
4427
+ if (!accepted.rest) await reachGateOrRest(ctx);
4154
4428
  }
4155
4429
  /**
4156
4430
  * Creates the runner API: `run` owns the one loop, `walk` and `restore` enter a position through
@@ -4179,21 +4453,12 @@ function createRunnerApi(ctx, modules) {
4179
4453
  },
4180
4454
  walk: (route, options) => {
4181
4455
  const from = options?.from;
4182
- if (from === void 0) return walkRoute(ctx, modules, route);
4183
- return walkRoute(ctx, modules, route, {
4184
- /**
4185
- * Enters the bookmark the walk starts from, checked the way `restore` checks it.
4186
- *
4187
- * @param bookmark - The bookmark to enter.
4188
- * @returns A promise that resolves once the loop rests at its node.
4189
- */
4190
- restore: (bookmark) => enterBookmark(ctx, modules, bookmark),
4191
- from
4192
- });
4456
+ if (from === void 0 || state.running === void 0) return walkRoute(ctx, modules, route);
4457
+ return walkFrom(ctx, modules, route, from);
4193
4458
  },
4194
4459
  bookmark: () => makeBookmark(ctx, modules),
4195
- restore: (bookmark) => enterBookmark(ctx, modules, bookmark),
4196
- describe: () => graphOf(ctx, modules),
4460
+ restore: (bookmark) => restoreBookmark(ctx, modules, bookmark),
4461
+ describe: () => graphOf(ctx, modules.features),
4197
4462
  state: () => readState(ctx),
4198
4463
  history: () => [...state.journal],
4199
4464
  setMode: (mode) => {
@@ -4521,7 +4786,7 @@ const flowPlugin = /*#__PURE__*/ createPlugin$1("flow", {
4521
4786
  },
4522
4787
  events: (register) => register.map({
4523
4788
  "flow:edge": "An edge was taken and its state committed",
4524
- "flow:rest": "The graph reached a rest node",
4789
+ "flow:rest": "The graph reached a rest node, or entered the node of a bookmark",
4525
4790
  "flow:error": "A node failed and the graph rolled back"
4526
4791
  }),
4527
4792
  createState: createFlowState,
@@ -8269,7 +8534,7 @@ function stopHost(state) {
8269
8534
  //#endregion
8270
8535
  //#region src/plugins/renderer/host/readback.ts
8271
8536
  /** Bytes of one pixel: the engine loads PNG and WebP only, so every source is 8-bit RGBA. */
8272
- const BYTES_PER_PIXEL = 4;
8537
+ const BYTES_PER_PIXEL$1 = 4;
8273
8538
  /**
8274
8539
  * Estimates the GPU bytes of one texture source: 4 bytes per pixel, every mip level, a level never
8275
8540
  * smaller than one pixel.
@@ -8293,7 +8558,7 @@ function sourceBytes(source) {
8293
8558
  width = Math.max(1, Math.floor(width / 2));
8294
8559
  height = Math.max(1, Math.floor(height / 2));
8295
8560
  }
8296
- return pixels * BYTES_PER_PIXEL;
8561
+ return pixels * BYTES_PER_PIXEL$1;
8297
8562
  }
8298
8563
  /**
8299
8564
  * Counts the texture sources the GPU holds and sums their estimated bytes. Pixi's
@@ -8561,7 +8826,7 @@ function resetWindow(state) {
8561
8826
  //#endregion
8562
8827
  //#region src/plugins/renderer/monitor/api.ts
8563
8828
  /** Bytes of one MiB. */
8564
- const BYTES_PER_MB = 1048576;
8829
+ const BYTES_PER_MB$1 = 1048576;
8565
8830
  /**
8566
8831
  * Rounds a number to a fixed count of decimals, so a counter reads well in a panel.
8567
8832
  *
@@ -8599,7 +8864,7 @@ function createMonitorApi(ctx, deps) {
8599
8864
  fps: state.lastStart !== void 0 && clock.now() - state.lastStart <= 1e3 ? roundTo(state.fps, 1) : 0,
8600
8865
  frameMs: roundTo(state.frameMs, 2),
8601
8866
  textures: usage.count,
8602
- textureMb: roundTo(usage.bytes / BYTES_PER_MB, 2),
8867
+ textureMb: roundTo(usage.bytes / BYTES_PER_MB$1, 2),
8603
8868
  views: counts.views,
8604
8869
  pooled: counts.pooled,
8605
8870
  renderPasses: deps.sync.renderPasses()
@@ -8937,7 +9202,7 @@ function parseFont(pixi, key, fnt) {
8937
9202
  * @param texture - The page texture `assets` uploaded.
8938
9203
  * @throws {Error} When the renderer does not draw.
8939
9204
  */
8940
- function installFont(sctx, key, fnt, texture) {
9205
+ function installFont$1(sctx, key, fnt, texture) {
8941
9206
  const pixi = sctx.deps.host.pixi();
8942
9207
  if (pixi === void 0) throw new Error("[game] Fonts need a renderer.\n Call fonts.install after renderer.host.ready().");
8943
9208
  const state = sctx.ctx.state.sync;
@@ -10207,7 +10472,9 @@ function clearFrames(state) {
10207
10472
  }
10208
10473
  /**
10209
10474
  * Frees a texture, its source and the crops cut from it. A slice keeps the source: it belongs to
10210
- * the page, which frees it. A texture destroyed twice is a no-op.
10475
+ * the page, which frees it. A source that goes lets go of its `change` listeners first, the ones
10476
+ * of its style too, so no bind group Pixi still keeps is told. A texture destroyed twice is a
10477
+ * no-op.
10211
10478
  *
10212
10479
  * @param state - The sync branch of the plugin state.
10213
10480
  * @param texture - The texture to free.
@@ -10215,7 +10482,13 @@ function clearFrames(state) {
10215
10482
  function destroyTexture(state, texture) {
10216
10483
  releaseFrames$1(state, texture);
10217
10484
  if (texture.destroyed) return;
10218
- texture.destroy(!state.slices.has(texture));
10485
+ if (state.slices.has(texture)) {
10486
+ texture.destroy(false);
10487
+ return;
10488
+ }
10489
+ texture.source.style.removeAllListeners("change");
10490
+ texture.source.removeAllListeners("change");
10491
+ texture.destroy(true);
10219
10492
  }
10220
10493
  //#endregion
10221
10494
  //#region src/plugins/renderer/sync/views.ts
@@ -11839,7 +12112,7 @@ function createSyncApi(ctx, deps) {
11839
12112
  removed: markRemoved
11840
12113
  }) },
11841
12114
  fonts: {
11842
- install: (key, fnt, texture) => installFont(sctx, key, fnt, texture),
12115
+ install: (key, fnt, texture) => installFont$1(sctx, key, fnt, texture),
11843
12116
  installed: (key) => isFontInstalled(state, key)
11844
12117
  },
11845
12118
  debug: {
@@ -14629,6 +14902,12 @@ const TIERS = [
14629
14902
  "feature",
14630
14903
  "lazy"
14631
14904
  ];
14905
+ /** The bytes of one megabyte. */
14906
+ const BYTES_PER_MB = 1048576;
14907
+ /** The bytes one pixel takes in GPU memory: RGBA, no mipmaps. */
14908
+ const BYTES_PER_PIXEL = 4;
14909
+ /** Megabytes are kept to three decimals: a value is rounded to a whole number of these parts. */
14910
+ const PARTS_PER_MB = 1e3;
14632
14911
  /**
14633
14912
  * Creates an empty map. It lives in its own non-exported function because lint rule L5 refuses a
14634
14913
  * collection built inside an exported declaration.
@@ -14923,6 +15202,38 @@ function fileUrl(base, path) {
14923
15202
  return `${base}${path.startsWith("/") ? path.slice(1) : path}`;
14924
15203
  }
14925
15204
  /**
15205
+ * Works out what an image costs in texture memory: `width × height × 4` bytes as megabytes,
15206
+ * rounded to 3 decimals. It is the number the scanner writes for a texture and for a font page;
15207
+ * the dev hot swap writes it again for a file that came back with another size.
15208
+ *
15209
+ * @param width - Width of the image in pixels.
15210
+ * @param height - Height of the image in pixels.
15211
+ * @returns The megabytes.
15212
+ * @example
15213
+ * ```ts
15214
+ * textureMb(256, 128); // 0.125
15215
+ * ```
15216
+ */
15217
+ function textureMb(width, height) {
15218
+ return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * PARTS_PER_MB) / PARTS_PER_MB;
15219
+ }
15220
+ /**
15221
+ * Adds up megabytes, rounded to 3 decimals: the pages of a font give the font, the files and the
15222
+ * atlas pages of a bundle give the bundle.
15223
+ *
15224
+ * @param parts - The records to add up.
15225
+ * @returns The sum.
15226
+ * @example
15227
+ * ```ts
15228
+ * sumMb([{ mb: 0.125 }, { mb: 0.063 }, { mb: 0.021 }]); // 0.209
15229
+ * ```
15230
+ */
15231
+ function sumMb(parts) {
15232
+ let total = 0;
15233
+ for (const part of parts) total += part.mb;
15234
+ return Math.round(total * PARTS_PER_MB) / PARTS_PER_MB;
15235
+ }
15236
+ /**
14926
15237
  * Turns the nine-slice metadata of a file into the options `renderer.sync.textures.create` takes.
14927
15238
  *
14928
15239
  * @param file - One file of a bundle.
@@ -14947,7 +15258,8 @@ function nineOf(file) {
14947
15258
  //#region src/plugins/assets/tiers.ts
14948
15259
  /**
14949
15260
  * @file assets plugin — loading one bundle and the boot sequence. One running load per bundle,
14950
- * every caller a waiter: the last one to leave aborts the fetches.
15261
+ * every caller a waiter: the last one to leave aborts the fetches. The loaders of one image, one
15262
+ * font and one sound are exported: the dev hot swap of `swap.ts` reads a changed file through them.
14951
15263
  */
14952
15264
  /** The extension of an AAC sound in an MP4 container; every other sound is an MP3. */
14953
15265
  const M4A = /\.m4a$/i;
@@ -15102,10 +15414,13 @@ function touch(state, record) {
15102
15414
  record.lastUsed = state.useCounter;
15103
15415
  }
15104
15416
  /**
15105
- * Creates the four empty maps a bundle's assets live in. It is its own function because lint
15106
- * rule L5 refuses a collection built inside an exported declaration.
15417
+ * Creates the four empty maps a bundle's assets live in: the home of a running load.
15107
15418
  *
15108
15419
  * @returns Empty maps for textures, fonts, audio and atlas pages.
15420
+ * @example
15421
+ * ```ts
15422
+ * emptyAssets().textures.size; // 0
15423
+ * ```
15109
15424
  */
15110
15425
  function emptyAssets() {
15111
15426
  return {
@@ -15202,6 +15517,22 @@ async function loadFont(run, file) {
15202
15517
  };
15203
15518
  }
15204
15519
  /**
15520
+ * Loads one sound: the bytes exactly as they were fetched, never decoded here, and the type of
15521
+ * their container.
15522
+ *
15523
+ * @param run - The running load.
15524
+ * @param file - The audio file of the manifest.
15525
+ * @returns The bytes and their MIME type.
15526
+ * @throws {Error} When the response is not ok.
15527
+ */
15528
+ async function loadSound(run, file) {
15529
+ const soundPath = pathOf(run.bundle, file);
15530
+ return {
15531
+ bytes: await (await fetchFile(run, soundPath)).arrayBuffer(),
15532
+ mime: mimeOf(soundPath)
15533
+ };
15534
+ }
15535
+ /**
15205
15536
  * Starts the one load of every atlas page of a bundle. A page lands in the `pages` map of the
15206
15537
  * running load, so a failure later frees it with the rest. Nobody may wait for a page, so its
15207
15538
  * failure is marked as handled here; the files on it still reject with it.
@@ -15269,12 +15600,7 @@ async function loadFile(run, file) {
15269
15600
  return;
15270
15601
  }
15271
15602
  if (kind === "audio") {
15272
- const soundPath = pathOf(run.bundle, file);
15273
- const response = await fetchFile(run, soundPath);
15274
- assets.audio.set(file.key, {
15275
- bytes: await response.arrayBuffer(),
15276
- mime: mimeOf(soundPath)
15277
- });
15603
+ assets.audio.set(file.key, await loadSound(run, file));
15278
15604
  return;
15279
15605
  }
15280
15606
  const texture = file.atlas === void 0 ? await loadImage(run, pathOf(run.bundle, file), nineOf(file)) : await sliceFile(run, file, file.atlas);
@@ -16326,6 +16652,8 @@ function releaseAll(state) {
16326
16652
  state.warned.clear();
16327
16653
  state.current = void 0;
16328
16654
  state.sceneBundle = void 0;
16655
+ state.stamps = void 0;
16656
+ state.swapping = void 0;
16329
16657
  state.io = void 0;
16330
16658
  }
16331
16659
  //#endregion
@@ -16406,102 +16734,617 @@ function unload(ctx, bundle) {
16406
16734
  });
16407
16735
  return;
16408
16736
  }
16409
- if (isPermanent(entry.tier)) {
16410
- ctx.log.warn(REFUSED, {
16411
- bundle,
16412
- reason: "tier"
16413
- });
16737
+ if (isPermanent(entry.tier)) {
16738
+ ctx.log.warn(REFUSED, {
16739
+ bundle,
16740
+ reason: "tier"
16741
+ });
16742
+ return;
16743
+ }
16744
+ state.records.get(bundle)?.inflight?.controller.abort();
16745
+ unloadBundle(ctx, bundle, "request");
16746
+ }
16747
+ /**
16748
+ * Tells whether a bundle is there. Headless every bundle of the manifest counts as loaded.
16749
+ *
16750
+ * @param ctx - Domain context of the plugin.
16751
+ * @param bundle - Name of the bundle.
16752
+ * @returns True when the bundle is loaded.
16753
+ */
16754
+ function isLoaded(ctx, bundle) {
16755
+ const state = ctx.state;
16756
+ if (state.io === void 0) return state.manifest.bundles[bundle] !== void 0;
16757
+ return state.records.get(bundle)?.status === "loaded";
16758
+ }
16759
+ /**
16760
+ * Reports what the loaded bundles cost.
16761
+ *
16762
+ * @param ctx - Domain context of the plugin.
16763
+ * @returns The used and allowed megabytes and one entry per loaded bundle, sorted by name.
16764
+ */
16765
+ function usage(ctx) {
16766
+ const bundles = [];
16767
+ for (const [name, record] of ctx.state.records) {
16768
+ const entry = ctx.state.manifest.bundles[name];
16769
+ if (record.status !== "loaded" || entry === void 0) continue;
16770
+ bundles.push({
16771
+ name,
16772
+ tier: entry.tier,
16773
+ mb: entry.mb,
16774
+ lastUsed: record.lastUsed
16775
+ });
16776
+ }
16777
+ bundles.sort((left, right) => left.name.localeCompare(right.name));
16778
+ return {
16779
+ textureMb: usedMb(ctx.state),
16780
+ budgetMb: ctx.config.textureBudgetMb,
16781
+ bundles
16782
+ };
16783
+ }
16784
+ /**
16785
+ * Creates the assets API: `app.assets`.
16786
+ *
16787
+ * @param ctx - Kernel context of the assets plugin.
16788
+ * @returns The plugin API.
16789
+ */
16790
+ function createAssetsApi(ctx) {
16791
+ const assets = withDeps$6(ctx);
16792
+ return {
16793
+ load: (bundle) => loadBundle(assets, bundle, void 0, "request"),
16794
+ unload: (bundle) => unload(assets, bundle),
16795
+ isLoaded: (bundle) => isLoaded(assets, bundle),
16796
+ texture: (key) => lookupTexture(assets, key),
16797
+ font: (key) => loadedRecord(assets, key)?.fonts.get(key),
16798
+ audio: (key) => loadedRecord(assets, key)?.audio.get(key),
16799
+ usage: () => usage(assets)
16800
+ };
16801
+ }
16802
+ //#endregion
16803
+ //#region src/plugins/assets/paths.ts
16804
+ /** The name of the folder of a feature or of a layer the scanner reads. */
16805
+ const ASSETS = "assets";
16806
+ /** That folder inside a path. */
16807
+ const ASSETS_FOLDER = `/${ASSETS}/`;
16808
+ /**
16809
+ * Reads the folder the scanner found a manifest path in. The scanner reads the `assets/` folder
16810
+ * of a feature or of a layer, so the path is cut after it; a path with no such folder, as in a
16811
+ * manifest written by hand, gives the folder of the file.
16812
+ *
16813
+ * @param path - A path of the manifest.
16814
+ * @returns The folder, ending in a slash, or `""` for a file at the root.
16815
+ * @example
16816
+ * ```ts
16817
+ * folderOf("features/ui/assets/fx/leaf.webp"); // "features/ui/assets/"
16818
+ * ```
16819
+ */
16820
+ function folderOf(path) {
16821
+ const scanned = path.indexOf(ASSETS_FOLDER);
16822
+ if (scanned !== -1) return path.slice(0, scanned + ASSETS_FOLDER.length);
16823
+ return path.slice(0, path.lastIndexOf("/") + 1);
16824
+ }
16825
+ /**
16826
+ * Tells whether the scanner would read a path, by its shape alone. It mirrors `scanOwner` of
16827
+ * `scan/scan.ts`, which runtime code does not import: the scanner reads `<layer>/assets/` and
16828
+ * `<features>/<feature>/assets/` under the game root, so `assets` is the second or the third
16829
+ * segment and a file follows it. The page knows neither the layers nor the name of the features
16830
+ * folder, so any folder counts as one. This is what finds the first file of an `assets/` folder
16831
+ * the booted manifest names nowhere.
16832
+ *
16833
+ * @param path - A path of the game, root-relative with `/`.
16834
+ * @returns True for a path under the `assets/` of a layer or of a feature.
16835
+ * @example
16836
+ * ```ts
16837
+ * isScannerPath("features/shop/assets/coin.png"); // true
16838
+ * ```
16839
+ */
16840
+ function isScannerPath(path) {
16841
+ const segments = path.split("/");
16842
+ const inLayer = segments[1] === ASSETS && segments.length > 2;
16843
+ const inFeature = segments[2] === ASSETS && segments.length > 3;
16844
+ return inLayer || inFeature;
16845
+ }
16846
+ /**
16847
+ * Builds the path index of a manifest: which file and bundle every path leads to, and the folders
16848
+ * the scanner read. A path under one of those folders that leads nowhere is a new asset.
16849
+ *
16850
+ * @param manifest - The manifest the page booted with.
16851
+ * @returns The index.
16852
+ */
16853
+ function indexPaths(manifest) {
16854
+ const owners = /* @__PURE__ */ new Map();
16855
+ const folders = /* @__PURE__ */ new Set();
16856
+ for (const [bundle, entry] of Object.entries(manifest.bundles)) for (const file of entry.files) {
16857
+ if (file.path === void 0) continue;
16858
+ const owner = {
16859
+ bundle,
16860
+ entry,
16861
+ file,
16862
+ path: file.path
16863
+ };
16864
+ for (const path of [file.path, ...(file.pages ?? []).map((page) => page.path)]) {
16865
+ owners.set(path, owner);
16866
+ folders.add(folderOf(path));
16867
+ }
16868
+ }
16869
+ folders.delete("");
16870
+ return {
16871
+ manifest,
16872
+ owners,
16873
+ folders: [...folders]
16874
+ };
16875
+ }
16876
+ /**
16877
+ * Tells why a stamp cannot be swapped in place: the set of files is not the one of the manifest
16878
+ * the page booted with. A manifest path the watch no longer stamps was removed or renamed, or its
16879
+ * nine-slice tag changed. A changed path the manifest does not know is a new asset when it lies
16880
+ * under a folder the manifest names, or where the scanner reads: the first file of an `assets/`
16881
+ * folder that had none counts too. The watch stamps every image of the game tree, so any other
16882
+ * unknown path (a favicon, an image of a feature outside its `assets/`) refuses nothing.
16883
+ *
16884
+ * @param index - The path index of the manifest.
16885
+ * @param stamps - The stamp that arrived.
16886
+ * @param changed - The paths whose bytes changed.
16887
+ * @returns The reason, or `undefined` when only bytes changed.
16888
+ */
16889
+ function refusalOf$1(index, stamps, changed) {
16890
+ for (const path of index.owners.keys()) if (!Object.hasOwn(stamps.files, path)) return `"${path}" left the game`;
16891
+ for (const path of changed) {
16892
+ if (index.owners.has(path)) continue;
16893
+ if (isScannerPath(path) || index.folders.some((folder) => path.startsWith(folder))) return `"${path}" is not in the manifest`;
16894
+ }
16895
+ }
16896
+ /**
16897
+ * Groups the changed files by their bundle. A font whose `.fnt` and pages changed in one save is
16898
+ * listed once: a font is replaced as a whole. A path the manifest does not know is left out.
16899
+ *
16900
+ * @param index - The path index of the manifest.
16901
+ * @param changed - The paths whose bytes changed.
16902
+ * @returns One batch per bundle, in the order the paths name them.
16903
+ */
16904
+ function batchesOf(index, changed) {
16905
+ const batches = /* @__PURE__ */ new Map();
16906
+ for (const path of changed) {
16907
+ const owner = index.owners.get(path);
16908
+ if (owner === void 0) continue;
16909
+ const { bundle, entry } = owner;
16910
+ const batch = batches.get(bundle) ?? {
16911
+ bundle,
16912
+ entry,
16913
+ owners: []
16914
+ };
16915
+ if (!batch.owners.includes(owner)) batch.owners.push(owner);
16916
+ batches.set(bundle, batch);
16917
+ }
16918
+ return [...batches.values()];
16919
+ }
16920
+ //#endregion
16921
+ //#region src/plugins/assets/stamp.ts
16922
+ /** The module the keys watch writes, as the hot footer reports its path. */
16923
+ const STAMP_FILE = "/.moku/assets-stamp.ts";
16924
+ /**
16925
+ * Tells whether a value maps paths to stamps.
16926
+ *
16927
+ * @param value - The `files` member of a stamp module.
16928
+ * @returns True for a plain object whose values are all strings.
16929
+ * @example
16930
+ * ```ts
16931
+ * isStampMap({ "features/ui/assets/fx-spark.webp": "2554:1791536552578" }); // true
16932
+ * ```
16933
+ */
16934
+ function isStampMap(value) {
16935
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((stamp) => typeof stamp === "string");
16936
+ }
16937
+ /**
16938
+ * Tells whether a value is a list of paths.
16939
+ *
16940
+ * @param value - The `changed` member of a stamp module.
16941
+ * @returns True for an array of strings.
16942
+ * @example
16943
+ * ```ts
16944
+ * isPathList(["features/ui/assets/fx-spark.webp"]); // true
16945
+ * ```
16946
+ */
16947
+ function isPathList(value) {
16948
+ return Array.isArray(value) && value.every((path) => typeof path === "string");
16949
+ }
16950
+ /**
16951
+ * Tells the stamp of the keys watch from any other default export.
16952
+ *
16953
+ * @param value - The default export of the stamp module.
16954
+ * @returns True when it carries a `files` map of strings and a `changed` list of strings.
16955
+ * @example
16956
+ * ```ts
16957
+ * isAssetStamps({ files: { "features/ui/assets/popup.mp3": "8120:1791536000000" }, changed: [] }); // true
16958
+ * ```
16959
+ */
16960
+ function isAssetStamps(value) {
16961
+ if (typeof value !== "object" || value === null) return false;
16962
+ if (!("files" in value) || !("changed" in value)) return false;
16963
+ return isStampMap(value.files) && isPathList(value.changed);
16964
+ }
16965
+ /**
16966
+ * Reads the stamp out of a hot swap, when the saved module is the stamp of the keys watch.
16967
+ *
16968
+ * @param payload - The saved file and its new exports.
16969
+ * @returns The stamp, or `undefined` for any other file and for a module that carries none.
16970
+ * @example
16971
+ * ```ts
16972
+ * stampsOf({ file: "/game/features/hud/view.tsx", module: {} }); // undefined
16973
+ * ```
16974
+ */
16975
+ function stampsOf(payload) {
16976
+ if (!payload.file.replaceAll("\\", "/").endsWith(STAMP_FILE)) return void 0;
16977
+ const stamps = payload.module.default;
16978
+ return isAssetStamps(stamps) ? stamps : void 0;
16979
+ }
16980
+ /**
16981
+ * Works out which files a stamp changed, and keeps its map for the next one. Against the map
16982
+ * applied last it is every path whose stamp differs. The first update after boot has no map to
16983
+ * compare with, so the list of the keys watch stands.
16984
+ *
16985
+ * @param state - The plugin state.
16986
+ * @param stamps - The stamp that arrived.
16987
+ * @returns The changed paths.
16988
+ */
16989
+ function changedPaths(state, stamps) {
16990
+ const applied = state.stamps;
16991
+ state.stamps = stamps.files;
16992
+ if (applied === void 0) return stamps.changed;
16993
+ return Object.keys(stamps.files).filter((path) => stamps.files[path] !== applied[path]);
16994
+ }
16995
+ //#endregion
16996
+ //#region src/plugins/assets/swap.ts
16997
+ /**
16998
+ * @file assets plugin — the dev hot swap of asset files. When the stamp of the keys watch changes
16999
+ * (`stamp.ts`), the changed files of the loaded bundles (`paths.ts`) are replaced here: a new
17000
+ * texture, font or sound per key, the old textures destroyed after the new ones are stored, then
17001
+ * `assets:replaced`. A file that fails is logged and its bundle keeps what it had. A changed set
17002
+ * of files reloads the page instead: a hook cannot refuse by throwing, the core bus catches what
17003
+ * a hook throws.
17004
+ */
17005
+ /**
17006
+ * Reloads the page. This is how a swap refuses, and what it does when a replace breaks after its
17007
+ * files arrived: the page boots again with the new manifest and the new files. Headless and in a
17008
+ * test there is no page, so nothing happens.
17009
+ */
17010
+ function reloadPage$1() {
17011
+ globalThis.location?.reload();
17012
+ }
17013
+ /**
17014
+ * Says why a file could not be replaced.
17015
+ *
17016
+ * @param error - What the load threw.
17017
+ * @returns The message of an error, or the value as a string.
17018
+ * @example
17019
+ * ```ts
17020
+ * reasonOf(new Error("The source image could not be decoded.")); // "The source image could not be decoded."
17021
+ * ```
17022
+ */
17023
+ function reasonOf$1(error) {
17024
+ return error instanceof Error ? error.message : String(error);
17025
+ }
17026
+ /**
17027
+ * Copies the io of a swap so every texture it uploads is remembered with the pixel size of its
17028
+ * image. The manifest needs that size, and the loaders of `tiers.ts` answer the texture alone;
17029
+ * the list is also what a swap that does not land gives back. The io is a plain record of
17030
+ * functions, so the copy carries the other four members as they are.
17031
+ *
17032
+ * @param io - The I/O seam of the plugin.
17033
+ * @param sizes - Filled with every texture this io creates.
17034
+ * @returns The io with a measuring `createTexture`.
17035
+ */
17036
+ function measuringIo(io, sizes) {
17037
+ return {
17038
+ ...io,
17039
+ createTexture: (image, options) => {
17040
+ const texture = io.createTexture(image, options);
17041
+ sizes.set(texture, {
17042
+ width: image.width,
17043
+ height: image.height
17044
+ });
17045
+ return texture;
17046
+ }
17047
+ };
17048
+ }
17049
+ /**
17050
+ * Builds the running load the file loaders of `tiers.ts` take, for the files of one bundle.
17051
+ *
17052
+ * @param swap - The running swap.
17053
+ * @param bundle - Name of the bundle.
17054
+ * @param sizes - Filled with every texture the load creates.
17055
+ * @returns The running load.
17056
+ */
17057
+ function loadingOf(swap, bundle, sizes) {
17058
+ const { config } = swap.ctx;
17059
+ return {
17060
+ io: measuringIo(swap.io, sizes),
17061
+ bundle,
17062
+ base: resolveBaseUrl(config.baseUrl, config.manifest),
17063
+ signal: new AbortController().signal,
17064
+ assets: emptyAssets()
17065
+ };
17066
+ }
17067
+ /**
17068
+ * Loads one changed file again, by what the manifest says it is. A font is read as a whole: its
17069
+ * `.fnt` and every page, whichever of them changed.
17070
+ *
17071
+ * @param run - The running load.
17072
+ * @param owner - The file to load.
17073
+ * @returns What the file brought.
17074
+ * @throws {Error} When a fetch or a decode fails, or the font lists no page.
17075
+ */
17076
+ async function loadFresh(run, owner) {
17077
+ const kind = kindOf$2(owner.file);
17078
+ if (kind === "font") return {
17079
+ kind,
17080
+ owner,
17081
+ font: await loadFont(run, owner.file)
17082
+ };
17083
+ if (kind === "audio") return {
17084
+ kind,
17085
+ owner,
17086
+ audio: await loadSound(run, owner.file)
17087
+ };
17088
+ return {
17089
+ kind,
17090
+ owner,
17091
+ texture: await loadImage(run, owner.path, nineOf(owner.file))
17092
+ };
17093
+ }
17094
+ /**
17095
+ * Loads every changed file of one bundle in parallel and waits for all of them, so nothing is
17096
+ * still on the way when the bundle is stored or given back.
17097
+ *
17098
+ * @param run - The running load.
17099
+ * @param owners - The files to load.
17100
+ * @returns What arrived, and the files that failed.
17101
+ */
17102
+ async function loadAll(run, owners) {
17103
+ const fresh = [];
17104
+ const failures = [];
17105
+ await Promise.all(owners.map((owner) => loadFresh(run, owner).then((loaded) => fresh.push(loaded), (error) => failures.push({
17106
+ path: owner.path,
17107
+ reason: reasonOf$1(error)
17108
+ }))));
17109
+ return {
17110
+ fresh,
17111
+ failures
17112
+ };
17113
+ }
17114
+ /**
17115
+ * Reads the record of a bundle once it stands still. A load that runs when the stamp arrives may
17116
+ * have fetched the old bytes, so it lands first.
17117
+ *
17118
+ * @param state - The plugin state.
17119
+ * @param bundle - Name of the bundle.
17120
+ * @returns The record when the bundle is loaded, else `undefined`: its next load reads the new
17121
+ * bytes, the dev server answers every file with `no-store`.
17122
+ */
17123
+ async function settledRecord(state, bundle) {
17124
+ const record = state.records.get(bundle);
17125
+ if (record?.status === "loading") await record.inflight?.promise;
17126
+ return record?.status === "loaded" ? record : void 0;
17127
+ }
17128
+ /**
17129
+ * Writes the pixel size of a new image, and what it costs, into its manifest record.
17130
+ *
17131
+ * @param target - The record of a texture file or of a font page.
17132
+ * @param size - The size of the decoded image.
17133
+ */
17134
+ function resize(target, size) {
17135
+ if (target === void 0 || size === void 0) return;
17136
+ target.width = size.width;
17137
+ target.height = size.height;
17138
+ target.mb = textureMb(size.width, size.height);
17139
+ }
17140
+ /**
17141
+ * Stores a new font under its key and frees the pages of the old one. The record of each page and
17142
+ * of the font follow the new images.
17143
+ *
17144
+ * @param io - The I/O seam of the plugin.
17145
+ * @param record - The record of the loaded bundle.
17146
+ * @param owner - The font file.
17147
+ * @param font - The font that was read again.
17148
+ * @param sizes - The pixel size of every new page.
17149
+ */
17150
+ function storeFont(io, record, owner, font, sizes) {
17151
+ const { file } = owner;
17152
+ const pages = file.pages ?? [];
17153
+ const old = record.fonts.get(file.key);
17154
+ record.fonts.set(file.key, font);
17155
+ for (const page of old?.pages ?? []) io.destroyTexture(page);
17156
+ for (const [at, texture] of font.pages.entries()) resize(pages[at], sizes.get(texture));
17157
+ file.mb = sumMb(pages);
17158
+ }
17159
+ /**
17160
+ * Stores what one changed file brought under its key. An old texture is destroyed only after the
17161
+ * new one is stored, so nothing ever answers a destroyed texture.
17162
+ *
17163
+ * @param io - The I/O seam of the plugin.
17164
+ * @param record - The record of the loaded bundle.
17165
+ * @param fresh - What the file brought.
17166
+ * @param sizes - The pixel size of every new texture.
17167
+ */
17168
+ function store(io, record, fresh, sizes) {
17169
+ const { file } = fresh.owner;
17170
+ if (fresh.kind === "font") {
17171
+ storeFont(io, record, fresh.owner, fresh.font, sizes);
17172
+ return;
17173
+ }
17174
+ if (fresh.kind === "audio") {
17175
+ record.audio.set(file.key, fresh.audio);
16414
17176
  return;
16415
17177
  }
16416
- state.records.get(bundle)?.inflight?.controller.abort();
16417
- unloadBundle(ctx, bundle, "request");
17178
+ const old = record.textures.get(file.key);
17179
+ record.textures.set(file.key, fresh.texture);
17180
+ if (old !== void 0) io.destroyTexture(old);
17181
+ resize(file, sizes.get(fresh.texture));
16418
17182
  }
16419
17183
  /**
16420
- * Tells whether a bundle is there. Headless every bundle of the manifest counts as loaded.
17184
+ * Makes the numbers and the picture follow the files that were stored: the megabytes of the
17185
+ * bundle, the budget, the views of the keys, the frame loop and the event.
16421
17186
  *
16422
17187
  * @param ctx - Domain context of the plugin.
16423
- * @param bundle - Name of the bundle.
16424
- * @returns True when the bundle is loaded.
17188
+ * @param batch - The bundle and its replaced files.
17189
+ * @param record - The record of the bundle.
16425
17190
  */
16426
- function isLoaded(ctx, bundle) {
16427
- const state = ctx.state;
16428
- if (state.io === void 0) return state.manifest.bundles[bundle] !== void 0;
16429
- return state.records.get(bundle)?.status === "loaded";
17191
+ function publish(ctx, batch, record) {
17192
+ const { bundle, entry } = batch;
17193
+ const keys = batch.owners.map((owner) => owner.file.key);
17194
+ entry.mb = sumMb([...entry.files, ...entry.pages ?? []]);
17195
+ enforceBudget(ctx);
17196
+ if (record.status !== "loaded") return;
17197
+ ctx.deps.renderer.sync.textures.invalidate(keys);
17198
+ ctx.deps.time.wake();
17199
+ ctx.emit("assets:replaced", {
17200
+ bundle,
17201
+ keys
17202
+ });
16430
17203
  }
16431
17204
  /**
16432
- * Reports what the loaded bundles cost.
17205
+ * Gives the new files of one bundle up: every file that failed is logged, and every texture the
17206
+ * swap uploaded goes back. Nothing was stored, so the bundle keeps what it had.
16433
17207
  *
16434
- * @param ctx - Domain context of the plugin.
16435
- * @returns The used and allowed megabytes and one entry per loaded bundle, sorted by name.
17208
+ * @param swap - The running swap.
17209
+ * @param failures - The files that could not be read.
17210
+ * @param sizes - Every new texture of the bundle.
16436
17211
  */
16437
- function usage(ctx) {
16438
- const bundles = [];
16439
- for (const [name, record] of ctx.state.records) {
16440
- const entry = ctx.state.manifest.bundles[name];
16441
- if (record.status !== "loaded" || entry === void 0) continue;
16442
- bundles.push({
16443
- name,
16444
- tier: entry.tier,
16445
- mb: entry.mb,
16446
- lastUsed: record.lastUsed
16447
- });
17212
+ function discard(swap, failures, sizes) {
17213
+ for (const failure of failures) swap.ctx.log.error("assets:replace-failed", failure);
17214
+ for (const texture of sizes.keys()) swap.io.destroyTexture(texture);
17215
+ }
17216
+ /**
17217
+ * Replaces the changed files of one bundle. Every file is loaded first and nothing is stored
17218
+ * before all of them are there. A file that fails is logged and the bundle keeps its old
17219
+ * textures, fonts and bytes: the new textures are given back, nothing is emitted and the page
17220
+ * stays. The stamp of the broken file is applied all the same, so it is read again only when it
17221
+ * is saved again. A bundle that was unloaded on the way gives its new textures back too.
17222
+ *
17223
+ * @param swap - The running swap.
17224
+ * @param batch - The bundle and its changed files.
17225
+ * @throws {Error} What the io, the renderer or the budget throws after the files arrived.
17226
+ */
17227
+ async function replaceBundle(swap, batch) {
17228
+ const { ctx, io } = swap;
17229
+ const record = await settledRecord(ctx.state, batch.bundle);
17230
+ if (record === void 0) return;
17231
+ const sizes = /* @__PURE__ */ new Map();
17232
+ const { fresh, failures } = await loadAll(loadingOf(swap, batch.bundle, sizes), batch.owners);
17233
+ if (failures.length > 0 || record.status !== "loaded") {
17234
+ discard(swap, failures, sizes);
17235
+ return;
16448
17236
  }
16449
- bundles.sort((left, right) => left.name.localeCompare(right.name));
16450
- return {
16451
- textureMb: usedMb(ctx.state),
16452
- budgetMb: ctx.config.textureBudgetMb,
16453
- bundles
16454
- };
17237
+ for (const loaded of fresh) store(io, record, loaded, sizes);
17238
+ publish(ctx, batch, record);
16455
17239
  }
16456
17240
  /**
16457
- * Creates the assets API: `app.assets`.
17241
+ * Runs one swap, bundle after bundle: a bundle with a file that failed does not stop the next
17242
+ * one. It never rejects, so the chain of swaps in the state cannot break. A throw after the files
17243
+ * arrived (from a destroy, the views or the budget) leaves a bundle whose stored state is not
17244
+ * known: every file of it is logged as failed, the page reloads and the swap stops there.
16458
17245
  *
16459
- * @param ctx - Kernel context of the assets plugin.
16460
- * @returns The plugin API.
17246
+ * @param swap - The running swap.
17247
+ * @param batches - The changed files, by bundle.
16461
17248
  */
16462
- function createAssetsApi(ctx) {
16463
- const assets = withDeps$6(ctx);
16464
- return {
16465
- load: (bundle) => loadBundle(assets, bundle, void 0, "request"),
16466
- unload: (bundle) => unload(assets, bundle),
16467
- isLoaded: (bundle) => isLoaded(assets, bundle),
16468
- texture: (key) => lookupTexture(assets, key),
16469
- font: (key) => loadedRecord(assets, key)?.fonts.get(key),
16470
- audio: (key) => loadedRecord(assets, key)?.audio.get(key),
16471
- usage: () => usage(assets)
17249
+ async function replaceAll(swap, batches) {
17250
+ for (const batch of batches) try {
17251
+ await replaceBundle(swap, batch);
17252
+ } catch (error) {
17253
+ const reason = reasonOf$1(error);
17254
+ for (const { path } of batch.owners) swap.ctx.log.error("assets:replace-failed", {
17255
+ path,
17256
+ reason
17257
+ });
17258
+ swap.reload();
17259
+ return;
17260
+ }
17261
+ }
17262
+ /**
17263
+ * Creates the handler of the dev hot swap of asset files. It acts only on the stamp of the keys
17264
+ * watch, and only with an io: a headless page has no file. What changed and whether the swap is
17265
+ * refused is decided at once; the replace itself waits for the swap before it, so two saves in a
17266
+ * row never overlap.
17267
+ *
17268
+ * @param ctx - Domain context of the plugin.
17269
+ * @param reload - What a refused or a broken swap calls. The page reload by default; a test
17270
+ * passes a spy.
17271
+ * @returns The handler of `ui:hot-swap`.
17272
+ */
17273
+ function createSwap$1(ctx, reload = reloadPage$1) {
17274
+ let index;
17275
+ return (payload) => {
17276
+ const { state } = ctx;
17277
+ const stamps = stampsOf(payload);
17278
+ const io = state.io;
17279
+ if (stamps === void 0 || io === void 0) return;
17280
+ const changed = changedPaths(state, stamps);
17281
+ if (index?.manifest !== state.manifest) index = indexPaths(state.manifest);
17282
+ const reason = refusalOf$1(index, stamps, changed);
17283
+ if (reason !== void 0) {
17284
+ ctx.log.info("assets:swap-refused", { reason });
17285
+ reload();
17286
+ return;
17287
+ }
17288
+ const batches = batchesOf(index, changed);
17289
+ if (batches.length === 0) return;
17290
+ state.swapping = (state.swapping ?? Promise.resolve()).then(() => replaceAll({
17291
+ ctx,
17292
+ io,
17293
+ reload
17294
+ }, batches));
16472
17295
  };
16473
17296
  }
16474
17297
  //#endregion
16475
17298
  //#region src/plugins/assets/handlers.ts
16476
17299
  /**
16477
- * @file assets plugin — event handlers.
17300
+ * @file assets plugin — event handlers: the rest node that settles the memory, and the dev hot
17301
+ * swap that replaces the asset files a save changed.
16478
17302
  */
16479
17303
  /**
16480
17304
  * Creates the hook handlers. `flow:rest` is the one moment the graph stands still, so it is where
16481
17305
  * the memory is settled and the neighbourhood of the position is queued. The loading itself is a
16482
- * background queue in the state and is never awaited here: an engine hook is synchronous.
17306
+ * background queue in the state and is never awaited here: an engine hook is synchronous. The
17307
+ * global `ui:hot-swap` is hooked with no `depends` on `ui`, as `effects` does: in a dev build it
17308
+ * brings the stamp of the keys watch, and the changed files are replaced behind it.
16483
17309
  *
16484
- * The handler builds its domain context when it first fires, not while this factory runs: the
17310
+ * A handler builds its domain context when it first fires, not while this factory runs: the
16485
17311
  * kernel registers hooks before it builds the plugin APIs, so nothing is resolvable yet.
16486
17312
  *
16487
17313
  * @param ctx - Kernel context of the assets plugin.
16488
- * @returns The one hook of the plugin.
17314
+ * @param reload - What a refused or a broken hot swap calls. The kernel passes nothing, which
17315
+ * means the page reload; a test passes a spy.
17316
+ * @returns The two hooks of the plugin.
16489
17317
  */
16490
- function createHandlers$4(ctx) {
17318
+ function createHandlers$4(ctx, reload) {
16491
17319
  let assets;
16492
- return {
16493
- /**
16494
- * Settles the texture memory and rebuilds the background preload.
16495
- *
16496
- * @param _payload - Where the graph rests. The position itself comes from the state.
16497
- */
16498
- "flow:rest": (_payload) => {
16499
- assets ??= withDeps$6(ctx);
16500
- if (assets.state.io === void 0) return;
16501
- enforceBudget(assets);
16502
- if (assets.deps.flow.state().mode === "fast") return;
16503
- startPreload(assets);
16504
- } };
17320
+ let swap;
17321
+ return {
17322
+ /**
17323
+ * Settles the texture memory and rebuilds the background preload.
17324
+ *
17325
+ * @param _payload - Where the graph rests. The position itself comes from the state.
17326
+ */
17327
+ "flow:rest": (_payload) => {
17328
+ assets ??= withDeps$6(ctx);
17329
+ if (assets.state.io === void 0) return;
17330
+ enforceBudget(assets);
17331
+ if (assets.deps.flow.state().mode === "fast") return;
17332
+ startPreload(assets);
17333
+ },
17334
+ /**
17335
+ * Replaces the asset files a dev save changed, when the saved module is the stamp of the keys
17336
+ * watch. Only dev emits the event; the swap answers at once and works behind it.
17337
+ *
17338
+ * @param payload - The saved file and its new exports.
17339
+ */
17340
+ "ui:hot-swap": (payload) => {
17341
+ if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) {
17342
+ assets ??= withDeps$6(ctx);
17343
+ swap ??= createSwap$1(assets, reload);
17344
+ swap(payload);
17345
+ }
17346
+ }
17347
+ };
16505
17348
  }
16506
17349
  //#endregion
16507
17350
  //#region src/plugins/assets/state.ts
@@ -16550,7 +17393,9 @@ function createAssetsState() {
16550
17393
  current: void 0,
16551
17394
  useCounter: 0,
16552
17395
  warned: emptySet$2(),
16553
- removers: []
17396
+ removers: [],
17397
+ stamps: void 0,
17398
+ swapping: void 0
16554
17399
  };
16555
17400
  }
16556
17401
  /**
@@ -16584,7 +17429,8 @@ const assetsPlugin = /*#__PURE__*/ createPlugin$1("assets", {
16584
17429
  events: (register) => register.map({
16585
17430
  "assets:bundle-loaded": "Every file of a bundle is a texture now",
16586
17431
  "assets:bundle-progress": "One more file of a running bundle load settled",
16587
- "assets:bundle-unloaded": "The textures of a bundle were destroyed"
17432
+ "assets:bundle-unloaded": "The textures of a bundle were destroyed",
17433
+ "assets:replaced": "Files of a loaded bundle were replaced by a dev hot swap"
16588
17434
  }),
16589
17435
  createState: createAssetsState,
16590
17436
  api: createAssetsApi,
@@ -16655,8 +17501,32 @@ async function untilAborted(ctx, bundle, signal) {
16655
17501
  if (load.failed && !signal.aborted) throw load.error;
16656
17502
  }
16657
17503
  /**
17504
+ * Takes the expected scene and clears the record, so one `expect` mounts one scene.
17505
+ *
17506
+ * @param ctx - Domain context of the plugin.
17507
+ * @returns The scene id that was recorded or expected, or `undefined` when there was none.
17508
+ */
17509
+ function takePending(ctx) {
17510
+ const target = ctx.state.pending;
17511
+ ctx.state.pending = void 0;
17512
+ return target;
17513
+ }
17514
+ /**
17515
+ * Tells whether the node is a transit node of a fast walk: the walk passes through it and builds
17516
+ * no scene there.
17517
+ *
17518
+ * @param node - The node being entered.
17519
+ * @param run - The runner's mode and signal.
17520
+ * @returns True for a node without `rest` entered in fast mode.
17521
+ */
17522
+ function isFastTransit(node, run) {
17523
+ return run.mode === "fast" && !node.rest;
17524
+ }
17525
+ /**
16658
17526
  * Reads the scene the node asks for. In fast mode a transit node only records it: the walk passes
16659
- * through many of them and the picture is built once, at the rest point.
17527
+ * through many of them and the picture is built once, at the rest point. In live mode a transit
17528
+ * node without a scene of its own takes the expected one, so a restore at a popup that waits for
17529
+ * its answer builds the scene under it; one with its own scene leaves the record alone.
16660
17530
  *
16661
17531
  * @param ctx - Domain context of the plugin.
16662
17532
  * @param node - The node being entered.
@@ -16665,32 +17535,33 @@ async function untilAborted(ctx, bundle, signal) {
16665
17535
  */
16666
17536
  function targetOf(ctx, node, run) {
16667
17537
  const state = ctx.state;
16668
- if (run.mode === "fast" && !node.rest) {
16669
- if (node.scene !== void 0) state.pending = node.scene;
17538
+ if (isFastTransit(node, run)) {
17539
+ state.pending = node.scene ?? state.pending;
16670
17540
  return;
16671
17541
  }
16672
- if (!node.rest) return node.scene;
17542
+ if (!node.rest) return node.scene ?? takePending(ctx);
16673
17543
  const target = node.scene ?? state.pending;
16674
17544
  state.pending = void 0;
16675
17545
  if (target === void 0 && state.current === void 0) ctx.log.warn("scenes: a rest node was entered with no scene", { path: node.path });
16676
17546
  return target;
16677
17547
  }
16678
17548
  /**
16679
- * Reads the scene an `over` node asks for. An over node never switches a mounted scene; a rest
16680
- * one takes the expected scene while nothing is mounted, so a restore at a popup builds the scene
16681
- * under it. A scene the over node names itself is only warned about.
17549
+ * Reads the scene an `over` node asks for. An over node never switches a mounted scene; it takes
17550
+ * the expected scene while nothing is mounted, so a restore at a popup builds the scene under it,
17551
+ * whether the popup rests or waits for its answer. In fast mode an over transit node takes
17552
+ * nothing and leaves the record alone: a walk builds no scene on the way. A scene the over node
17553
+ * names itself is only warned about.
16682
17554
  *
16683
17555
  * @param ctx - Domain context of the plugin.
16684
17556
  * @param node - The over node being entered.
17557
+ * @param run - The runner's mode and signal.
16685
17558
  * @returns The expected scene id, or `undefined` when nothing has to happen.
16686
17559
  */
16687
- function overTargetOf(ctx, node) {
16688
- const state = ctx.state;
17560
+ function overTargetOf(ctx, node, run) {
16689
17561
  if (node.scene !== void 0) ctx.log.warn("scenes: an over node cannot name a scene", { path: node.path });
16690
- if (!node.rest || state.current !== void 0) return void 0;
16691
- const target = state.pending;
16692
- state.pending = void 0;
16693
- return target;
17562
+ if (isFastTransit(node, run)) return void 0;
17563
+ if (ctx.state.current !== void 0) return void 0;
17564
+ return takePending(ctx);
16694
17565
  }
16695
17566
  /**
16696
17567
  * Builds the scene in one synchronous block: the layers, then the projections of the old scene
@@ -16736,7 +17607,7 @@ function apply(ctx, scene) {
16736
17607
  * ```
16737
17608
  */
16738
17609
  async function enterScene(ctx, node, run) {
16739
- const target = node.over ? overTargetOf(ctx, node) : targetOf(ctx, node, run);
17610
+ const target = node.over ? overTargetOf(ctx, node, run) : targetOf(ctx, node, run);
16740
17611
  if (target === void 0 || target === ctx.state.current) return;
16741
17612
  const scene = ctx.state.scenes.get(target);
16742
17613
  if (scene === void 0) throw new Error(`[game] Scene "${target}" is not registered.\n List it in the scenes of a feature.`);
@@ -17868,9 +18739,9 @@ async function stopAudio(state) {
17868
18739
  //#endregion
17869
18740
  //#region src/plugins/audio/handlers.ts
17870
18741
  /**
17871
- * @file audio plugin — the four hooks. They are the whole of what drives this plugin: the player
17872
- * sets the volumes, the graph sets the music, the OS sets the pause and the budget sets what is
17873
- * still decoded.
18742
+ * @file audio plugin — the five hooks. They are the whole of what drives this plugin: the player
18743
+ * sets the volumes, the graph sets the music, the OS sets the pause, and the budget and a dev hot
18744
+ * swap set what is still decoded.
17874
18745
  */
17875
18746
  /**
17876
18747
  * Applies the volumes of the committed player. A `volumes` that throws — a save without the
@@ -17921,12 +18792,25 @@ function resumeAfterPause(ctx) {
17921
18792
  });
17922
18793
  }
17923
18794
  /**
17924
- * Creates the four hook handlers. Each one builds the domain context when it first fires, not
18795
+ * Forgets the decoded buffer and the one warning of every key named. A key that is no sound is in
18796
+ * neither, so it costs nothing. A source that is playing holds its own buffer and plays on.
18797
+ *
18798
+ * @param state - The plugin state.
18799
+ * @param keys - Asset keys whose bytes left or changed.
18800
+ */
18801
+ function evict(state, keys) {
18802
+ for (const key of keys) {
18803
+ state.decoded.delete(key);
18804
+ state.warned.delete(key);
18805
+ }
18806
+ }
18807
+ /**
18808
+ * Creates the five hook handlers. Each one builds the domain context when it first fires, not
17925
18809
  * while this factory runs: the kernel registers hooks before it builds the plugin APIs, so
17926
18810
  * nothing is resolvable yet.
17927
18811
  *
17928
18812
  * @param ctx - Kernel context of the audio plugin.
17929
- * @returns The four hooks of the plugin.
18813
+ * @returns The five hooks of the plugin.
17930
18814
  */
17931
18815
  function createHandlers$3(ctx) {
17932
18816
  let audio;
@@ -17986,11 +18870,17 @@ function createHandlers$3(ctx) {
17986
18870
  * @param payload - The bundle that left and the keys it carried.
17987
18871
  */
17988
18872
  "assets:bundle-unloaded": (payload) => {
17989
- const state = domain().state;
17990
- for (const key of payload.keys) {
17991
- state.decoded.delete(key);
17992
- state.warned.delete(key);
17993
- }
18873
+ evict(domain().state, payload.keys);
18874
+ },
18875
+ /**
18876
+ * Drops the decoded sounds a dev hot swap replaced: `assets.audio(key)` already answers the
18877
+ * new bytes, so the next play of a key decodes them. Nothing that is playing is stopped: a
18878
+ * source keeps the buffer it started with and a streamed track keeps its Blob.
18879
+ *
18880
+ * @param payload - The bundle whose files were replaced and the keys with new bytes.
18881
+ */
18882
+ "assets:replaced": (payload) => {
18883
+ evict(domain().state, payload.keys);
17994
18884
  }
17995
18885
  };
17996
18886
  }
@@ -20694,11 +21584,25 @@ function stopEffects(state) {
20694
21584
  //#endregion
20695
21585
  //#region src/plugins/effects/handlers.ts
20696
21586
  /**
20697
- * @file effects plugin — the two hooks: a bundle that left takes the effects drawn with its
20698
- * textures along, particles and `Displacement` maps alike, whatever their space; a dev hot swap
20699
- * replaces the registered emitters its module exports.
21587
+ * @file effects plugin — the three hooks: a bundle that left takes the effects drawn with its
21588
+ * textures along, particles and `Displacement` maps alike, whatever their space; so do the files
21589
+ * a dev hot swap replaced, whose effects are built again from the new textures; a dev hot swap of
21590
+ * a module replaces the registered emitters it exports.
20700
21591
  */
20701
21592
  /**
21593
+ * Retires everything drawn with the textures of `keys`: the particle instances and their bakes,
21594
+ * the `Displacement` filters, and the one-shot warning of each key, so it can warn again. The
21595
+ * next frame builds each effect again from the texture its key answers then.
21596
+ *
21597
+ * @param effects - Domain context of the effects plugin.
21598
+ * @param keys - The asset keys whose textures are gone.
21599
+ */
21600
+ function retireKeys(effects, keys) {
21601
+ retireParticleKeys(effects, keys);
21602
+ retireFilterMaps(effects, keys);
21603
+ for (const key of keys) effects.state.warned.delete(`texture:${key}`);
21604
+ }
21605
+ /**
20702
21606
  * Replaces every registered emitter the saved module exports and drops its bake, so the next
20703
21607
  * instance bakes the new config; a live instance keeps its own bake. An id no feature registered
20704
21608
  * warns `effects:hot-unknown-emitter`: a new emitter needs a feature, so a reload.
@@ -20736,9 +21640,19 @@ function createHandlers$2(ctx) {
20736
21640
  */
20737
21641
  "assets:bundle-unloaded": (payload) => {
20738
21642
  effects ??= withDeps$3(ctx);
20739
- retireParticleKeys(effects, payload.keys);
20740
- retireFilterMaps(effects, payload.keys);
20741
- for (const key of payload.keys) effects.state.warned.delete(`texture:${key}`);
21643
+ retireKeys(effects, payload.keys);
21644
+ },
21645
+ /**
21646
+ * Retires the particle instances and the `Displacement` filters drawn with the textures a dev
21647
+ * hot swap replaced and drops their bakes. The old textures are destroyed and the new ones
21648
+ * stand under the same keys, so the next frame builds each effect again from the new one.
21649
+ * Only dev emits it.
21650
+ *
21651
+ * @param payload - The bundle and the keys with new bytes.
21652
+ */
21653
+ "assets:replaced": (payload) => {
21654
+ effects ??= withDeps$3(ctx);
21655
+ retireKeys(effects, payload.keys);
20742
21656
  },
20743
21657
  /**
20744
21658
  * Replaces the registered emitters a dev hot swap brought. Only dev emits it.
@@ -24998,6 +25912,8 @@ function parseTags(source, warn) {
24998
25912
  const CACHE_SEPARATOR = String.fromCodePoint(0);
24999
25913
  /** How many laid-out blocks are kept. The oldest goes when a new one does not fit. */
25000
25914
  const CACHE_LIMIT = 1024;
25915
+ /** How every inline icon tag starts. A resolved text without it holds no icon. */
25916
+ const ICON_OPEN = "<icon=";
25001
25917
  /** The name the system is registered under; it shows up in a world error message. */
25002
25918
  const TEXT_SYSTEM_NAME = "text.resolve";
25003
25919
  /**
@@ -25302,9 +26218,36 @@ function rememberBound(ctx, entity, text, unit) {
25302
26218
  seen.locale = ctx.deps.i18n.locale();
25303
26219
  }
25304
26220
  /**
26221
+ * Writes the string a bound label already shows once more, and measures it again. A mark on a
26222
+ * label whose number stands still means the picture under it moved: its font arrived or was
26223
+ * replaced, or its style was swapped. The display adapter rebuilds only what `changed(Text)`
26224
+ * names, so the same string is written.
26225
+ *
26226
+ * @param ctx - Domain context of the text plugin.
26227
+ * @param entity - The entity.
26228
+ * @param text - Its component value.
26229
+ */
26230
+ function showAgain(ctx, entity, text) {
26231
+ ctx.deps.world.ecs.set(entity, Text, { resolved: text.resolved });
26232
+ ctx.state.measured.set(entity, sizeOf(layoutFor(ctx, text.resolved, text.style)));
26233
+ }
26234
+ /**
26235
+ * Reports a bound label whose style is not a digits style, once per style. An unknown style name
26236
+ * is not reported here: `styleOf` reports it by its name.
26237
+ *
26238
+ * @param ctx - Domain context of the text plugin.
26239
+ * @param styleName - The style name of the bound label.
26240
+ */
26241
+ function warnWithoutDigits(ctx, styleName) {
26242
+ const style = ctx.state.styles.get(styleName);
26243
+ if (style === void 0 || style.digits) return;
26244
+ warnOnce(ctx, `bind-style:${styleName}`, "text: bind on a style without digits", { style: styleName });
26245
+ }
26246
+ /**
25305
26247
  * One frame of a bound label: the value read every frame, the string built and written only
25306
26248
  * when its unit moved. A label that stands still costs a read and a few compares; no tag is
25307
- * parsed, and the locale is read only for a `"duration"`.
26249
+ * parsed, and the locale is read only for a `"duration"`. A marked label that stands still is
26250
+ * written once with the string it shows.
25308
26251
  *
25309
26252
  * @param ctx - Domain context of the text plugin.
25310
26253
  * @param entity - The entity.
@@ -25313,13 +26256,15 @@ function rememberBound(ctx, entity, text, unit) {
25313
26256
  * @param frame - What this run of the system shares.
25314
26257
  */
25315
26258
  function stepBound(ctx, entity, text, bind, frame) {
25316
- const style = ctx.state.styles.get(text.style);
25317
- if (style !== void 0 && !style.digits) warnOnce(ctx, `bind-style:${text.style}`, "text: bind on a style without digits", { style: text.style });
26259
+ warnWithoutDigits(ctx, text.style);
25318
26260
  const value = boundValue(ctx, entity, bind, frame);
25319
26261
  const format = value === void 0 ? "int" : formatOf(ctx, entity, bind, value);
25320
26262
  const unit = value === void 0 ? void 0 : unitOf(value, format);
25321
- ctx.state.dirty.delete(entity);
25322
- if (isShown(ctx, entity, text, unit, format)) return;
26263
+ const marked = ctx.state.dirty.delete(entity);
26264
+ if (isShown(ctx, entity, text, unit, format)) {
26265
+ if (marked) showAgain(ctx, entity, text);
26266
+ return;
26267
+ }
25323
26268
  showBound(ctx, entity, text, value === void 0 ? "" : formatBound(value, format, (ms) => ctx.deps.i18n.duration(ms)), value);
25324
26269
  rememberBound(ctx, entity, text, unit);
25325
26270
  }
@@ -25391,17 +26336,43 @@ function createTextSystem(ctx) {
25391
26336
  });
25392
26337
  }
25393
26338
  /**
25394
- * Marks labels for the next layout phase: every one of them, or only the ones whose content is a
25395
- * message, which is what a locale change touches.
26339
+ * Marks labels for the next layout phase: every one of them, or only the ones `keep` names. A
26340
+ * locale change names the labels whose content is a message; a dev hot swap names the labels
26341
+ * drawn with a replaced font or icon.
25396
26342
  *
25397
26343
  * @param ctx - Domain context of the text plugin.
25398
- * @param onlyMessages - True to leave the plain strings alone.
26344
+ * @param keep - Tells whether a label is marked. Left out, every label is.
26345
+ * @returns How many labels were marked.
25399
26346
  */
25400
- function markDirty(ctx, onlyMessages = false) {
26347
+ function markDirty(ctx, keep = () => true) {
26348
+ let marked = 0;
25401
26349
  for (const [entity, text] of ctx.deps.world.ecs.query(Text)) {
25402
- if (onlyMessages && typeof text.content === "string") continue;
26350
+ if (!keep(text)) continue;
25403
26351
  ctx.state.dirty.add(entity);
26352
+ marked += 1;
25404
26353
  }
26354
+ return marked;
26355
+ }
26356
+ /**
26357
+ * Tells whether a label is drawn with one of the asset keys: a font its style names, or an inline
26358
+ * icon of its resolved text. The text is read through the tag grammar, the way it is drawn, so an
26359
+ * escaped `\<icon=…>` is text and not an icon. A text that holds no `<icon=` at all is not read:
26360
+ * most labels have no icon, and this runs for every label of a swap.
26361
+ *
26362
+ * @param ctx - Domain context of the text plugin.
26363
+ * @param text - The component value of the label.
26364
+ * @param keys - The asset keys to look for.
26365
+ * @returns True when a glyph font or an icon of the label is one of `keys`.
26366
+ */
26367
+ function drawsWith(ctx, text, keys) {
26368
+ const style = styleOf$1(ctx, text.style);
26369
+ if ([
26370
+ style.font,
26371
+ style.bold,
26372
+ style.italic
26373
+ ].some((font) => font !== void 0 && keys.includes(font))) return true;
26374
+ if (!text.resolved.includes(ICON_OPEN)) return false;
26375
+ return parseTags(text.resolved, warnFor(ctx)).some((run) => run.kind === "icon" && keys.includes(run.key));
25405
26376
  }
25406
26377
  /**
25407
26378
  * Forgets an entity whose `Text` left. Works on the state alone, because it is also what the
@@ -25430,6 +26401,40 @@ const SYNTHETIC_ITALIC = -.2;
25430
26401
  /** What a shadow copy is painted with, so its tint alone gives it the shadow colour. */
25431
26402
  const WHITE = 16777215;
25432
26403
  /**
26404
+ * Reads one font when `assets` can answer for it: the advance table first, then the install in the
26405
+ * renderer. A font that has both already is left alone.
26406
+ *
26407
+ * @param ctx - Domain context of the text plugin.
26408
+ * @param key - The asset key of the font.
26409
+ * @returns True when its advance table arrived, so what was measured without it is stale.
26410
+ * @throws {Error} When the `.fnt` is in neither BMFont format.
26411
+ */
26412
+ function installFont(ctx, key) {
26413
+ const renderer = ctx.deps.renderer;
26414
+ const needsTable = !ctx.state.tables.has(key);
26415
+ const needsInstall = renderer.host.ready() && !ctx.state.installed.has(key);
26416
+ if (!needsTable && !needsInstall) return false;
26417
+ const font = ctx.deps.assets.font(key);
26418
+ if (font === void 0) return false;
26419
+ if (needsTable) ctx.state.tables.set(key, parseAdvances(font.fnt, key));
26420
+ if (needsInstall) {
26421
+ renderer.sync.fonts.install(key, font.fnt, font.texture);
26422
+ ctx.state.installed.add(key);
26423
+ }
26424
+ return needsTable;
26425
+ }
26426
+ /**
26427
+ * Forgets one font: its advance table and the mark that the renderer holds it, so the next
26428
+ * `installFont` reads both again.
26429
+ *
26430
+ * @param ctx - Domain context of the text plugin.
26431
+ * @param key - The asset key of the font.
26432
+ */
26433
+ function dropFont(ctx, key) {
26434
+ ctx.state.tables.delete(key);
26435
+ ctx.state.installed.delete(key);
26436
+ }
26437
+ /**
25433
26438
  * Reads the fonts of `fontKeys` that `assets` can answer for: the advance table first, then the
25434
26439
  * install in the renderer. Runs in `onStart` and on every bundle that lands.
25435
26440
  *
@@ -25437,24 +26442,8 @@ const WHITE = 16777215;
25437
26442
  * @throws {Error} When a `.fnt` is in neither BMFont format.
25438
26443
  */
25439
26444
  function installFonts(ctx) {
25440
- const renderer = ctx.deps.renderer;
25441
- const ready = renderer.host.ready();
25442
26445
  let arrived = false;
25443
- for (const key of ctx.state.fontKeys) {
25444
- const needsTable = !ctx.state.tables.has(key);
25445
- const needsInstall = ready && !ctx.state.installed.has(key);
25446
- if (!needsTable && !needsInstall) continue;
25447
- const font = ctx.deps.assets.font(key);
25448
- if (font === void 0) continue;
25449
- if (needsTable) {
25450
- ctx.state.tables.set(key, parseAdvances(font.fnt, key));
25451
- arrived = true;
25452
- }
25453
- if (needsInstall) {
25454
- renderer.sync.fonts.install(key, font.fnt, font.texture);
25455
- ctx.state.installed.add(key);
25456
- }
25457
- }
26446
+ for (const key of ctx.state.fontKeys) if (installFont(ctx, key)) arrived = true;
25458
26447
  if (arrived) refresh(ctx);
25459
26448
  }
25460
26449
  /**
@@ -25467,8 +26456,7 @@ function releaseFonts(ctx, keys) {
25467
26456
  let left = false;
25468
26457
  for (const key of keys) {
25469
26458
  if (!ctx.state.fontKeys.has(key)) continue;
25470
- ctx.state.tables.delete(key);
25471
- ctx.state.installed.delete(key);
26459
+ dropFont(ctx, key);
25472
26460
  left = true;
25473
26461
  }
25474
26462
  if (left) refresh(ctx);
@@ -25483,6 +26471,73 @@ function refresh(ctx) {
25483
26471
  markDirty(ctx);
25484
26472
  }
25485
26473
  /**
26474
+ * Reloads the page. This is how a font replace that failed ends, so the game is not left drawing
26475
+ * with a font whose table was released. Headless and in a test there is no page, so nothing
26476
+ * happens.
26477
+ */
26478
+ function reloadPage() {
26479
+ globalThis.location?.reload();
26480
+ }
26481
+ /**
26482
+ * Says why a font could not be installed again.
26483
+ *
26484
+ * @param error - What the install threw.
26485
+ * @returns The message of an error, or the value as a string.
26486
+ * @example
26487
+ * ```ts
26488
+ * reasonOf(new Error("The page is gone.")); // "The page is gone."
26489
+ * ```
26490
+ */
26491
+ function reasonOf(error) {
26492
+ return error instanceof Error ? error.message : String(error);
26493
+ }
26494
+ /**
26495
+ * Installs every replaced font again, one by one: its table and its mark go, then the new file
26496
+ * and the new page are read. No other font is touched and no label is marked here. The stamp of
26497
+ * the keys watch is written after a failed scan too, so a `.fnt` the scanner refused still gets
26498
+ * here, and reading it throws after its table was released. That font is logged and the page
26499
+ * reloads.
26500
+ *
26501
+ * @param ctx - Domain context of the text plugin.
26502
+ * @param fonts - The font keys among what a dev hot swap replaced.
26503
+ * @param reload - What a font that cannot be installed calls.
26504
+ * @returns False when a font failed, so the page is on its way out.
26505
+ */
26506
+ function reinstallFonts(ctx, fonts, reload) {
26507
+ for (const key of fonts) try {
26508
+ dropFont(ctx, key);
26509
+ installFont(ctx, key);
26510
+ } catch (error) {
26511
+ ctx.log.error("text:font-replace-failed", {
26512
+ key,
26513
+ reason: reasonOf(error)
26514
+ });
26515
+ reload();
26516
+ return false;
26517
+ }
26518
+ return true;
26519
+ }
26520
+ /**
26521
+ * Applies what a dev hot swap replaced. `assets` answers the new font and the new texture of
26522
+ * every key already, and the old pages and textures are destroyed. A font among the keys is
26523
+ * installed again here, so the font registry of the renderer holds the new page at once. Only
26524
+ * the labels drawn with a replaced font or inline icon are marked, and the generation moves: the
26525
+ * next frame writes each of them, and the adapter builds it again from the new textures even when
26526
+ * its string stands still. A label of another font is not written and keeps its objects.
26527
+ *
26528
+ * @param ctx - Domain context of the text plugin.
26529
+ * @param keys - The asset keys with new bytes.
26530
+ * @param reload - What a font that cannot be installed calls. The page reload by default; a test
26531
+ * passes a spy.
26532
+ */
26533
+ function applyReplaced(ctx, keys, reload = reloadPage) {
26534
+ const fonts = keys.filter((key) => ctx.state.fontKeys.has(key));
26535
+ if (fonts.length > 0) ctx.state.cache.clear();
26536
+ if (!reinstallFonts(ctx, fonts, reload)) return;
26537
+ if (markDirty(ctx, (text) => drawsWith(ctx, text, keys)) === 0) return;
26538
+ ctx.state.generation += 1;
26539
+ }
26540
+ /**
25486
26541
  * Where a line starts inside the widest one.
25487
26542
  *
25488
26543
  * @param style - The style of the label.
@@ -25698,7 +26753,7 @@ function buildRun(ctx, pixi, run, style, at) {
25698
26753
  return objects;
25699
26754
  }
25700
26755
  /**
25701
- * The style and the laid-out block of one component value.
26756
+ * The style and the laid-out block of one component value, under the generation of this moment.
25702
26757
  *
25703
26758
  * @param ctx - Domain context of the text plugin.
25704
26759
  * @param value - The component value.
@@ -25707,7 +26762,8 @@ function buildRun(ctx, pixi, run, style, at) {
25707
26762
  function labelOf(ctx, value) {
25708
26763
  return {
25709
26764
  style: styleOf$1(ctx, value.style),
25710
- layout: layoutFor(ctx, value.resolved, value.style)
26765
+ layout: layoutFor(ctx, value.resolved, value.style),
26766
+ generation: ctx.state.generation
25711
26767
  };
25712
26768
  }
25713
26769
  /**
@@ -25821,10 +26877,27 @@ function samePoint(first, second) {
25821
26877
  return first.x === second.x && first.y === second.y;
25822
26878
  }
25823
26879
  /**
26880
+ * Tells whether a container already shows a value: the string, the style name and the anchor it
26881
+ * was last updated with, drawn with the style object that is current and under the generation
26882
+ * that is current. A replaced style is a new object under the same name. A font or an icon a dev
26883
+ * hot swap replaced moves the generation, because the objects hold textures that are destroyed.
26884
+ *
26885
+ * @param ctx - Domain context of the text plugin.
26886
+ * @param container - The container of this label.
26887
+ * @param previous - The value the container was drawn from.
26888
+ * @param next - The value to draw now.
26889
+ * @returns True when nothing has to be drawn.
26890
+ */
26891
+ function isCurrent(ctx, container, previous, next) {
26892
+ const drawn = ctx.state.drawn.get(container);
26893
+ return previous.resolved === next.resolved && previous.style === next.style && samePoint(previous.anchor, next.anchor) && drawn?.style === styleOf$1(ctx, next.style) && drawn.generation === ctx.state.generation;
26894
+ }
26895
+ /**
25824
26896
  * Draws a changed value into a container that holds a label already. A ticking counter keeps its
25825
- * objects: with the same style object, the same anchor and the same lines and runs, the new text
25826
- * and spots are written into them. Anything else frees the children and fills the container
25827
- * again.
26897
+ * objects: with the same style object, the same generation, the same anchor and the same lines
26898
+ * and runs, the new text and spots are written into them. Anything else frees the children and
26899
+ * fills the container again. A label of an older generation is always filled again: writing the
26900
+ * string it shows already into a `BitmapText` rebuilds no glyph.
25828
26901
  *
25829
26902
  * @param ctx - Domain context of the text plugin.
25830
26903
  * @param pixi - The Pixi module the renderer loaded.
@@ -25835,7 +26908,7 @@ function samePoint(first, second) {
25835
26908
  function redraw(ctx, pixi, container, previous, next) {
25836
26909
  const drawn = ctx.state.drawn.get(container);
25837
26910
  const label = labelOf(ctx, next);
25838
- if (drawn?.style === label.style && samePoint(previous.anchor, next.anchor) && sameShape(drawn.layout, label.layout)) {
26911
+ if (drawn?.style === label.style && drawn.generation === label.generation && samePoint(previous.anchor, next.anchor) && sameShape(drawn.layout, label.layout)) {
25839
26912
  retext(ctx, pixi, container, next, label);
25840
26913
  return;
25841
26914
  }
@@ -25869,7 +26942,7 @@ function createTextAdapter(ctx) {
25869
26942
  if (pixi === void 0 || object === void 0) return;
25870
26943
  const container = asContainer(object);
25871
26944
  container.alpha = next.alpha;
25872
- if (previous.resolved === next.resolved && previous.style === next.style && samePoint(previous.anchor, next.anchor) && ctx.state.drawn.get(container)?.style === styleOf$1(ctx, next.style)) return;
26945
+ if (isCurrent(ctx, container, previous, next)) return;
25873
26946
  redraw(ctx, pixi, container, previous, next);
25874
26947
  },
25875
26948
  destroy: (object) => {
@@ -26080,19 +27153,22 @@ function createTextApi(ctx) {
26080
27153
  //#endregion
26081
27154
  //#region src/plugins/text/handlers.ts
26082
27155
  /**
26083
- * @file text plugin — the three hooks. None of them does work in place: a bundle brings fonts, a
26084
- * bundle takes them away, a locale change marks the messages. The layout phase of the next frame
26085
- * is where the strings are built again.
27156
+ * @file text plugin — the four hooks. None of them draws in place: a bundle brings fonts, a
27157
+ * bundle takes them away, a locale change marks the messages, a dev hot swap marks the labels of
27158
+ * a replaced font or icon. The layout phase of the next frame is where the strings are built
27159
+ * again.
26086
27160
  */
26087
27161
  /**
26088
- * Creates the three hook handlers. Each one builds the domain context when it first fires, not
27162
+ * Creates the four hook handlers. Each one builds the domain context when it first fires, not
26089
27163
  * while this factory runs: the kernel registers hooks before it builds the plugin APIs, so
26090
27164
  * nothing is resolvable yet.
26091
27165
  *
26092
27166
  * @param ctx - Kernel context of the text plugin.
26093
- * @returns The three hooks of the plugin.
27167
+ * @param reload - What a font that a dev hot swap replaced and that cannot be installed calls.
27168
+ * The kernel passes nothing, which means the page reload; a test passes a spy.
27169
+ * @returns The four hooks of the plugin.
26094
27170
  */
26095
- function createHandlers(ctx) {
27171
+ function createHandlers(ctx, reload) {
26096
27172
  let text;
26097
27173
  /**
26098
27174
  * The domain context, built on the first hook that fires.
@@ -26122,6 +27198,15 @@ function createHandlers(ctx) {
26122
27198
  releaseFonts(domain(), payload.keys);
26123
27199
  },
26124
27200
  /**
27201
+ * Installs the fonts a dev hot swap replaced again and marks the labels drawn with a replaced
27202
+ * font or inline icon. The next frame builds each of them again from the new textures.
27203
+ *
27204
+ * @param payload - The bundle and the asset keys that have new bytes.
27205
+ */
27206
+ "assets:replaced": (payload) => {
27207
+ applyReplaced(domain(), payload.keys, reload);
27208
+ },
27209
+ /**
26125
27210
  * Marks every label whose content is a message. The next layout phase resolves them again,
26126
27211
  * and `ui` solves once over the new sizes; no projection is re-run.
26127
27212
  *
@@ -26129,7 +27214,7 @@ function createHandlers(ctx) {
26129
27214
  */
26130
27215
  "i18n:locale-changed": (_payload) => {
26131
27216
  const tctx = domain();
26132
- markDirty(tctx, true);
27217
+ markDirty(tctx, (label) => typeof label.content !== "string");
26133
27218
  tctx.state.cache.clear();
26134
27219
  tctx.deps.time.wake();
26135
27220
  }
@@ -26180,6 +27265,7 @@ function createTextState$1() {
26180
27265
  cache: emptyMap$2(),
26181
27266
  drawn: emptyWeakMap(),
26182
27267
  dirty: emptySet$1(),
27268
+ generation: 0,
26183
27269
  warned: emptySet$1(),
26184
27270
  removers: []
26185
27271
  };
@@ -31844,6 +32930,8 @@ function createLayoutApi(ctx) {
31844
32930
  const HOT_GLOBAL = "__moku_hot";
31845
32931
  /** A strings module `assets:keys` generates; the group is its locale. */
31846
32932
  const STRINGS_FILE = /\/generated\/strings\.([\w-]+)\.ts$/;
32933
+ /** The stamp module the keys watch of `moku-game dev` writes: it names asset files, no view. */
32934
+ const ASSETS_STAMP = "/.moku/assets-stamp.ts";
31847
32935
  /**
31848
32936
  * The `kind` of every value a game registers by value at start: ECS component and tag types
31849
32937
  * (filters too), nodes, slots, flows, asset bundles and plugin owners. Any other `kind` marks
@@ -32002,6 +33090,41 @@ function localeOf(file) {
32002
33090
  return STRINGS_FILE.exec(file.replaceAll("\\", "/"))?.[1];
32003
33091
  }
32004
33092
  /**
33093
+ * Tells whether the saved module is the stamp of the keys watch, read from its path with `/`
33094
+ * separators.
33095
+ *
33096
+ * @param file - The path of the saved module.
33097
+ * @returns True for `.moku/assets-stamp.ts` of a game.
33098
+ * @example
33099
+ * ```ts
33100
+ * isAssetsStamp("/game/.moku/assets-stamp.ts"); // true
33101
+ * ```
33102
+ */
33103
+ function isAssetsStamp(file) {
33104
+ return file.replaceAll("\\", "/").endsWith(ASSETS_STAMP);
33105
+ }
33106
+ /**
33107
+ * Starts the swaps of one module: its exports for the event, and nothing swapped yet.
33108
+ *
33109
+ * @param module - The exports of the saved module.
33110
+ * @returns The swaps, every list empty.
33111
+ * @example
33112
+ * ```ts
33113
+ * noSwaps({ GAP: 8 }).components; // []
33114
+ * ```
33115
+ */
33116
+ function noSwaps(module) {
33117
+ return {
33118
+ module,
33119
+ components: [],
33120
+ projections: [],
33121
+ animations: [],
33122
+ emitters: [],
33123
+ strings: [],
33124
+ textStyles: []
33125
+ };
33126
+ }
33127
+ /**
32005
33128
  * Files one export under the kind it is, in the order component, projection, animation, emitter,
32006
33129
  * strings, text styles. The `default` export of a strings file counts as strings.
32007
33130
  *
@@ -32041,15 +33164,7 @@ function sortExports(next, file) {
32041
33164
  const exports = Object.entries(next);
32042
33165
  if (exports.length === 0) return { reason: "no exports" };
32043
33166
  const locale = localeOf(file);
32044
- const swaps = {
32045
- module: Object.fromEntries(exports),
32046
- components: [],
32047
- projections: [],
32048
- animations: [],
32049
- emitters: [],
32050
- strings: [],
32051
- textStyles: []
32052
- };
33167
+ const swaps = noSwaps(Object.fromEntries(exports));
32053
33168
  for (const entry of exports) {
32054
33169
  const reason = fileExport(swaps, entry, locale);
32055
33170
  if (reason !== void 0) return { reason };
@@ -32137,10 +33252,28 @@ function summaryOf(file, swaps) {
32137
33252
  };
32138
33253
  }
32139
33254
  /**
33255
+ * Hands the stamp of the keys watch on: the event with the exports of the module, and the log
33256
+ * line the editor listens to, every list empty. Nothing is sorted, replaced or repainted: the
33257
+ * stamp names asset files, and `assets` hooks the event to replace them. A listener cannot refuse
33258
+ * by throwing, the bus catches what a hook throws, so `assets` reloads the page itself.
33259
+ *
33260
+ * @param ctx - Domain context of the ui plugin.
33261
+ * @param file - The path of the stamp module.
33262
+ * @param next - Its new module namespace.
33263
+ */
33264
+ function forwardStamp(ctx, file, next) {
33265
+ const swaps = noSwaps(Object.fromEntries(Object.entries(next)));
33266
+ ctx.emit("ui:hot-swap", {
33267
+ file,
33268
+ module: swaps.module
33269
+ });
33270
+ ctx.log.info("ui:hot-swap", summaryOf(file, swaps));
33271
+ }
33272
+ /**
32140
33273
  * Builds the handler of one app: sort the exports, run the replaces that may throw, write the
32141
33274
  * components and the text styles, and repaint every view. The repaint runs for every module that
32142
33275
  * is not refused, so a module of styles only, whose bindings Bun already patched, shows on the
32143
- * next frame too.
33276
+ * next frame too. The stamp of the keys watch is the one module that is only forwarded.
32144
33277
  *
32145
33278
  * @param ctx - Domain context of the ui plugin.
32146
33279
  * @param jsx - The jsx module, which owns the component registry and the roots.
@@ -32148,6 +33281,10 @@ function summaryOf(file, swaps) {
32148
33281
  */
32149
33282
  function createSwap(ctx, jsx) {
32150
33283
  return (next, file) => {
33284
+ if (isAssetsStamp(file) && hasMembers(next)) {
33285
+ forwardStamp(ctx, file, next);
33286
+ return;
33287
+ }
32151
33288
  const sorted = sortExports(next, file);
32152
33289
  if ("reason" in sorted) refuse(ctx, file, sorted.reason);
32153
33290
  replaceRefusable(ctx, file, sorted);