@moku-labs/game 0.14.1 → 0.14.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.
@@ -1327,7 +1327,7 @@ function createFeaturesApi(ctx) {
1327
1327
  * asError("no audio device").message; // "no audio device"
1328
1328
  * ```
1329
1329
  */
1330
- function asError$4(error) {
1330
+ function asError$5(error) {
1331
1331
  return error instanceof Error ? error : new Error(String(error));
1332
1332
  }
1333
1333
  /**
@@ -1339,7 +1339,7 @@ function asError$4(error) {
1339
1339
  * @param error - What the handler threw or rejected with.
1340
1340
  */
1341
1341
  function reportFailure(log, kind, error) {
1342
- log.error("flow:fx-handler-failed", { kind }, asError$4(error));
1342
+ log.error("flow:fx-handler-failed", { kind }, asError$5(error));
1343
1343
  }
1344
1344
  /**
1345
1345
  * Hands one released hint to every `onHint` listener, in registration order. A listener lives
@@ -1355,7 +1355,7 @@ function notifyHint(state, log, item) {
1355
1355
  for (const listener of listeners) try {
1356
1356
  listener(item);
1357
1357
  } catch (error) {
1358
- log.error("flow:hint-listener-failed", { kind: item.kind }, asError$4(error));
1358
+ log.error("flow:hint-listener-failed", { kind: item.kind }, asError$5(error));
1359
1359
  }
1360
1360
  }
1361
1361
  /**
@@ -1708,9 +1708,10 @@ function deliver(state, answer) {
1708
1708
  resolve?.(answer);
1709
1709
  }
1710
1710
  /**
1711
- * Keeps an answer that arrived at a closed gate for one frame. Nothing is held in fast mode or
1712
- * while time is not running: there is no frame that could re-offer it. The first answer wins, a
1713
- * second one in the same frame is dropped.
1711
+ * Keeps an answer that arrived at a closed gate, with the frame and the game time of the refusal.
1712
+ * Nothing is held in fast mode or while time is not running: there is no frame that could age it.
1713
+ * Without a hold time (`holdMs` 0 or less) the first answer wins and a second one is dropped. With
1714
+ * one, every refused answer replaces the held one: the last press is the one the player means.
1714
1715
  *
1715
1716
  * @param ctx - Domain context of the flow plugin.
1716
1717
  * @param answer - The answer the closed gate refused.
@@ -1718,15 +1719,18 @@ function deliver(state, answer) {
1718
1719
  function hold(ctx, answer) {
1719
1720
  if (ctx.state.fx.mode === "fast") return;
1720
1721
  if (!ctx.deps.time.isRunning()) return;
1721
- if (ctx.state.gate.held !== void 0) return;
1722
+ if (ctx.config.holdMs <= 0 && ctx.state.gate.held !== void 0) return;
1723
+ const { frame, elapsed } = ctx.deps.time.snapshot();
1722
1724
  ctx.state.gate.held = {
1723
1725
  answer,
1724
- frame: ctx.deps.time.snapshot().frame
1726
+ frame,
1727
+ at: elapsed
1725
1728
  };
1726
1729
  }
1727
1730
  /**
1728
1731
  * Offers the held answer to the gate that has just opened. It is offered once: an answer the new
1729
- * gate does not accept is dropped, not kept for the next one.
1732
+ * gate does not accept is dropped, not kept for the next one. A gate that opens in fast mode gets
1733
+ * no offer: a fast walk answers its own gates, and a press of the live play before it is dropped.
1730
1734
  *
1731
1735
  * @param ctx - Domain context of the flow plugin.
1732
1736
  */
@@ -1734,13 +1738,15 @@ function offerHeld(ctx) {
1734
1738
  const held = ctx.state.gate.held;
1735
1739
  if (held === void 0) return;
1736
1740
  ctx.state.gate.held = void 0;
1741
+ if (ctx.state.fx.mode === "fast") return;
1737
1742
  if (!accepts(ctx.state.gate, held.answer)) return;
1738
1743
  deliver(ctx.state.gate, held.answer);
1739
1744
  }
1740
1745
  /**
1741
1746
  * Creates the gate API: the single entry of player answers. The gate closes before the answer is
1742
- * handed on; an answer to a closed gate is held for one frame, first wins; `narrow` lets one
1743
- * answer through; `pointer(true)` delays entering an `over` node.
1747
+ * handed on; an answer to a closed gate is held for the next gate, for one frame or for
1748
+ * `config.holdMs` of game time; `narrow` lets one answer through; `pointer(true)` delays entering
1749
+ * an `over` node.
1744
1750
  *
1745
1751
  * @param ctx - Domain context of the flow plugin.
1746
1752
  * @returns The public gate API plus the methods injected into `runner` and `fx`.
@@ -1778,9 +1784,14 @@ function createGateApi(ctx) {
1778
1784
  narrow: (allow) => {
1779
1785
  ctx.state.gate.narrow = allow;
1780
1786
  },
1781
- clearHeld: (frame) => {
1782
- if (ctx.state.gate.held === void 0) return;
1783
- if (ctx.state.gate.held.frame === frame) return;
1787
+ clearHeld: (frame, elapsed) => {
1788
+ const held = ctx.state.gate.held;
1789
+ if (held === void 0) return;
1790
+ if (held.frame === frame) return;
1791
+ if (ctx.config.holdMs > 0 && elapsed - held.at <= ctx.config.holdMs) return;
1792
+ ctx.state.gate.held = void 0;
1793
+ },
1794
+ dropHeld: () => {
1784
1795
  ctx.state.gate.held = void 0;
1785
1796
  }
1786
1797
  };
@@ -1908,10 +1919,11 @@ function registerSchedule(ctx, fx) {
1908
1919
  }
1909
1920
  /**
1910
1921
  * Connects flow to its dependencies in `onInit`: posts `elapsed` from `clock.onElapsed` into the
1911
- * inbox, flushes effect completions and the gate hold in `time.onFrame("signals")`, and registers
1912
- * the `schedule` handler. `onInit` has no access to the plugin's own API, so it builds its own
1913
- * module objects — they are pure functions over `ctx.state`, which is the one place the modules
1914
- * keep their data, so these objects and the API's objects are the same gate and the same fx.
1922
+ * inbox, flushes effect completions and ages the gate hold in `time.onFrame("signals")`, with the
1923
+ * frame and the elapsed game time of that frame, and registers the `schedule` handler. `onInit`
1924
+ * has no access to the plugin's own API, so it builds its own module objects — they are pure
1925
+ * functions over `ctx.state`, which is the one place the modules keep their data, so these
1926
+ * objects and the API's objects are the same gate and the same fx.
1915
1927
  *
1916
1928
  * @param ctx - Kernel context of the flow plugin.
1917
1929
  */
@@ -1926,9 +1938,9 @@ function connectFlow(ctx) {
1926
1938
  payload: input
1927
1939
  });
1928
1940
  });
1929
- flowCtx.deps.time.onFrame("signals", ({ frame }) => {
1941
+ flowCtx.deps.time.onFrame("signals", ({ frame, elapsed }) => {
1930
1942
  fx.flushSettled();
1931
- gate.clearHeld(frame);
1943
+ gate.clearHeld(frame, elapsed);
1932
1944
  });
1933
1945
  registerSchedule(flowCtx, fx);
1934
1946
  }
@@ -2086,6 +2098,25 @@ function nodeProblems(flow) {
2086
2098
  return problems;
2087
2099
  }
2088
2100
  /**
2101
+ * Reports a `recover` on a node that does not rest. The recovery makes its node the rest point
2102
+ * of the loop, so only a rest node can carry one.
2103
+ *
2104
+ * @param flow - The flow being validated.
2105
+ * @returns One sentence per problem.
2106
+ * @example
2107
+ * ```ts
2108
+ * recoverProblems(boardFlow); // []: no transit node of the board has a recover
2109
+ * ```
2110
+ */
2111
+ function recoverProblems(flow) {
2112
+ const problems = [];
2113
+ for (const [name, entry] of Object.entries(flow.nodes)) {
2114
+ if (entry.kind !== "node" || entry.recover === void 0 || entry.rest) continue;
2115
+ problems.push(`[game] Flow "${flow.id}": node "${name}" has recover but is not a rest node.\n Add rest: true, or remove recover.`);
2116
+ }
2117
+ return problems;
2118
+ }
2119
+ /**
2089
2120
  * Reports a start node that is not in `nodes`, and every node the start cannot reach.
2090
2121
  *
2091
2122
  * @param flow - The flow being validated.
@@ -2286,6 +2317,32 @@ function safeNodeProblems(config, features) {
2286
2317
  return [`[game] The safe node "${safeNode}" is not a checkpoint.\n Mark it checkpoint: true or choose another rest node.`];
2287
2318
  }
2288
2319
  /**
2320
+ * Warns about a `recover` on a node that is not the safe node. The runner calls `recover` only in
2321
+ * the safe node, after it gave up on a transition, so the hook never runs anywhere else. The safe
2322
+ * node is the configured one, or the start of the main flow. Nothing is warned about while that
2323
+ * path names no node: a wrong `safeNode` is a problem of its own, and behind a start that is a
2324
+ * sub-flow or a slot the node the loop enters is not known before the game runs.
2325
+ *
2326
+ * @param flows - Every collected flow by id.
2327
+ * @param features - Features API, read for a safe node inside a slot contribution.
2328
+ * @param config - Resolved flow config: `mainFlow`, `safeNode`.
2329
+ * @returns One warning per node whose `recover` never runs.
2330
+ */
2331
+ function recoverWarnings(flows, features, config) {
2332
+ const { mainFlow } = config;
2333
+ if (!mainFlow) return [];
2334
+ const safePath = config.safeNode ?? mainFlow.start;
2335
+ const safe = findNode(mainFlow, safePath, features.contributions);
2336
+ if (safe?.entry.kind !== "node") return [];
2337
+ const warnings = [];
2338
+ for (const flow of flows.values()) for (const [name, entry] of Object.entries(flow.nodes)) {
2339
+ if (entry.kind !== "node" || entry.recover === void 0) continue;
2340
+ if (safe.flow.id === flow.id && safe.name === name) continue;
2341
+ warnings.push(`[game] Flow "${flow.id}": node "${name}" has recover but is not the safe node.\n The engine runs recover only in the safe node "${safePath}". Move recover there or remove it.`);
2342
+ }
2343
+ return warnings;
2344
+ }
2345
+ /**
2289
2346
  * Warns when two contributions of one slot have a node with the same name. A bookmark path keeps
2290
2347
  * node names only, so `restore` cannot tell the two apart and enters the first one in order.
2291
2348
  *
@@ -2326,9 +2383,11 @@ function sizeWarnings(flow) {
2326
2383
  * unreachable node, outcome without an edge, missing target, barrier edge that does not reach a
2327
2384
  * rest node of the same flow, cycle with no rest node, slot without a name, equal contribution
2328
2385
  * `order`, one flow contributed twice to a slot, `inbox` type that is not an outcome, rest node
2329
- * with neither `run` nor outcomes, a scene id no feature registers, an `over` node that names a
2330
- * scene, `safeNode` that is not a checkpoint, colliding ids. Types are bypassed by JSON data and
2331
- * casts, so this runs even though the edge table is checked at compile time.
2386
+ * with neither `run` nor outcomes, a `recover` on a node that is not a rest node, a scene id no
2387
+ * feature registers, an `over` node that names a scene, `safeNode` that is not a checkpoint,
2388
+ * colliding ids. A `recover` on a node that is not the safe node is a warning: it never runs
2389
+ * there. Types are bypassed by JSON data and casts, so this runs even though the edge table is
2390
+ * checked at compile time.
2332
2391
  *
2333
2392
  * @param flows - Every collected flow by id, the main flow first.
2334
2393
  * @param features - Features API: slot contributions and feature names.
@@ -2339,10 +2398,10 @@ function validateGraph(flows, features, config) {
2339
2398
  const problems = [];
2340
2399
  const warnings = [];
2341
2400
  for (const flow of flows.values()) {
2342
- problems.push(...edgeProblems(flow), ...nodeProblems(flow), ...reachProblems(flow), ...cycleProblems(flow));
2401
+ problems.push(...edgeProblems(flow), ...nodeProblems(flow), ...recoverProblems(flow), ...reachProblems(flow), ...cycleProblems(flow));
2343
2402
  warnings.push(...sizeWarnings(flow));
2344
2403
  }
2345
- warnings.push(...slotNameWarnings(flows, features));
2404
+ warnings.push(...slotNameWarnings(flows, features), ...recoverWarnings(flows, features, config));
2346
2405
  problems.push(...flowIdProblems(flows), ...featureProblems(flows, features), ...sceneProblems(flows, features), ...safeNodeProblems(config, features));
2347
2406
  return {
2348
2407
  problems,
@@ -2882,8 +2941,22 @@ function continueSlot(frames, finished) {
2882
2941
  //#endregion
2883
2942
  //#region src/plugins/flow/runner/edge.ts
2884
2943
  /**
2944
+ * Turns whatever a node failed with into an error the log takes.
2945
+ *
2946
+ * @param value - The thrown value or the rejection reason.
2947
+ * @returns The value itself when it is an error, a new error with its text otherwise.
2948
+ * @example
2949
+ * ```ts
2950
+ * asError("no save").message; // "no save"
2951
+ * ```
2952
+ */
2953
+ function asError$4(value) {
2954
+ return value instanceof Error ? value : new Error(String(value));
2955
+ }
2956
+ /**
2885
2957
  * Commits the transaction of one node, releases its hints, journals the edge and emits
2886
- * `flow:edge`. This is the only commit of the engine.
2958
+ * `flow:edge`. This is the only commit of the engine: the edge of a node, and the recovery edge
2959
+ * of the safe node, both go through it.
2887
2960
  *
2888
2961
  * @param ctx - Domain context of the flow plugin.
2889
2962
  * @param modules - Injected sibling APIs.
@@ -2916,7 +2989,11 @@ function commitEdge(ctx, modules, step, next) {
2916
2989
  }
2917
2990
  /**
2918
2991
  * Discards everything the failed node did, rolls back and decides between a retry and the safe
2919
- * node. A failure while the loop already recovers in the safe node is fatal.
2992
+ * node. Every failure is written to the log as one error entry `flow:error`, right before the
2993
+ * event of the same name. When the loop gives up it stands on the safe node, whose `recover` is
2994
+ * then still to run, and an answer the gate still held is dropped; a retry keeps it. A failure
2995
+ * while the loop already recovers in the safe node is fatal: it is no event and no log entry, it
2996
+ * rejects `run()`.
2920
2997
  *
2921
2998
  * @param ctx - Domain context of the flow plugin.
2922
2999
  * @param modules - Injected sibling APIs.
@@ -2938,17 +3015,25 @@ function handleFailure(ctx, modules, transaction, error, path, safe) {
2938
3015
  const retry = state.failures <= ctx.config.retries;
2939
3016
  const safeTarget = () => safeFrames(ctx, modules.features.contributions);
2940
3017
  const target = retry ? state.restFrame ?? safeTarget() : safeTarget();
3018
+ const rolledBackTo = framePath(target);
3019
+ ctx.log.error("flow:error", {
3020
+ path,
3021
+ rolledBackTo,
3022
+ retry
3023
+ }, asError$4(error));
2941
3024
  ctx.emit("flow:error", {
2942
3025
  path,
2943
3026
  error,
2944
- rolledBackTo: framePath(target),
3027
+ rolledBackTo,
2945
3028
  retry
2946
3029
  });
2947
3030
  state.stack = [...target];
2948
3031
  state.slotAfter = void 0;
2949
3032
  if (!retry) {
2950
3033
  safe.inside = true;
3034
+ safe.recover = true;
2951
3035
  state.failures = 0;
3036
+ modules.gate.dropHeld();
2952
3037
  }
2953
3038
  return "continue";
2954
3039
  }
@@ -3057,7 +3142,9 @@ function type() {
3057
3142
  }
3058
3143
  /**
3059
3144
  * Defines a node. `out` of the body has one method per declared outcome; `run` is required
3060
- * unless `rest: true`. `Game` is bound by `defineGame<Types>()`; here it is the engine's `Json`.
3145
+ * unless `rest: true`. A rest node may carry `recover`: the engine runs it before the node is
3146
+ * entered as the safe node. `Game` is bound by `defineGame<Types>()`; here it is the engine's
3147
+ * `Json`.
3061
3148
  *
3062
3149
  * @param spec - Input tag, outcomes, flags, inbox and the body.
3063
3150
  * @returns The node as plain data.
@@ -3084,7 +3171,8 @@ function defineNode(spec) {
3084
3171
  checkpoint: spec.checkpoint === true,
3085
3172
  barrier: spec.barrier === true,
3086
3173
  inbox: spec.inbox ?? [],
3087
- ...spec.scene === void 0 ? {} : { scene: spec.scene }
3174
+ ...spec.scene === void 0 ? {} : { scene: spec.scene },
3175
+ ...spec.recover === void 0 ? {} : { recover: spec.recover }
3088
3176
  };
3089
3177
  return spec.run ? {
3090
3178
  ...node,
@@ -3255,10 +3343,11 @@ function refuseEffect(signal) {
3255
3343
  * Builds the `fx` of a node context: awaited effects by call, cosmetic hints by `emit`. A `guide`
3256
3344
  * narrows the gate while it runs; the runner lifts the narrow when the node is left. A body that
3257
3345
  * 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.
3346
+ * any more: the gate and the narrow belong to the node the loop entered after it. The `recover`
3347
+ * of a rest node gets the same `fx`, bound to the signal of its recovery.
3259
3348
  *
3260
3349
  * @param modules - Injected sibling APIs.
3261
- * @param step - The node run in progress.
3350
+ * @param step - The run in progress: its abort signal, and how it tells a walk that a gate opened.
3262
3351
  * @returns The effects gateway of one node run.
3263
3352
  */
3264
3353
  function nodeFx(modules, step) {
@@ -3379,6 +3468,7 @@ function stopped(state) {
3379
3468
  }
3380
3469
  /**
3381
3470
  * Resolves as soon as the node is aborted, so a body that ignores its signal never holds the loop.
3471
+ * A recovery races its `recover` against the same promise.
3382
3472
  *
3383
3473
  * @param signal - The node's abort signal.
3384
3474
  * @returns The abort outcome.
@@ -3583,6 +3673,136 @@ function waitForPointer(ctx) {
3583
3673
  });
3584
3674
  }
3585
3675
  //#endregion
3676
+ //#region src/plugins/flow/runner/recover.ts
3677
+ /**
3678
+ * Takes the recovery the loop owes the safe node. It is owed once per give-up, to the first node
3679
+ * entered after it, and a node without `recover` uses it up as well.
3680
+ *
3681
+ * @param safe - How the loop stands after it gave up.
3682
+ * @param node - The node being entered.
3683
+ * @returns True when the node has a `recover` that must run now.
3684
+ */
3685
+ function takeRecovery(safe, node) {
3686
+ if (!safe.recover) return false;
3687
+ safe.recover = false;
3688
+ return node.recover !== void 0;
3689
+ }
3690
+ /**
3691
+ * Builds the one object a `recover` receives: the node context without `input` and `out`.
3692
+ *
3693
+ * @param modules - Injected sibling APIs.
3694
+ * @param recovery - The recovery in progress.
3695
+ * @returns The recovery context.
3696
+ */
3697
+ function recoverContext(modules, recovery) {
3698
+ return {
3699
+ player: recovery.transaction.player,
3700
+ session: recovery.transaction.session,
3701
+ rng: recovery.transaction.rng,
3702
+ fx: nodeFx(modules, recovery),
3703
+ signal: recovery.signal,
3704
+ now: recovery.now
3705
+ };
3706
+ }
3707
+ /**
3708
+ * Calls the `recover` of a node and turns what it does into an outcome. Nothing is thrown out of
3709
+ * here.
3710
+ *
3711
+ * @param modules - Injected sibling APIs.
3712
+ * @param recovery - The recovery in progress.
3713
+ * @returns That it returned, the abort or the failure.
3714
+ */
3715
+ async function callRecover(modules, recovery) {
3716
+ try {
3717
+ await recovery.node.recover?.(recoverContext(modules, recovery));
3718
+ if (recovery.signal.aborted) return {
3719
+ kind: "aborted",
3720
+ reason: abortReason(recovery.signal)
3721
+ };
3722
+ return { kind: "recovered" };
3723
+ } catch (error) {
3724
+ if (recovery.signal.aborted) return {
3725
+ kind: "aborted",
3726
+ reason: abortReason(recovery.signal)
3727
+ };
3728
+ return {
3729
+ kind: "failed",
3730
+ error
3731
+ };
3732
+ }
3733
+ }
3734
+ /**
3735
+ * Takes the recovery edge: what `recover` wrote is committed as an edge of the safe node onto
3736
+ * itself, with the outcome `"recover"`, and the node becomes the rest point. The journal entry,
3737
+ * `flow:edge`, the rest mark of the store and `flow:rest` are the ones of any edge into a rest
3738
+ * node, so a later rollback returns to the recovered state.
3739
+ *
3740
+ * @param ctx - Domain context of the flow plugin.
3741
+ * @param modules - Injected sibling APIs.
3742
+ * @param recovery - The recovery whose `recover` returned.
3743
+ */
3744
+ function commitRecovery(ctx, modules, recovery) {
3745
+ commitEdge(ctx, modules, {
3746
+ location: recovery.location,
3747
+ transaction: recovery.transaction,
3748
+ result: {
3749
+ outcome: "recover",
3750
+ payload: null
3751
+ },
3752
+ barrier: false,
3753
+ now: recovery.now,
3754
+ path: recovery.path
3755
+ }, recovery.path);
3756
+ ctx.deps.model.store.markRest();
3757
+ arrive(ctx, {
3758
+ stack: [...ctx.state.runner.stack],
3759
+ next: recovery.path,
3760
+ rest: true,
3761
+ checkpoint: recovery.node.checkpoint
3762
+ });
3763
+ }
3764
+ /**
3765
+ * Runs the `recover` of the safe node as one transaction, before the node is entered: an abort
3766
+ * controller of its own, an open transaction, `recover` raced against the abort, then the
3767
+ * recovery edge. A `recover` that fails is a failure inside the safe node: fatal, `run()` rejects.
3768
+ * An aborted one leaves like an aborted node: `"stop"` ends the loop, `"restore"` lets the next
3769
+ * turn enter the bookmark. `safe.inside` is not touched: it stays true until the safe node's own
3770
+ * edge commits or a restore leaves the node, so a failure of the safe node after its recovery is
3771
+ * still fatal, never a loop.
3772
+ *
3773
+ * @param ctx - Domain context of the flow plugin.
3774
+ * @param modules - Injected sibling APIs.
3775
+ * @param place - The safe node the loop stands on.
3776
+ * @param safe - How the loop stands after it gave up.
3777
+ * @returns The outcome that ends this turn, or `undefined` when the node is entered now.
3778
+ * @throws {Error} When `recover` failed.
3779
+ */
3780
+ async function recoverNode(ctx, modules, place, safe) {
3781
+ const state = ctx.state.runner;
3782
+ const abort = new AbortController();
3783
+ state.abort = abort;
3784
+ const recovery = {
3785
+ ...place,
3786
+ transaction: ctx.deps.model.store.begin(),
3787
+ signal: abort.signal,
3788
+ now: ctx.deps.clock.now(),
3789
+ /**
3790
+ * Tells a walk that the recovery opened the gate for an answer.
3791
+ */
3792
+ gateOpened: () => {
3793
+ notifyGateOpen(state);
3794
+ }
3795
+ };
3796
+ const outcome = await Promise.race([callRecover(modules, recovery), abortOutcome(abort.signal)]);
3797
+ modules.gate.narrow(void 0);
3798
+ modules.fx.endGuides();
3799
+ if (outcome.kind === "failed") return handleFailure(ctx, modules, recovery.transaction, outcome.error, place.path, safe);
3800
+ if (outcome.kind === "aborted") return handleAbort(modules, recovery.transaction, outcome.reason);
3801
+ commitRecovery(ctx, modules, recovery);
3802
+ if (stopped(state)) return "stop";
3803
+ if (state.seam?.restoring !== void 0) return "continue";
3804
+ }
3805
+ //#endregion
3586
3806
  //#region src/plugins/flow/runner/stop.ts
3587
3807
  /**
3588
3808
  * Waits `timeoutMs` of real time. The deadline is not counted in frames: a paused clock runs no
@@ -3629,8 +3849,9 @@ async function settle$1(ctx, running) {
3629
3849
  }
3630
3850
  /**
3631
3851
  * Stops the runner: the active node is aborted with reason `"stop"`, the loop is given
3632
- * `settleTimeoutMs` to settle, and a background flush that is still running is awaited. The loop
3633
- * itself discards the open transaction and shuts the gate while it leaves the aborted node.
3852
+ * `settleTimeoutMs` to settle, an answer the gate still held is dropped, and a background flush
3853
+ * that is still running is awaited. The loop itself discards the open transaction and shuts the
3854
+ * gate while it leaves the aborted node.
3634
3855
  *
3635
3856
  * @param ctx - Config and state of the flow plugin.
3636
3857
  * @returns A promise that resolves when the graph stands still.
@@ -3648,6 +3869,7 @@ async function stopRunner(ctx) {
3648
3869
  }
3649
3870
  const settled = running === void 0 || await settle$1(ctx, running);
3650
3871
  state.running = void 0;
3872
+ ctx.state.gate.held = void 0;
3651
3873
  if (settled) state.abort = void 0;
3652
3874
  const flushing = state.flushing;
3653
3875
  if (flushing !== void 0) {
@@ -3658,8 +3880,8 @@ async function stopRunner(ctx) {
3658
3880
  //#endregion
3659
3881
  //#region src/plugins/flow/runner/loop.ts
3660
3882
  /**
3661
- * Runs one node: wait for the pointer, `onEnter` stages, open the transaction, produce the
3662
- * outcome, then commit on the edge or recover.
3883
+ * Runs one node: wait for the pointer, the recovery the loop owes the safe node, `onEnter` stages,
3884
+ * open the transaction, produce the outcome, then commit on the edge or recover.
3663
3885
  *
3664
3886
  * @param ctx - Domain context of the flow plugin.
3665
3887
  * @param modules - Injected sibling APIs.
@@ -3674,6 +3896,14 @@ async function runNode(ctx, modules, location, node, safe) {
3674
3896
  if (node.over) await waitForPointer(ctx);
3675
3897
  if (stopped(state)) return "stop";
3676
3898
  if (state.seam?.restoring !== void 0) return "continue";
3899
+ if (takeRecovery(safe, node)) {
3900
+ const ended = await recoverNode(ctx, modules, {
3901
+ location,
3902
+ node,
3903
+ path
3904
+ }, safe);
3905
+ if (ended !== void 0) return ended;
3906
+ }
3677
3907
  const abort = new AbortController();
3678
3908
  state.abort = abort;
3679
3909
  const now = ctx.deps.clock.now();
@@ -3784,18 +4014,25 @@ function enterSlot(ctx, modules, location, slot, safe) {
3784
4014
  * node too. A restore leaves the whole document waiting for the provider, and a rollback of the
3785
4015
  * node would drop it unsent; and the frames of the transit node are what a failure of it must
3786
4016
  * 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.
4017
+ * every node entered here; only a rest node is announced as a checkpoint. A restore wins over
4018
+ * what belonged to the old position: a recovery the loop still owed the safe node does not run on
4019
+ * the restored state, the loop is no longer inside the safe node, so a failure of the restored
4020
+ * node is retried and not fatal, and an answer the gate still held is dropped.
3788
4021
  *
3789
4022
  * @param ctx - Domain context of the flow plugin.
3790
4023
  * @param modules - Injected sibling APIs; the features API resolves a path through a slot.
3791
4024
  * @param bookmark - The bookmark to enter.
4025
+ * @param safe - How the loop stands after it gave up. Both of its flags are cleared.
3792
4026
  * @throws {Error} When the graph has no node at the bookmark's path.
3793
4027
  */
3794
- function enterBookmark(ctx, modules, bookmark) {
4028
+ function enterBookmark(ctx, modules, bookmark, safe) {
3795
4029
  const state = ctx.state.runner;
3796
4030
  const location = findNode(requireMainFlow(ctx), bookmark.path, modules.features.contributions);
3797
4031
  loopSeam(state).restoring = void 0;
3798
4032
  if (location === void 0) throw new Error(`[game] The bookmark names no node "${bookmark.path}".\n Bookmark a node of the running graph.`);
4033
+ safe.inside = false;
4034
+ safe.recover = false;
4035
+ modules.gate.dropHeld();
3799
4036
  ctx.deps.model.store.restore({
3800
4037
  player: bookmark.player,
3801
4038
  session: bookmark.session,
@@ -3826,7 +4063,7 @@ async function advance(ctx, modules, safe) {
3826
4063
  if (stopped(state)) return "stop";
3827
4064
  const restoring = state.seam?.restoring;
3828
4065
  if (restoring !== void 0) {
3829
- enterBookmark(ctx, modules, restoring);
4066
+ enterBookmark(ctx, modules, restoring, safe);
3830
4067
  return "continue";
3831
4068
  }
3832
4069
  const location = locateFrames(ctx, state.stack);
@@ -3838,7 +4075,8 @@ async function advance(ctx, modules, safe) {
3838
4075
  /**
3839
4076
  * Runs the graph until aborted: enter node, `onEnter` stages, begin a transaction, await the
3840
4077
  * body, commit on the edge, release hints, journal, emit `flow:edge`, follow the edge. An error
3841
- * discards, rolls back and retries, then enters `safeNode`; a failure inside `safeNode` rejects.
4078
+ * discards, rolls back, is logged and retries, then enters `safeNode`, whose `recover` runs first
4079
+ * as one committed edge; a failure inside `safeNode` rejects.
3842
4080
  * Abort reasons: `"stop"` ends the loop, `"inbox"` turns the event into the outcome, `"restore"`
3843
4081
  * enters the bookmark's frame.
3844
4082
  *
@@ -3862,7 +4100,10 @@ async function runLoop(ctx, modules) {
3862
4100
  state.restFrame = [...state.stack];
3863
4101
  state.slotAfter = void 0;
3864
4102
  state.failures = 0;
3865
- const safe = { inside: false };
4103
+ const safe = {
4104
+ inside: false,
4105
+ recover: false
4106
+ };
3866
4107
  const loop = { running: true };
3867
4108
  while (loop.running) loop.running = await advance(ctx, modules, safe) === "continue";
3868
4109
  }
@@ -4518,8 +4759,8 @@ function createModules$3(ctx) {
4518
4759
  };
4519
4760
  }
4520
4761
  /**
4521
- * Reduces the gate to what a game may call. `open`, `close`, `narrow` and `clearHeld` belong to
4522
- * the runner and to `fx`.
4762
+ * Reduces the gate to what a game may call. `open`, `close`, `narrow`, `clearHeld` and `dropHeld`
4763
+ * belong to the runner, to `fx` and to the plugin root.
4523
4764
  *
4524
4765
  * @param gate - The full gate module.
4525
4766
  * @returns The public gate API.
@@ -4809,7 +5050,8 @@ const flowPlugin = /*#__PURE__*/ createPlugin$1("flow", {
4809
5050
  safeNode: void 0,
4810
5051
  retries: 1,
4811
5052
  settleTimeoutMs: 2e3,
4812
- journalLimit: 500
5053
+ journalLimit: 500,
5054
+ holdMs: 0
4813
5055
  },
4814
5056
  events: (register) => register.map({
4815
5057
  "flow:edge": "An edge was taken and its state committed",
@@ -6278,6 +6520,7 @@ function clearWorld(state) {
6278
6520
  state.ecs.offFrame.length = 0;
6279
6521
  for (const off of state.projection.offHints) off();
6280
6522
  state.projection.offHints.length = 0;
6523
+ state.projection.converged.length = 0;
6281
6524
  state.projection.specs.clear();
6282
6525
  state.projection.mounted.clear();
6283
6526
  state.projection.byEntity.clear();
@@ -7388,13 +7631,63 @@ function keyedItems(pctx, spec, snapshot) {
7388
7631
  return next;
7389
7632
  }
7390
7633
  /**
7634
+ * The last step of a reconcile a restore or a load caused: every live view is written to its rest
7635
+ * pose, whether its item changed or not. An aborted node finishes its animation, which leaves a
7636
+ * view on the end pose, and a view whose item is the same object as before is never touched by
7637
+ * the entries. It runs after the flush: every handle is finished and every track of the entity is
7638
+ * cancelled by then, also an additive one, which writes its base back when it ends. So nothing
7639
+ * writes after it. Muted fields are left out, and `view` does not run again.
7640
+ *
7641
+ * @param pctx - Domain context of the projection module.
7642
+ * @param mounted - The mounted projection, already flushed.
7643
+ */
7644
+ function convergeMounted(pctx, mounted) {
7645
+ for (const view of mounted.live.values()) writeRestNow(pctx, view, [...view.rest.keys()]);
7646
+ }
7647
+ /**
7648
+ * The same last step for the elements another plugin owns, which no reconcile of a projection
7649
+ * meets: each is written to the rest pose its owner recorded with `setRest`. Every track on the
7650
+ * entity is ended first, so nothing writes after it. Only a component the entity still carries
7651
+ * is written and muted fields are left out. An element that is leaving has its tracks ended and
7652
+ * keeps its place, and the recorded pose of an entity that is gone is skipped. So is one recorded
7653
+ * for a projection view: its own rest pose wins, as in `restOf`, and it is already home.
7654
+ *
7655
+ * @param pctx - Domain context of the projection module.
7656
+ */
7657
+ function convergeElements(pctx) {
7658
+ const { ecs, components } = pctx.deps;
7659
+ const state = pctx.ctx.state.projection;
7660
+ for (const [entity, rest] of state.rests) {
7661
+ if (ecs.ownerOf(entity) === void 0 || state.byEntity.has(entity)) continue;
7662
+ cancelTracks(pctx, entity);
7663
+ if (ecs.has(entity, components.Exiting)) continue;
7664
+ const carried = [];
7665
+ for (const [name, value] of rest) if (storedValue(pctx, entity, value.type) !== void 0) carried.push(name);
7666
+ writeRestNow(pctx, createViewRecord(entity, "", "", void 0, rest), carried);
7667
+ }
7668
+ }
7669
+ /**
7670
+ * Tells every `onConverged` listener that the picture is home, so an owner puts back what only
7671
+ * it knows: `ui` builds its roots again. A listener that throws is logged and the others still
7672
+ * run.
7673
+ *
7674
+ * @param pctx - Domain context of the projection module.
7675
+ */
7676
+ function tellConverged(pctx) {
7677
+ for (const listener of [...pctx.ctx.state.projection.converged]) try {
7678
+ listener();
7679
+ } catch (error) {
7680
+ pctx.ctx.log.error("world:converged-listener-failed", {}, asError$2(error));
7681
+ }
7682
+ }
7683
+ /**
7391
7684
  * Reconciles one mounted projection against the snapshot.
7392
7685
  *
7393
7686
  * @param pctx - Domain context of the projection module.
7394
7687
  * @param spec - The projection spec.
7395
7688
  * @param mounted - The mounted projection.
7396
7689
  * @param snapshot - The model snapshot of this reconcile.
7397
- * @param options - Mode and force flag of this reconcile.
7690
+ * @param options - Mode, force flag and converge flag of this reconcile.
7398
7691
  * @param counts - The counters of this reconcile.
7399
7692
  * @param used - The hints that reached an entry.
7400
7693
  */
@@ -7423,13 +7716,16 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
7423
7716
  }
7424
7717
  mounted.items = next;
7425
7718
  if (options.direct) flushMounted(pctx, mounted);
7719
+ if (options.converge) convergeMounted(pctx, mounted);
7426
7720
  counts.queued += mounted.queue.length;
7427
7721
  }
7428
7722
  /**
7429
- * Runs one reconcile over every mounted projection, or over the named ones only.
7723
+ * Runs one reconcile over every mounted projection, or over the named ones only. A converging
7724
+ * one then brings the elements other plugins own home and tells their owners.
7430
7725
  *
7431
7726
  * @param pctx - Domain context of the projection module.
7432
- * @param options - Which projections, play or direct, and whether `view` is forced.
7727
+ * @param options - Which projections, play or direct, whether `view` is forced, and whether
7728
+ * every live view and every element is brought to its rest pose at the end.
7433
7729
  */
7434
7730
  function reconcile(pctx, options) {
7435
7731
  const state = pctx.ctx.state.projection;
@@ -7443,6 +7739,10 @@ function reconcile(pctx, options) {
7443
7739
  counts.projections += 1;
7444
7740
  reconcileOne(pctx, spec, mounted, snapshot, options, counts, used);
7445
7741
  }
7742
+ if (options.converge) {
7743
+ convergeElements(pctx);
7744
+ tellConverged(pctx);
7745
+ }
7446
7746
  counts.hintsRouted = used.size;
7447
7747
  counts.hintsDropped = state.hints.length - used.size;
7448
7748
  if (pctx.ctx.config.reconciledEvent) pctx.ctx.emit("world:reconciled", {
@@ -7479,6 +7779,22 @@ function isDirectCause(cause) {
7479
7779
  return cause === "load" || cause === "restore" || cause === "mount" || cause === "rerun";
7480
7780
  }
7481
7781
  /**
7782
+ * Tells whether a cause replaces the state, so every live view has to stand on its rest pose
7783
+ * when the reconcile ends, also a view whose item is the same object as before, and every
7784
+ * element on the rest pose its owner recorded.
7785
+ *
7786
+ * @param cause - Why the reconcile runs.
7787
+ * @returns True for a load and a restore.
7788
+ * @example
7789
+ * ```ts
7790
+ * isConvergeCause("restore"); // true
7791
+ * isConvergeCause("rerun"); // false
7792
+ * ```
7793
+ */
7794
+ function isConvergeCause(cause) {
7795
+ return cause === "load" || cause === "restore";
7796
+ }
7797
+ /**
7482
7798
  * Creates the empty view tables of a freshly mounted projection. These live in their own
7483
7799
  * non-exported functions because lint rule L5 refuses a collection built inside an exported
7484
7800
  * declaration.
@@ -7609,16 +7925,19 @@ function createProjectionApi(ctx, deps) {
7609
7925
  };
7610
7926
  const state = ctx.state.projection;
7611
7927
  /**
7612
- * Reconciles what is dirty. The cause table decides play or direct.
7928
+ * Reconciles what is dirty. The cause table decides play or direct, and whether every live view
7929
+ * and every element with a recorded rest pose is brought to it: a restore and a load do that.
7613
7930
  */
7614
7931
  const reconcileIfDirty = () => {
7615
7932
  const dirty = state.dirty;
7616
7933
  state.dirty = void 0;
7617
7934
  if (dirty !== void 0 && (dirty.roots.has("player") || dirty.roots.has("session"))) {
7618
7935
  const direct = dirty.force || dirty.causes.some((cause) => isDirectCause(cause)) || deps.ecs.mode() !== "live";
7936
+ const converge = dirty.causes.some((cause) => isConvergeCause(cause));
7619
7937
  reconcile(pctx, {
7620
7938
  direct,
7621
- force: dirty.force
7939
+ force: dirty.force,
7940
+ converge
7622
7941
  });
7623
7942
  }
7624
7943
  state.hints.length = 0;
@@ -7678,7 +7997,8 @@ function createProjectionApi(ctx, deps) {
7678
7997
  if (fresh.length > 0) reconcile(pctx, {
7679
7998
  names: fresh.map((spec) => spec.name),
7680
7999
  direct: true,
7681
- force: true
8000
+ force: true,
8001
+ converge: false
7682
8002
  });
7683
8003
  },
7684
8004
  unmount: (names) => {
@@ -7792,6 +8112,13 @@ function createProjectionApi(ctx, deps) {
7792
8112
  state.rests.delete(entity);
7793
8113
  };
7794
8114
  },
8115
+ onConverged: (listener) => {
8116
+ state.converged.push(listener);
8117
+ return () => {
8118
+ const at = state.converged.indexOf(listener);
8119
+ if (at !== -1) state.converged.splice(at, 1);
8120
+ };
8121
+ },
7795
8122
  rerunAll: forceRerun,
7796
8123
  markDirty: (roots, cause) => {
7797
8124
  const dirty = state.dirty ?? emptyDirty();
@@ -7955,6 +8282,7 @@ function exposeProjection(projection) {
7955
8282
  setRest: projection.setRest,
7956
8283
  restOf: projection.restOf,
7957
8284
  registerKey: projection.registerKey,
8285
+ onConverged: projection.onConverged,
7958
8286
  rerunAll: projection.rerunAll
7959
8287
  };
7960
8288
  }
@@ -8081,6 +8409,7 @@ function createProjectionState() {
8081
8409
  keys: emptyMap$7(),
8082
8410
  keysByEntity: emptyMap$7(),
8083
8411
  mutes: emptyMap$7(),
8412
+ converged: [],
8084
8413
  offHints: []
8085
8414
  };
8086
8415
  }
@@ -18210,8 +18539,9 @@ async function bufferOf(ctx, key) {
18210
18539
  /**
18211
18540
  * Plays one sound: the handler of the kind `"sfx"`. Every play is its own source, so the same key
18212
18541
  * twice in one frame is heard twice, and the promise resolves when the sound STARTED. The handler
18213
- * ignores its signal: a sound that began plays to its end. While the first gesture's `resume()` is
18214
- * pending the sound waits in `pendingSfx`, one per key; before any gesture it is dropped.
18542
+ * ignores its signal: a sound that began plays to its end. While a gesture's `resume()` is
18543
+ * pending the sound waits in `pendingSfx`, one per key. A locked context no gesture is resuming
18544
+ * drops it: the start attempt of the unlock queues nothing.
18215
18545
  *
18216
18546
  * @param ctx - Domain context of the plugin.
18217
18547
  * @param descriptor - The `sfx` descriptor `anim` built.
@@ -18411,7 +18741,7 @@ function refused(ctx, key) {
18411
18741
  /**
18412
18742
  * Switches the music: the handler of the kind `"music"` and what `scenes:changed` calls. The same
18413
18743
  * key does nothing, a new key cross-fades, `null` fades out. While the context is locked the key
18414
- * is only remembered; the first touch starts it. A key that is still on its way counts as the
18744
+ * is only remembered; the unlock starts it. A key that is still on its way counts as the
18415
18745
  * same key, and every other request replaces it: the replaced switch starts nothing when its
18416
18746
  * buffer or its element is ready, so one tap that unlocks the context and enters a scene with the
18417
18747
  * same track starts it once. `config.music` picks the decode or the stream path.
@@ -18569,17 +18899,22 @@ function resetSession(state) {
18569
18899
  //#endregion
18570
18900
  //#region src/plugins/audio/unlock.ts
18571
18901
  /**
18572
- * @file audio plugin — the unlock: the only file that touches `window`. A browser starts every
18573
- * audio context suspended and resumes it on a user gesture, so two listeners wait for the first
18574
- * one. They sit on `window`, not on the canvas: the gesture may as well be a button of the
18575
- * game's loading page, which `input` never sees.
18902
+ * @file audio plugin — the unlock: the only file that touches `window`. A browser starts an audio
18903
+ * context suspended and lets it run on a user gesture, so two listeners wait for the first one.
18904
+ * They sit on `window`, not on the canvas: the gesture may as well be a button of the game's
18905
+ * loading page, which `input` never sees. A shell that asks for no gesture, such as a native
18906
+ * webview with the requirement switched off, lets the context run at once. So the plugin also
18907
+ * asks once at start, and unlocks with no touch where the answer is yes.
18576
18908
  */
18577
18909
  /** The two gestures that unlock a context. The first of them to arrive wins. */
18578
18910
  const UNLOCK_EVENTS = ["pointerdown", "touchend"];
18579
18911
  /**
18580
- * Reacts to a resume that went through. The context is unlocked only when it really runs; the
18581
- * sounds fired during the resume and the track a scene declared while it was locked start here. A
18582
- * context that still does not run drops the sounds: the next gesture is a new moment.
18912
+ * Reacts to a resume that went through, of a gesture or of the start attempt. The context is
18913
+ * unlocked only when it really runs; the sounds fired during the resume and the track a scene
18914
+ * declared while it was locked start here. A context that still does not run drops the sounds:
18915
+ * the next gesture is a new moment. A gesture and the start attempt that settle together both
18916
+ * come here, and the music still starts once: a track that is on its way or already plays is not
18917
+ * started again. A later gesture on an unlocked context tries a remembered track again.
18583
18918
  *
18584
18919
  * @param ctx - Domain context of the plugin.
18585
18920
  */
@@ -18632,8 +18967,50 @@ function resumeContext(ctx) {
18632
18967
  });
18633
18968
  }
18634
18969
  /**
18635
- * Installs the two unlock listeners on `window`. Without a DOM there is nothing to listen on and
18636
- * the context stays locked for the whole run, which is what a headless game wants.
18970
+ * Reacts to the start attempt that fulfilled. Nothing happens when the app stopped meanwhile, when
18971
+ * a gesture already unlocked the context, or when the context still does not run: the listeners
18972
+ * are there for that. Otherwise the listeners go and the context is unlocked the way a gesture
18973
+ * unlocks it.
18974
+ *
18975
+ * @param ctx - Domain context of the plugin.
18976
+ * @param context - The context the attempt was made on.
18977
+ */
18978
+ function onStartResumed(ctx, context) {
18979
+ const state = ctx.state;
18980
+ if (state.context !== context || state.unlocked || context.state !== "running") return;
18981
+ removeUnlock(state);
18982
+ onResumed(ctx);
18983
+ }
18984
+ /**
18985
+ * Leaves a refused start attempt alone. Nobody asked for sound yet, so there is nothing to report,
18986
+ * and the listeners are installed: the first gesture asks again.
18987
+ */
18988
+ function ignoreRefusal() {}
18989
+ /**
18990
+ * Unlocks the context in `onStart` where the shell lets audio play, and waits for a gesture where
18991
+ * it does not. A context that already runs is unlocked at once, with no listener. Any other gets
18992
+ * the two listeners and one `resume()`. `resuming` is not set for it: a browser that wants a
18993
+ * gesture leaves that promise pending, and a sound must not queue behind a promise that may never
18994
+ * settle. The attempt needs no `window`, so it also serves a context given through
18995
+ * `config.context` in a runtime without a DOM.
18996
+ *
18997
+ * @param ctx - Domain context of the plugin.
18998
+ */
18999
+ function startUnlock(ctx) {
19000
+ const context = ctx.state.context;
19001
+ if (context === void 0) return;
19002
+ if (context.state === "running") {
19003
+ onResumed(ctx);
19004
+ return;
19005
+ }
19006
+ installUnlock(ctx);
19007
+ context.resume().then(() => {
19008
+ onStartResumed(ctx, context);
19009
+ }, ignoreRefusal);
19010
+ }
19011
+ /**
19012
+ * Installs the two unlock listeners on `window`. Without a DOM there is nothing to listen on:
19013
+ * the context stays locked unless the start attempt of `startUnlock` runs it.
18637
19014
  *
18638
19015
  * @param ctx - Domain context of the plugin.
18639
19016
  */
@@ -18720,9 +19097,10 @@ function createContext(config) {
18720
19097
  }
18721
19098
  /**
18722
19099
  * Starts the plugin in `onStart`: the audio session first, before any sound exists, then both
18723
- * effect handlers, then the context with its graph and its unlock. The handlers are registered
18724
- * even headless, so a node that awaits a sound resolves in plain Bun exactly as it does in a
18725
- * browser.
19100
+ * effect handlers, then the context with its graph and its unlock. The unlock asks the context to
19101
+ * run at once: a shell that lets audio play is unlocked with no touch, and a browser that wants a
19102
+ * gesture gets the two listeners. The handlers are registered even headless, so a node that
19103
+ * awaits a sound resolves in plain Bun exactly as it does in a browser.
18726
19104
  *
18727
19105
  * @param ctx - Kernel context of the audio plugin.
18728
19106
  */
@@ -18737,7 +19115,7 @@ function startAudio(ctx) {
18737
19115
  state.context = context;
18738
19116
  state.buses = buildGraph(context, state.buses);
18739
19117
  applyAllGains(state);
18740
- installUnlock(audio);
19118
+ startUnlock(audio);
18741
19119
  }
18742
19120
  /**
18743
19121
  * Frees everything `onStart` opened: the two handler registrations, the two window listeners, the
@@ -24501,8 +24879,8 @@ function leave(ctx) {
24501
24879
  * Runs the Back chain once. `back()` and the provider's `onBack` both go through here, so a test
24502
24880
  * and a real press end the same way. Escape is what ui answers with the `escape` button of its
24503
24881
  * top root; it also ends the editing of a text field. While the graph moves between nodes the gate
24504
- * is closed: it holds the answer for one frame, so the press counts as taken and never leaves the
24505
- * app in the middle of a step.
24882
+ * is closed: it holds the answer for the next gate (one frame, or `flow.holdMs`), so the press
24883
+ * counts as taken and never leaves the app in the middle of a step.
24506
24884
  *
24507
24885
  * @param ctx - Kernel context of the platform plugin.
24508
24886
  * @returns What took the press, `"none"` without a provider.
@@ -29945,9 +30323,10 @@ function restValueOf(current, value, owned) {
29945
30323
  /**
29946
30324
  * Records the rest pose of one extra component with a value, so a `change` hook can bring it
29947
30325
  * home with `view.toRest`; a tag has no rest. The fields the type gives to a plugin are skipped.
30326
+ * The visual of a live element is recorded the same way each time its look is written.
29948
30327
  *
29949
30328
  * @param ctx - Domain context of the ui plugin.
29950
- * @param element - The element that carries the extra.
30329
+ * @param element - The element that carries the component.
29951
30330
  * @param value - The value of this render.
29952
30331
  */
29953
30332
  function recordRest(ctx, element, value) {
@@ -30915,14 +31294,17 @@ function createReconciler(ctx, modules, fields) {
30915
31294
  * Writes the visual and input components of a live element again. A visual or an input the
30916
31295
  * element no longer carries is removed, so a variant can trade the rectangle for a nine-slice and
30917
31296
  * back, and a button that became disabled stops answering the gate. `Scroll` is only ever added:
30918
- * its offset belongs to the finger, and a new look or a new rect never resets it. The extras of
30919
- * the `components` prop are diffed last.
31297
+ * its offset belongs to the finger, and a new look or a new rect never resets it. The look
31298
+ * written is recorded as the rest of the visual, so the rest the world holds is always the one
31299
+ * the element is drawn with: a restore brings it back to exactly that. The extras of the
31300
+ * `components` prop are diffed last.
30920
31301
  *
30921
31302
  * @param element - The element that changed.
30922
31303
  */
30923
31304
  function writeLive(element) {
30924
31305
  writeOrder(element);
30925
- const values = [...visualOf$1(element), ...inputOf(element)];
31306
+ const visuals = visualOf$1(element);
31307
+ const values = [...visuals, ...inputOf(element)];
30926
31308
  for (const type of [...VISUALS, ...INPUTS]) if (!values.some((value) => value.type === type) && ecs.has(element.entity, type)) ecs.remove(element.entity, type);
30927
31309
  for (const value of values) {
30928
31310
  if (!ecs.has(element.entity, value.type)) {
@@ -30933,6 +31315,7 @@ function createReconciler(ctx, modules, fields) {
30933
31315
  const owned = ecs.typeOf(value.type.componentName)?.owned ?? [];
30934
31316
  ecs.set(element.entity, asHandle(value.type), livePatch(value.value, owned));
30935
31317
  }
31318
+ for (const value of visuals) recordRest(ctx, element, value);
30936
31319
  writeExtras(element);
30937
31320
  }
30938
31321
  /**
@@ -31223,6 +31606,39 @@ function createReconciler(ctx, modules, fields) {
31223
31606
  else modules.layout.enter(element);
31224
31607
  }
31225
31608
  /**
31609
+ * Asks for every root to be built again. The world calls it after a restore or a load, when it
31610
+ * brought every element to its rest pose and ended every track on it. The next reconcile does
31611
+ * the work, because only then the tree of the restored state is here to build from.
31612
+ */
31613
+ function converged() {
31614
+ state.remount = true;
31615
+ ctx.deps.time.wake();
31616
+ }
31617
+ /**
31618
+ * Builds every root again from nothing: its element tree is dropped at once, with no exit, and
31619
+ * the reconcile of this pass enters it through the mount path. So after a restore or a load an
31620
+ * element stands exactly as if its root had just been mounted with the restored state: a new
31621
+ * entity with the components of the description, then its `enter` hook, then its loop. What a
31622
+ * mount does not bring is gone with the old tree: the local state of a component, a scroll
31623
+ * offset, the keyboard focus, a text being edited, a hover. A root that is leaving keeps
31624
+ * leaving.
31625
+ */
31626
+ function remountRoots() {
31627
+ state.remount = false;
31628
+ for (const root of state.roots.values()) {
31629
+ if (state.removing.has(root.entity)) continue;
31630
+ const element = root.element === void 0 ? void 0 : lookup(root.element);
31631
+ if (element !== void 0) {
31632
+ forgetSubtree(element);
31633
+ forgetInstances(state, element.identity);
31634
+ despawnTree(element);
31635
+ }
31636
+ root.element = void 0;
31637
+ root.dirty = true;
31638
+ root.needsSolve = true;
31639
+ }
31640
+ }
31641
+ /**
31226
31642
  * Reconciles one root from the node its `Tree` carries.
31227
31643
  *
31228
31644
  * @param root - The root to reconcile.
@@ -31283,13 +31699,15 @@ function createReconciler(ctx, modules, fields) {
31283
31699
  }
31284
31700
  }
31285
31701
  /**
31286
- * The first of the two systems of phase `layout`: the sweep, the popups that may leave, the
31287
- * pull of the text being typed, the scroll offsets and the windowed ranges, the bound texts that
31288
- * show a string of another size, the viewport, the dirty roots and the hosted views.
31702
+ * The first of the two systems of phase `layout`: the sweep, the roots a restore asked to build
31703
+ * again, the popups that may leave, the pull of the text being typed, the scroll offsets and the
31704
+ * windowed ranges, the bound texts that show a string of another size, the viewport, the dirty
31705
+ * roots and the hosted views.
31289
31706
  */
31290
31707
  function reconcile() {
31291
31708
  sweep();
31292
31709
  state.reconciles += 1;
31710
+ if (state.remount) remountRoots();
31293
31711
  settlePopups(ctx, state, unmountRoot);
31294
31712
  fields.pull();
31295
31713
  const containers = scrollContainers();
@@ -31429,7 +31847,8 @@ function createReconciler(ctx, modules, fields) {
31429
31847
  unmountRoot,
31430
31848
  markPointer,
31431
31849
  playEnter,
31432
- sweep
31850
+ sweep,
31851
+ converged
31433
31852
  };
31434
31853
  }
31435
31854
  //#endregion
@@ -31555,6 +31974,7 @@ function createJsxApi(ctx, modules) {
31555
31974
  applyTap: (entity) => applyTap(ctx, entity),
31556
31975
  markPointer: frame.markPointer,
31557
31976
  playEnter: frame.playEnter,
31977
+ converged: frame.converged,
31558
31978
  key: (input) => focus.key(input),
31559
31979
  blur: focus.blur
31560
31980
  };
@@ -31828,7 +32248,9 @@ function restVisual(element) {
31828
32248
  }
31829
32249
  /**
31830
32250
  * Records the rest pose of one element before a hook runs: the transform from `Box` and the
31831
- * style, and the visual the element is actually drawn with.
32251
+ * style, and the visual the element is actually drawn with. The content of a scroll records no
32252
+ * rest `Transform`: the scroll step owns its pose, and the world brings an element back to every
32253
+ * rest it holds after a restore.
31832
32254
  *
31833
32255
  * @param ctx - Domain context of the ui plugin.
31834
32256
  * @param element - The element that entered or moved.
@@ -31836,7 +32258,7 @@ function restVisual(element) {
31836
32258
  */
31837
32259
  function writeRest(ctx, element, parent) {
31838
32260
  const projection = ctx.deps.world.projection;
31839
- projection.setRest(element.entity, Transform, restTransform(element.rect, parent, element.style, element.fit));
32261
+ if (element.type !== "content") projection.setRest(element.entity, Transform, restTransform(element.rect, parent, element.style, element.fit));
31840
32262
  const visual = restVisual(element);
31841
32263
  if (visual !== void 0) projection.setRest(element.entity, visual.component, visual.value);
31842
32264
  }
@@ -31948,8 +32370,8 @@ function still(element) {
31948
32370
  * element plays its `change.Transform` motion when it has one, else takes the pose through a
31949
32371
  * 0 ms rest track, which lands on the next frame step and lets an additive loop re-base on the
31950
32372
  * new rest; an element that has not spawned yet only records it, and so does one whose
31951
- * `change.Box` motion already played. The content of a scroll keeps its `Transform`: the scroll
31952
- * step owns it.
32373
+ * `change.Box` motion already played. The content of a scroll keeps its `Transform` and records
32374
+ * no rest with the world: the scroll step owns it.
31953
32375
  *
31954
32376
  * @param ctx - Domain context of the ui plugin.
31955
32377
  * @param element - The element whose rect, style or fit may have changed.
@@ -31962,6 +32384,7 @@ function repose(ctx, element, parent, hooked, hint) {
31962
32384
  if (samePose(element.rest, next)) return;
31963
32385
  const previous = element.rest;
31964
32386
  element.rest = next;
32387
+ if (element.type === "content") return;
31965
32388
  ctx.deps.world.projection.setRest(element.entity, Transform, next);
31966
32389
  if (!element.live || hooked) return;
31967
32390
  const hook = element.motion?.change?.Transform;
@@ -31969,7 +32392,7 @@ function repose(ctx, element, parent, hooked, hint) {
31969
32392
  play(ctx, element, (view) => hook(view, previous, next, hint));
31970
32393
  return;
31971
32394
  }
31972
- if (element.type !== "content") handleOf(ctx, element)?.toRest(Transform, { ms: 0 });
32395
+ handleOf(ctx, element)?.toRest(Transform, { ms: 0 });
31973
32396
  }
31974
32397
  /**
31975
32398
  * Moves the rest `Transform` of a root element up by the keyboard lift, or back to its own rest
@@ -33401,7 +33824,8 @@ function registerComponents(ctx, jsx) {
33401
33824
  }
33402
33825
  /**
33403
33826
  * Opens the two systems of phase `layout`, the seven world hooks of `Tree`, `Box`, `Pressed` and
33404
- * `PointerOver`, the two effect handlers, the hint listener that buffers every released hint for
33827
+ * `PointerOver`, the listener that asks for every root to be built again after a restore or a
33828
+ * load, the two effect handlers, the hint listener that buffers every released hint for
33405
33829
  * the `change` hooks of the next frame step, the three tap listeners (the `LocalWrite` patch, the
33406
33830
  * pointer tap that clears the keyboard focus, and the tap that starts or ends the editing of a
33407
33831
  * text field), the pointer door that opens the keyboard inside the DOM listener, the key listener
@@ -33429,7 +33853,7 @@ function openRegistrations(ctx, jsx, layout) {
33429
33853
  })), ecs.onAdded(Tree, (entity) => {
33430
33854
  const place = projection.keyOf(entity);
33431
33855
  jsx.mountRoot(entity, place?.projection ?? "ui", ecs.get(entity, Layer)?.name ?? "ui");
33432
- }), ecs.onRemoved(Tree, (entity) => jsx.unmountRoot(entity)), ecs.onAdded(Box, (entity) => jsx.playEnter(entity)), ecs.onAdded(Pressed, (entity) => jsx.markPointer(entity, "pressed", true)), ecs.onRemoved(Pressed, (entity) => jsx.markPointer(entity, "pressed", false)), ecs.onAdded(PointerOver, (entity) => jsx.markPointer(entity, "hover", true)), ecs.onRemoved(PointerOver, (entity) => jsx.markPointer(entity, "hover", false)), ctx.deps.flow.fx.handle("popup", layout.popupHandler(jsx)), ctx.deps.flow.fx.handle("guide", layout.guideHandler()), ctx.deps.flow.fx.onHint((hint) => {
33856
+ }), ecs.onRemoved(Tree, (entity) => jsx.unmountRoot(entity)), ecs.onAdded(Box, (entity) => jsx.playEnter(entity)), projection.onConverged(jsx.converged), ecs.onAdded(Pressed, (entity) => jsx.markPointer(entity, "pressed", true)), ecs.onRemoved(Pressed, (entity) => jsx.markPointer(entity, "pressed", false)), ecs.onAdded(PointerOver, (entity) => jsx.markPointer(entity, "hover", true)), ecs.onRemoved(PointerOver, (entity) => jsx.markPointer(entity, "hover", false)), ctx.deps.flow.fx.handle("popup", layout.popupHandler(jsx)), ctx.deps.flow.fx.handle("guide", layout.guideHandler()), ctx.deps.flow.fx.onHint((hint) => {
33433
33857
  ctx.state.jsx.hints.push(hint);
33434
33858
  }), ctx.deps.input.onTap(jsx.applyTap), ctx.deps.input.onTap(() => jsx.blur()), ctx.deps.input.onTap(jsx.tapped), ctx.deps.input.onPointer(jsx.pointer), ctx.deps.input.onKey(jsx.key), ctx.deps.input.controls.add(LocalWrite), () => jsx.releaseHosted(), () => ecs.despawnOwnedBy(UI_OWNER));
33435
33859
  }
@@ -33482,6 +33906,7 @@ function stopUi(state) {
33482
33906
  state.jsx.hosts.clear();
33483
33907
  state.jsx.hosted.clear();
33484
33908
  state.jsx.scrolls.clear();
33909
+ state.jsx.remount = false;
33485
33910
  state.jsx.hints.length = 0;
33486
33911
  state.jsx.focus.entity = void 0;
33487
33912
  state.jsx.focus.ring = void 0;
@@ -33824,7 +34249,8 @@ function createJsxState() {
33824
34249
  text: createTextState(),
33825
34250
  hints: [],
33826
34251
  scrolls: emptySet(),
33827
- windowRenders: 0
34252
+ windowRenders: 0,
34253
+ remount: false
33828
34254
  };
33829
34255
  }
33830
34256
  //#endregion
@@ -35424,6 +35850,7 @@ function defineMotion(spec) {
35424
35850
  * | flow | retries | 1 |
35425
35851
  * | flow | settleTimeoutMs | 2000 |
35426
35852
  * | flow | journalLimit | 500 |
35853
+ * | flow | holdMs | 0, an answer to a closed gate lives one frame |
35427
35854
  * | world | settleMs | 350 |
35428
35855
  * | world | reconciledEvent | false |
35429
35856
  * | renderer | mount | undefined, inert without a mount |