@moku-labs/game 0.8.0 → 0.10.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 (60) hide show
  1. package/README.md +88 -26
  2. package/bin/moku-game.mjs +4 -0
  3. package/dist/app/page.d.mts +41 -0
  4. package/dist/app/page.mjs +376 -0
  5. package/dist/app/system.d.mts +129 -0
  6. package/dist/app/system.mjs +307 -0
  7. package/dist/app.d.mts +37 -0
  8. package/dist/app.mjs +235 -0
  9. package/dist/assets-BHwTLVta.mjs +7391 -0
  10. package/dist/assets.d.mts +97 -33
  11. package/dist/assets.mjs +1 -7150
  12. package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
  13. package/dist/cli.d.mts +70 -0
  14. package/dist/cli.mjs +1650 -0
  15. package/dist/{commands-C1kIfOG5.d.mts → commands-C4QlqijJ.d.mts} +5 -6
  16. package/dist/config-CdARrrhU.mjs +81 -0
  17. package/dist/control.d.mts +11 -10
  18. package/dist/control.mjs +3 -3
  19. package/dist/{define-BPpZcpxW.d.mts → define-CCc0ozKe.d.mts} +2 -2
  20. package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
  21. package/dist/{memory-CvgdnsQO.mjs → drafts-BawpSaVp.mjs} +1 -104
  22. package/dist/fake-MpzwZhIZ.mjs +87 -0
  23. package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
  24. package/dist/{headless-q98SwADR.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
  25. package/dist/hot.d.mts +8 -8
  26. package/dist/hot.mjs +15 -14
  27. package/dist/index-xxSxKJJW.d.mts +1611 -0
  28. package/dist/index.d.mts +6 -1597
  29. package/dist/index.mjs +4 -34343
  30. package/dist/inspect-la_KkjCS.mjs +1 -0
  31. package/dist/inspect.d.mts +7 -8
  32. package/dist/inspect.mjs +3 -2
  33. package/dist/jsx-dev-runtime.d.mts +1 -1
  34. package/dist/jsx-runtime.d.mts +1 -1
  35. package/dist/lint.d.mts +55 -4
  36. package/dist/lint.mjs +999 -42
  37. package/dist/memory-CWlqhS7l.mjs +136 -0
  38. package/dist/memory-DwDhfFWK.d.mts +50 -0
  39. package/dist/project.d.mts +29 -13
  40. package/dist/project.mjs +1120 -631
  41. package/dist/{run-CUPSFM85.mjs → run-wYty_Lwo.mjs} +4 -4
  42. package/dist/{runtime-CvDZZg1S.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
  43. package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
  44. package/dist/{sources-BBUd15vQ.mjs → sources-Duzj6lhV.mjs} +3 -3
  45. package/dist/src-DSl1eNB1.mjs +34366 -0
  46. package/dist/testing.d.mts +115 -34
  47. package/dist/testing.mjs +175 -70
  48. package/dist/{types-DlgsfOmW.d.mts → types-BM4shh8K.d.mts} +300 -263
  49. package/dist/{types-DgdMLZnV.d.mts → types-BUwDHKXm.d.mts} +1372 -1373
  50. package/dist/{types-DF5kkW-C.d.mts → types-CefgYzFt.d.mts} +4 -5
  51. package/dist/{types-CDSKa0vl.d.mts → types-DAdwEJMp.d.mts} +2 -2
  52. package/dist/{types-BWzCSRM2.d.mts → types-ZQLYXo5X.d.mts} +2983 -20
  53. package/dist/types-hQgI_wrq.d.mts +544 -0
  54. package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
  55. package/dist/visual.d.mts +10 -11
  56. package/dist/visual.mjs +5 -5
  57. package/llms.txt +170 -57
  58. package/package.json +30 -2
  59. package/dist/registry-DlpRCibU.mjs +0 -384
  60. package/dist/types-ERb2Y9U5.d.mts +0 -2967
@@ -1,5 +1,8 @@
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 { Dt as AnyFlow, Ht as ClockSource, Nt as NodeTable, Wt as FakeClock, Xt as Json, ht as FeaturePlugin } from "./types-ZQLYXo5X.mjs";
2
+ import { t as createApp } from "./index-xxSxKJJW.mjs";
3
+ import { n as saveOf, t as memory } from "./memory-DwDhfFWK.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-G4nuZ-xz.mjs";
5
+ import { AnyPluginInstance } from "@moku-labs/core";
3
6
 
4
7
  //#region src/plugins/clock/fake.d.ts
5
8
  /**
@@ -23,51 +26,129 @@ import { a as createHeadless, i as ReproResult, n as HeadlessGame, o as runRepro
23
26
  */
24
27
  declare function fakeClock(start?: number): FakeClock;
25
28
  //#endregion
26
- //#region src/plugins/model/store/providers/memory.d.ts
29
+ //#region src/testing/isolate.d.ts
27
30
  /**
28
- * Creates an in-memory provider that keeps what it was committed and records every call.
29
- * It is the default when a game configures no `playerProvider`, and the double every test uses:
30
- * `provider.calls` is the whole persistence protocol of one run, in order. The document lives as
31
- * long as the provider does, so a second app over the same instance reads what the first one
32
- * wrote, and nothing survives the process.
31
+ * A stub for one node or sub-flow of the isolated flow: the outcome it ends with, and its payload.
33
32
  *
34
- * `load()` reads the fixture, or everything committed since; without either, the player is new.
35
- * `commitDurable()` resolves at once: there is no disk behind it.
33
+ * @example
34
+ * ```ts
35
+ * const closed: Stub<"closed"> = stub("closed"); // { kind: "stub", outcome: "closed" }
36
+ * ```
37
+ */
38
+ type Stub<Name extends string = string> = {
39
+ readonly kind: "stub";
40
+ readonly outcome: Name;
41
+ readonly payload?: Json;
42
+ };
43
+ /**
44
+ * The outcome names of one entry of a node table. Distributes over a union: for a flow widened
45
+ * to `Flow.AnyFlow` an entry is a node, a sub-flow or a slot, and every outcome name is allowed,
46
+ * not only the slot's `done`.
47
+ */
48
+ type OutcomeNameOf<Entry> = Entry extends {
49
+ readonly outcomes: infer Tags;
50
+ } ? keyof Tags & string : never;
51
+ /**
52
+ * One optional stub per node of the flow. The outcome must be one the replaced entry declares:
53
+ * a wrong one and an unknown key are compile errors, and `isolate()` refuses them at run time too.
54
+ * A flow widened to `Flow.AnyFlow` takes every key and every outcome.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * // boardFlow holds settings: settingsFlow (outcome closed) and energy (outcomes watch, later).
59
+ * const stubs: StubsOf<typeof boardFlow.nodes> = { settings: stub("closed"), energy: stub("later") };
60
+ * ```
61
+ */
62
+ type StubsOf<Nodes extends NodeTable> = { readonly [Name in keyof Nodes & string]?: Stub<OutcomeNameOf<Nodes[Name]>> };
63
+ /**
64
+ * What `isolate` composes around one feature.
36
65
  *
37
- * @param fixture - The save `load()` starts from. Omitted: a new player.
38
- * @param fixture.state - The saved document.
39
- * @param fixture.version - Schema version of the saved document.
40
- * @returns A provider that keeps its document and records its calls.
41
66
  * @example
42
67
  * ```ts
43
- * // A new player is saved at once. One roll reaches the provider at the next rest node.
44
- * const provider = memory();
45
- * const game = await createHeadless(createGame(provider));
68
+ * const options: IsolateOptions<typeof boardFlow.nodes> = {
69
+ * flow: boardFlow, shared: sharedLayer, stubs: { settings: stub("closed") }, player: fresh
70
+ * };
71
+ * ```
72
+ */
73
+ type IsolateOptions<Nodes extends NodeTable> = {
74
+ /** The feature's flow. It becomes the one node of the harness main flow, named by `as`. */flow: AnyFlow & {
75
+ readonly nodes: Nodes;
76
+ };
77
+ /**
78
+ * The key the game's main flow holds the flow under. `flow.id` by default.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * // The mini game's main flow holds info: infoFlow, whose id is "infoPopup".
83
+ * isolate(infoFeature, { flow: infoFlow, as: "info", player: { count: 0 } });
84
+ * // paths read "info/show", as in the game
85
+ * ```
86
+ */
87
+ as?: string; /** The shared layer, composed as `logicOnly` before the feature. */
88
+ shared?: FeaturePlugin; /** Node name of `flow` to the stub that replaces it. A node not listed runs for real. */
89
+ 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. */
93
+ seed?: number;
94
+ };
95
+ /**
96
+ * What one app of an isolated feature may pin over the options.
46
97
  *
47
- * await game.walk([{ at: "home", intent: "roll" }]);
48
- * provider.calls.map(call => call.method); // ["load", "commit", "commit"]
98
+ * @example
99
+ * ```ts
100
+ * const game = await createHeadless(boardOnly({ player: { ...fresh, coins: 7 }, clock: fakeClock(1_000_000) }));
49
101
  * ```
50
102
  */
51
- declare function memory(fixture?: {
52
- state: SaveDoc;
53
- version: number;
54
- }): PlayerStateProvider & {
55
- calls: ProviderCall[];
103
+ type IsolateSeams = {
104
+ /** The player this app starts from, instead of `options.player`. */player?: Json; /** The session this app starts with, instead of `options.session`. */
105
+ session?: Json; /** The rng seed of this app, instead of `options.seed`. */
106
+ seed?: number; /** The clock source of this app, `fakeClock()` in a test. The system clock by default. */
107
+ clock?: ClockSource;
56
108
  };
57
109
  /**
58
- * Builds a save document from a player tree, with no rng streams drawn yet.
59
- * Short fixtures in tests read as `saveOf({ coins: 5 })` instead of spelling out the rng branch.
110
+ * An app of an isolated feature, as `createApp` returns it: not started, the engine's logic
111
+ * plugins on it.
112
+ */
113
+ type IsolatedApp = ReturnType<typeof createApp>;
114
+ /**
115
+ * A stub: the replaced node or sub-flow ends at once with this outcome. Written as a sub-flow of
116
+ * one transit node, so a route can still substitute it for one visit:
117
+ * `{ at: "board/energy", result: { outcome: "watch" } }`.
118
+ *
119
+ * @param outcome - An outcome the replaced entry declares.
120
+ * @param payload - The payload it carries, for an outcome with data.
121
+ * @returns The stub as plain data.
122
+ * @example
123
+ * ```ts
124
+ * stub("closed"); // { kind: "stub", outcome: "closed" }
125
+ * stub("orderComplete", { rewardId: "r1" }); // { kind: "stub", outcome: "orderComplete", payload: { rewardId: "r1" } }
126
+ * ```
127
+ */
128
+ declare function stub<const Name extends string>(outcome: Name, payload?: Json): Stub<Name>;
129
+ /**
130
+ * 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
132
+ * replaced by a sub-flow that ends with the stub's outcome. Every exit of the flow lands on the
133
+ * rest node `exited`, whose intent `again` enters the flow once more. Paths read as in the game:
134
+ * the harness holds the flow under `options.as`, its id by default. A node that is not stubbed
135
+ * runs for real, even when it belongs to a feature that is not composed.
60
136
  *
61
- * @param player - Player tree of the save.
62
- * @param seed - Rng seed of the save.
63
- * @returns The save document.
137
+ * @param feature - The feature under test.
138
+ * @param options - Its flow, the shared layer, the stubs, the starting state.
139
+ * @returns A factory of apps, not started; pass one to `createHeadless`.
140
+ * @throws {Error} For a stub key that is not a node of the flow, or an outcome the node does not declare.
64
141
  * @example
65
142
  * ```ts
66
- * // A returning player with 5 coins. The test starts from this save, not from `initialPlayer`.
67
- * saveOf({ coins: 5 }, 42); // { player: { coins: 5 }, rng: { seed: 42, streams: {} } }
68
- * const provider = memory({ state: saveOf({ coins: 5 }, 42), version: 1 });
143
+ * // tests/fixtures/mini-game: Home alone; its info popup is stubbed to end with ok at once.
144
+ * const homeOnly = isolate(homeFeature, {
145
+ * flow: mainFlow, stubs: { info: stub("ok") }, player: startingPlayer, session: startingSession
146
+ * });
147
+ * const game = await createHeadless(homeOnly());
148
+ * const state = await game.walk([{ at: "main/home", intent: "info" }]);
149
+ * state.path; // "main/home"
69
150
  * ```
70
151
  */
71
- declare function saveOf(player: Json, seed?: number): SaveDoc;
152
+ declare function isolate<Nodes extends NodeTable>(feature: FeaturePlugin, options: IsolateOptions<Nodes>): (seams?: IsolateSeams) => IsolatedApp;
72
153
  //#endregion
73
- export { type HeadlessApp, type HeadlessGame, type Repro, type ReproResult, createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
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 };
package/dist/testing.mjs CHANGED
@@ -1,89 +1,194 @@
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";
3
- //#region src/plugins/clock/fake.ts
1
+ import { Vt as exit, Wt as type, t as createApp } from "./src-DSl1eNB1.mjs";
2
+ import { n as saveOf, t as memory } from "./memory-CWlqhS7l.mjs";
3
+ import { t as fakeClock } from "./fake-MpzwZhIZ.mjs";
4
+ import { i as stepFrames, r as runRepro, t as createHeadless } from "./headless-BsNdJs9W.mjs";
5
+ //#region src/testing/isolate.ts
6
+ const noPayload = null;
7
+ /** Id of the harness main flow. */
8
+ const harnessId = "isolated";
9
+ /** Name of the harness's rest node that every exit of the isolated flow leads to. */
10
+ const exitedName = "exited";
11
+ /** Name of the one transit node of a stub. */
12
+ const endName = "end";
13
+ /** The rng seed of an isolated app that pins none. */
14
+ const defaultSeed = 1;
4
15
  /**
5
- * Removes and returns the timer that is due first, up to and including `until`.
6
- * Timers due at the same moment come out in the order they were scheduled.
16
+ * A stub: the replaced node or sub-flow ends at once with this outcome. Written as a sub-flow of
17
+ * one transit node, so a route can still substitute it for one visit:
18
+ * `{ at: "board/energy", result: { outcome: "watch" } }`.
7
19
  *
8
- * @param state - Innards of the fake clock.
9
- * @param until - Latest moment that counts as due.
10
- * @returns The timer to fire, or `undefined` when nothing is due.
20
+ * @param outcome - An outcome the replaced entry declares.
21
+ * @param payload - The payload it carries, for an outcome with data.
22
+ * @returns The stub as plain data.
23
+ * @example
24
+ * ```ts
25
+ * stub("closed"); // { kind: "stub", outcome: "closed" }
26
+ * stub("orderComplete", { rewardId: "r1" }); // { kind: "stub", outcome: "orderComplete", payload: { rewardId: "r1" } }
27
+ * ```
11
28
  */
12
- function takeDue(state, until) {
13
- let earliest;
14
- for (const timer of state.timers) {
15
- if (timer.at > until) continue;
16
- if (earliest === void 0 || timer.at < earliest.at) earliest = timer;
17
- }
18
- if (earliest === void 0) return void 0;
19
- const due = earliest;
20
- state.timers = state.timers.filter((timer) => timer !== due);
21
- return due;
29
+ function stub(outcome, payload) {
30
+ return payload === void 0 ? {
31
+ kind: "stub",
32
+ outcome
33
+ } : {
34
+ kind: "stub",
35
+ outcome,
36
+ payload
37
+ };
22
38
  }
23
39
  /**
24
- * Moves the clock to `until`, firing every timer that falls due on the way, in due order.
25
- * A timer scheduled inside a callback fires in the same run when it is due before `until`.
40
+ * Builds the sub-flow `stub:<name>` that replaces one entry: one transit node `end` that ends
41
+ * with the stub's outcome. Input and outcomes are the replaced entry's, and every outcome leaves
42
+ * the sub-flow, so the parent's edge table stays valid and a route can substitute it by path.
26
43
  *
27
- * @param state - Innards of the fake clock.
28
- * @param until - Moment to stop at.
44
+ * @param name - The node name of the replaced entry.
45
+ * @param original - The replaced node, sub-flow or slot.
46
+ * @param stubbed - The stub.
47
+ * @returns The sub-flow.
48
+ * @throws {Error} When the replaced entry does not declare the stub's outcome.
29
49
  */
30
- function runDue(state, until) {
31
- let due = takeDue(state, until);
32
- while (due !== void 0) {
33
- state.now = Math.max(state.now, due.at);
34
- due.callback();
35
- due = takeDue(state, until);
36
- }
37
- state.now = Math.max(state.now, until);
50
+ function stubEntry(name, original, stubbed) {
51
+ const outcomes = original.outcomes;
52
+ 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(", ")}.`);
53
+ const end = {
54
+ kind: "node",
55
+ input: original.input,
56
+ outcomes,
57
+ rest: false,
58
+ over: false,
59
+ checkpoint: false,
60
+ barrier: false,
61
+ inbox: [],
62
+ run: () => ({
63
+ outcome: stubbed.outcome,
64
+ payload: stubbed.payload ?? noPayload
65
+ })
66
+ };
67
+ const exits = Object.fromEntries(Object.keys(outcomes).map((outcome) => [outcome, exit(outcome)]));
68
+ return {
69
+ kind: "flow",
70
+ id: `stub:${name}`,
71
+ input: original.input,
72
+ outcomes,
73
+ nodes: { [endName]: end },
74
+ start: endName,
75
+ edges: { [endName]: exits }
76
+ };
38
77
  }
39
78
  /**
40
- * Creates a fake source for tests. Timers fire synchronously inside `advance`, in due order. A
41
- * negative delay counts as zero and an unknown handle is ignored.
79
+ * Rebuilds a flow with the stubs applied by node name. The flow itself is never changed; without
80
+ * stubs it is returned as it is. A key whose stub is `undefined` stubs nothing.
42
81
  *
43
- * @param start - Starting moment in epoch milliseconds. Defaults to 0.
44
- * @returns A clock source with the test-only controls `advance` and `set`.
45
- * @example
46
- * ```ts
47
- * // An energy point refills 60 s after it was spent. The test moves the clock, not the minute.
48
- * const clock = fakeClock(1000);
49
- * const app = createApp({ pluginConfigs: { clock: { source: clock } } });
50
- * const seen: number[] = [];
82
+ * @param flow - The feature's flow.
83
+ * @param stubs - Node name to the stub that replaces it.
84
+ * @returns The flow with the stubbed entries replaced.
85
+ * @throws {Error} For a key that is not a node of the flow, or an outcome the node does not declare.
86
+ */
87
+ function withStubs(flow, stubs) {
88
+ const entries = Object.entries(stubs).filter((entry) => entry[1] !== void 0);
89
+ if (entries.length === 0) return flow;
90
+ const nodes = { ...flow.nodes };
91
+ for (const [name, stubbed] of entries) {
92
+ const original = Object.hasOwn(flow.nodes, name) ? flow.nodes[name] : void 0;
93
+ 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(", ")}.`);
94
+ nodes[name] = stubEntry(name, original, stubbed);
95
+ }
96
+ return {
97
+ ...flow,
98
+ nodes
99
+ };
100
+ }
101
+ /**
102
+ * Builds the harness main flow `isolated`: the feature's flow as its node `name`, and, for a flow
103
+ * with outcomes, the rest node `exited` that every exit leads to and whose intent `again` enters
104
+ * the flow once more. An exit of the top-level flow would be fatal, so the flow is never the main
105
+ * flow itself. A flow without outcomes gets no `exited`: it would be unreachable.
51
106
  *
52
- * app.clock.onElapsed(({ now }) => seen.push(now));
53
- * app.clock.scheduleAt(61_000);
54
- * clock.advance(60_000); // the timer fires inside advance, nothing waits
55
- * seen; // [61000]
56
- * ```
107
+ * @param flow - The feature's flow, stubs applied.
108
+ * @param name - The node name the flow is held under: the key the game's main flow uses.
109
+ * @returns The harness main flow.
110
+ * @throws {Error} When a flow with outcomes is to be held under the name `exited`.
57
111
  */
58
- function fakeClock(start = 0) {
59
- const state = {
60
- now: start,
61
- nextId: 1,
62
- timers: []
112
+ function harnessOf(flow, name = flow.id) {
113
+ const outcomes = Object.keys(flow.outcomes);
114
+ const harness = {
115
+ kind: "flow",
116
+ id: harnessId,
117
+ input: type(),
118
+ outcomes: {},
119
+ start: name
120
+ };
121
+ if (outcomes.length === 0) return {
122
+ ...harness,
123
+ nodes: { [name]: flow },
124
+ edges: { [name]: {} }
63
125
  };
126
+ if (name === exitedName) throw new Error(`[game] isolate: "${exitedName}" is the rest node of the harness.\n Pass another name in as.`);
127
+ const exited = {
128
+ kind: "node",
129
+ input: type(),
130
+ outcomes: { again: type() },
131
+ rest: true,
132
+ over: false,
133
+ checkpoint: false,
134
+ barrier: false,
135
+ inbox: []
136
+ };
137
+ const toExited = Object.fromEntries(outcomes.map((outcome) => [outcome, exitedName]));
64
138
  return {
65
- now: () => state.now,
66
- setTimer: (callback, delayMs) => {
67
- const id = state.nextId;
68
- state.nextId += 1;
69
- state.timers.push({
70
- id,
71
- at: state.now + Math.max(0, delayMs),
72
- callback
73
- });
74
- return id;
75
- },
76
- clearTimer: (handle) => {
77
- if (typeof handle !== "number") return;
78
- state.timers = state.timers.filter((timer) => timer.id !== handle);
139
+ ...harness,
140
+ nodes: {
141
+ [name]: flow,
142
+ [exitedName]: exited
79
143
  },
80
- advance: (ms) => {
81
- runDue(state, state.now + Math.max(0, ms));
82
- },
83
- set: (moment) => {
84
- state.now = moment;
144
+ edges: {
145
+ [name]: toExited,
146
+ [exitedName]: { again: name }
85
147
  }
86
148
  };
87
149
  }
150
+ /**
151
+ * 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
153
+ * replaced by a sub-flow that ends with the stub's outcome. Every exit of the flow lands on the
154
+ * rest node `exited`, whose intent `again` enters the flow once more. Paths read as in the game:
155
+ * the harness holds the flow under `options.as`, its id by default. A node that is not stubbed
156
+ * runs for real, even when it belongs to a feature that is not composed.
157
+ *
158
+ * @param feature - The feature under test.
159
+ * @param options - Its flow, the shared layer, the stubs, the starting state.
160
+ * @returns A factory of apps, not started; pass one to `createHeadless`.
161
+ * @throws {Error} For a stub key that is not a node of the flow, or an outcome the node does not declare.
162
+ * @example
163
+ * ```ts
164
+ * // tests/fixtures/mini-game: Home alone; its info popup is stubbed to end with ok at once.
165
+ * const homeOnly = isolate(homeFeature, {
166
+ * flow: mainFlow, stubs: { info: stub("ok") }, player: startingPlayer, session: startingSession
167
+ * });
168
+ * const game = await createHeadless(homeOnly());
169
+ * const state = await game.walk([{ at: "main/home", intent: "info" }]);
170
+ * state.path; // "main/home"
171
+ * ```
172
+ */
173
+ function isolate(feature, options) {
174
+ const mainFlow = harnessOf(withStubs(options.flow, options.stubs ?? {}), options.as);
175
+ const plugins = [
176
+ ...options.shared === void 0 ? [] : [options.shared.logicOnly],
177
+ feature.logicOnly,
178
+ ...options.plugins ?? []
179
+ ];
180
+ return (seams = {}) => createApp({
181
+ plugins: [...plugins],
182
+ pluginConfigs: {
183
+ model: {
184
+ initialPlayer: seams.player ?? options.player,
185
+ initialSession: seams.session ?? options.session ?? {},
186
+ seed: seams.seed ?? options.seed ?? defaultSeed
187
+ },
188
+ flow: { mainFlow },
189
+ clock: { source: seams.clock }
190
+ }
191
+ });
192
+ }
88
193
  //#endregion
89
- export { createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
194
+ export { createHeadless, fakeClock, isolate, memory, runRepro, saveOf, stepFrames, stub };