@moku-labs/game 0.0.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/LICENSE +21 -0
- package/README.md +428 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +6008 -0
- package/dist/registry-DWV5C0Mf.mjs +666 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/testing.d.mts +161 -0
- package/dist/testing.mjs +366 -0
- package/dist/types-BfsmUzLC.d.mts +1908 -0
- package/package.json +81 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { E as Json, L as RngState, M as ProviderCall, N as SaveDoc, S as FakeClock, U as Api, h as RouteStep, j as PlayerStateProvider, m as JournalEntry, p as FlowState, t as Api$1, y as Answer } from "./types-BfsmUzLC.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/clock/fake.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Creates a fake source for tests. Timers fire synchronously inside `advance`, in due order.
|
|
6
|
+
*
|
|
7
|
+
* @param start - Starting moment in epoch milliseconds. Defaults to 0.
|
|
8
|
+
* @returns A clock source with the test-only controls `advance` and `set`.
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* const clock = fakeClock(1000);
|
|
12
|
+
* clock.advance(5000);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
declare function fakeClock(start?: number): FakeClock;
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/plugins/flow/headless.d.ts
|
|
18
|
+
/**
|
|
19
|
+
* The part of an app the headless helpers use. Structural on purpose: any app created with the
|
|
20
|
+
* default plugins fits.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const app: HeadlessApp = createApp({ pluginConfigs: { flow: { mainFlow } } });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
type HeadlessApp = {
|
|
28
|
+
start(): Promise<void>;
|
|
29
|
+
stop(): Promise<void>;
|
|
30
|
+
time: Api;
|
|
31
|
+
flow: Api$1;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A game played without a screen: answers go through the gate, effects resolve instantly.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const game: HeadlessGame = await createHeadless(app);
|
|
39
|
+
* await game.walk([{ at: "home", intent: "play" }]);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
type HeadlessGame = {
|
|
43
|
+
walk(route: readonly RouteStep[]): Promise<FlowState>;
|
|
44
|
+
answer(answer: Answer): boolean;
|
|
45
|
+
state(): FlowState;
|
|
46
|
+
history(): readonly JournalEntry[];
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A reproducible run: a starting state, an optional checkpoint and the route played from it.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const repro: Repro = { player: { coins: 0 }, checkpoint: "home", route: [{ at: "home", intent: "play" }] };
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
type Repro = {
|
|
58
|
+
player: Json;
|
|
59
|
+
session?: Json;
|
|
60
|
+
rng?: RngState;
|
|
61
|
+
checkpoint?: string;
|
|
62
|
+
route: RouteStep[];
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* What a repro run ends with: the path where the loop rests and the committed state.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const { path, player }: ReproResult = await runRepro(app, repro);
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
type ReproResult = {
|
|
73
|
+
path: string[];
|
|
74
|
+
player: Json;
|
|
75
|
+
session: Json;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Starts an app headless: sets flow mode `"fast"`, awaits `app.start()`, starts `flow.run()`
|
|
79
|
+
* unless the app's own `onStart` already did, and waits until the loop rests for the first time.
|
|
80
|
+
* `game.state().path` therefore names the first rest node of the graph as soon as the call
|
|
81
|
+
* resolves. A fatal error of the loop rejects this call, and a later one is re-thrown by `walk`
|
|
82
|
+
* and by `stop`, so a headless test never loses it.
|
|
83
|
+
*
|
|
84
|
+
* @param app - An app that is not started yet.
|
|
85
|
+
* @returns The game: walk it, answer it, read it, stop it.
|
|
86
|
+
* @throws {Error} When the loop failed fatally before it reached its first rest node.
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* const game = await createHeadless(app);
|
|
90
|
+
* expect(game.state().path).toBe("home");
|
|
91
|
+
* await game.walk([{ at: "board/awaitIntent", intent: "merge", payload: { from: "c2", to: "c3" } }]);
|
|
92
|
+
* await game.stop();
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
declare function createHeadless(app: HeadlessApp): Promise<HeadlessGame>;
|
|
96
|
+
/**
|
|
97
|
+
* Plays a repro: starts the app headless, restores the bookmark built from its state and
|
|
98
|
+
* checkpoint, then walks its route. The app keeps running, so the caller can read more than the
|
|
99
|
+
* result and stops it itself.
|
|
100
|
+
*
|
|
101
|
+
* @param app - An app that is not started yet.
|
|
102
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
103
|
+
* @returns The path the run ended at, and the committed state.
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* const result = await runRepro(app, { player: saved, checkpoint: "home", route });
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
declare function runRepro(app: HeadlessApp, repro: Repro): Promise<ReproResult>;
|
|
110
|
+
/**
|
|
111
|
+
* Steps the frame loop by hand: `count` calls of `app.time.step(deltaMs)`. A headless game has no
|
|
112
|
+
* frame source, so this is the only thing that moves the frame phases.
|
|
113
|
+
*
|
|
114
|
+
* @param app - A started app.
|
|
115
|
+
* @param count - Number of frames.
|
|
116
|
+
* @param deltaMs - Milliseconds per frame.
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* stepFrames(app, 60, 16);
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
declare function stepFrames(app: HeadlessApp, count: number, deltaMs: number): void;
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/plugins/model/store/providers/memory.d.ts
|
|
125
|
+
/**
|
|
126
|
+
* Creates an in-memory provider that keeps what it was committed and records every call.
|
|
127
|
+
* It is the default when a game configures no `playerProvider`, and the double every test uses:
|
|
128
|
+
* `provider.calls` is the whole persistence protocol of one run, in order. The document lives as
|
|
129
|
+
* long as the provider does, so a second app over the same instance reads what the first one
|
|
130
|
+
* wrote, and nothing survives the process.
|
|
131
|
+
*
|
|
132
|
+
* @param fixture - The save `load()` starts from. Omitted: a new player.
|
|
133
|
+
* @param fixture.state - The saved document.
|
|
134
|
+
* @param fixture.version - Schema version of the saved document.
|
|
135
|
+
* @returns A provider that keeps its document and records its calls.
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* const provider = memory({ state: saveOf({ coins: 5 }, 42), version: 1 });
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
declare function memory(fixture?: {
|
|
142
|
+
state: SaveDoc;
|
|
143
|
+
version: number;
|
|
144
|
+
}): PlayerStateProvider & {
|
|
145
|
+
calls: ProviderCall[];
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Builds a save document from a player tree, with no rng streams drawn yet.
|
|
149
|
+
* Short fixtures in tests read as `saveOf({ coins: 5 })` instead of spelling out the rng branch.
|
|
150
|
+
*
|
|
151
|
+
* @param player - Player tree of the save.
|
|
152
|
+
* @param seed - Rng seed of the save.
|
|
153
|
+
* @returns The save document.
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* const provider = memory({ state: saveOf({ coins: 5 }, 42), version: 1 });
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
declare function saveOf(player: Json, seed?: number): SaveDoc;
|
|
160
|
+
//#endregion
|
|
161
|
+
export { type HeadlessApp, type HeadlessGame, type Repro, type ReproResult, createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
|
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { a as graphHash, l as memory, u as saveOf } from "./registry-DWV5C0Mf.mjs";
|
|
2
|
+
//#region src/plugins/clock/fake.ts
|
|
3
|
+
/**
|
|
4
|
+
* Removes and returns the timer that is due first, up to and including `until`.
|
|
5
|
+
* Timers due at the same moment come out in the order they were scheduled.
|
|
6
|
+
*
|
|
7
|
+
* @param state - Innards of the fake clock.
|
|
8
|
+
* @param until - Latest moment that counts as due.
|
|
9
|
+
* @returns The timer to fire, or `undefined` when nothing is due.
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const due = takeDue(state, 1500);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
function takeDue(state, until) {
|
|
16
|
+
let earliest;
|
|
17
|
+
for (const timer of state.timers) {
|
|
18
|
+
if (timer.at > until) continue;
|
|
19
|
+
if (earliest === void 0 || timer.at < earliest.at) earliest = timer;
|
|
20
|
+
}
|
|
21
|
+
if (earliest === void 0) return void 0;
|
|
22
|
+
const due = earliest;
|
|
23
|
+
state.timers = state.timers.filter((timer) => timer !== due);
|
|
24
|
+
return due;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Moves the clock to `until`, firing every timer that falls due on the way, in due order.
|
|
28
|
+
* A timer scheduled inside a callback fires in the same run when it is due before `until`.
|
|
29
|
+
*
|
|
30
|
+
* @param state - Innards of the fake clock.
|
|
31
|
+
* @param until - Moment to stop at.
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* runDue(state, state.now + 5000);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
function runDue(state, until) {
|
|
38
|
+
let due = takeDue(state, until);
|
|
39
|
+
while (due !== void 0) {
|
|
40
|
+
state.now = Math.max(state.now, due.at);
|
|
41
|
+
due.callback();
|
|
42
|
+
due = takeDue(state, until);
|
|
43
|
+
}
|
|
44
|
+
state.now = Math.max(state.now, until);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Creates a fake source for tests. Timers fire synchronously inside `advance`, in due order.
|
|
48
|
+
*
|
|
49
|
+
* @param start - Starting moment in epoch milliseconds. Defaults to 0.
|
|
50
|
+
* @returns A clock source with the test-only controls `advance` and `set`.
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const clock = fakeClock(1000);
|
|
54
|
+
* clock.advance(5000);
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
function fakeClock(start = 0) {
|
|
58
|
+
const state = {
|
|
59
|
+
now: start,
|
|
60
|
+
nextId: 1,
|
|
61
|
+
timers: []
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
/**
|
|
65
|
+
* Reads the fake moment.
|
|
66
|
+
*
|
|
67
|
+
* @returns The current moment in epoch milliseconds.
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* const moment = clock.now();
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
now: () => state.now,
|
|
74
|
+
/**
|
|
75
|
+
* Schedules one callback. A negative delay counts as zero.
|
|
76
|
+
*
|
|
77
|
+
* @param callback - Function to run when the delay has passed.
|
|
78
|
+
* @param delayMs - Delay in milliseconds.
|
|
79
|
+
* @returns The handle to give back to `clearTimer`.
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const handle = clock.setTimer(fire, 1000);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
setTimer: (callback, delayMs) => {
|
|
86
|
+
const id = state.nextId;
|
|
87
|
+
state.nextId += 1;
|
|
88
|
+
state.timers.push({
|
|
89
|
+
id,
|
|
90
|
+
at: state.now + Math.max(0, delayMs),
|
|
91
|
+
callback
|
|
92
|
+
});
|
|
93
|
+
return id;
|
|
94
|
+
},
|
|
95
|
+
/**
|
|
96
|
+
* Cancels a pending timer. An unknown handle is ignored.
|
|
97
|
+
*
|
|
98
|
+
* @param handle - Handle returned by `setTimer`.
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* clock.clearTimer(handle);
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
clearTimer: (handle) => {
|
|
105
|
+
if (typeof handle !== "number") return;
|
|
106
|
+
state.timers = state.timers.filter((timer) => timer.id !== handle);
|
|
107
|
+
},
|
|
108
|
+
/**
|
|
109
|
+
* Moves the clock forward and fires every timer that falls due, in due order.
|
|
110
|
+
* A negative amount moves nothing.
|
|
111
|
+
*
|
|
112
|
+
* @param ms - Milliseconds to move forward.
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* clock.advance(60_000);
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
advance: (ms) => {
|
|
119
|
+
runDue(state, state.now + Math.max(0, ms));
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* Jumps the clock to a moment without firing any timer. Moving back is allowed: that is a
|
|
123
|
+
* device clock set by hand.
|
|
124
|
+
*
|
|
125
|
+
* @param moment - Moment in epoch milliseconds.
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* clock.set(400);
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
set: (moment) => {
|
|
132
|
+
state.now = moment;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/plugins/flow/headless.ts
|
|
138
|
+
const noPayload = null;
|
|
139
|
+
/** Seed of a repro that pins no rng state, the seed `saveOf` writes. */
|
|
140
|
+
const defaultSeed = 1;
|
|
141
|
+
/**
|
|
142
|
+
* Turns whatever `run()` rejected with into an error that can be thrown again.
|
|
143
|
+
*
|
|
144
|
+
* @param value - The rejection value.
|
|
145
|
+
* @returns The value itself when it is an error, a wrapped one otherwise.
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* throw asError(fatal.error);
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
function asError(value) {
|
|
152
|
+
if (value instanceof Error) return value;
|
|
153
|
+
return /* @__PURE__ */ new Error(`[game] The headless run failed.\n ${String(value)}.`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Starts the one loop unless the app's own `onStart` already did, and keeps a fatal error until
|
|
157
|
+
* someone asks for it. `run()` rejects only on a fatal error, and nobody awaits it here.
|
|
158
|
+
*
|
|
159
|
+
* @param app - The started app.
|
|
160
|
+
* @returns The holder of the fatal error.
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* const fatal = startLoop(app);
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
function startLoop(app) {
|
|
167
|
+
const fatal = { error: void 0 };
|
|
168
|
+
if (app.flow.state().running) return fatal;
|
|
169
|
+
app.flow.run().catch((error) => {
|
|
170
|
+
fatal.error = error;
|
|
171
|
+
});
|
|
172
|
+
return fatal;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Waits until the loop rests for the first time, or ends. An empty route walks nowhere: it only
|
|
176
|
+
* waits for the gate the loop opens at the node it enters, so a start that is a chain of transit
|
|
177
|
+
* nodes is played out before anything reads the game.
|
|
178
|
+
*
|
|
179
|
+
* @param app - The started app whose loop is running.
|
|
180
|
+
* @param fatal - Holder of the fatal error of `run()`.
|
|
181
|
+
* @returns Resolves once the loop rests at its first rest node.
|
|
182
|
+
* @throws {Error} When the loop failed fatally instead of reaching a rest node.
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* await settleAtFirstRest(app, fatal);
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
async function settleAtFirstRest(app, fatal) {
|
|
189
|
+
await app.flow.walk([]).catch((error) => {
|
|
190
|
+
throw asError(fatal.error ?? error);
|
|
191
|
+
});
|
|
192
|
+
if (fatal.error !== void 0) throw asError(fatal.error);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Starts an app headless: sets flow mode `"fast"`, awaits `app.start()`, starts `flow.run()`
|
|
196
|
+
* unless the app's own `onStart` already did, and waits until the loop rests for the first time.
|
|
197
|
+
* `game.state().path` therefore names the first rest node of the graph as soon as the call
|
|
198
|
+
* resolves. A fatal error of the loop rejects this call, and a later one is re-thrown by `walk`
|
|
199
|
+
* and by `stop`, so a headless test never loses it.
|
|
200
|
+
*
|
|
201
|
+
* @param app - An app that is not started yet.
|
|
202
|
+
* @returns The game: walk it, answer it, read it, stop it.
|
|
203
|
+
* @throws {Error} When the loop failed fatally before it reached its first rest node.
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* const game = await createHeadless(app);
|
|
207
|
+
* expect(game.state().path).toBe("home");
|
|
208
|
+
* await game.walk([{ at: "board/awaitIntent", intent: "merge", payload: { from: "c2", to: "c3" } }]);
|
|
209
|
+
* await game.stop();
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
async function createHeadless(app) {
|
|
213
|
+
app.flow.setMode("fast");
|
|
214
|
+
await app.start();
|
|
215
|
+
const fatal = startLoop(app);
|
|
216
|
+
await settleAtFirstRest(app, fatal);
|
|
217
|
+
return {
|
|
218
|
+
/**
|
|
219
|
+
* Walks a route through the running loop.
|
|
220
|
+
*
|
|
221
|
+
* @param route - The player's answers and substituted sub-flow results, in order.
|
|
222
|
+
* @returns The state the walk ended in.
|
|
223
|
+
* @throws {Error} When a step is never reached, or the loop failed fatally.
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* await game.walk([{ at: "home", intent: "play" }]);
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
walk: async (route) => {
|
|
230
|
+
const state = await app.flow.walk(route).catch((error) => {
|
|
231
|
+
throw asError(fatal.error ?? error);
|
|
232
|
+
});
|
|
233
|
+
if (fatal.error !== void 0) throw asError(fatal.error);
|
|
234
|
+
return state;
|
|
235
|
+
},
|
|
236
|
+
/**
|
|
237
|
+
* Gives one answer to the gate, the way a tap does.
|
|
238
|
+
*
|
|
239
|
+
* @param answer - Intent and optional payload.
|
|
240
|
+
* @returns Whether the gate took it.
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* game.answer({ intent: "play" });
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
answer: (answer) => app.flow.gate.answer(answer),
|
|
247
|
+
/**
|
|
248
|
+
* Reads where the graph stands.
|
|
249
|
+
*
|
|
250
|
+
* @returns Whether it runs, the path, the stack, what it waits for and the mode.
|
|
251
|
+
* @example
|
|
252
|
+
* ```ts
|
|
253
|
+
* expect(game.state().path).toBe("home");
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
state: () => app.flow.state(),
|
|
257
|
+
/**
|
|
258
|
+
* Reads the edges taken since the last checkpoint.
|
|
259
|
+
*
|
|
260
|
+
* @returns A copy of the journal.
|
|
261
|
+
* @example
|
|
262
|
+
* ```ts
|
|
263
|
+
* const [last] = game.history().slice(-1);
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
history: () => app.flow.history(),
|
|
267
|
+
/**
|
|
268
|
+
* Stops the app, then re-throws a fatal error of the loop.
|
|
269
|
+
*
|
|
270
|
+
* @throws {Error} When the loop failed fatally.
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* await game.stop();
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
stop: async () => {
|
|
277
|
+
await app.stop();
|
|
278
|
+
if (fatal.error !== void 0) throw asError(fatal.error);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Reads the rest node a repro starts at: its checkpoint, or the start of the main flow.
|
|
284
|
+
*
|
|
285
|
+
* @param app - The started app.
|
|
286
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
287
|
+
* @returns The path of the node the repro enters.
|
|
288
|
+
* @throws {Error} When the graph has no main flow to start from.
|
|
289
|
+
* @example
|
|
290
|
+
* ```ts
|
|
291
|
+
* const path = reproPath(app, repro);
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
function reproPath(app, repro) {
|
|
295
|
+
if (repro.checkpoint !== void 0) return repro.checkpoint;
|
|
296
|
+
const graph = app.flow.describe();
|
|
297
|
+
const start = graph.flows[graph.main]?.start;
|
|
298
|
+
if (start === void 0) throw new Error(`[game] runRepro() found no flow "${graph.main}" to start from.\n Name the checkpoint the repro was taken at.`);
|
|
299
|
+
return start;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Builds the bookmark a repro is entered with: its state, its checkpoint and the hash of the
|
|
303
|
+
* graph that runs now, so a checkpoint is accepted and any other rest node is checked.
|
|
304
|
+
*
|
|
305
|
+
* @param app - The started app.
|
|
306
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
307
|
+
* @returns The bookmark to restore.
|
|
308
|
+
* @example
|
|
309
|
+
* ```ts
|
|
310
|
+
* const bookmark = reproBookmark(app, repro);
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
function reproBookmark(app, repro) {
|
|
314
|
+
return {
|
|
315
|
+
path: reproPath(app, repro),
|
|
316
|
+
input: noPayload,
|
|
317
|
+
player: repro.player,
|
|
318
|
+
session: repro.session ?? {},
|
|
319
|
+
rng: repro.rng ?? {
|
|
320
|
+
seed: defaultSeed,
|
|
321
|
+
streams: {}
|
|
322
|
+
},
|
|
323
|
+
graph: graphHash(app.flow.describe())
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Plays a repro: starts the app headless, restores the bookmark built from its state and
|
|
328
|
+
* checkpoint, then walks its route. The app keeps running, so the caller can read more than the
|
|
329
|
+
* result and stops it itself.
|
|
330
|
+
*
|
|
331
|
+
* @param app - An app that is not started yet.
|
|
332
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
333
|
+
* @returns The path the run ended at, and the committed state.
|
|
334
|
+
* @example
|
|
335
|
+
* ```ts
|
|
336
|
+
* const result = await runRepro(app, { player: saved, checkpoint: "home", route });
|
|
337
|
+
* ```
|
|
338
|
+
*/
|
|
339
|
+
async function runRepro(app, repro) {
|
|
340
|
+
const game = await createHeadless(app);
|
|
341
|
+
await app.flow.restore(reproBookmark(app, repro));
|
|
342
|
+
const state = await game.walk(repro.route);
|
|
343
|
+
const ended = app.flow.bookmark();
|
|
344
|
+
return {
|
|
345
|
+
path: state.stack.map((frame) => frame.node),
|
|
346
|
+
player: ended.player,
|
|
347
|
+
session: ended.session
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Steps the frame loop by hand: `count` calls of `app.time.step(deltaMs)`. A headless game has no
|
|
352
|
+
* frame source, so this is the only thing that moves the frame phases.
|
|
353
|
+
*
|
|
354
|
+
* @param app - A started app.
|
|
355
|
+
* @param count - Number of frames.
|
|
356
|
+
* @param deltaMs - Milliseconds per frame.
|
|
357
|
+
* @example
|
|
358
|
+
* ```ts
|
|
359
|
+
* stepFrames(app, 60, 16);
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
function stepFrames(app, count, deltaMs) {
|
|
363
|
+
for (let frame = 0; frame < count; frame += 1) app.time.step(deltaMs);
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
export { createHeadless, fakeClock, memory, runRepro, saveOf, stepFrames };
|