@moku-labs/game 0.14.11 → 0.14.13

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 (56) hide show
  1. package/README.md +13 -12
  2. package/dist/app/page.d.mts +8 -3
  3. package/dist/app/page.mjs +57 -12
  4. package/dist/app/system.d.mts +5 -3
  5. package/dist/app/system.mjs +2 -0
  6. package/dist/app.d.mts +15 -7
  7. package/dist/app.mjs +131 -27
  8. package/dist/{assets-BrKSg1O6.mjs → assets-CEMfBogh.mjs} +471 -120
  9. package/dist/assets.d.mts +30 -13
  10. package/dist/assets.mjs +1 -1
  11. package/dist/cli.d.mts +105 -8
  12. package/dist/cli.mjs +1036 -128
  13. package/dist/{commands-d5Zlh9hf.d.mts → commands-DAETmK86.d.mts} +43 -7
  14. package/dist/{config-CdARrrhU.mjs → config-B87aFTW-.mjs} +25 -4
  15. package/dist/control.d.mts +130 -17
  16. package/dist/control.mjs +4 -4
  17. package/dist/{define-BN15lAWb.d.mts → define-BASd9ZUh.d.mts} +32 -4
  18. package/dist/dev-DbHgx-R-.mjs +89 -0
  19. package/dist/{fake-MpzwZhIZ.mjs → fake-6mazmHK3.mjs} +20 -2
  20. package/dist/features-DowEilZm.mjs +266 -0
  21. package/dist/{headless-CrhefcLu.d.mts → headless-cFveciuY.d.mts} +1 -1
  22. package/dist/hot.d.mts +4 -2
  23. package/dist/hot.mjs +7 -4
  24. package/dist/{index-0GIP3tgm.d.mts → index-DedbED3T.d.mts} +317 -69
  25. package/dist/index.d.mts +5 -6
  26. package/dist/index.mjs +3 -3
  27. package/dist/inspect.d.mts +12 -7
  28. package/dist/inspect.mjs +2 -2
  29. package/dist/jsx-dev-runtime.d.mts +1 -1
  30. package/dist/jsx-runtime.d.mts +1 -1
  31. package/dist/lint.d.mts +8 -3
  32. package/dist/lint.mjs +432 -20
  33. package/dist/project.d.mts +15 -1
  34. package/dist/project.mjs +1 -1
  35. package/dist/{run-Bf2hKyjZ.mjs → run-BbipFRc6.mjs} +250 -51
  36. package/dist/{runtime-aLkoeoGj.d.mts → runtime-DoSlRx8X.d.mts} +1 -1
  37. package/dist/{session-DgukhR_7.mjs → session-CeKgO2hy.mjs} +30 -2
  38. package/dist/{sources-Duzj6lhV.mjs → sources-Cdvb0rOS.mjs} +33 -6
  39. package/dist/{src-Do697seR.mjs → src-BEbJ_sSx.mjs} +29154 -11121
  40. package/dist/testing.d.mts +119 -14
  41. package/dist/testing.mjs +97 -7
  42. package/dist/{types-BZa187kz.d.mts → types-7GXcXxxv.d.mts} +196 -242
  43. package/dist/{types-BT9S_Lo2.d.mts → types-BGcda5Le.d.mts} +4464 -735
  44. package/dist/{types-yiHtzg1Y.d.mts → types-C73hbmxi.d.mts} +129 -17
  45. package/dist/{types-Bc9AvZVO.d.mts → types-DngFQJro.d.mts} +43 -5
  46. package/dist/{types-kNiV7iFj.d.mts → types-GHE4J5Uf.d.mts} +1027 -192
  47. package/dist/{visual-dMW8iqAE.mjs → visual-2m4wJL_z.mjs} +111 -7
  48. package/dist/visual-_XhoGeIC.d.mts +345 -0
  49. package/dist/visual.d.mts +1 -332
  50. package/dist/visual.mjs +130 -20
  51. package/dist/{watch-CWI_on4T.mjs → watch-BEdrp80i.mjs} +149 -30
  52. package/llms.txt +103 -51
  53. package/package.json +8 -6
  54. package/dist/dev-Cc50Dk7H.mjs +0 -47
  55. package/dist/memory-DR1ibF_s.d.mts +0 -50
  56. package/dist/types-DMoi447V.d.mts +0 -656
@@ -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-BT9S_Lo2.mjs";
2
- import { t as createApp } from "./index-0GIP3tgm.mjs";
3
- import { n as saveOf, t as memory } from "./memory-DR1ibF_s.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-CrhefcLu.mjs";
1
+ import { $n as NodeFx, In as FeaturePlugin, Jn as Descriptor, Qn as Hint, Rn as AnyFlow, Wn as NodeTable, ir as FakeClock, nr as ClockSource, ur as Json } from "./types-BGcda5Le.mjs";
2
+ import { t as createApp } from "./index-DedbED3T.mjs";
3
+ import { b as saveOf, v as WithGameState, y as memory } from "./types-C73hbmxi.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-cFveciuY.mjs";
5
5
  import { AnyPluginInstance } from "@moku-labs/core";
6
6
 
7
7
  //#region src/plugins/clock/fake.d.ts
@@ -26,6 +26,101 @@ import { AnyPluginInstance } from "@moku-labs/core";
26
26
  */
27
27
  declare function fakeClock(start?: number): FakeClock;
28
28
  //#endregion
29
+ //#region src/testing/fake-fx.d.ts
30
+ /**
31
+ * What a test tells `fakeFx`.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const options: FakeFxOptions = {
36
+ * answer: descriptor => (descriptor.kind === "popup" ? { intent: "again" } : undefined),
37
+ * path: "board/place"
38
+ * };
39
+ * ```
40
+ */
41
+ type FakeFxOptions = {
42
+ /**
43
+ * What an awaited effect resolves with: a value, or a promise of one. Called once for every
44
+ * `await fx(descriptor)`, at the call, with the descriptor itself. Without it every awaited
45
+ * effect resolves `undefined`. A promise that never settles holds the body at its `await`, as a
46
+ * real effect can. A throw and a rejection stand for a handler that fails, and end as they do
47
+ * in the game: the awaited effect rejects, except for a descriptor with `cosmetic: true`
48
+ * (`sfx`, `play`, `haptic`, `music`), which resolves `undefined`. The game also writes the warn
49
+ * entry `flow:fx-cosmetic-failed` there. The fake has no log and writes nothing.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * // The popup is answered, a sound never ends: a body that waits for the sound stays there.
54
+ * fakeFx({
55
+ * answer: descriptor =>
56
+ * descriptor.kind === "sfx" ? new Promise<never>(() => undefined) : { intent: "again" }
57
+ * });
58
+ * ```
59
+ */
60
+ answer?: (descriptor: Descriptor) => unknown;
61
+ /**
62
+ * The path of the node under test. Only the error of a refused `fx.start` names it. `"test"`
63
+ * by default.
64
+ */
65
+ path?: string;
66
+ };
67
+ /**
68
+ * What `fakeFx` returns: the `fx` for the node context, and the three lists it records into.
69
+ * Each list is one array for the whole life of the fake: a list read before the body ran holds
70
+ * the later calls too, and a test starts a list over with `list.length = 0`. The lists hold the
71
+ * objects the body passed, not copies. The holder is frozen, so a list cannot be replaced by
72
+ * another array the fake would not write to.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const { fx, started }: FakeFx = fakeFx();
77
+ *
78
+ * fx.start({ kind: "sfx", payload: { key: "ui.tap" } });
79
+ * started; // [{ kind: "sfx", payload: { key: "ui.tap" } }]
80
+ * ```
81
+ */
82
+ type FakeFx = {
83
+ /** The `fx` to put into the node context: `{ ...context, fx: fake.fx }`. */readonly fx: NodeFx; /** Every `await fx(descriptor)` in call order, recorded at the call, answered or not. */
84
+ readonly awaited: Descriptor[]; /** Every `fx.start(descriptor)` in call order. A refused start is not in it. */
85
+ readonly started: Descriptor[]; /** Every `fx.emit(hint)` in call order. */
86
+ readonly emitted: Hint[];
87
+ };
88
+ /**
89
+ * Builds the `fx` of a node context for a unit test that calls `node.run(context)` itself, with no
90
+ * app and no runner. It records every call and plays no effect: `await fx(descriptor)` goes to
91
+ * `awaited` and resolves with `options.answer(descriptor)`, `fx.start(descriptor)` goes to
92
+ * `started`, `fx.emit(hint)` goes to `emitted`. `fx.start` refuses a descriptor with `answers`
93
+ * and a `guide` with the error the game throws. An `answer` that fails rejects the awaited
94
+ * effect, and resolves `undefined` for a `cosmetic` descriptor, as in the game. There is no
95
+ * handler, no log, no fast mode and no abort signal here: for those, walk the graph with
96
+ * `createHeadless`.
97
+ *
98
+ * @param options - The answer of an awaited effect, and the node path for the refusal message.
99
+ * @returns The `fx` and the lists `awaited`, `started` and `emitted`.
100
+ * @example
101
+ * ```ts
102
+ * // The body of the node "place" runs fx.start(sfx("ui.tap")), then pushes input.cell to
103
+ * // session.marks. `context` is the rest of its context: input, player, session, rng, out,
104
+ * // signal and now.
105
+ * const fx = fakeFx();
106
+ *
107
+ * await place.run?.({ ...context, fx: fx.fx });
108
+ *
109
+ * fx.started; // [{ kind: "sfx", payload: { key: "ui.tap", bus: "sfx" }, cosmetic: true }]
110
+ * fx.awaited; // []: the body waited for no effect
111
+ *
112
+ * // The node "retry" awaits a popup that takes answers. The test gives the player's pick.
113
+ * const picked = fakeFx({
114
+ * answer: descriptor => (descriptor.kind === "popup" ? { intent: "again" } : undefined)
115
+ * });
116
+ *
117
+ * await retry.run?.({ ...context, fx: picked.fx });
118
+ *
119
+ * picked.awaited; // [{ kind: "popup", payload: { name: "Retry" }, answers: ["again", "home"] }]
120
+ * ```
121
+ */
122
+ declare function fakeFx(options?: FakeFxOptions): FakeFx;
123
+ //#endregion
29
124
  //#region src/testing/isolate.d.ts
30
125
  /**
31
126
  * A stub for one node or sub-flow of the isolated flow: the outcome it ends with, and its payload.
@@ -65,12 +160,12 @@ type StubsOf<Nodes extends NodeTable> = { readonly [Name in keyof Nodes & string
65
160
  *
66
161
  * @example
67
162
  * ```ts
68
- * const options: IsolateOptions<typeof boardFlow.nodes> = {
163
+ * const options: IsolateOptions<typeof boardFlow.nodes, Player> = {
69
164
  * flow: boardFlow, shared: sharedLayer, stubs: { settings: stub("closed") }, player: fresh
70
165
  * };
71
166
  * ```
72
167
  */
73
- type IsolateOptions<Nodes extends NodeTable> = {
168
+ type IsolateOptions<Nodes extends NodeTable, Player extends Json = Json, Session extends Json = Json> = {
74
169
  /** The feature's flow. It becomes the one node of the harness main flow, named by `as`. */flow: AnyFlow & {
75
170
  readonly nodes: Nodes;
76
171
  };
@@ -87,9 +182,9 @@ type IsolateOptions<Nodes extends NodeTable> = {
87
182
  as?: string; /** The shared layer, composed as `logicOnly` before the feature. */
88
183
  shared?: FeaturePlugin; /** Node name of `flow` to the stub that replaces it. A node not listed runs for real. */
89
184
  stubs?: StubsOf<Nodes>; /** More plugins the feature needs headless, for example a feature-own plugin. */
90
- plugins?: readonly AnyPluginInstance[]; /** The player a new save starts from. */
91
- player: Json; /** The session at start. `{}` by default. */
92
- session?: Json; /** The rng seed. `1` by default. */
185
+ plugins?: readonly AnyPluginInstance[]; /** The player a new save starts from. Its type types `model.store.snapshot().player`. */
186
+ player: Player; /** The session at start. `{}` by default. Its type types `model.store.snapshot().session`. */
187
+ session?: Session; /** The rng seed. `1` by default. */
93
188
  seed?: number;
94
189
  };
95
190
  /**
@@ -111,6 +206,12 @@ type IsolateSeams = {
111
206
  * plugins on it.
112
207
  */
113
208
  type IsolatedApp = ReturnType<typeof createApp>;
209
+ /**
210
+ * The app of an isolated feature with `model.store.snapshot()` typed by the player and session
211
+ * of the `isolate` call, as `WithGameState` types `game.headless()`. A call whose player and
212
+ * session are `Model.Json` reads a `Model.Json` snapshot, as before. Only the type.
213
+ */
214
+ type IsolatedAppOf<Player, Session> = WithGameState<IsolatedApp, Player, Session>;
114
215
  /**
115
216
  * A stub: the replaced node or sub-flow ends at once with this outcome. Written as a sub-flow of
116
217
  * one transit node, so a route can still substitute it for one visit:
@@ -128,14 +229,16 @@ type IsolatedApp = ReturnType<typeof createApp>;
128
229
  declare function stub<const Name extends string>(outcome: Name, payload?: Json): Stub<Name>;
129
230
  /**
130
231
  * Composes one feature without the rest of the game: the shared layer and the feature as
131
- * `logicOnly`, the feature's flow inside a harness main flow `isolated`, and every stubbed node
232
+ * `logicOnly` (a parent feature is composed with its sub-features), the feature's flow inside a
233
+ * harness main flow `isolated`, and every stubbed node
132
234
  * replaced by a sub-flow that ends with the stub's outcome. Every exit of the flow lands on the
133
235
  * rest node `exited`, whose intent `again` enters the flow once more. Paths read as in the game:
134
236
  * the harness holds the flow under `options.as`, its id by default. A node that is not stubbed
135
237
  * runs for real, even when it belongs to a feature that is not composed.
136
238
  *
137
239
  * @param feature - The feature under test.
138
- * @param options - Its flow, the shared layer, the stubs, the starting state.
240
+ * @param options - Its flow, the shared layer, the stubs, the starting state. The types of
241
+ * `player` and `session` type the app's `model.store.snapshot()`.
139
242
  * @returns A factory of apps, not started; pass one to `createHeadless`.
140
243
  * @throws {Error} For a stub key that is not a node of the flow, or an outcome the node does not declare.
141
244
  * @example
@@ -144,11 +247,13 @@ declare function stub<const Name extends string>(outcome: Name, payload?: Json):
144
247
  * const homeOnly = isolate(homeFeature, {
145
248
  * flow: mainFlow, stubs: { info: stub("ok") }, player: startingPlayer, session: startingSession
146
249
  * });
147
- * const game = await createHeadless(homeOnly());
250
+ * const app = homeOnly();
251
+ * const game = await createHeadless(app);
148
252
  * const state = await game.walk([{ at: "main/home", intent: "info" }]);
149
253
  * state.path; // "main/home"
254
+ * app.model.store.snapshot().player.count; // 0, typed number: startingPlayer types the snapshot
150
255
  * ```
151
256
  */
152
- declare function isolate<Nodes extends NodeTable>(feature: FeaturePlugin, options: IsolateOptions<Nodes>): (seams?: IsolateSeams) => IsolatedApp;
257
+ declare function isolate<Nodes extends NodeTable, Player extends Json = Json, Session extends Json = Json>(feature: FeaturePlugin, options: IsolateOptions<Nodes, Player, Session>): (seams?: IsolateSeams) => IsolatedAppOf<Player, Session>;
153
258
  //#endregion
154
- export { type HeadlessApp, type HeadlessGame, type IsolateOptions, type IsolateSeams, type Repro, type ReproResult, type Stub, type StubsOf, createHeadless, fakeClock, isolate, memory, runRepro, saveOf, stepFrames, stub };
259
+ export { type FakeFx, type FakeFxOptions, type HeadlessApp, type HeadlessGame, type IsolateOptions, type IsolateSeams, type Repro, type ReproResult, type Stub, type StubsOf, createHeadless, fakeClock, fakeFx, isolate, memory, runRepro, saveOf, stepFrames, stub };
package/dist/testing.mjs CHANGED
@@ -1,7 +1,93 @@
1
- import { Kt as type, Ut as exit, t as createApp } from "./src-Do697seR.mjs";
1
+ import { $t as refuseStart, en as exit, p as flattenFeatures, rn as type, t as createApp } from "./src-BEbJ_sSx.mjs";
2
2
  import { n as saveOf, t as memory } from "./memory-CWlqhS7l.mjs";
3
- import { t as fakeClock } from "./fake-MpzwZhIZ.mjs";
3
+ import { t as fakeClock } from "./fake-6mazmHK3.mjs";
4
4
  import { i as stepFrames, r as runRepro, t as createHeadless } from "./headless-BA6ffzqD.mjs";
5
+ //#region src/testing/fake-fx.ts
6
+ /** The node path a refused `fx.start` names when the test gives none. */
7
+ const defaultPath = "test";
8
+ /**
9
+ * Builds the `fx` of a node context for a unit test that calls `node.run(context)` itself, with no
10
+ * app and no runner. It records every call and plays no effect: `await fx(descriptor)` goes to
11
+ * `awaited` and resolves with `options.answer(descriptor)`, `fx.start(descriptor)` goes to
12
+ * `started`, `fx.emit(hint)` goes to `emitted`. `fx.start` refuses a descriptor with `answers`
13
+ * and a `guide` with the error the game throws. An `answer` that fails rejects the awaited
14
+ * effect, and resolves `undefined` for a `cosmetic` descriptor, as in the game. There is no
15
+ * handler, no log, no fast mode and no abort signal here: for those, walk the graph with
16
+ * `createHeadless`.
17
+ *
18
+ * @param options - The answer of an awaited effect, and the node path for the refusal message.
19
+ * @returns The `fx` and the lists `awaited`, `started` and `emitted`.
20
+ * @example
21
+ * ```ts
22
+ * // The body of the node "place" runs fx.start(sfx("ui.tap")), then pushes input.cell to
23
+ * // session.marks. `context` is the rest of its context: input, player, session, rng, out,
24
+ * // signal and now.
25
+ * const fx = fakeFx();
26
+ *
27
+ * await place.run?.({ ...context, fx: fx.fx });
28
+ *
29
+ * fx.started; // [{ kind: "sfx", payload: { key: "ui.tap", bus: "sfx" }, cosmetic: true }]
30
+ * fx.awaited; // []: the body waited for no effect
31
+ *
32
+ * // The node "retry" awaits a popup that takes answers. The test gives the player's pick.
33
+ * const picked = fakeFx({
34
+ * answer: descriptor => (descriptor.kind === "popup" ? { intent: "again" } : undefined)
35
+ * });
36
+ *
37
+ * await retry.run?.({ ...context, fx: picked.fx });
38
+ *
39
+ * picked.awaited; // [{ kind: "popup", payload: { name: "Retry" }, answers: ["again", "home"] }]
40
+ * ```
41
+ */
42
+ function fakeFx(options = {}) {
43
+ const path = options.path ?? defaultPath;
44
+ const awaited = [];
45
+ const started = [];
46
+ const emitted = [];
47
+ /**
48
+ * Records one awaited effect and answers it. Both happen at the call. An `answer` that throws
49
+ * or rejects is a handler that fails: the effect rejects, never the call. A `cosmetic`
50
+ * descriptor swallows that failure and resolves `undefined`, as the gateway of the game does.
51
+ *
52
+ * @param descriptor - The descriptor the body awaits.
53
+ * @returns A promise of what `options.answer` gives for it.
54
+ */
55
+ const run = async (descriptor) => {
56
+ awaited.push(descriptor);
57
+ try {
58
+ return await options.answer?.(descriptor);
59
+ } catch (error) {
60
+ if (descriptor.cosmetic !== true) throw error;
61
+ }
62
+ };
63
+ const fx = Object.assign(run, {
64
+ /**
65
+ * Records one hint. Nothing releases it: the test has no edge to commit.
66
+ *
67
+ * @param item - The hint.
68
+ */
69
+ emit: (item) => {
70
+ emitted.push(item);
71
+ },
72
+ /**
73
+ * Records one started effect, unless the real `fx.start` would refuse it.
74
+ *
75
+ * @param descriptor - The descriptor the body starts.
76
+ * @throws {Error} When the descriptor has `answers` or is a `guide`.
77
+ */
78
+ start: (descriptor) => {
79
+ refuseStart(descriptor, path);
80
+ started.push(descriptor);
81
+ }
82
+ });
83
+ return Object.freeze({
84
+ fx,
85
+ awaited,
86
+ started,
87
+ emitted
88
+ });
89
+ }
90
+ //#endregion
5
91
  //#region src/testing/isolate.ts
6
92
  const noPayload = null;
7
93
  /** Id of the harness main flow. */
@@ -149,14 +235,16 @@ function harnessOf(flow, name = flow.id) {
149
235
  }
150
236
  /**
151
237
  * Composes one feature without the rest of the game: the shared layer and the feature as
152
- * `logicOnly`, the feature's flow inside a harness main flow `isolated`, and every stubbed node
238
+ * `logicOnly` (a parent feature is composed with its sub-features), the feature's flow inside a
239
+ * harness main flow `isolated`, and every stubbed node
153
240
  * replaced by a sub-flow that ends with the stub's outcome. Every exit of the flow lands on the
154
241
  * rest node `exited`, whose intent `again` enters the flow once more. Paths read as in the game:
155
242
  * the harness holds the flow under `options.as`, its id by default. A node that is not stubbed
156
243
  * runs for real, even when it belongs to a feature that is not composed.
157
244
  *
158
245
  * @param feature - The feature under test.
159
- * @param options - Its flow, the shared layer, the stubs, the starting state.
246
+ * @param options - Its flow, the shared layer, the stubs, the starting state. The types of
247
+ * `player` and `session` type the app's `model.store.snapshot()`.
160
248
  * @returns A factory of apps, not started; pass one to `createHeadless`.
161
249
  * @throws {Error} For a stub key that is not a node of the flow, or an outcome the node does not declare.
162
250
  * @example
@@ -165,16 +253,18 @@ function harnessOf(flow, name = flow.id) {
165
253
  * const homeOnly = isolate(homeFeature, {
166
254
  * flow: mainFlow, stubs: { info: stub("ok") }, player: startingPlayer, session: startingSession
167
255
  * });
168
- * const game = await createHeadless(homeOnly());
256
+ * const app = homeOnly();
257
+ * const game = await createHeadless(app);
169
258
  * const state = await game.walk([{ at: "main/home", intent: "info" }]);
170
259
  * state.path; // "main/home"
260
+ * app.model.store.snapshot().player.count; // 0, typed number: startingPlayer types the snapshot
171
261
  * ```
172
262
  */
173
263
  function isolate(feature, options) {
174
264
  const mainFlow = harnessOf(withStubs(options.flow, options.stubs ?? {}), options.as);
175
265
  const plugins = [
176
266
  ...options.shared === void 0 ? [] : [options.shared.logicOnly],
177
- feature.logicOnly,
267
+ ...flattenFeatures([feature.logicOnly]),
178
268
  ...options.plugins ?? []
179
269
  ];
180
270
  return (seams = {}) => createApp({
@@ -191,4 +281,4 @@ function isolate(feature, options) {
191
281
  });
192
282
  }
193
283
  //#endregion
194
- export { createHeadless, fakeClock, isolate, memory, runRepro, saveOf, stepFrames, stub };
284
+ export { createHeadless, fakeClock, fakeFx, isolate, memory, runRepro, saveOf, stepFrames, stub };