@moku-labs/game 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +8 -5
  2. package/dist/assets.d.mts +97 -33
  3. package/dist/assets.mjs +350 -110
  4. package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
  5. package/dist/{commands-C1kIfOG5.d.mts → commands-DuwCxK5t.d.mts} +5 -6
  6. package/dist/control.d.mts +4 -4
  7. package/dist/control.mjs +1 -1
  8. package/dist/{define-BPpZcpxW.d.mts → define-B6pHpdBi.d.mts} +2 -2
  9. package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
  10. package/dist/{headless-q98SwADR.d.mts → headless-SCQXJxQf.d.mts} +1 -1
  11. package/dist/hot.d.mts +4 -2
  12. package/dist/hot.mjs +8 -6
  13. package/dist/index-BO9kZzY3.d.mts +1611 -0
  14. package/dist/index.d.mts +6 -1597
  15. package/dist/index.mjs +4 -34343
  16. package/dist/inspect.d.mts +7 -8
  17. package/dist/inspect.mjs +1 -1
  18. package/dist/jsx-dev-runtime.d.mts +1 -1
  19. package/dist/jsx-runtime.d.mts +1 -1
  20. package/dist/lint.d.mts +55 -4
  21. package/dist/lint.mjs +993 -40
  22. package/dist/project.d.mts +29 -13
  23. package/dist/project.mjs +986 -497
  24. package/dist/{run-CUPSFM85.mjs → run-CofJEdYx.mjs} +2 -2
  25. package/dist/{runtime-CvDZZg1S.d.mts → runtime-CdNJ0UBm.d.mts} +1 -1
  26. package/dist/{sources-BBUd15vQ.mjs → sources-DNnzyCYf.mjs} +1 -1
  27. package/dist/src-536arXjV.mjs +34644 -0
  28. package/dist/testing.d.mts +130 -3
  29. package/dist/testing.mjs +192 -3
  30. package/dist/{types-BWzCSRM2.d.mts → types-B0v5VVa3.d.mts} +2982 -19
  31. package/dist/{types-DF5kkW-C.d.mts → types-CQueODzo.d.mts} +4 -5
  32. package/dist/{types-CDSKa0vl.d.mts → types-DUMoYPYe.d.mts} +2 -2
  33. package/dist/{types-DlgsfOmW.d.mts → types-VZVWwqCg.d.mts} +49 -12
  34. package/dist/{types-DgdMLZnV.d.mts → types-xXfyk56Q.d.mts} +7 -8
  35. package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
  36. package/dist/visual.d.mts +10 -11
  37. package/dist/visual.mjs +2 -2
  38. package/llms.txt +55 -18
  39. package/package.json +1 -1
  40. package/dist/memory-CvgdnsQO.mjs +0 -259
  41. package/dist/registry-DlpRCibU.mjs +0 -384
  42. package/dist/types-ERb2Y9U5.d.mts +0 -2967
@@ -1,6 +1,5 @@
1
- import { lt as Events, t as Api, ut as Require } from "./types-ERb2Y9U5.mjs";
2
- import { D as Point, E as FilterSlot, L as ComponentHandle, V as Entity, _ as PixiModule, g as PixiFilter, k as Api$1, m as Api$2, o as Events$1, t as Api$3, v as PixiSprite, y as PixiTexture, z as ComponentType } from "./types-BWzCSRM2.mjs";
3
- import { b as AnimApi } from "./types-DlgsfOmW.mjs";
1
+ import { D as Point, E as FilterSlot, L as ComponentHandle, V as Entity, _ as PixiModule, bn as Events$1, g as PixiFilter, k as Api$1, m as Api$2, o as Events, t as Api$3, ut as Api, v as PixiSprite, xn as Require, y as PixiTexture, z as ComponentType } from "./types-B0v5VVa3.mjs";
2
+ import { b as AnimApi } from "./types-VZVWwqCg.mjs";
4
3
  import { Log } from "@moku-labs/common/browser";
5
4
  import { PluginCtx } from "@moku-labs/core";
6
5
 
@@ -644,10 +643,10 @@ type EffectsCtx = KernelSlice & {
644
643
  /**
645
644
  * Payload of the `assets:bundle-unloaded` hook: `keys` names every texture that is gone.
646
645
  */
647
- type BundleUnloaded = Events$1["assets:bundle-unloaded"];
646
+ type BundleUnloaded = Events["assets:bundle-unloaded"];
648
647
  /**
649
648
  * Payload of the global `ui:hot-swap` hook: the saved file and its new exports.
650
649
  */
651
- type HotSwap = Events["ui:hot-swap"];
650
+ type HotSwap = Events$1["ui:hot-swap"];
652
651
  //#endregion
653
652
  export { NoiseValue as _, types_d_exports as a, EmitterValue as c, ColorMatrixValue as d, DisplacementValue as f, GlowValue as g, FilterValueOf as h, State as i, AlphaValue as l, FilterSpec as m, EffectsApi as n, EmitterConfig as o, FilterComponent as p, EffectsStats as r, EmitterDefinition as s, Config as t, BlurValue as u, OutlineValue as v, UniformDeclaration as y };
@@ -1,5 +1,5 @@
1
- import { R as Json, q as Snapshot } from "./types-ERb2Y9U5.mjs";
2
- import { t as HeadlessApp } from "./headless-q98SwADR.mjs";
1
+ import { Jt as Json, rn as Snapshot } from "./types-B0v5VVa3.mjs";
2
+ import { t as HeadlessApp } from "./headless-SCQXJxQf.mjs";
3
3
  import { Log } from "@moku-labs/common/browser";
4
4
 
5
5
  //#region src/plugins/flow/doors/types.d.ts
@@ -1,6 +1,5 @@
1
- import { $ as Events$4, A as Api, E as Descriptor, F as Api$2, L as Events$3, R as Json, T as TypeTag, lt as Events$2, t as Api$1, ut as Require } from "./types-ERb2Y9U5.mjs";
2
- import { $ as Ease, I as AnyComponentValue, K as Owner, L as ComponentHandle, P as AnyComponent, V as Entity, ct as TrackSegment, et as LayerSort, it as NumericFields, k as Api$3, m as Api$4, o as Events$5, rt as MotionHandle, st as TrackOptions, t as Api$5, tt as LayerSpec, z as ComponentType } from "./types-BWzCSRM2.mjs";
3
- import { D as TextApi, H as InputApi, I as I18nApi, c as UiNode, i as Finding, n as ElementChange, o as JsxState, r as ElementMotion, v as StylesState } from "./types-DgdMLZnV.mjs";
1
+ import { $ as Ease, Bt as Api, Ft as TypeTag, Gt as Api$5, I as AnyComponentValue, It as Descriptor, Jt as Json, K as Owner, L as ComponentHandle, P as AnyComponent, V as Entity, bn as Events$2, ct as TrackSegment, et as LayerSort, it as NumericFields, k as Api$2, ln as Events$4, m as Api$3, o as Events$5, qt as Events$3, rt as MotionHandle, st as TrackOptions, t as Api$4, tt as LayerSpec, ut as Api$1, xn as Require, z as ComponentType } from "./types-B0v5VVa3.mjs";
2
+ import { D as TextApi, H as InputApi, I as I18nApi, c as UiNode, i as Finding, n as ElementChange, o as JsxState, r as ElementMotion, v as StylesState } from "./types-xXfyk56Q.mjs";
4
3
  import { Log } from "@moku-labs/common/browser";
5
4
  import { PluginCtx } from "@moku-labs/core";
6
5
  import { Node, Yoga } from "yoga-layout/load";
@@ -633,7 +632,7 @@ type RunningTimeline = {
633
632
  ended: boolean;
634
633
  };
635
634
  declare namespace types_d_exports$3 {
636
- export { AnimApi, AnimCtx, AnimationDefinition, AnimationSpec, AnyAnimationDefinition, BuildTools, Config$3 as Config, Cursor, Deps$3 as Deps, ErasedFields, Events$1 as Events, ExternalPlayer, FrameLoop, FramesValue, FxStep, HapticDescriptor, HapticKind, KernelSlice$3 as KernelSlice, MarkListener, MotionKeyframe, PlayDescriptor, PlayHandle, Pose, RunningTimeline, SfxDescriptor, SlotRecord, SlotTags, SlotValue, SlotValues, State$3 as State, Step, StepMotion, Target, TimelineRuntime, Track };
635
+ export { AnimApi, AnimCtx, AnimPlayerValue, AnimationDefinition, AnimationSpec, AnyAnimationDefinition, BuildTools, Config$3 as Config, Cursor, Deps$3 as Deps, ErasedFields, Events$1 as Events, ExternalPlayer, FrameLoop, FramesValue, FxStep, HapticDescriptor, HapticKind, KernelSlice$3 as KernelSlice, MarkListener, MotionKeyframe, PlayDescriptor, PlayHandle, Pose, RunningTimeline, SfxDescriptor, SlotRecord, SlotTags, SlotValue, SlotValues, State$3 as State, Step, StepMotion, Target, TimelineRuntime, Track };
637
636
  }
638
637
  /**
639
638
  * What a step and a slot are pointed at: the projection key of a live view or of an element a
@@ -1057,6 +1056,44 @@ type AnimApi = {
1057
1056
  */
1058
1057
  replace(definition: AnyAnimationDefinition): void;
1059
1058
  };
1059
+ /**
1060
+ * What a system may do with anim, read with `res(AnimPlayer)`: play. A system that needs more
1061
+ * than play is a plugin. Play on a change, never on every frame: keep the handle in a resource, or
1062
+ * take away the tag that asked for the play.
1063
+ *
1064
+ * @example
1065
+ * ```ts
1066
+ * // A fresh item pops once: the system plays, then takes the Fresh tag away.
1067
+ * const popIn = system({ name: "popIn", phase: "input", query: [Fresh], run: (items, { world, res }) => {
1068
+ * for (const [entity] of items) {
1069
+ * res(AnimPlayer).play(pop, { thing: entity });
1070
+ * world.untag(entity, Fresh);
1071
+ * }
1072
+ * } });
1073
+ * ```
1074
+ */
1075
+ type AnimPlayerValue = {
1076
+ /**
1077
+ * Plays an animation from a system, the way `app.anim.play` does. The registered animation of
1078
+ * `animation.id` wins when a feature brought one, so a hot swap reaches the system. Played from
1079
+ * phase `input`, the tree moves in `animate` of the same frame; from a later phase it moves
1080
+ * from the next frame.
1081
+ *
1082
+ * @param animation - What `defineAnimation` returned.
1083
+ * @param slots - One target, or a list of targets, per declared slot.
1084
+ * @returns The handle: `cancel()`, `finish()`, `active()`, `done`, `marks()`.
1085
+ * @throws {Error} Before `anim` started: `[game] AnimPlayer is not ready: anim has not started.`
1086
+ * @example
1087
+ * ```ts
1088
+ * const lift = defineAnimation("look.hover", { slots: { thing: type<Target>() },
1089
+ * build: ({ thing }) => tween(thing, Transform, { scale: 1.1 }, { ms: 100 }) });
1090
+ * // Inside a system of phase "input": entity 1048576 is the item under the pointer.
1091
+ * const handle = res(AnimPlayer).play(lift, { thing: 1_048_576 });
1092
+ * handle.active(); // true until the lift lands 100 ms later, then false
1093
+ * ```
1094
+ */
1095
+ play<Tags extends SlotTags>(animation: AnimationDefinition<Tags>, slots: SlotValues<Tags>): PlayHandle;
1096
+ };
1060
1097
  /**
1061
1098
  * Resolved dependency APIs. `renderer` is not among them: `anim` writes its `Sprite` and reads
1062
1099
  * its `Transform` through the component objects, which are plain data, composes the root pose
@@ -1066,7 +1103,7 @@ type AnimApi = {
1066
1103
  type Deps$3 = {
1067
1104
  time: Api;
1068
1105
  flow: Api$1;
1069
- world: Api$3;
1106
+ world: Api$2;
1070
1107
  };
1071
1108
  /**
1072
1109
  * What the kernel context offers before the deps are attached.
@@ -1212,8 +1249,8 @@ type Deps$2 = {
1212
1249
  anim: AnimApi;
1213
1250
  time: Api;
1214
1251
  flow: Api$1;
1215
- world: Api$3;
1216
- renderer: Api$4;
1252
+ world: Api$2;
1253
+ renderer: Api$3;
1217
1254
  input: InputApi;
1218
1255
  i18n: I18nApi;
1219
1256
  text: TextApi;
@@ -1569,8 +1606,8 @@ type ScenesApi = {
1569
1606
  */
1570
1607
  type Deps$1 = {
1571
1608
  flow: Api$1;
1572
- world: Api$3;
1573
- assets: Api$5;
1609
+ world: Api$2;
1610
+ assets: Api$4;
1574
1611
  time: Api;
1575
1612
  };
1576
1613
  /**
@@ -1945,8 +1982,8 @@ type AudioApi = {
1945
1982
  */
1946
1983
  type Deps = {
1947
1984
  flow: Api$1;
1948
- assets: Api$5;
1949
- model: Api$2;
1985
+ assets: Api$4;
1986
+ model: Api$5;
1950
1987
  time: Api;
1951
1988
  };
1952
1989
  /**
@@ -1996,4 +2033,4 @@ type MusicRequest = {
1996
2033
  fadeMs: number;
1997
2034
  };
1998
2035
  //#endregion
1999
- export { HAPTIC_KINDS as A, sfx as B, MotionKeyframe as C, HapticKind as D, types_d_exports$3 as E, mark as F, use as G, spawned as H, parallel as I, wait as K, play as L, external as M, frames as N, SfxDescriptor as O, haptic as P, sequence as R, FramesValue as S, Target as T, stagger as U, spawn as V, tween as W, State$2 as _, SoundEntry as a, AnimApi as b, AnySceneProjection as c, SceneDefinition as d, SceneSpec as f, Config$2 as g, types_d_exports$1 as h, MusicOptions as i, defineAnimation as j, Step as k, DefineScene as l, State$1 as m, Config as n, State as o, ScenesApi as p, MusicDescriptor as r, types_d_exports as s, AudioApi as t, LayerMap as u, UiApi as v, State$3 as w, Config$3 as x, types_d_exports$2 as y, set as z };
2036
+ export { Step as A, set as B, FramesValue as C, types_d_exports$3 as D, Target as E, haptic as F, tween as G, spawn as H, mark as I, use as K, parallel as L, defineAnimation as M, external as N, HapticKind as O, frames as P, play as R, Config$3 as S, State$3 as T, spawned as U, sfx as V, stagger as W, State$2 as _, SoundEntry as a, AnimApi as b, AnySceneProjection as c, SceneDefinition as d, SceneSpec as f, Config$2 as g, types_d_exports$1 as h, MusicOptions as i, HAPTIC_KINDS as j, SfxDescriptor as k, DefineScene as l, State$1 as m, Config as n, State as o, ScenesApi as p, wait as q, MusicDescriptor as r, types_d_exports as s, AudioApi as t, LayerMap as u, UiApi as v, MotionKeyframe as w, AnimPlayerValue as x, types_d_exports$2 as y, sequence as z };
@@ -1,5 +1,4 @@
1
- import { A as Api, E as Descriptor, O as Hint, R as Json, rt as Api$2, t as Api$1, ut as Require } from "./types-ERb2Y9U5.mjs";
2
- import { B as ComponentValue, D as Point$2, F as AnyComponentType, I as AnyComponentValue, O as ViewportSize, P as AnyComponent, V as Entity, Z as ChangeHook, at as ProjectionMotion, it as NumericFields, k as Api$3, lt as ViewHandle, m as Api$4, nt as Motion, o as Events$1, rt as MotionHandle, t as Api$5, z as ComponentType } from "./types-BWzCSRM2.mjs";
1
+ import { B as ComponentValue, Bt as Api, D as Point$2, F as AnyComponentType, I as AnyComponentValue, It as Descriptor, Jt as Json, O as ViewportSize, P as AnyComponent, Rt as Hint, V as Entity, Z as ChangeHook, at as ProjectionMotion, it as NumericFields, k as Api$2, lt as ViewHandle, m as Api$3, nt as Motion, o as Events$1, pn as Api$5, rt as MotionHandle, t as Api$4, ut as Api$1, xn as Require, z as ComponentType } from "./types-B0v5VVa3.mjs";
3
2
  import { Log } from "@moku-labs/common/browser";
4
3
  import { PluginCtx } from "@moku-labs/core";
5
4
 
@@ -635,8 +634,8 @@ type InputApi = {
635
634
  type Deps$1 = {
636
635
  time: Api;
637
636
  flow: Api$1;
638
- world: Api$3;
639
- renderer: Api$4;
637
+ world: Api$2;
638
+ renderer: Api$3;
640
639
  };
641
640
  /**
642
641
  * What the kernel context offers before the deps are attached.
@@ -1511,11 +1510,11 @@ type TextApi = {
1511
1510
  type Deps = {
1512
1511
  time: Api;
1513
1512
  flow: Api$1;
1514
- world: Api$3;
1515
- renderer: Api$4;
1516
- assets: Api$5;
1513
+ world: Api$2;
1514
+ renderer: Api$3;
1515
+ assets: Api$4;
1517
1516
  i18n: I18nApi;
1518
- clock: Api$2;
1517
+ clock: Api$5;
1519
1518
  };
1520
1519
  /**
1521
1520
  * What the kernel context offers before the deps are attached.
@@ -60,22 +60,26 @@ function tag(name) {
60
60
  return tagType;
61
61
  }
62
62
  /**
63
- * Declares a resource: one mutable object per world, created from a deep clone of the defaults
64
- * on first read.
63
+ * Declares a resource: one mutable object per world, made on first read. Plain defaults are
64
+ * deep-cloned. A factory is called instead, once per world, so the value may hold a function or a
65
+ * `Map`.
65
66
  *
66
67
  * @param name - Storage name, unique per world.
67
- * @param defaults - The value a fresh world starts with.
68
+ * @param defaults - The value a fresh world starts with, or the factory that makes it.
68
69
  * @returns The resource type.
69
70
  * @example
70
71
  * ```ts
71
72
  * const Pointer = resource("Pointer", { x: 0, y: 0, down: false });
72
73
  * Pointer.defaults; // { x: 0, y: 0, down: false }
74
+ *
75
+ * // A resource that holds a Map: a factory, called once per world on first read.
76
+ * const Looks = resource("boardLooks", () => ({ shown: new Map<Entity, { look: string }>() }));
73
77
  * ```
74
78
  */
75
79
  function resource(name, defaults) {
76
80
  return {
77
81
  resourceName: name,
78
- defaults: Object.freeze({ ...defaults })
82
+ defaults: typeof defaults === "function" ? defaults : Object.freeze({ ...defaults })
79
83
  };
80
84
  }
81
85
  /**
package/dist/visual.d.mts CHANGED
@@ -1,10 +1,9 @@
1
- import { F as Api, Z as Api$1 } from "./types-ERb2Y9U5.mjs";
2
- import { k as Api$3, m as Api$2 } from "./types-BWzCSRM2.mjs";
3
- import { r as Repro } from "./headless-q98SwADR.mjs";
4
- import { a as InputOf, r as ControlApp } from "./types-CDSKa0vl.mjs";
5
- import { H as InputApi } from "./types-DgdMLZnV.mjs";
6
- import { b as AnimApi, v as UiApi } from "./types-DlgsfOmW.mjs";
7
- import { t as commands } from "./commands-C1kIfOG5.mjs";
1
+ import { Gt as Api$2, k as Api$1, m as Api, sn as Api$3 } from "./types-B0v5VVa3.mjs";
2
+ import { r as Repro } from "./headless-SCQXJxQf.mjs";
3
+ import { a as InputOf, r as ControlApp } from "./types-DUMoYPYe.mjs";
4
+ import { H as InputApi } from "./types-xXfyk56Q.mjs";
5
+ import { b as AnimApi, v as UiApi } from "./types-VZVWwqCg.mjs";
6
+ import { t as commands } from "./commands-DuwCxK5t.mjs";
8
7
 
9
8
  //#region src/visual/types.d.ts
10
9
  /** The base commands of the `/control` door, keyed by their short names. */
@@ -81,11 +80,11 @@ type VisualTest = {
81
80
  type VisualApp = ControlApp & {
82
81
  readonly anim: AnimApi;
83
82
  readonly ui: UiApi;
84
- readonly renderer: Api$2;
85
- readonly world: Api$3;
86
- readonly model: Api;
83
+ readonly renderer: Api;
84
+ readonly world: Api$1;
85
+ readonly model: Api$2;
87
86
  readonly input: InputApi;
88
- readonly lifecycle: Api$1;
87
+ readonly lifecycle: Api$3;
89
88
  };
90
89
  /**
91
90
  * The dev page the browser leg opens: its URL, the viewport width in CSS px (390 by default), the
package/dist/visual.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { n as commands, t as run } from "./run-CUPSFM85.mjs";
2
- import { n as read, t as sources } from "./sources-BBUd15vQ.mjs";
1
+ import { n as commands, t as run } from "./run-CofJEdYx.mjs";
2
+ import { n as read, t as sources } from "./sources-DNnzyCYf.mjs";
3
3
  import { createBrandConsole } from "@moku-labs/common/cli";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
package/llms.txt CHANGED
@@ -7,14 +7,14 @@ Facts: Bun only. ESM only. `"sideEffects": false`. `pixi.js` `^8.0.0` is a peer
7
7
  ## Entry points
8
8
 
9
9
  - `@moku-labs/game`: everything a game imports.
10
- - `@moku-labs/game/testing`: headless play, repro runs, fake clock, in-memory save provider. No `node:` import.
10
+ - `@moku-labs/game/testing`: headless play, repro runs, isolated feature tests (`isolate`, `stub`), fake clock, in-memory save provider. No `node:` import.
11
11
  - `@moku-labs/game/visual` (node and bun only): the visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv`.
12
12
  - `@moku-labs/game/assets` (node and bun only): the asset key scanner, the production packer and the string compiler. The package bin `moku-game-assets` runs it.
13
13
  - `@moku-labs/game/inspect`: the read door of the editor, safe in a production build. See "Doors for the editor".
14
14
  - `@moku-labs/game/control`: the write door of the editor, dev builds only. See "Doors for the editor".
15
15
  - `@moku-labs/game/hot` (the Bun dev server only): the `bunfig.toml` plugin that hot swaps views in a running page. See "Hot swap".
16
16
  - `@moku-labs/game/lint`: the oxlint JS plugin `moku-game` with the game lint rules. See "Lint for games".
17
- - `@moku-labs/game/project` (node and bun only): the project index, where every engine id of a game is defined, line read fresh at the call. The package bin `moku-game-index` runs it. See "Project index".
17
+ - `@moku-labs/game/project` (node and bun only): the project index, where every engine id of a game is defined, line read fresh at the call. It follows the tsconfig `paths` of the game. The package bin `moku-game-index` runs it. See "Project index".
18
18
  - `@moku-labs/game/fonts/*`: the shipped MSDF body font and its licence. See "Fonts".
19
19
  - `@moku-labs/game/jsx-runtime` and `@moku-labs/game/jsx-dev-runtime`: what `"jsxImportSource": "@moku-labs/game"` resolves to. A game never imports them by hand.
20
20
 
@@ -23,11 +23,11 @@ Root exports:
23
23
  - Class: `SaveUnreadableError`.
24
24
  - Plugin instances: `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin`, `effectsPlugin`, `platformPlugin`; the list `screen` = `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`. `effects`, `audio` and `platform` are opt-in: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]`, `platform` last.
25
25
  - Screen helpers from the root: `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree`, `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `sprite`, `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`, `Traceable`, `Touchable`, `Held`, `Hovered`, `PointerOver`, `Pressed`, `Pointer`, `Traced`, `defineBundles`, `load`, `defineScene`.
26
- - Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`; `tr`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`; `music`.
26
+ - Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`, the resource `AnimPlayer`; `tr`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`; `music`.
27
27
  - Effects helpers from the root (V5): `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha`.
28
28
  - Platform from the root (V6): the types `PlatformProvider`, `BackResult`, `HapticKind` and the frozen list `HAPTIC_KINDS`.
29
29
  - `defineGame<Types>()` returns, typed by the game's `player`, `session`, `assets`, `bundles`, `scenes`, `strings` and `textStyles`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, `defineAnimation`, `frames`, `sfx`, `play`, `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `music`, `Frames`, `defineEmitter`, `Emitter`, `Displacement`. `Types` is `{ player; session; assets: string; bundles?: string; scenes?: string; strings: Record<string, unknown>; textStyles?: string; emitters?: string }`.
30
- - `@moku-labs/game/assets`: `scanAssets(options)`, `emitKeys`, `emitManifest`, `compileStrings(root, out, options?)`, `checkStrings`, `packAssets(options)`, `exportStrings`, `importStrings`, `runCli(argv, strings)` with `strings: StringsTools` = `{ compile: compileStrings, exportStrings, importStrings }`. Audio files are `.mp3` or `.m4a`. Asset key = feature + "." + path inside `assets/`. `runCli` (a game's `bun run assets:keys`) scans the assets and compiles `features/*/strings/<locale>.json` into `generated/strings.ts` (the `Strings` type for `defineGame`) and one `generated/strings.<locale>.ts` per locale; `--check` fails when any output is out of date; `--pack <dir>` writes the production build. `--pseudo` also writes the pseudo-locale `en-XA`. `--export <dir>` and `--import <dir>` exchange strings with translators; `--source <locale>` names the locale they read from, `"en"` by default. The package bin `moku-game-assets` is this CLI: a game's script is `"assets:keys": "moku-game-assets --root src --manifest public/assets/manifest.json --keys src/generated/assets.ts"`.
30
+ - `@moku-labs/game/assets`: `scanAssets(options)`, `emitKeys`, `emitManifest`, `compileStrings(root, out, options?)`, `checkStrings`, `packAssets(options)`, `exportStrings`, `importStrings`, `runCli(argv, strings)` with `strings: StringsTools` = `{ compile: compileStrings, exportStrings, importStrings }`. Audio files are `.mp3` or `.m4a`. Asset key = feature (or layer name) + "." + path inside `assets/`. `runCli` (a game's `bun run assets:keys`) scans the assets and compiles `features/*/strings/<locale>.json`, and the `strings/` of every layer, into `generated/strings.ts` (the `Strings` type for `defineGame`) and one `generated/strings.<locale>.ts` per locale; `--check` fails when any output is out of date; `--pack <dir>` writes the production build. `--layer <folder>[=<name>]`, repeatable and good with every mode, scans `<root>/<folder>/` like one more feature under `<name>` (`<folder>` alone maps to itself): its `assets.ts`, `assets/` and `strings/<locale>.json`. A game on the layered layout passes `--layer shared=ui`, so `shared/assets/*` keeps the `ui.*` keys. A missing layer folder is a warning. `scanAssets`, `compileStrings`, `exportStrings` and `importStrings` take the same map as `layers: { shared: "ui" }`. `--pseudo` also writes the pseudo-locale `en-XA`. `--export <dir>` and `--import <dir>` exchange strings with translators; `--source <locale>` names the locale they read from, `"en"` by default. The package bin `moku-game-assets` is this CLI: a game's script is `"assets:keys": "moku-game-assets --root src --manifest public/assets/manifest.json --keys src/generated/assets.ts"`.
31
31
  - Type namespaces: `Time`, `Lifecycle`, `Model`, `Clock`, `Flow`, `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio`, `Effects`, `Platform`. Use `import type { Flow } from "@moku-labs/game"` and then `Flow.RouteStep`, `Flow.NodeContext`, `Model.Json`, `Model.PlayerStateProvider`, `Clock.ClockSource`, `Time.Phase`, `Lifecycle.PauseReason`, `Anim.Target`, `I18n.Message`. `Text` is the component, so the text types are `TextTypes`.
32
32
  - `defineNode` and `defineFlow` are NOT root exports. They come from `defineGame<Types>()`.
33
33
 
@@ -136,7 +136,7 @@ type NodeContext = {
136
136
 
137
137
  ## Lint for games
138
138
 
139
- `.oxlintrc.json` of a game (oxlint 1.86.0 or later):
139
+ `.oxlintrc.json` of a game (oxlint 1.86.0 or later). The engine ships no default severity: a game turns each rule on.
140
140
 
141
141
  ```json
142
142
  {
@@ -148,20 +148,28 @@ type NodeContext = {
148
148
  "moku-game/no-module-state": "error",
149
149
  "moku-game/determinism": "error",
150
150
  "moku-game/rules-siblings": "error",
151
- "moku-game/static-keys": "error"
151
+ "moku-game/static-keys": "error",
152
+ "moku-game/layer-imports": "error",
153
+ "moku-game/feature-door": "error",
154
+ "moku-game/test-suffix": "error"
152
155
  }
153
156
  }
154
157
  ```
155
158
 
156
159
  - `moku-game/lazy-imports` (L2): no static value import of `pixi.js` or `yoga-layout`. `import type` and `import()` pass. Default files `**`.
157
- - `moku-game/native-imports` (L13): no `@moku-labs/system`, `@moku-labs/native`, `@tauri-apps/*` in the logic, `kit.ts` and `game.ts`. Use `platform-bridge.ts`.
160
+ - `moku-game/native-imports` (L13): no `@moku-labs/system`, `@moku-labs/native`, `@tauri-apps/*` in the logic, `kit.ts` and `plugins/`. Wire the platform in `platform-bridge.ts`, `native.ts` or `web/`.
158
161
  - `moku-game/dev-imports`: `@moku-labs/editor` and `@moku-labs/game/control` only in `web/main.ts`, `web/dev*.ts`, `web/editor*.ts`, `*.dev.ts(x)` and tests.
159
162
  - `moku-game/no-module-state` (L5): no module-scope `let` or `var`, no module-scope `new Map/Set/WeakMap/WeakSet`. Default files `**`.
160
- - `moku-game/determinism` (L3): no `Math.random`, `Date.now`, `performance.now`, `new Date()`, `setTimeout`, `setInterval` in the logic. `new Date(now)` passes.
161
- - `moku-game/rules-siblings` (L4): a file under `rules/` imports only `./` siblings.
163
+ - `moku-game/determinism` (L3): no `Math.random`, `Date.now`, `performance.now`, `new Date()`, `setTimeout`, `setInterval` in the logic. `new Date(now)` passes. Plugin folders, `**/{plugin,plugins}/**`, are the effect side and are skipped: a plugin answers `schedule` with a timer.
164
+ - `moku-game/rules-siblings` (L4): a file under `rules/` imports only `./` siblings, `@core/types` and `@shared/rules`, by alias or by relative path. `@core/kit`, `../tables` and `import type … from "@moku-labs/game"` are reported.
162
165
  - `moku-game/static-keys`: a JSX `key` is a literal, a template of such parts, `props.id` or `props.<name>Key`, a call, or a `const` of one. `tabKeys[props.tab]`, `a ?? b`, `a || b`, `a ? b : c` and `props.tab` are reported: pass the key in as a prop, `<TabButton id="tabSound" />`. Default files `**/*.tsx`.
163
- - Logic = `**/state.ts`, `**/tables.ts`, `**/{nodes,flows,rules,features}/**`. Tests (`tests/**`, `**/__tests__/**`, `*.test.ts`) are skipped by every rule.
164
- - Every rule takes `["error", { "files": [...], "ignores": [...] }]`. Globs are relative to the directory oxlint runs in; a key given replaces the default.
166
+ - `moku-game/layer-imports`: the order is core ← shared ← features ← `game.ts`; plugins import core and shared; every layer may import `generated/`. An import, export-from or `import()` that reaches a layer above its own is reported, `import type` too: `@features/home` from `shared/`. Only `game.ts` imports `game.ts`.
167
+ - `moku-game/feature-door`: another feature is imported only through its `index.ts`, `@features/orders`, never `@features/orders/flow/merge`. Only `game.ts` imports the barrels `@features` and `@plugins`. A feature does not import its own `index.ts`. A relative import that leaves the feature is written as an alias: `@features/<f>`, `@shared`, `@core/<file>`. Inside one feature any relative import passes.
168
+ - `moku-game/test-suffix`: a file in a test kind folder ends with its suffix. `tests/e2e/` `.e2e.ts`, `tests/visual/` `.visual.ts`, `tests/editor/` `.editor.ts`, `__tests__/`, `__tests__/unit/` and `__tests__/integration/` `.test.ts`, `__tests__/isolated/` `.isolated.ts`, `__tests__/visual/` `.visual.ts`; a `.tsx` file adds an `x`. Helpers go to `tests/helpers/` or `__tests__/fixtures/`.
169
+ - Layers under the game root: `core/**`, `shared/**`, `features/<f>/**`, `plugins/<p>/**`, `generated/**`, `game.ts`, `tests/**`. A file outside them (`kit.ts`, `nodes/`, `web/`) keeps the layout rules quiet, so a game on the template layout lints as before.
170
+ - Aliases: `layer-imports`, `feature-door` and `rules-siblings` read the `compilerOptions.paths` of `tsconfig.json` (comments, trailing commas and relative `extends` included), the way the project index does. Without `paths` they read the v15 table: `@core/*` → `core/*`, `@shared` → `shared/index.ts`, `@shared/rules` → `shared/rules/index.ts`, `@features` → `features/index.ts`, `@features/*` → `features/*/index.ts`, `@plugins` → `plugins/index.ts`, `@generated/*` → `generated/*`, `@tests/*` → `tests/*`. `aliasTargetsOf(tsconfig, root, specifier)` from `@moku-labs/game/lint` answers what they read: for a game without a tsconfig, `aliasTargetsOf("/game/tsconfig.json", "/game", "@features/orders")` is `["/game/features/orders/index.ts"]`.
171
+ - Logic = `**/state.ts`, `**/tables.ts`, `**/game.ts`, `**/{core,nodes,flows,rules,features,shared}/**`. Tests (`tests/**`, `**/__tests__/**`, `*.test.ts`) are skipped by every rule but `test-suffix`.
172
+ - Every rule takes `["error", { "files": [...], "ignores": [...] }]`. Globs are relative to the directory oxlint runs in; a key given replaces the default. `layer-imports`, `feature-door` and `rules-siblings` also take `root` (the folder of `core/`, `shared/`, `features/`, `plugins/` and `game.ts`, default `.`) and `tsconfig` (default `tsconfig.json`); `test-suffix` takes `root` and `suffixes` (kind folder to suffix, replaces the table whole).
165
173
 
166
174
  ## Minimal game
167
175
 
@@ -396,6 +404,7 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
396
404
 
397
405
  `world`, `renderer`, `input`, `assets`, `scenes`. Each has a README at `src/plugins/<name>/README.md`; the root README lists their APIs, events and config. Rendering: WebGPU preferred, with Pixi's WebGL fallback.
398
406
  - `app.scenes`: `current()` the mounted scene id, `expect(id)` the scene the next rest node without its own `scene` mounts (the restore door calls it; an unknown id throws).
407
+ - `resource(name, defaults)` clones plain JSON defaults into every world. `resource(name, () => value)` calls the factory once per world on first read, so the value may hold a `Map` or a function: `const Looks = resource("boardLooks", () => ({ shown: new Map<World.Entity, { look: string }>() }))`. A system keeps its own state there; `snapshot()` leaves a value that is not JSON out.
399
408
  - `app.world.projection.replace(spec)` swaps the spec of a registered projection; a mounted one runs its new `view` on the next frame. An unknown name throws. The caller is the dev hot swap.
400
409
 
401
410
  ### Interface plugins (V3, built)
@@ -407,7 +416,7 @@ A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` se
407
416
  - `defineComponent(name, { local?, outcomes?, view(props, local) })`. A component with `outcomes` is a popup: `await fx(popup(Component, props))` opens the gate for its outcomes and resolves with `{ intent, payload }` of the pressed button. A button with `local` writes into the nearest component instance; it never reaches the graph.
408
417
  - `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. `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: {}`.
409
418
  - 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.
410
- - 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. Config `maxTracks: 2000`.
419
+ - 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`.
411
420
  - 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.
412
421
  - `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".
413
422
 
@@ -521,11 +530,12 @@ stop();
521
530
  project.close();
522
531
  ```
523
532
 
524
- - `openProject({ root, manifest?, debounceMs? })` resolves `ProjectApi`: `index` (frozen, replaced per batch), `find(key)` (a `Found[]`: the anchor plus `line`, `range` `[startLine, startCol, endLine, endCol]` 1-based with an exclusive end column, `hash`, and `broken: true` when the file does not parse now), `watch(onIndex)` (one call per batch that changed bytes, with `(index, change)`; returns the stop function), `changed(path)` (re-index one root-relative path now, no watch call), `close()`. Without `typescript` it rejects with `[game] The project index needs the "typescript" package.`
533
+ - `openProject({ root, manifest?, tsconfig?, debounceMs? })` resolves `ProjectApi`: `index` (frozen, replaced per batch), `find(key)` (a `Found[]`: the anchor plus `line`, `range` `[startLine, startCol, endLine, endCol]` 1-based with an exclusive end column, `hash`, and `broken: true` when the file does not parse now), `watch(onIndex)` (one call per batch that changed bytes, with `(index, change)`; returns the stop function), `changed(path)` (re-index one root-relative path now, no watch call), `close()`. Without `typescript` it rejects with `[game] The project index needs the "typescript" package.`
534
+ - Aliases: the index follows imports through the `compilerOptions.paths` of `tsconfig.json` at the root (or the root-relative `tsconfig`), `extends` included, as TypeScript does, so `@core/kit`, `@shared` and `@features/home` reach their files. `index.tsconfig` names that file when it holds `paths`. `@moku-labs/game` stays a package. A tsconfig edit rebuilds the aliases in the next watch batch.
525
535
  - Keys: `flow:<id>`, `node:<flow>/<node>` (every entry of a `nodes` table: node, sub-flow, slot), `feature:<id>`, `scene:<id>`, `projection:<name>`, `emitter:<id>`, `textStyle:<key>`, `style:<path>#<binding>` (a module-level `const` of `defineStyle`), `style:<path>#<function>` (every `defineStyle` call inside a module-level function or function-valued const; `find` answers each call) and `style:<path>#<function>.<property>` (a call under an object property there; a style in a class, an object const or at module scope stays unresolved), `component:<Name>` (in a `.tsx` file a module-level upper-case `function Name` or function-valued `const Name`; anywhere `defineComponent("Name", …)`, bound to its const; `uses` are the files that render `<binding>`), `jsx:<key>`. Definers are recognised by binding from the kit's `defineGame()` destructure, renamed imports too. A non-literal id goes to `index.unresolved` with the reason, never guessed.
526
536
  - JSX keys are patterns: literal text stays, same-file consts and functions are followed, a key-carrying prop (`id`, or a name that ends in `Key`) becomes a hole of its name, `props.id` reads `{id}` and `props.amountKey` reads `{amountKey}`, any other hole `*`. `` key={`${id}Picture`} `` reads `jsx:card*Picture` (stem `card`); `id="settingsBoard"` on `<Signboard>` reads `jsx:settingsBoard` (kind `idProp`, component `Signboard`, prop `id`); `amountKey="giftReward"` on `<Amount>` reads `jsx:giftReward` (prop `amountKey`). `find("jsx:settingsBoardClose")`, a key `game.locate` reports, answers the `{id}Close` pattern of the kit and the `id=` prop that fills it; a hole takes only the props of its own name.
527
537
  - `ProjectChange` is `{ revision, files, moved: [{ key, from, to }], removed }`. A broken file keeps its last good keys and gets `files[path].state === "broken"`. Paths are root-relative POSIX; a path that leaves the root is refused.
528
- - CLI: `moku-game-index --root <dir> where <key>` prints `<path>:<line>` per place (exit 1 for an unknown key); `--json` prints the index; `--check` exits 1 on a broken file or a key in conflict and lists the unresolved items as info. `--root` is required, `--manifest <path>` is root-relative.
538
+ - CLI: `moku-game-index --root <dir> where <key>` prints `<path>:<line>` per place (exit 1 for an unknown key); `--json` prints the index; `--check` exits 1 on a broken file or a key in conflict and lists the unresolved items as info. With a tsconfig, `--check` also prints the aliases line, `aliases: tsconfig.json, 8 patterns`, or `aliases: none (no tsconfig paths)`. `--root` is required, `--manifest <path>` and `--tsconfig <path>` are root-relative.
529
539
 
530
540
  ## Hot swap
531
541
 
@@ -536,15 +546,15 @@ Dev only. A save of a view file swaps the module in the running page: same state
536
546
  plugins = ["@moku-labs/game/hot"]
537
547
  ```
538
548
 
539
- - View files swap: `.tsx`, `styles.ts`, `view.ts`, `animations.ts`, `effects.ts`, `generated/strings.<locale>.ts`. Styles, components, function components, projections of a registered name, animations, text styles, emitters and strings. After a strings JSON edit run `bun run assets:keys`; its output is what swaps.
540
- - Logic files reload the page: `rules/`, `nodes/`, `flows/`, `state.ts`, `tables.ts`, `kit.ts`, a feature `index.ts`. A view file that exports a scene, system, ECS component, filter, flow, node, feature, plugin, a new projection or a new animation is refused: `ui` logs `ui:hot-refused` and throws, and Bun reloads the page.
549
+ - View files swap: `.tsx`, `styles.ts`, `view.ts`, `animations.ts`, `effects.ts`, `generated/strings.<locale>.ts`. On the layered layout also any `.ts` directly in a kind folder: `styles/`, `motion/`, `effects/`, `views/`, `world/projections/`, `world/layout/`, in a feature or in `shared/`. Styles, components, function components, projections of a registered name, animations, text styles, emitters and strings. After a strings JSON edit run `bun run assets:keys`; its output is what swaps.
550
+ - Logic files reload the page: `rules/`, `nodes/`, `flows/`, `state.ts`, `tables.ts`, `kit.ts`, a feature `index.ts`, `world/components/`, `world/systems/`. A view file that exports a scene, system, ECS component, filter, flow, node, feature, plugin, a new projection or a new animation is refused: `ui` logs `ui:hot-refused` and throws, and Bun reloads the page.
541
551
  - A swap logs `ui:hot-swap` and emits the global event `ui:hot-swap` `{ file, module }`.
542
552
  - Another layout: `export default hot({ include, exclude })` from the game's own `hot.ts`, listed as `plugins = ["./hot.ts"]`.
543
553
  - Keep a view helper in the module of the projection that uses it. A function another module stored into an object at load time keeps the old version.
544
554
 
545
555
  ## Fonts
546
556
 
547
- The package ships one MSDF body font: `fonts/font-body.fnt` and `fonts/font-body.png` (Pangolin Regular) with `fonts/LICENSE.txt` (SIL Open Font License 1.1). Copy it into the game; a path in `node_modules` is not an asset key, the scanner reads keys only from `features/<feature>/assets/`. The licence goes next to `assets/`, not inside it.
557
+ The package ships one MSDF body font: `fonts/font-body.fnt` and `fonts/font-body.png` (Pangolin Regular) with `fonts/LICENSE.txt` (SIL Open Font License 1.1). Copy it into the game; a path in `node_modules` is not an asset key, the scanner reads keys only from `features/<feature>/assets/` and from the layers of `--layer`. The licence goes next to `assets/`, not inside it.
548
558
 
549
559
  ```bash
550
560
  mkdir -p src/features/ui/assets
@@ -557,7 +567,7 @@ The scanner keys it `ui.font-body`, which is the `text` config default `fonts.bo
557
567
 
558
568
  ## Testing entry
559
569
 
560
- Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`, `fakeClock`, `memory`, `saveOf`. The visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv` come from `@moku-labs/game/visual`, node and bun only (see `docs/testing.md` in the engine repository).
570
+ Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`, `fakeClock`, `memory`, `saveOf`, `isolate`, `stub`. The visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv` come from `@moku-labs/game/visual`, node and bun only (see `docs/testing.md` in the engine repository).
561
571
 
562
572
  - `createHeadless(app): Promise<HeadlessGame>`. Pass an app that is not started. It sets mode `"fast"`, awaits `app.start()`, starts `flow.run()` unless the app's `onStart` already did, and resolves when the loop rests for the first time: `game.state().path` already names the first rest node. A fatal error of `run()` rejects the call.
563
573
  - `HeadlessGame`: `walk(route): Promise<FlowState>`, `answer(answer): boolean`, `state()`, `history()`, `stop()`. `walk` and `stop` throw a fatal error of the loop again when the headless runner owns `run()`.
@@ -588,6 +598,31 @@ it("plays two rolls without a screen", async () => {
588
598
  });
589
599
  ```
590
600
 
601
+ Isolated feature tests play one feature without the rest of the game:
602
+
603
+ - `isolate(feature, options): (seams?) => App`. It composes `options.shared` and the feature as `logicOnly`, then `options.plugins`. The feature's `flow` becomes the node `as` (default `flow.id`) of a harness main flow `isolated`, so paths read as in the game. Options: `flow`, `player` (both required), `as?`, `shared?`, `stubs?`, `plugins?`, `session?` (`{}`), `seed?` (`1`). The factory takes the seams of one app, `player`, `session`, `seed`, `clock`; they win over the options. Pass the app to `createHeadless`.
604
+ - `stub(outcome, payload?)`: `stub("closed")` is `{ kind: "stub", outcome: "closed" }`. `stubs: { <node name>: stub(...) }` replaces that node or sub-flow of `flow`; it ends at once with the outcome.
605
+ - A node that is not stubbed runs for real, the flow of another feature too.
606
+ - A stub is a sub-flow, so a route may still substitute it for one visit: `{ at: "board/energy", result: { outcome: "watch" } }`.
607
+ - A checkpoint compacts `history()`: assert on `state()` or on `flow:edge` events.
608
+ - Every exit of a flow with outcomes lands on the rest node `exited`; `{ at: "exited", intent: "again" }` enters the flow once more.
609
+ - A wrong stub key or outcome does not compile, and throws at run time: `[game] isolate: no node "enrgy" in flow "board".`
610
+
611
+ ```ts
612
+ import { createHeadless, isolate, stub } from "@moku-labs/game/testing";
613
+ import { homeFeature } from "./features/home";
614
+ import { mainFlow } from "./flows/main";
615
+
616
+ // The mini game: home opens the popup info, here stubbed with its outcome ok.
617
+ const homeOnly = isolate(homeFeature, { flow: mainFlow, stubs: { info: stub("ok") }, player: { count: 0 } });
618
+ const game = await createHeadless(homeOnly());
619
+
620
+ game.state().path; // "main/home": the harness holds the flow under its id
621
+ await game.walk([{ at: "main/home", intent: "info" }]); // the stub ends with ok at once
622
+ game.state().path; // "main/home"
623
+ await game.stop();
624
+ ```
625
+
591
626
  For timers, pass `clock: { source: fakeClock(1_000_000) }` in `pluginConfigs`, call `clock.advance(ms)`, then yield the microtask queue before reading the state. For a fixed game, pass `model: { seed: 42 }`. In tests, compose features as `plugins: [feature.logicOnly]`.
592
627
 
593
628
  ## File layout of a game
@@ -603,4 +638,6 @@ src/
603
638
  tests/ headless scenarios with createHeadless and walk
604
639
  ```
605
640
 
641
+ The layered layout puts the game under `core/` (the kit and its types), `shared/` (the shared layer), `features/<f>/` with kind folders (`flow/`, `rules/`, `views/`, `styles/`, `motion/`, `world/`), `plugins/`, `generated/` and `game.ts`, with tsconfig aliases `@core/*`, `@shared`, `@features/*`. The lint rules `layer-imports`, `feature-door` and `test-suffix`, the hot kind folders, `--layer shared=ui` and the project index follow it.
642
+
606
643
  This is the layout of the worked example, the merge game in moku-labs/demos (https://github.com/moku-labs/demos): `kit.ts`, `state.ts`, `tables.ts`, `rules/`, `nodes/`, `flows/`, `game.ts`, written on the public API only, with its e2e and visual tests. The engine repository keeps a smaller fixture, `tests/fixtures/mini-game/`, which is not in the npm package. A game with a screen adds `features/<name>/view.tsx` (projections and components), `features/<name>/animations.ts`, `features/<name>/strings/<locale>.json` and the `generated/` folder `bun run assets:keys` writes. Each plugin has a README: `src/plugins/<name>/README.md` for `time`, `lifecycle`, `model`, `clock`, `flow`, `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`, `audio`, `effects`, `platform`. Moku Core specification: https://github.com/moku-labs/core/tree/main/specification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/game",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "2D puzzle game engine on TypeScript and PixiJS, built on @moku-labs/core.",
5
5
  "type": "module",
6
6
  "sideEffects": false,