@moku-labs/game 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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",
@@ -18210,8 +18452,9 @@ async function bufferOf(ctx, key) {
18210
18452
  /**
18211
18453
  * Plays one sound: the handler of the kind `"sfx"`. Every play is its own source, so the same key
18212
18454
  * 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.
18455
+ * ignores its signal: a sound that began plays to its end. While a gesture's `resume()` is
18456
+ * pending the sound waits in `pendingSfx`, one per key. A locked context no gesture is resuming
18457
+ * drops it: the start attempt of the unlock queues nothing.
18215
18458
  *
18216
18459
  * @param ctx - Domain context of the plugin.
18217
18460
  * @param descriptor - The `sfx` descriptor `anim` built.
@@ -18411,7 +18654,7 @@ function refused(ctx, key) {
18411
18654
  /**
18412
18655
  * Switches the music: the handler of the kind `"music"` and what `scenes:changed` calls. The same
18413
18656
  * 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
18657
+ * is only remembered; the unlock starts it. A key that is still on its way counts as the
18415
18658
  * same key, and every other request replaces it: the replaced switch starts nothing when its
18416
18659
  * buffer or its element is ready, so one tap that unlocks the context and enters a scene with the
18417
18660
  * same track starts it once. `config.music` picks the decode or the stream path.
@@ -18569,17 +18812,22 @@ function resetSession(state) {
18569
18812
  //#endregion
18570
18813
  //#region src/plugins/audio/unlock.ts
18571
18814
  /**
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.
18815
+ * @file audio plugin — the unlock: the only file that touches `window`. A browser starts an audio
18816
+ * context suspended and lets it run on a user gesture, so two listeners wait for the first one.
18817
+ * They sit on `window`, not on the canvas: the gesture may as well be a button of the game's
18818
+ * loading page, which `input` never sees. A shell that asks for no gesture, such as a native
18819
+ * webview with the requirement switched off, lets the context run at once. So the plugin also
18820
+ * asks once at start, and unlocks with no touch where the answer is yes.
18576
18821
  */
18577
18822
  /** The two gestures that unlock a context. The first of them to arrive wins. */
18578
18823
  const UNLOCK_EVENTS = ["pointerdown", "touchend"];
18579
18824
  /**
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.
18825
+ * Reacts to a resume that went through, of a gesture or of the start attempt. The context is
18826
+ * unlocked only when it really runs; the sounds fired during the resume and the track a scene
18827
+ * declared while it was locked start here. A context that still does not run drops the sounds:
18828
+ * the next gesture is a new moment. A gesture and the start attempt that settle together both
18829
+ * come here, and the music still starts once: a track that is on its way or already plays is not
18830
+ * started again. A later gesture on an unlocked context tries a remembered track again.
18583
18831
  *
18584
18832
  * @param ctx - Domain context of the plugin.
18585
18833
  */
@@ -18632,8 +18880,50 @@ function resumeContext(ctx) {
18632
18880
  });
18633
18881
  }
18634
18882
  /**
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.
18883
+ * Reacts to the start attempt that fulfilled. Nothing happens when the app stopped meanwhile, when
18884
+ * a gesture already unlocked the context, or when the context still does not run: the listeners
18885
+ * are there for that. Otherwise the listeners go and the context is unlocked the way a gesture
18886
+ * unlocks it.
18887
+ *
18888
+ * @param ctx - Domain context of the plugin.
18889
+ * @param context - The context the attempt was made on.
18890
+ */
18891
+ function onStartResumed(ctx, context) {
18892
+ const state = ctx.state;
18893
+ if (state.context !== context || state.unlocked || context.state !== "running") return;
18894
+ removeUnlock(state);
18895
+ onResumed(ctx);
18896
+ }
18897
+ /**
18898
+ * Leaves a refused start attempt alone. Nobody asked for sound yet, so there is nothing to report,
18899
+ * and the listeners are installed: the first gesture asks again.
18900
+ */
18901
+ function ignoreRefusal() {}
18902
+ /**
18903
+ * Unlocks the context in `onStart` where the shell lets audio play, and waits for a gesture where
18904
+ * it does not. A context that already runs is unlocked at once, with no listener. Any other gets
18905
+ * the two listeners and one `resume()`. `resuming` is not set for it: a browser that wants a
18906
+ * gesture leaves that promise pending, and a sound must not queue behind a promise that may never
18907
+ * settle. The attempt needs no `window`, so it also serves a context given through
18908
+ * `config.context` in a runtime without a DOM.
18909
+ *
18910
+ * @param ctx - Domain context of the plugin.
18911
+ */
18912
+ function startUnlock(ctx) {
18913
+ const context = ctx.state.context;
18914
+ if (context === void 0) return;
18915
+ if (context.state === "running") {
18916
+ onResumed(ctx);
18917
+ return;
18918
+ }
18919
+ installUnlock(ctx);
18920
+ context.resume().then(() => {
18921
+ onStartResumed(ctx, context);
18922
+ }, ignoreRefusal);
18923
+ }
18924
+ /**
18925
+ * Installs the two unlock listeners on `window`. Without a DOM there is nothing to listen on:
18926
+ * the context stays locked unless the start attempt of `startUnlock` runs it.
18637
18927
  *
18638
18928
  * @param ctx - Domain context of the plugin.
18639
18929
  */
@@ -18720,9 +19010,10 @@ function createContext(config) {
18720
19010
  }
18721
19011
  /**
18722
19012
  * 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.
19013
+ * effect handlers, then the context with its graph and its unlock. The unlock asks the context to
19014
+ * run at once: a shell that lets audio play is unlocked with no touch, and a browser that wants a
19015
+ * gesture gets the two listeners. The handlers are registered even headless, so a node that
19016
+ * awaits a sound resolves in plain Bun exactly as it does in a browser.
18726
19017
  *
18727
19018
  * @param ctx - Kernel context of the audio plugin.
18728
19019
  */
@@ -18737,7 +19028,7 @@ function startAudio(ctx) {
18737
19028
  state.context = context;
18738
19029
  state.buses = buildGraph(context, state.buses);
18739
19030
  applyAllGains(state);
18740
- installUnlock(audio);
19031
+ startUnlock(audio);
18741
19032
  }
18742
19033
  /**
18743
19034
  * Frees everything `onStart` opened: the two handler registrations, the two window listeners, the
@@ -24501,8 +24792,8 @@ function leave(ctx) {
24501
24792
  * Runs the Back chain once. `back()` and the provider's `onBack` both go through here, so a test
24502
24793
  * and a real press end the same way. Escape is what ui answers with the `escape` button of its
24503
24794
  * 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.
24795
+ * is closed: it holds the answer for the next gate (one frame, or `flow.holdMs`), so the press
24796
+ * counts as taken and never leaves the app in the middle of a step.
24506
24797
  *
24507
24798
  * @param ctx - Kernel context of the platform plugin.
24508
24799
  * @returns What took the press, `"none"` without a provider.
@@ -35424,6 +35715,7 @@ function defineMotion(spec) {
35424
35715
  * | flow | retries | 1 |
35425
35716
  * | flow | settleTimeoutMs | 2000 |
35426
35717
  * | flow | journalLimit | 500 |
35718
+ * | flow | holdMs | 0, an answer to a closed gate lives one frame |
35427
35719
  * | world | settleMs | 350 |
35428
35720
  * | world | reconciledEvent | false |
35429
35721
  * | renderer | mount | undefined, inert without a mount |