@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.
- package/README.md +88 -26
- package/bin/moku-game.mjs +4 -0
- package/dist/app/page.d.mts +41 -0
- package/dist/app/page.mjs +376 -0
- package/dist/app/system.d.mts +129 -0
- package/dist/app/system.mjs +307 -0
- package/dist/app.d.mts +37 -0
- package/dist/app.mjs +235 -0
- package/dist/assets-BHwTLVta.mjs +7391 -0
- package/dist/assets.d.mts +97 -33
- package/dist/assets.mjs +1 -7150
- package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
- package/dist/cli.d.mts +70 -0
- package/dist/cli.mjs +1650 -0
- package/dist/{commands-C1kIfOG5.d.mts → commands-C4QlqijJ.d.mts} +5 -6
- package/dist/config-CdARrrhU.mjs +81 -0
- package/dist/control.d.mts +11 -10
- package/dist/control.mjs +3 -3
- package/dist/{define-BPpZcpxW.d.mts → define-CCc0ozKe.d.mts} +2 -2
- package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
- package/dist/{memory-CvgdnsQO.mjs → drafts-BawpSaVp.mjs} +1 -104
- package/dist/fake-MpzwZhIZ.mjs +87 -0
- package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
- package/dist/{headless-q98SwADR.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
- package/dist/hot.d.mts +8 -8
- package/dist/hot.mjs +15 -14
- package/dist/index-xxSxKJJW.d.mts +1611 -0
- package/dist/index.d.mts +6 -1597
- package/dist/index.mjs +4 -34343
- package/dist/inspect-la_KkjCS.mjs +1 -0
- package/dist/inspect.d.mts +7 -8
- package/dist/inspect.mjs +3 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/lint.d.mts +55 -4
- package/dist/lint.mjs +999 -42
- package/dist/memory-CWlqhS7l.mjs +136 -0
- package/dist/memory-DwDhfFWK.d.mts +50 -0
- package/dist/project.d.mts +29 -13
- package/dist/project.mjs +1120 -631
- package/dist/{run-CUPSFM85.mjs → run-wYty_Lwo.mjs} +4 -4
- package/dist/{runtime-CvDZZg1S.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
- package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
- package/dist/{sources-BBUd15vQ.mjs → sources-Duzj6lhV.mjs} +3 -3
- package/dist/src-DSl1eNB1.mjs +34366 -0
- package/dist/testing.d.mts +115 -34
- package/dist/testing.mjs +175 -70
- package/dist/{types-DlgsfOmW.d.mts → types-BM4shh8K.d.mts} +300 -263
- package/dist/{types-DgdMLZnV.d.mts → types-BUwDHKXm.d.mts} +1372 -1373
- package/dist/{types-DF5kkW-C.d.mts → types-CefgYzFt.d.mts} +4 -5
- package/dist/{types-CDSKa0vl.d.mts → types-DAdwEJMp.d.mts} +2 -2
- package/dist/{types-BWzCSRM2.d.mts → types-ZQLYXo5X.d.mts} +2983 -20
- package/dist/types-hQgI_wrq.d.mts +544 -0
- package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
- package/dist/visual.d.mts +10 -11
- package/dist/visual.mjs +5 -5
- package/llms.txt +170 -57
- package/package.json +30 -2
- package/dist/registry-DlpRCibU.mjs +0 -384
- package/dist/types-ERb2Y9U5.d.mts +0 -2967
package/dist/testing.d.mts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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/
|
|
29
|
+
//#region src/testing/isolate.d.ts
|
|
27
30
|
/**
|
|
28
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
48
|
-
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* const game = await createHeadless(boardOnly({ player: { ...fresh, coins: 7 }, clock: fakeClock(1_000_000) }));
|
|
49
101
|
* ```
|
|
50
102
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
|
62
|
-
* @param
|
|
63
|
-
* @returns
|
|
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
|
-
* //
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
9
|
-
* @param
|
|
10
|
-
* @returns The
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
28
|
-
* @param
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
44
|
-
* @
|
|
45
|
-
* @
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 };
|