@moku-labs/game 0.14.1 → 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.
@@ -1,7 +1,7 @@
1
- import { Dt as AnyFlow, Ht as ClockSource, Nt as NodeTable, Wt as FakeClock, Xt as Json, ht as FeaturePlugin } from "./types-BWDESfx2.mjs";
2
- import { t as createApp } from "./index-yUO4Dx2C.mjs";
3
- import { n as saveOf, t as memory } from "./memory-D1IKoFr1.mjs";
4
- import { a as createHeadless, i as ReproResult, n as HeadlessGame, o as runRepro, r as Repro, s as stepFrames, t as HeadlessApp } from "./headless-IfD6_JMH.mjs";
1
+ import { Dt as AnyFlow, Ht as ClockSource, Nt as NodeTable, Wt as FakeClock, Xt as Json, ht as FeaturePlugin } from "./types-BAWV0bNA.mjs";
2
+ import { t as createApp } from "./index-CGynRcOj.mjs";
3
+ import { n as saveOf, t as memory } from "./memory-DUNdViKq.mjs";
4
+ import { a as createHeadless, i as ReproResult, n as HeadlessGame, o as runRepro, r as Repro, s as stepFrames, t as HeadlessApp } from "./headless-CBQhbYPZ.mjs";
5
5
  import { AnyPluginInstance } from "@moku-labs/core";
6
6
 
7
7
  //#region src/plugins/clock/fake.d.ts
package/dist/testing.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { Kt as type, Ut as exit, t as createApp } from "./src-Dgmdzl8R.mjs";
1
+ import { Kt as type, Ut as exit, t as createApp } from "./src-BD-MZliM.mjs";
2
2
  import { n as saveOf, t as memory } from "./memory-CWlqhS7l.mjs";
3
3
  import { t as fakeClock } from "./fake-MpzwZhIZ.mjs";
4
4
  import { i as stepFrames, r as runRepro, t as createHeadless } from "./headless-BA6ffzqD.mjs";
@@ -1339,10 +1339,16 @@ type GateSpec = {
1339
1339
  */
1340
1340
  type GateState = {
1341
1341
  /** The spec of the open gate. `undefined`: the gate is closed. */open: GateSpec | undefined; /** Resolver of the promise returned by `open`. */
1342
- resolve: ((answer: Answer) => void) | undefined; /** An answer given while the gate was closed, kept for one frame. */
1342
+ resolve: ((answer: Answer) => void) | undefined;
1343
+ /**
1344
+ * An answer given while the gate was closed, kept for the next gate: for the frame it was given
1345
+ * in, or for `config.holdMs` of game time. `frame` is the frame of the refusal and `at` is
1346
+ * `time.snapshot().elapsed` at that moment: the age is counted from it.
1347
+ */
1343
1348
  held: {
1344
1349
  answer: Answer;
1345
1350
  frame: number;
1351
+ at: number;
1346
1352
  } | undefined; /** The one answer a running `guide` lets through. */
1347
1353
  narrow: Allow | undefined; /** True while a pointer is down. Entering an `over` node waits for false. */
1348
1354
  pointerActive: boolean; /** Ends the pending pointer wait of the runner. Set while the wait runs, so a stop needs no frame. */
@@ -1362,15 +1368,21 @@ type GateState = {
1362
1368
  type GateApi = {
1363
1369
  /**
1364
1370
  * Gives one answer. Accepted only while the gate is open and the intent is allowed. An answer
1365
- * to a closed gate is held for one frame and re-offered when the gate opens within it.
1371
+ * to a closed gate is refused and held: it is offered once to the next gate that opens, which
1372
+ * takes it when it lists the intent and a running `guide` lets it through. With `holdMs: 0` the
1373
+ * hold lives for the frame the answer was given in and the first answer wins. With `holdMs`
1374
+ * above 0 the last answer wins and it is kept for that much game time. Nothing is held in fast
1375
+ * mode or while no frame source runs.
1366
1376
  *
1367
1377
  * @param answer - Intent and optional payload.
1368
- * @returns Whether the answer was accepted.
1378
+ * @returns Whether the answer was accepted. `false` for a held answer too: it is not taken yet.
1369
1379
  * @example
1370
1380
  * ```ts
1371
1381
  * // The player dropped the item of cell c2 onto c3 while the board rests.
1372
1382
  * app.flow.gate.answer({ intent: "merge", payload: { from: "c2", to: "c3" } }); // true
1373
1383
  * app.flow.gate.answer({ intent: "quit" }); // false: the resting node does not list "quit"
1384
+ * // With holdMs: 1100, a tap during the merge animation is played when the board rests again.
1385
+ * app.flow.gate.answer({ intent: "tap", payload: { cell: "c4" } }); // false now, taken later
1374
1386
  * ```
1375
1387
  */
1376
1388
  answer(answer: Answer): boolean;
@@ -1759,6 +1771,33 @@ type NodeContext<Game extends GameState = GameState, Input = void, Tags extends
1759
1771
  * The body of a node: plain `await` code that ends with `out.name(data)`.
1760
1772
  */
1761
1773
  type NodeRun<Game extends GameState, Input, Tags extends OutcomeTags> = (ctx: NodeContext<Game, Input, Tags>) => Result<keyof Tags & string> | Promise<Result<keyof Tags & string>>;
1774
+ /**
1775
+ * The one object the `recover` of a rest node receives: the node context without `input` and
1776
+ * `out`. The transition that led here failed, so there is no input, and a recovery ends with no
1777
+ * outcome: the node is entered right after it. `player` and `session` are drafts of the
1778
+ * recovery's own transaction, `now` is `clock.now()` read once when the recovery starts.
1779
+ *
1780
+ * @example
1781
+ * ```ts
1782
+ * // The safe node "home" of a board game. The board failed: the session still says "board".
1783
+ * recover: ({ session }: RecoverContext<Game>) => {
1784
+ * session.screen = "home";
1785
+ * session.moves = [];
1786
+ * }
1787
+ * ```
1788
+ */
1789
+ type RecoverContext<Game extends GameState = GameState> = {
1790
+ player: Game["player"];
1791
+ session: Game["session"];
1792
+ rng: RngView;
1793
+ fx: NodeFx;
1794
+ signal: AbortSignal;
1795
+ now: number;
1796
+ };
1797
+ /**
1798
+ * The recovery of a rest node: plain `await` code that puts the state right and returns nothing.
1799
+ */
1800
+ type NodeRecover<Game extends GameState> = (context: RecoverContext<Game>) => void | Promise<void>;
1762
1801
  /**
1763
1802
  * The part of a node, or of a flow used as a node, that an edge table looks at.
1764
1803
  */
@@ -1795,11 +1834,12 @@ type NodeDefinition<Input, Tags extends OutcomeTags, Game extends GameState = Ga
1795
1834
  readonly barrier: boolean;
1796
1835
  readonly inbox: readonly (keyof Tags & string)[]; /** Id of the scene the node is shown on. Absent: the node keeps the current scene. */
1797
1836
  readonly scene?: string;
1798
- readonly run?: NodeRun<Game, Input, Tags>;
1837
+ readonly run?: NodeRun<Game, Input, Tags>; /** The recovery of a rest node. Absent: the node is entered as it is after a failure. */
1838
+ readonly recover?: NodeRecover<Game>;
1799
1839
  };
1800
1840
  /**
1801
1841
  * What the author passes to `defineNode`. `run` is required unless `rest: true`: a rest node
1802
- * without a body is a pure wait.
1842
+ * without a body is a pure wait. `recover` exists on a rest node only.
1803
1843
  *
1804
1844
  * @example
1805
1845
  * ```ts
@@ -1822,9 +1862,34 @@ type NodeSpec<Game extends GameState, Input, Tags extends OutcomeTags> = {
1822
1862
  } & ({
1823
1863
  rest: true;
1824
1864
  run?: NodeRun<Game, Input, Tags>;
1865
+ /**
1866
+ * Puts the state right before this node is entered as the safe node. When a transition
1867
+ * failed and its retries are used up, the graph rolls back to the rest point that failed
1868
+ * and moves to `flow.safeNode`. Without `recover` that node opens its gate on the state of
1869
+ * the rest point it did not come from. With it the engine runs `recover` first, as one
1870
+ * transaction: what it writes is committed as an edge of the node onto itself, with the
1871
+ * outcome `"recover"`, and becomes the rest point a later rollback returns to. Then the node
1872
+ * is entered as usual. It runs only in the safe node, only after the loop gave up, never on
1873
+ * a retry and never after a `restore`. A `recover` that throws rejects `run()`.
1874
+ *
1875
+ * @example
1876
+ * ```ts
1877
+ * // "home" is flow.safeNode. The board failed twice: the session still describes the board.
1878
+ * const home = defineNode({
1879
+ * rest: true,
1880
+ * checkpoint: true,
1881
+ * outcomes: { play: type() },
1882
+ * recover: ({ session }) => {
1883
+ * session.screen = "home"; // the gate of "home" opens on this, not on "board"
1884
+ * }
1885
+ * });
1886
+ * ```
1887
+ */
1888
+ recover?: NodeRecover<Game>;
1825
1889
  } | {
1826
1890
  rest?: false;
1827
- run: NodeRun<Game, Input, Tags>;
1891
+ run: NodeRun<Game, Input, Tags>; /** A transit node has no `recover`: writing one is a compile error. */
1892
+ recover?: never;
1828
1893
  });
1829
1894
  /**
1830
1895
  * The signature of `defineNode` bound to one game. `defineGame<Types>()` returns it.
@@ -2046,8 +2111,13 @@ type AnyNodeContext = {
2046
2111
  now: number;
2047
2112
  };
2048
2113
  /**
2049
- * Any node as the runner holds it. `run` is a method on purpose: method parameters are compared
2050
- * bivariantly, so every typed `NodeDefinition` fits and the runner calls the body without a cast.
2114
+ * The recovery context as the runner builds it. Every typed `RecoverContext` is assignable to it.
2115
+ */
2116
+ type AnyRecoverContext = Omit<AnyNodeContext, "input" | "out">;
2117
+ /**
2118
+ * Any node as the runner holds it. `run` and `recover` are methods on purpose: method parameters
2119
+ * are compared bivariantly, so every typed `NodeDefinition` fits and the runner calls both
2120
+ * without a cast.
2051
2121
  *
2052
2122
  * @example
2053
2123
  * ```ts
@@ -2063,6 +2133,7 @@ type AnyNode = AnyWired & {
2063
2133
  readonly inbox: readonly string[]; /** Id of the scene the node is shown on. Absent: the node keeps the current scene. */
2064
2134
  readonly scene?: string;
2065
2135
  run?(ctx: AnyNodeContext): Result | Promise<Result>;
2136
+ recover?(context: AnyRecoverContext): void | Promise<void>;
2066
2137
  };
2067
2138
  /**
2068
2139
  * One entry of a flow's `nodes`: a node, a sub-flow or a slot. The `kind` field tells them apart.
@@ -2810,7 +2881,7 @@ declare function to<const TargetName extends string, Payload, Output>(target: Ta
2810
2881
  */
2811
2882
  declare function slot(name: string): SlotNode;
2812
2883
  declare namespace types_d_exports$3 {
2813
- export { Allow, Answer, AnyFlow, AnyNode, AnyNodeContext, Api$3 as Api, Bookmark, BundlesOf, CheckedEdges, Config$3 as Config, Contribution, DefineNode, Deps$3 as Deps, Descriptor, Edges, EmitterIdOf, EnterCallback, Events$3 as Events, Exit, FeatureDescription, FeatureEmitter, FeatureFilter, FeaturePlugin, FeaturesApi, FlowCtx, FlowDefinition, FlowEntry, FlowGraph, FlowKit, FlowSpec, FlowState, FxApi, FxHandler, GameState, GameTypes, GateApi, GraphError, GraphNode, GuideOptions, Hint, InboxApi, JournalEntry, KernelSlice$3 as KernelSlice, LifecycleChanged, Mapped, NodeContext, NodeDefinition, NodeFx, NodeInfo, NodeSpec, NodeTable, OutcomeTags, Position, Result, RouteStep, RunnerApi, SceneIdOf, SlotNode, Stage, State$3 as State, StringTableLike, Target, TextStylesOf, TypeTag, WorldEvent };
2884
+ export { Allow, Answer, AnyFlow, AnyNode, AnyNodeContext, Api$3 as Api, Bookmark, BundlesOf, CheckedEdges, Config$3 as Config, Contribution, DefineNode, Deps$3 as Deps, Descriptor, Edges, EmitterIdOf, EnterCallback, Events$3 as Events, Exit, FeatureDescription, FeatureEmitter, FeatureFilter, FeaturePlugin, FeaturesApi, FlowCtx, FlowDefinition, FlowEntry, FlowGraph, FlowKit, FlowSpec, FlowState, FxApi, FxHandler, GameState, GameTypes, GateApi, GraphError, GraphNode, GuideOptions, Hint, InboxApi, JournalEntry, KernelSlice$3 as KernelSlice, LifecycleChanged, Mapped, NodeContext, NodeDefinition, NodeFx, NodeInfo, NodeSpec, NodeTable, OutcomeTags, Position, RecoverContext, Result, RouteStep, RunnerApi, SceneIdOf, SlotNode, Stage, State$3 as State, StringTableLike, Target, TextStylesOf, TypeTag, WorldEvent };
2814
2885
  }
2815
2886
  /**
2816
2887
  * flow plugin events.
@@ -2825,7 +2896,11 @@ declare namespace types_d_exports$3 {
2825
2896
  * ```
2826
2897
  */
2827
2898
  type Events$3 = {
2828
- /** An edge was taken and its state committed. */"flow:edge": {
2899
+ /**
2900
+ * An edge was taken and its state committed. Also the recovery of the safe node: `node` and
2901
+ * `next` are then the safe node and `outcome` is `"recover"`.
2902
+ */
2903
+ "flow:edge": {
2829
2904
  flow: string;
2830
2905
  node: string;
2831
2906
  outcome: string;
@@ -2846,7 +2921,11 @@ type Events$3 = {
2846
2921
  "flow:rest": {
2847
2922
  path: string;
2848
2923
  checkpoint: boolean;
2849
- }; /** A node failed and the graph rolled back. */
2924
+ };
2925
+ /**
2926
+ * A node failed and the graph rolled back. The engine writes the same failure to the log first,
2927
+ * as an error entry `flow:error`, so a game without a hook still sees it.
2928
+ */
2850
2929
  "flow:error": {
2851
2930
  path: string;
2852
2931
  error: unknown;
@@ -2868,6 +2947,21 @@ type Config$3 = {
2868
2947
  retries: number; /** How long `onStop` waits for the active node to settle after abort, in real milliseconds. */
2869
2948
  settleTimeoutMs: number; /** Journal entries kept between checkpoints. */
2870
2949
  journalLimit: number;
2950
+ /**
2951
+ * How long an answer a closed gate refused is kept for the next gate, in milliseconds of game
2952
+ * time (`time.snapshot().elapsed`, which stands still during a pause). `0`: for the frame it was
2953
+ * given in, and the first answer wins. Above `0`: the last answer wins, and it is dropped once
2954
+ * it is older than `holdMs`. `Infinity`: kept until the next gate opens.
2955
+ *
2956
+ * @example
2957
+ * ```ts
2958
+ * // The move animation of a board game closes the gate for about a second. A tap on the next
2959
+ * // cell during it is played when the board rests again, not lost.
2960
+ * createApp({ pluginConfigs: { flow: { mainFlow, safeNode: "home", holdMs: 1100 } } });
2961
+ * app.flow.gate.answer({ intent: "tap", payload: { cell: 4 } }); // false: kept for the next gate
2962
+ * ```
2963
+ */
2964
+ holdMs: number;
2871
2965
  };
2872
2966
  /**
2873
2967
  * flow plugin state: one branch per module.
@@ -1,5 +1,5 @@
1
- import { Xt as Json, an as Snapshot } from "./types-BWDESfx2.mjs";
2
- import { t as HeadlessApp } from "./headless-IfD6_JMH.mjs";
1
+ import { Xt as Json, an as Snapshot } from "./types-BAWV0bNA.mjs";
2
+ import { t as HeadlessApp } from "./headless-CBQhbYPZ.mjs";
3
3
  import { Log } from "@moku-labs/common/browser";
4
4
 
5
5
  //#region src/plugins/flow/doors/types.d.ts
@@ -1,5 +1,5 @@
1
- import { A as Api$2, B as ComponentType, F as AnyComponent, H as Entity, It as TypeTag, L as AnyComponentValue, Lt as Descriptor, R as ComponentHandle, Sn as Require, Xt as Json, Yt as Events$2, at as NumericFields, ct as TrackOptions, dn as Events$3, dt as Api$1, et as Ease, h as Api$5, hn as Api, it as MotionHandle, lt as TrackSegment, nt as LayerSpec, q as Owner, qt as Api$4, s as Events$4, t as Api$3, tt as LayerSort, xn as Events$5 } from "./types-BWDESfx2.mjs";
2
- import { A as InputApi, c as UiNode, i as Finding, n as ElementChange, o as JsxState, q as I18nApi, r as ElementMotion, rt as StylesState, x as TextApi } from "./types-BArxen_M.mjs";
1
+ import { A as Api$2, B as ComponentType, F as AnyComponent, H as Entity, It as TypeTag, L as AnyComponentValue, Lt as Descriptor, R as ComponentHandle, Sn as Require, Xt as Json, Yt as Events$2, at as NumericFields, ct as TrackOptions, dn as Events$3, dt as Api$1, et as Ease, h as Api$5, hn as Api, it as MotionHandle, lt as TrackSegment, nt as LayerSpec, q as Owner, qt as Api$4, s as Events$4, t as Api$3, tt as LayerSort, xn as Events$5 } from "./types-BAWV0bNA.mjs";
2
+ import { A as InputApi, c as UiNode, i as Finding, n as ElementChange, o as JsxState, q as I18nApi, r as ElementMotion, rt as StylesState, x as TextApi } from "./types-zzul0fH3.mjs";
3
3
  import { Log } from "@moku-labs/common/browser";
4
4
  import { PluginCtx } from "@moku-labs/core";
5
5
  import { Node, Yoga } from "yoga-layout/load";
@@ -1594,7 +1594,7 @@ type BusState = {
1594
1594
  muted: boolean;
1595
1595
  };
1596
1596
  /**
1597
- * The music that is playing, or the key remembered until the first touch unlocks the context.
1597
+ * The music that is playing, or the key remembered until the context is unlocked.
1598
1598
  * `source` plays a decoded track, `stream` a streamed one; they are never both set. Both
1599
1599
  * `undefined`: the key is only remembered (a locked context, or a refused `play()`).
1600
1600
  *
@@ -1628,7 +1628,7 @@ type State$1 = {
1628
1628
  /** `undefined` means headless: no graph, no sound, every member still keeps its value. */context: AudioContextLike | undefined;
1629
1629
  buses: Record<Bus, BusState>; /** A lifecycle push is in force: every bus is held at zero. */
1630
1630
  paused: boolean;
1631
- unlocked: boolean; /** A gesture called `resume()` and it has not settled yet. */
1631
+ unlocked: boolean; /** A gesture called `resume()` and it has not settled yet. The start attempt never sets it. */
1632
1632
  resuming: boolean; /** Sounds fired while `resuming`, one per key; played when the context runs, dropped when it does not. */
1633
1633
  pendingSfx: Map<string, SfxRequest>; /** Per key, evicted per key on `assets:bundle-unloaded` and `assets:replaced`. */
1634
1634
  decoded: Map<string, Promise<AudioBuffer>>; /** Asset keys that already got their one warning. */
@@ -1720,14 +1720,20 @@ type AudioApi = {
1720
1720
  */
1721
1721
  muted(bus: Bus): boolean;
1722
1722
  /**
1723
- * Tells whether the browser let the audio context run. It is false until the first pointer
1724
- * event on the page resumed it, and false for the whole of a headless run.
1723
+ * Tells whether the audio context runs. A shell that lets audio play with no gesture, such as a
1724
+ * native webview with the requirement switched off, makes it true at start: at once when the
1725
+ * context is created running, otherwise when the `resume()` of `onStart` fulfils. A browser
1726
+ * that wants a gesture keeps it false until the first pointer event on the page resumed the
1727
+ * context. It is false for the whole of a headless run.
1725
1728
  *
1726
1729
  * @returns True once the context is running.
1727
1730
  * @example
1728
1731
  * ```ts
1729
- * // A "tap to start" screen stays up until the browser allowed sound.
1730
- * app.audio.unlocked(); // false before the first touch, true after it
1732
+ * // A "tap for sound" hint stays on screen only while the shell holds the sound back.
1733
+ * await app.start();
1734
+ * app.audio.unlocked(); // true where the context was created running, false in a browser tab
1735
+ * // The player touches the page of that tab.
1736
+ * app.audio.unlocked(); // true
1731
1737
  * ```
1732
1738
  */
1733
1739
  unlocked(): boolean;
@@ -1,5 +1,5 @@
1
- import { A as Api$1, B as ComponentType, D as FilterSlot, H as Entity, O as Point, R as ComponentHandle, Sn as Require, _ as PixiFilter, b as PixiTexture, dt as Api, h as Api$2, s as Events, t as Api$3, v as PixiModule, xn as Events$1, y as PixiSprite } from "./types-BWDESfx2.mjs";
2
- import { x as AnimApi } from "./types-oQoQEj-y.mjs";
1
+ import { A as Api$1, B as ComponentType, D as FilterSlot, H as Entity, O as Point, R as ComponentHandle, Sn as Require, _ as PixiFilter, b as PixiTexture, dt as Api, h as Api$2, s as Events, t as Api$3, v as PixiModule, xn as Events$1, y as PixiSprite } from "./types-BAWV0bNA.mjs";
2
+ import { x as AnimApi } from "./types-DO_5UT1J.mjs";
3
3
  import { Log } from "@moku-labs/common/browser";
4
4
  import { PluginCtx } from "@moku-labs/core";
5
5
 
@@ -1,7 +1,7 @@
1
- import { Dt as AnyFlow, Ht as ClockSource, Jt as Config, Wt as FakeClock, Xt as Json, a as Config$3, g as Config$2, ht as FeaturePlugin, n as AssetsIo, pt as Config$1, tn as PlayerStateProvider, u as Manifest, v as PixiModule } from "./types-BWDESfx2.mjs";
2
- import { o as AudioContextLike, s as Config$4 } from "./types-oQoQEj-y.mjs";
3
- import { _t as audioPlugin, ct as PlatformProvider, ht as effectsPlugin, i as screen, ot as platformPlugin, t as createApp } from "./index-yUO4Dx2C.mjs";
4
- import { t as memory } from "./memory-D1IKoFr1.mjs";
1
+ import { Dt as AnyFlow, Ht as ClockSource, Jt as Config, Wt as FakeClock, Xt as Json, a as Config$3, g as Config$2, ht as FeaturePlugin, n as AssetsIo, pt as Config$1, tn as PlayerStateProvider, u as Manifest, v as PixiModule } from "./types-BAWV0bNA.mjs";
2
+ import { o as AudioContextLike, s as Config$4 } from "./types-DO_5UT1J.mjs";
3
+ import { _t as audioPlugin, ct as PlatformProvider, ht as effectsPlugin, i as screen, ot as platformPlugin, t as createApp } from "./index-CGynRcOj.mjs";
4
+ import { t as memory } from "./memory-DUNdViKq.mjs";
5
5
  import { AnyPluginInstance } from "@moku-labs/core";
6
6
 
7
7
  //#region src/app/types.d.ts
@@ -1,4 +1,4 @@
1
- import { A as Api$2, B as ComponentType, F as AnyComponent, H as Entity, I as AnyComponentType, L as AnyComponentValue, Lt as Descriptor, O as Point$2, Q as ChangeHook, Sn as Require, V as ComponentValue, Vt as Api$5, Xt as Json, at as NumericFields, dt as Api$1, h as Api$3, hn as Api, it as MotionHandle, k as ViewportSize, ot as ProjectionMotion, rt as Motion, s as Events$1, t as Api$4, ut as ViewHandle, zt as Hint } from "./types-BWDESfx2.mjs";
1
+ import { A as Api$2, B as ComponentType, F as AnyComponent, H as Entity, I as AnyComponentType, L as AnyComponentValue, Lt as Descriptor, O as Point$2, Q as ChangeHook, Sn as Require, V as ComponentValue, Vt as Api$5, Xt as Json, at as NumericFields, dt as Api$1, h as Api$3, hn as Api, it as MotionHandle, k as ViewportSize, ot as ProjectionMotion, rt as Motion, s as Events$1, t as Api$4, ut as ViewHandle, zt as Hint } from "./types-BAWV0bNA.mjs";
2
2
  import { Log } from "@moku-labs/common/browser";
3
3
  import { PluginCtx } from "@moku-labs/core";
4
4
 
package/dist/visual.d.mts CHANGED
@@ -1,9 +1,9 @@
1
- import { A as Api$1, h as Api, ln as Api$3, qt as Api$2 } from "./types-BWDESfx2.mjs";
2
- import { r as UiApi, x as AnimApi } from "./types-oQoQEj-y.mjs";
3
- import { A as InputApi } from "./types-BArxen_M.mjs";
4
- import { r as Repro } from "./headless-IfD6_JMH.mjs";
5
- import { a as InputOf, r as ControlApp } from "./types-InZGjvkI.mjs";
6
- import { t as commands } from "./commands-Cpn2ga7s.mjs";
1
+ import { A as Api$1, h as Api, ln as Api$3, qt as Api$2 } from "./types-BAWV0bNA.mjs";
2
+ import { r as UiApi, x as AnimApi } from "./types-DO_5UT1J.mjs";
3
+ import { A as InputApi } from "./types-zzul0fH3.mjs";
4
+ import { r as Repro } from "./headless-CBQhbYPZ.mjs";
5
+ import { a as InputOf, r as ControlApp } from "./types-BVHYu1kz.mjs";
6
+ import { t as commands } from "./commands-DWuuqLHu.mjs";
7
7
 
8
8
  //#region src/visual/types.d.ts
9
9
  /** The base commands of the `/control` door, keyed by their short names. */
package/llms.txt CHANGED
@@ -43,8 +43,11 @@ Root exports:
43
43
  - Transit node: has `run`, no `rest`. Rest node: `rest: true`. The graph waits there. Without `run` it is a pure wait.
44
44
  - A pure wait ends in two ways. A gate answer whose `intent` is an outcome name: the answer payload becomes the outcome payload. Or a world event whose `type` is listed in the node's `inbox`: the event payload becomes the outcome payload.
45
45
  - Transition: rest node to rest node. It is a transaction. On an error the runner discards, rolls back to the last rest point, retries `retries` times, then enters `safeNode`. A failure inside `safeNode` rejects `run()`.
46
+ - Every failure is written into the log by the engine: an `error` entry `flow:error` with `{ path, rolledBackTo, retry, error: { message, stack } }`. Read it with `app.log.trace()`. A game needs no plugin for it.
47
+ - `recover` on a rest node: `recover: async ({ player, session, rng, fx, signal, now }) => { ... }`. It runs only when the runner gave up on a transition and enters this node as `safeNode`, before the node is entered. The rollback left the state of the failed rest point; `recover` writes the drafts so the safe node can draw itself. What it wrote commits as one edge with `outcome: "recover"` and becomes the rest point. A `recover` that throws rejects `run()`. A retry does not run it. So the safe node stays a pure wait: no `run` body, no extra outcome, no self edge.
46
48
  - Entering a rest node calls `model.store.markRest()`: the save provider gets the patches. Leaving a `barrier` node calls `markBarrier(txId)`: a durable write, and rollback cannot cross it.
47
49
  - `checkpoint: true` on a rest node: the journal is compacted there. `safeNode` must be a checkpoint.
50
+ - The gate keeps an answer it refused while closed and offers it once to the next gate that opens. Config `flow.holdMs`, in milliseconds of game time: `0` (default) keeps it for one frame and the first answer wins; above `0` the last answer wins and it is dropped when older than `holdMs`; `Infinity` keeps it until the next gate opens. Set it above the longest transit animation a tap should survive. A gate that does not list the intent drops it. Nothing is kept in a fast walk or while time does not run. A held answer is dropped when the runner gives up and enters `safeNode`, when a bookmark is entered and when the graph stops.
48
51
  - `over: true`: entering the node waits while a pointer is down.
49
52
  - Path: node names joined by `/` through sub-flows and slots. Examples: `"home"`, `"board/awaitIntent"`, `"afterBonus/thanks"`.
50
53
  - Edge target: a node name, `exit(...)` or `to(...)`. A payload must fit the input of the target. A node without input accepts any payload.
@@ -422,6 +425,7 @@ A tutorial step is `await fx(guide({ allow: { intent: "roll" }, hand: "tap", tex
422
425
  - Flow ids are unique. A feature name is not a flow id. It is also not a plugin name: `time`, `lifecycle`, `model`, `clock`, `flow`, `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`, `effects`, `audio`, `platform`, `log`, `env`.
423
426
  - Two contributions of one slot need different `order` values.
424
427
  - `safeNode` is the path of a checkpoint node.
428
+ - `recover` is allowed on a rest node only. On a node that is not `safeNode` it is a warning: it never runs there.
425
429
  - A flow above 15 nodes is a warning. Split it into sub-flows of 7 to 15 nodes.
426
430
 
427
431
  ## Plugins
@@ -477,7 +481,7 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
477
481
  - `Bookmark` is `{ path, input, player, session, rng, graph, scene?, rest? }`. Serialisable. `scene` is written by the `game.bookmark` door and read by `game.restore`; `flow.restore` ignores it. `path` names a rest node, or the transit node that waits at the gate for an effect that takes answers (a `popup`, or a hand-written descriptor with `answers`); `rest: { path, input }` then names the rest point before it (left out when the graph started at that node or a restore entered it), and the state is the one committed when that node was entered. `restore` needs a running graph. A rest checkpoint is always accepted. Any other node is accepted only when the graph did not change since the bookmark; with another graph a transit bookmark is entered at `rest` instead when that is a checkpoint, and the log gets `flow:restore-fell-back`.
478
482
  - `restore` of a rest node resolves when the node is entered. `restore` of a transit node runs it again from its start, so its effects before the gate play again, and resolves once its gate is open. The open gate is not the draw: the popup shows on the next frame. `restore` also resolves when the loop ends before the bookmark was entered, after a stop or a fatal error, and nothing is entered then.
479
483
  - Events: `flow:edge` `{ flow, node, outcome, payload, next, patches: { doc, session }, index, now }` after the commit of an edge. `flow:rest` `{ path, checkpoint }`. `flow:error` `{ path, error, rolledBackTo, retry }`.
480
- - Config: `mainFlow` default `undefined` and required before `run()`, `safeNode: string | undefined` default `undefined` which means the main flow's `start`, `retries` default `1`, `settleTimeoutMs` default `2000`, `journalLimit` default `500`.
484
+ - Config: `mainFlow` default `undefined` and required before `run()`, `safeNode: string | undefined` default `undefined` which means the main flow's `start`, `retries` default `1`, `settleTimeoutMs` default `2000`, `journalLimit` default `500`, `holdMs` default `0`.
481
485
 
482
486
  ### Screen plugins (V2, built)
483
487
 
@@ -496,7 +500,7 @@ A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` se
496
500
  - `tr(key, params?)` returns a frozen `Message` `{ key, params }`; no locale is read at the call site. Strings live in `features/<feature>/strings/<locale>.json` as ICU MessageFormat 1 (`{n, plural, one {# order} other {# orders}}`, `select`, `selectordinal`, `number`, `date`, `time`; no skeletons, no `currency`). `bun run assets:keys` compiles them. A compiled module calls the root helpers `messageArgument(value, intl)` (an element stays an element part, a list goes through `Intl.ListFormat`, a number through `Intl.NumberFormat`, else text) and `messageDuration(ms)` (`95_000` gives `{ minutes: 1, seconds: 35 }`). `app.i18n`: `locale()`, `setLocale(locale)` (async, emits `i18n:locale-changed`), `format(message, locale?)` returns `Part[]`, `plain(message)` returns a string, `has(key)`, `locales()`, `replace(locale, messages)` (dev hot swap of a generated strings file). Config: `locale: "en"`, `fallback: "en"`, `locales: {}`.
497
501
  - Text: `Text` component, `label({ text, style, at, anchor? })` for board text in a projection view, `defineTextStyles({ "hud.digits": { font, size, fill, bold?, italic?, stroke?, strokeWidth?, letterSpacing?, align?, wrap?, digits? } })` under the feature's `textStyles` key. Tags inside a string: `<b>`, `<i>`, `<color=#rrggbb>`, `<icon=key>`, `\<`, `\n`. Built-in styles `body` and `digits` from config `fonts: { body: "ui.font-body", digits: "ui.font-digits" }`. `app.text.measure(content, style)` gives the same numbers in a browser and in Bun. `app.text.replaceStyles(styles)` is the dev hot swap of a saved `defineTextStyles` value.
498
502
  - Animation is data: `defineAnimation(id, { slots: { name: type<Anim.Target>() }, build: (slots, { at }) => Step })`. Steps: `sequence(...)`, `parallel(...)`, `stagger(items, ms, item => Step)`, `tween(target, Component, to, { ms, ease?, delayMs?, additive? })`, `set(target, Component, patch)`, `wait(ms)`, `mark(name)`, `frames(target, { keys, fps, loop? })`, `sfx(key, { bus? })`, `haptic(kind)`, `use(animation, slots, tools?)`, `spawn` (a temporary entity, flying coins, that the timeline despawns when it ends; see the anim README). `Frames({ keys, fps, loop, playing })` is a component that walks a sprite through its textures for the entity's whole life. A target is `{ projection, key }` or an entity. A node plays it with `await fx(play(animation, slots))`; in fast mode it finishes at once. `defineMotion({ states, transition?, on: { enter?, exit?, change? } })` builds the enter, exit and change hooks for a projection or the `motion` prop of a tag. Events `anim:mark` `{ animation, mark }` and `anim:finished` `{ animation }`. `app.anim.replace(definition)` is the dev hot swap: the next `play` builds the new tree. A system cannot `require` a plugin, so it plays through the resource `AnimPlayer`: `res(AnimPlayer).play(animation, slots)` returns the same handle as `app.anim.play`, and the registered animation of `animation.id` wins, so a hot swap reaches the system. Play on a change, never every frame; from phase `input` the tree moves in `animate` of the same frame. Before `anim` starts it throws `[game] AnimPlayer is not ready: anim has not started.` Config `maxTracks: 2000`.
499
- - Audio is opt-in: `plugins: [...screen, audioPlugin]`. `fx(sfx("orders.complete"))` resolves when the sound started. `await fx(music("board.theme"))` cross-fades; `music(null)` fades out; a scene's `music` key switches the track on `scenes:changed`. Buses `master`, `music`, `sfx`. Volumes come from the committed player: config `volumes: player => (player as Player).settings.audio`, applied on every `model:committed`. `app.audio`: `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `muted(bus)`, `unlocked()`, `journal()` (the last started sounds, empty unless config `journal` is above 0). Config: `buses: { master: 1, music: 0.6, sfx: 1 }`, `musicFadeMs: 600`, `volumes`, `context`, `journal: 0`, `music: "decode" | "stream"` default `"decode"` (gapless buffer; `"stream"` is an `<audio>` element, less memory, a gap at every loop), `session: "ambient" | "playback" | "auto"` default `"ambient"` (iOS only: ambient mixes with other apps and obeys the silent switch). Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound.
503
+ - Audio is opt-in: `plugins: [...screen, audioPlugin]`. `fx(sfx("orders.complete"))` resolves when the sound started. `await fx(music("board.theme"))` cross-fades; `music(null)` fades out; a scene's `music` key switches the track on `scenes:changed`. Buses `master`, `music`, `sfx`. Volumes come from the committed player: config `volumes: player => (player as Player).settings.audio`, applied on every `model:committed`. `app.audio`: `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `muted(bus)`, `unlocked()`, `journal()` (the last started sounds, empty unless config `journal` is above 0). Config: `buses: { master: 1, music: 0.6, sfx: 1 }`, `musicFadeMs: 600`, `volumes`, `context`, `journal: 0`, `music: "decode" | "stream"` default `"decode"` (gapless buffer; `"stream"` is an `<audio>` element, less memory, a gap at every loop), `session: "ambient" | "playback" | "auto"` default `"ambient"` (iOS only: ambient mixes with other apps and obeys the silent switch). The context is asked to run at start: where the shell lets audio play (a native webview with the gesture requirement off) `unlocked()` is true with no touch; a browser that wants a gesture unlocks on the first `pointerdown` or `touchend`. Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound.
500
504
  - `app.ui`: `tree()` the live screen as plain data (a text node carries `content`, the words it draws after `tr()` and `bind`; a text field carries `value`), `find(key)` the entity of a keyed element, `fill(key, value)` types into the text field with that `key`, `lint()` tap targets under `tapTargetPt` (default `44`), text that overflows in some locale, absolute elements without `reason`. Config: `tapTargetPt: 44`, `breakpoints: { tall: 2, wide: 1.5 }`. `ui` emits only the dev global event `ui:hot-swap`, see "Hot swap".
501
505
 
502
506
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/game",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "2D puzzle game engine on TypeScript and PixiJS, built on @moku-labs/core.",
5
5
  "type": "module",
6
6
  "sideEffects": [