@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,5 +1,7 @@
1
- import { G as SaveDoc, R as Json, U as PlayerStateProvider, W as ProviderCall, at as FakeClock } from "./types-ERb2Y9U5.mjs";
2
- import { a as createHeadless, i as ReproResult, n as HeadlessGame, o as runRepro, r as Repro, s as stepFrames, t as HeadlessApp } from "./headless-q98SwADR.mjs";
1
+ import { $t as PlayerStateProvider, Et as AnyFlow, Jt as Json, Mt as NodeTable, en as ProviderCall, gn as FakeClock, mn as ClockSource, mt as FeaturePlugin, tn as SaveDoc } from "./types-B0v5VVa3.mjs";
2
+ import { a as createHeadless, i as ReproResult, n as HeadlessGame, o as runRepro, r as Repro, s as stepFrames, t as HeadlessApp } from "./headless-SCQXJxQf.mjs";
3
+ import { t as createApp } from "./index-BO9kZzY3.mjs";
4
+ import { AnyPluginInstance } from "@moku-labs/core";
3
5
 
4
6
  //#region src/plugins/clock/fake.d.ts
5
7
  /**
@@ -70,4 +72,129 @@ declare function memory(fixture?: {
70
72
  */
71
73
  declare function saveOf(player: Json, seed?: number): SaveDoc;
72
74
  //#endregion
73
- export { type HeadlessApp, type HeadlessGame, type Repro, type ReproResult, createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
75
+ //#region src/testing/isolate.d.ts
76
+ /**
77
+ * A stub for one node or sub-flow of the isolated flow: the outcome it ends with, and its payload.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * const closed: Stub<"closed"> = stub("closed"); // { kind: "stub", outcome: "closed" }
82
+ * ```
83
+ */
84
+ type Stub<Name extends string = string> = {
85
+ readonly kind: "stub";
86
+ readonly outcome: Name;
87
+ readonly payload?: Json;
88
+ };
89
+ /**
90
+ * The outcome names of one entry of a node table. Distributes over a union: for a flow widened
91
+ * to `Flow.AnyFlow` an entry is a node, a sub-flow or a slot, and every outcome name is allowed,
92
+ * not only the slot's `done`.
93
+ */
94
+ type OutcomeNameOf<Entry> = Entry extends {
95
+ readonly outcomes: infer Tags;
96
+ } ? keyof Tags & string : never;
97
+ /**
98
+ * One optional stub per node of the flow. The outcome must be one the replaced entry declares:
99
+ * a wrong one and an unknown key are compile errors, and `isolate()` refuses them at run time too.
100
+ * A flow widened to `Flow.AnyFlow` takes every key and every outcome.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * // boardFlow holds settings: settingsFlow (outcome closed) and energy (outcomes watch, later).
105
+ * const stubs: StubsOf<typeof boardFlow.nodes> = { settings: stub("closed"), energy: stub("later") };
106
+ * ```
107
+ */
108
+ type StubsOf<Nodes extends NodeTable> = { readonly [Name in keyof Nodes & string]?: Stub<OutcomeNameOf<Nodes[Name]>> };
109
+ /**
110
+ * What `isolate` composes around one feature.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const options: IsolateOptions<typeof boardFlow.nodes> = {
115
+ * flow: boardFlow, shared: sharedLayer, stubs: { settings: stub("closed") }, player: fresh
116
+ * };
117
+ * ```
118
+ */
119
+ type IsolateOptions<Nodes extends NodeTable> = {
120
+ /** The feature's flow. It becomes the one node of the harness main flow, named by `as`. */flow: AnyFlow & {
121
+ readonly nodes: Nodes;
122
+ };
123
+ /**
124
+ * The key the game's main flow holds the flow under. `flow.id` by default.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * // The mini game's main flow holds info: infoFlow, whose id is "infoPopup".
129
+ * isolate(infoFeature, { flow: infoFlow, as: "info", player: { count: 0 } });
130
+ * // paths read "info/show", as in the game
131
+ * ```
132
+ */
133
+ as?: string; /** The shared layer, composed as `logicOnly` before the feature. */
134
+ shared?: FeaturePlugin; /** Node name of `flow` to the stub that replaces it. A node not listed runs for real. */
135
+ stubs?: StubsOf<Nodes>; /** More plugins the feature needs headless, for example a feature-own plugin. */
136
+ plugins?: readonly AnyPluginInstance[]; /** The player a new save starts from. */
137
+ player: Json; /** The session at start. `{}` by default. */
138
+ session?: Json; /** The rng seed. `1` by default. */
139
+ seed?: number;
140
+ };
141
+ /**
142
+ * What one app of an isolated feature may pin over the options.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const game = await createHeadless(boardOnly({ player: { ...fresh, coins: 7 }, clock: fakeClock(1_000_000) }));
147
+ * ```
148
+ */
149
+ type IsolateSeams = {
150
+ /** The player this app starts from, instead of `options.player`. */player?: Json; /** The session this app starts with, instead of `options.session`. */
151
+ session?: Json; /** The rng seed of this app, instead of `options.seed`. */
152
+ seed?: number; /** The clock source of this app, `fakeClock()` in a test. The system clock by default. */
153
+ clock?: ClockSource;
154
+ };
155
+ /**
156
+ * An app of an isolated feature, as `createApp` returns it: not started, the engine's logic
157
+ * plugins on it.
158
+ */
159
+ type IsolatedApp = ReturnType<typeof createApp>;
160
+ /**
161
+ * A stub: the replaced node or sub-flow ends at once with this outcome. Written as a sub-flow of
162
+ * one transit node, so a route can still substitute it for one visit:
163
+ * `{ at: "board/energy", result: { outcome: "watch" } }`.
164
+ *
165
+ * @param outcome - An outcome the replaced entry declares.
166
+ * @param payload - The payload it carries, for an outcome with data.
167
+ * @returns The stub as plain data.
168
+ * @example
169
+ * ```ts
170
+ * stub("closed"); // { kind: "stub", outcome: "closed" }
171
+ * stub("orderComplete", { rewardId: "r1" }); // { kind: "stub", outcome: "orderComplete", payload: { rewardId: "r1" } }
172
+ * ```
173
+ */
174
+ declare function stub<const Name extends string>(outcome: Name, payload?: Json): Stub<Name>;
175
+ /**
176
+ * Composes one feature without the rest of the game: the shared layer and the feature as
177
+ * `logicOnly`, the feature's flow inside a harness main flow `isolated`, and every stubbed node
178
+ * replaced by a sub-flow that ends with the stub's outcome. Every exit of the flow lands on the
179
+ * rest node `exited`, whose intent `again` enters the flow once more. Paths read as in the game:
180
+ * the harness holds the flow under `options.as`, its id by default. A node that is not stubbed
181
+ * runs for real, even when it belongs to a feature that is not composed.
182
+ *
183
+ * @param feature - The feature under test.
184
+ * @param options - Its flow, the shared layer, the stubs, the starting state.
185
+ * @returns A factory of apps, not started; pass one to `createHeadless`.
186
+ * @throws {Error} For a stub key that is not a node of the flow, or an outcome the node does not declare.
187
+ * @example
188
+ * ```ts
189
+ * // tests/fixtures/mini-game: Home alone; its info popup is stubbed to end with ok at once.
190
+ * const homeOnly = isolate(homeFeature, {
191
+ * flow: mainFlow, stubs: { info: stub("ok") }, player: startingPlayer, session: startingSession
192
+ * });
193
+ * const game = await createHeadless(homeOnly());
194
+ * const state = await game.walk([{ at: "main/home", intent: "info" }]);
195
+ * state.path; // "main/home"
196
+ * ```
197
+ */
198
+ declare function isolate<Nodes extends NodeTable>(feature: FeaturePlugin, options: IsolateOptions<Nodes>): (seams?: IsolateSeams) => IsolatedApp;
199
+ //#endregion
200
+ 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 };
package/dist/testing.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { n as saveOf, t as memory } from "./memory-CvgdnsQO.mjs";
2
- import { i as stepFrames, r as runRepro, t as createHeadless } from "./headless-CvamCUcR.mjs";
1
+ import { Jt as saveOf, Vt as exit, Wt as type, qt as memory, t as createApp } from "./src-536arXjV.mjs";
2
+ import { i as stepFrames, r as runRepro, t as createHeadless } from "./headless-BsNdJs9W.mjs";
3
3
  //#region src/plugins/clock/fake.ts
4
4
  /**
5
5
  * Removes and returns the timer that is due first, up to and including `until`.
@@ -86,4 +86,193 @@ function fakeClock(start = 0) {
86
86
  };
87
87
  }
88
88
  //#endregion
89
- export { createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
89
+ //#region src/testing/isolate.ts
90
+ const noPayload = null;
91
+ /** Id of the harness main flow. */
92
+ const harnessId = "isolated";
93
+ /** Name of the harness's rest node that every exit of the isolated flow leads to. */
94
+ const exitedName = "exited";
95
+ /** Name of the one transit node of a stub. */
96
+ const endName = "end";
97
+ /** The rng seed of an isolated app that pins none. */
98
+ const defaultSeed = 1;
99
+ /**
100
+ * A stub: the replaced node or sub-flow ends at once with this outcome. Written as a sub-flow of
101
+ * one transit node, so a route can still substitute it for one visit:
102
+ * `{ at: "board/energy", result: { outcome: "watch" } }`.
103
+ *
104
+ * @param outcome - An outcome the replaced entry declares.
105
+ * @param payload - The payload it carries, for an outcome with data.
106
+ * @returns The stub as plain data.
107
+ * @example
108
+ * ```ts
109
+ * stub("closed"); // { kind: "stub", outcome: "closed" }
110
+ * stub("orderComplete", { rewardId: "r1" }); // { kind: "stub", outcome: "orderComplete", payload: { rewardId: "r1" } }
111
+ * ```
112
+ */
113
+ function stub(outcome, payload) {
114
+ return payload === void 0 ? {
115
+ kind: "stub",
116
+ outcome
117
+ } : {
118
+ kind: "stub",
119
+ outcome,
120
+ payload
121
+ };
122
+ }
123
+ /**
124
+ * Builds the sub-flow `stub:<name>` that replaces one entry: one transit node `end` that ends
125
+ * with the stub's outcome. Input and outcomes are the replaced entry's, and every outcome leaves
126
+ * the sub-flow, so the parent's edge table stays valid and a route can substitute it by path.
127
+ *
128
+ * @param name - The node name of the replaced entry.
129
+ * @param original - The replaced node, sub-flow or slot.
130
+ * @param stubbed - The stub.
131
+ * @returns The sub-flow.
132
+ * @throws {Error} When the replaced entry does not declare the stub's outcome.
133
+ */
134
+ function stubEntry(name, original, stubbed) {
135
+ const outcomes = original.outcomes;
136
+ if (!Object.hasOwn(outcomes, stubbed.outcome)) throw new Error(`[game] stub at "${name}" ends with "${stubbed.outcome}", which "${name}" does not declare.\n Use one of: ${Object.keys(outcomes).join(", ")}.`);
137
+ const end = {
138
+ kind: "node",
139
+ input: original.input,
140
+ outcomes,
141
+ rest: false,
142
+ over: false,
143
+ checkpoint: false,
144
+ barrier: false,
145
+ inbox: [],
146
+ run: () => ({
147
+ outcome: stubbed.outcome,
148
+ payload: stubbed.payload ?? noPayload
149
+ })
150
+ };
151
+ const exits = Object.fromEntries(Object.keys(outcomes).map((outcome) => [outcome, exit(outcome)]));
152
+ return {
153
+ kind: "flow",
154
+ id: `stub:${name}`,
155
+ input: original.input,
156
+ outcomes,
157
+ nodes: { [endName]: end },
158
+ start: endName,
159
+ edges: { [endName]: exits }
160
+ };
161
+ }
162
+ /**
163
+ * Rebuilds a flow with the stubs applied by node name. The flow itself is never changed; without
164
+ * stubs it is returned as it is. A key whose stub is `undefined` stubs nothing.
165
+ *
166
+ * @param flow - The feature's flow.
167
+ * @param stubs - Node name to the stub that replaces it.
168
+ * @returns The flow with the stubbed entries replaced.
169
+ * @throws {Error} For a key that is not a node of the flow, or an outcome the node does not declare.
170
+ */
171
+ function withStubs(flow, stubs) {
172
+ const entries = Object.entries(stubs).filter((entry) => entry[1] !== void 0);
173
+ if (entries.length === 0) return flow;
174
+ const nodes = { ...flow.nodes };
175
+ for (const [name, stubbed] of entries) {
176
+ const original = Object.hasOwn(flow.nodes, name) ? flow.nodes[name] : void 0;
177
+ if (original === void 0) throw new Error(`[game] isolate: no node "${name}" in flow "${flow.id}".\n Stub keys are node names of the flow: ${Object.keys(flow.nodes).join(", ")}.`);
178
+ nodes[name] = stubEntry(name, original, stubbed);
179
+ }
180
+ return {
181
+ ...flow,
182
+ nodes
183
+ };
184
+ }
185
+ /**
186
+ * Builds the harness main flow `isolated`: the feature's flow as its node `name`, and, for a flow
187
+ * with outcomes, the rest node `exited` that every exit leads to and whose intent `again` enters
188
+ * the flow once more. An exit of the top-level flow would be fatal, so the flow is never the main
189
+ * flow itself. A flow without outcomes gets no `exited`: it would be unreachable.
190
+ *
191
+ * @param flow - The feature's flow, stubs applied.
192
+ * @param name - The node name the flow is held under: the key the game's main flow uses.
193
+ * @returns The harness main flow.
194
+ * @throws {Error} When a flow with outcomes is to be held under the name `exited`.
195
+ */
196
+ function harnessOf(flow, name = flow.id) {
197
+ const outcomes = Object.keys(flow.outcomes);
198
+ const harness = {
199
+ kind: "flow",
200
+ id: harnessId,
201
+ input: type(),
202
+ outcomes: {},
203
+ start: name
204
+ };
205
+ if (outcomes.length === 0) return {
206
+ ...harness,
207
+ nodes: { [name]: flow },
208
+ edges: { [name]: {} }
209
+ };
210
+ if (name === exitedName) throw new Error(`[game] isolate: "${exitedName}" is the rest node of the harness.\n Pass another name in as.`);
211
+ const exited = {
212
+ kind: "node",
213
+ input: type(),
214
+ outcomes: { again: type() },
215
+ rest: true,
216
+ over: false,
217
+ checkpoint: false,
218
+ barrier: false,
219
+ inbox: []
220
+ };
221
+ const toExited = Object.fromEntries(outcomes.map((outcome) => [outcome, exitedName]));
222
+ return {
223
+ ...harness,
224
+ nodes: {
225
+ [name]: flow,
226
+ [exitedName]: exited
227
+ },
228
+ edges: {
229
+ [name]: toExited,
230
+ [exitedName]: { again: name }
231
+ }
232
+ };
233
+ }
234
+ /**
235
+ * Composes one feature without the rest of the game: the shared layer and the feature as
236
+ * `logicOnly`, the feature's flow inside a harness main flow `isolated`, and every stubbed node
237
+ * replaced by a sub-flow that ends with the stub's outcome. Every exit of the flow lands on the
238
+ * rest node `exited`, whose intent `again` enters the flow once more. Paths read as in the game:
239
+ * the harness holds the flow under `options.as`, its id by default. A node that is not stubbed
240
+ * runs for real, even when it belongs to a feature that is not composed.
241
+ *
242
+ * @param feature - The feature under test.
243
+ * @param options - Its flow, the shared layer, the stubs, the starting state.
244
+ * @returns A factory of apps, not started; pass one to `createHeadless`.
245
+ * @throws {Error} For a stub key that is not a node of the flow, or an outcome the node does not declare.
246
+ * @example
247
+ * ```ts
248
+ * // tests/fixtures/mini-game: Home alone; its info popup is stubbed to end with ok at once.
249
+ * const homeOnly = isolate(homeFeature, {
250
+ * flow: mainFlow, stubs: { info: stub("ok") }, player: startingPlayer, session: startingSession
251
+ * });
252
+ * const game = await createHeadless(homeOnly());
253
+ * const state = await game.walk([{ at: "main/home", intent: "info" }]);
254
+ * state.path; // "main/home"
255
+ * ```
256
+ */
257
+ function isolate(feature, options) {
258
+ const mainFlow = harnessOf(withStubs(options.flow, options.stubs ?? {}), options.as);
259
+ const plugins = [
260
+ ...options.shared === void 0 ? [] : [options.shared.logicOnly],
261
+ feature.logicOnly,
262
+ ...options.plugins ?? []
263
+ ];
264
+ return (seams = {}) => createApp({
265
+ plugins: [...plugins],
266
+ pluginConfigs: {
267
+ model: {
268
+ initialPlayer: seams.player ?? options.player,
269
+ initialSession: seams.session ?? options.session ?? {},
270
+ seed: seams.seed ?? options.seed ?? defaultSeed
271
+ },
272
+ flow: { mainFlow },
273
+ clock: { source: seams.clock }
274
+ }
275
+ });
276
+ }
277
+ //#endregion
278
+ export { createHeadless, fakeClock, isolate, memory, runRepro, saveOf, stepFrames, stub };