@moku-labs/game 0.0.1 → 0.0.2
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 +424 -37
- package/dist/assets.d.mts +161 -0
- package/dist/assets.mjs +4729 -0
- package/dist/component-DGg5DqKK.mjs +44 -0
- package/dist/control.d.mts +162 -0
- package/dist/control.mjs +758 -0
- package/dist/define-sFoO3y6X.d.mts +263 -0
- package/dist/headless-CvamCUcR.mjs +186 -0
- package/dist/headless-KaSWcd0s.d.mts +181 -0
- package/dist/index.d.mts +1204 -50
- package/dist/index.mjs +20978 -2010
- package/dist/inspect.d.mts +109 -0
- package/dist/inspect.mjs +519 -0
- package/dist/jsx-dev-runtime.d.mts +2 -0
- package/dist/jsx-dev-runtime.mjs +2 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +2 -0
- package/dist/memory-CvgdnsQO.mjs +259 -0
- package/dist/registry-DlpRCibU.mjs +384 -0
- package/dist/runtime-DRlwxkIv.mjs +182 -0
- package/dist/runtime-DiOTkDZz.d.mts +77 -0
- package/dist/session-DmAxY6Ll.mjs +145 -0
- package/dist/testing.d.mts +23 -111
- package/dist/testing.mjs +12 -289
- package/dist/types-BxkNNYul.d.mts +2840 -0
- package/dist/types-CTPS9GBu.d.mts +742 -0
- package/dist/types-DD-QrG_z.d.mts +588 -0
- package/dist/types-DWILGrPn.d.mts +622 -0
- package/dist/types-DYLnSgMI.d.mts +570 -0
- package/dist/types-JNc_UQBo.d.mts +2896 -0
- package/dist/types-yg_ywtT-.d.mts +1859 -0
- package/dist/visual-BDUHSRvf.mjs +734 -0
- package/package.json +26 -4
- package/dist/registry-DWV5C0Mf.mjs +0 -666
- package/dist/types-BfsmUzLC.d.mts +0 -1908
|
@@ -0,0 +1,2896 @@
|
|
|
1
|
+
import { Log } from "@moku-labs/common/browser";
|
|
2
|
+
import { AnyPluginInstance, PluginCtx } from "@moku-labs/core";
|
|
3
|
+
|
|
4
|
+
//#region src/config.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Global configuration of a game.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* createApp({ config: { orientation: "portrait", referenceSide: 1080, referenceLong: 2100 } });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
type Config$5 = {
|
|
14
|
+
/** Screen orientation the game is designed for. */orientation: "portrait" | "landscape"; /** Short side of the reference resolution in pixels. */
|
|
15
|
+
referenceSide: number;
|
|
16
|
+
/**
|
|
17
|
+
* The long side, in reference units, the layout needs inside the safe area. The viewport scale
|
|
18
|
+
* fits both sides, so a wide screen gives the layout more width instead of less height.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* // A board column of 2084 u: on a 768x1024 tablet the scale is 1024 / 2100 = 0.488 and the
|
|
23
|
+
* // reference width grows to 1575 u, so the whole column fits and nothing shrinks alone.
|
|
24
|
+
* createApp({ config: { referenceLong: 2100 } });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
referenceLong: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Global events. Empty: every event belongs to a plugin.
|
|
31
|
+
*/
|
|
32
|
+
type Events$3 = Record<never, never>;
|
|
33
|
+
/**
|
|
34
|
+
* Public API type of a plugin instance, read from its phantom carrier.
|
|
35
|
+
* Mirrors the kernel's non-exported `ExtractPluginApi`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* type TimeApi = ApiOf<typeof timePlugin>; // the `Api` type of src/plugins/time/types.ts
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
type ApiOf<Plugin> = Plugin extends {
|
|
43
|
+
readonly _phantom: {
|
|
44
|
+
readonly api: infer PluginApi;
|
|
45
|
+
};
|
|
46
|
+
} ? PluginApi : never;
|
|
47
|
+
/**
|
|
48
|
+
* Structural type of `ctx.require`, for domain factories that resolve their own dependencies.
|
|
49
|
+
* The bound repeats the kernel's plugin shape so the kernel's generic `require` is assignable to it.
|
|
50
|
+
*/
|
|
51
|
+
type Require = <Plugin extends {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly spec: unknown;
|
|
54
|
+
readonly _phantom: {
|
|
55
|
+
readonly config: unknown;
|
|
56
|
+
readonly state: unknown;
|
|
57
|
+
readonly api: unknown;
|
|
58
|
+
readonly events: Record<string, unknown>;
|
|
59
|
+
};
|
|
60
|
+
}>(plugin: Plugin) => ApiOf<Plugin>;
|
|
61
|
+
declare namespace types_d_exports$4 {
|
|
62
|
+
export { Api$4 as Api, ClockCtx, ClockSource, Config$4 as Config, Elapsed, FakeClock, State$4 as State };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Source of time and timers. The system source is the default; a game passes its own when time
|
|
66
|
+
* must come from somewhere else, for example a server.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* // Server time: the device clock only measures the distance from the last sync. The plugin keeps
|
|
71
|
+
* // at most one timer alive, so the source remembers one.
|
|
72
|
+
* const serverClock = (serverNow: number): ClockSource => {
|
|
73
|
+
* const syncedAt = performance.now();
|
|
74
|
+
* let timer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
*
|
|
76
|
+
* return {
|
|
77
|
+
* now: () => serverNow + (performance.now() - syncedAt),
|
|
78
|
+
* setTimer: (callback, delayMs) => (timer = setTimeout(callback, delayMs)),
|
|
79
|
+
* clearTimer: () => clearTimeout(timer)
|
|
80
|
+
* };
|
|
81
|
+
* };
|
|
82
|
+
*
|
|
83
|
+
* createApp({ pluginConfigs: { clock: { source: serverClock(1_790_000_000_000) } } });
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
type ClockSource = {
|
|
87
|
+
/**
|
|
88
|
+
* Reads the current moment.
|
|
89
|
+
*
|
|
90
|
+
* @returns Epoch milliseconds. A fraction is cut by the plugin; going back is tolerated.
|
|
91
|
+
*/
|
|
92
|
+
now(): number;
|
|
93
|
+
/**
|
|
94
|
+
* Schedules one callback. The plugin keeps at most one timer alive.
|
|
95
|
+
*
|
|
96
|
+
* @param callback - Function to run when the delay has passed.
|
|
97
|
+
* @param delayMs - Delay in milliseconds, never negative.
|
|
98
|
+
* @returns A handle of any shape; the plugin only hands it back to `clearTimer`.
|
|
99
|
+
*/
|
|
100
|
+
setTimer(callback: () => void, delayMs: number): unknown;
|
|
101
|
+
/**
|
|
102
|
+
* Cancels a pending timer. A handle this source did not produce must be ignored.
|
|
103
|
+
*
|
|
104
|
+
* @param handle - Handle returned by `setTimer`.
|
|
105
|
+
*/
|
|
106
|
+
clearTimer(handle: unknown): void;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Fake source for tests, from `@moku-labs/game/testing`. It needs no real timer.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* // An energy point refills after 60 s. The test does not wait: it moves the clock.
|
|
114
|
+
* const clock = fakeClock(1000);
|
|
115
|
+
* const app = createApp({ pluginConfigs: { clock: { source: clock } } });
|
|
116
|
+
*
|
|
117
|
+
* app.clock.scheduleAt(61_000);
|
|
118
|
+
* clock.advance(60_000); // the listeners of onElapsed get { now: 61000 }
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
type FakeClock = ClockSource & {
|
|
122
|
+
/**
|
|
123
|
+
* Moves the clock forward and fires every timer that falls due on the way, in due order.
|
|
124
|
+
*
|
|
125
|
+
* @param ms - Milliseconds to move forward. A negative amount moves nothing.
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* clock.advance(5000); // now() is 5000 later, a timer due in 3000 has fired
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
advance(ms: number): void;
|
|
132
|
+
/**
|
|
133
|
+
* Jumps to a moment without firing any timer. Moving back is allowed: that is a player who
|
|
134
|
+
* sets the device clock by hand.
|
|
135
|
+
*
|
|
136
|
+
* @param moment - Moment in epoch milliseconds.
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* clock.set(400); // the device clock went back; app.clock.now() does not decrease
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
set(moment: number): void;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Input delivered to `onElapsed` listeners when a due moment arrives or the game resumes.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* const input: Elapsed = { now: 1_790_000_060_000 };
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
type Elapsed = {
|
|
153
|
+
now: number;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* clock plugin config.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* createApp({ pluginConfigs: { clock: { source: fakeClock(1000) } } });
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
type Config$4 = {
|
|
164
|
+
/**
|
|
165
|
+
* Time source. `undefined` means the system source: `Date.now` and `setTimeout`.
|
|
166
|
+
*/
|
|
167
|
+
source: ClockSource | undefined;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* clock plugin state.
|
|
171
|
+
*/
|
|
172
|
+
type State$4 = {
|
|
173
|
+
source: ClockSource;
|
|
174
|
+
last: number;
|
|
175
|
+
dueAt: number | undefined;
|
|
176
|
+
handle: unknown;
|
|
177
|
+
listeners: Array<(input: Elapsed) => void>;
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* clock plugin API, `app.clock`. Trusted time for rules that depend on real time: energy refill,
|
|
181
|
+
* generators, daily rewards. The clock holds ONE pending moment; the rules decide the next one.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* // The whole cycle: ask the rules for the nearest moment, wait for it, apply, ask again.
|
|
186
|
+
* app.clock.onElapsed(({ now }) => {
|
|
187
|
+
* applyRefills(now);
|
|
188
|
+
* app.clock.scheduleAt(nextRefillAt(now)); // undefined when nothing is pending
|
|
189
|
+
* });
|
|
190
|
+
* app.clock.scheduleAt(nextRefillAt(app.clock.now()));
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
type Api$4 = {
|
|
194
|
+
/**
|
|
195
|
+
* Reads trusted time. It never decreases, whatever the device clock does, so a player who sets
|
|
196
|
+
* the clock back gains nothing.
|
|
197
|
+
*
|
|
198
|
+
* @returns The current moment in epoch milliseconds, an integer.
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* // Stamp a generator when it is used, and compare later.
|
|
202
|
+
* player.generator.usedAt = app.clock.now(); // 1790000000000
|
|
203
|
+
* const ready = app.clock.now() - player.generator.usedAt >= 60_000;
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
now(): number;
|
|
207
|
+
/**
|
|
208
|
+
* Replaces the single pending due moment. A moment in the past is not delivered synchronously:
|
|
209
|
+
* it fires on the next macrotask of the source.
|
|
210
|
+
*
|
|
211
|
+
* @param moment - Moment in epoch milliseconds, or `undefined` to cancel.
|
|
212
|
+
* @example
|
|
213
|
+
* ```ts
|
|
214
|
+
* // A generator refills in 60 s. Only one moment is pending: the nearest one.
|
|
215
|
+
* app.clock.scheduleAt(app.clock.now() + 60_000);
|
|
216
|
+
* app.clock.scheduleAt(undefined); // nothing is due any more: cancel
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
scheduleAt(moment: number | undefined): void;
|
|
220
|
+
/**
|
|
221
|
+
* Registers a listener for the `elapsed` input: a due moment arrived, or `poke` was called.
|
|
222
|
+
* The clock does not reschedule by itself.
|
|
223
|
+
*
|
|
224
|
+
* @param listener - Called with `{ now }`, the trusted moment of the delivery.
|
|
225
|
+
* @returns The unsubscribe function.
|
|
226
|
+
* @example
|
|
227
|
+
* ```ts
|
|
228
|
+
* const off = app.clock.onElapsed(({ now }) => {
|
|
229
|
+
* applyRefills(now); // now: 1790000060000
|
|
230
|
+
* });
|
|
231
|
+
*
|
|
232
|
+
* off(); // the screen that showed the timer is closed
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
onElapsed(listener: (input: Elapsed) => void): () => void;
|
|
236
|
+
/**
|
|
237
|
+
* Delivers `elapsed` right now, without touching the pending due moment. The engine calls it
|
|
238
|
+
* when the game resumes; a game calls it after it changed something the rules depend on.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```ts
|
|
242
|
+
* // The game synced with its server and the time source jumped forward: let the rules catch up.
|
|
243
|
+
* // On resume from background the engine pokes by itself, a game never does that.
|
|
244
|
+
* app.clock.poke(); // onElapsed listeners get { now } at once; the pending moment stays
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
poke(): void;
|
|
248
|
+
/**
|
|
249
|
+
* Reads the pending due moment, for inspection and tests.
|
|
250
|
+
*
|
|
251
|
+
* @returns The pending moment, or `undefined` when nothing is scheduled.
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* app.clock.scheduleAt(1500);
|
|
255
|
+
* app.clock.dueAt(); // 1500
|
|
256
|
+
* await app.stop();
|
|
257
|
+
* app.clock.dueAt(); // undefined
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
dueAt(): number | undefined;
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Domain context of the clock plugin.
|
|
264
|
+
*/
|
|
265
|
+
type ClockCtx = PluginCtx<Config$4, State$4> & {
|
|
266
|
+
readonly global: object;
|
|
267
|
+
};
|
|
268
|
+
declare namespace types_d_exports$3 {
|
|
269
|
+
export { Api$3 as Api, Config$3 as Config, Events$2 as Events, LifecycleCtx, PauseReason, State$3 as State };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Why the game is paused. The named reasons are the engine's own; any other string is allowed.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* ```ts
|
|
276
|
+
* const reason: PauseReason = "background";
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
type PauseReason = "background" | "devtools" | "system-dialog" | "device-lost" | (string & {});
|
|
280
|
+
/**
|
|
281
|
+
* lifecycle plugin events.
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* ```ts
|
|
285
|
+
* // The payload of the first `app.lifecycle.push("background")`.
|
|
286
|
+
* const payload: Events["lifecycle:changed"] = {
|
|
287
|
+
* reason: "background", action: "push",
|
|
288
|
+
* reasons: ["background"], paused: true, resumed: false
|
|
289
|
+
* };
|
|
290
|
+
* ```
|
|
291
|
+
*/
|
|
292
|
+
type Events$2 = {
|
|
293
|
+
/**
|
|
294
|
+
* The pause stack changed. `reason` and `action` say what changed; `resumed` is true only on
|
|
295
|
+
* the change that emptied the stack.
|
|
296
|
+
*/
|
|
297
|
+
"lifecycle:changed": {
|
|
298
|
+
reason: PauseReason;
|
|
299
|
+
action: "push" | "pop";
|
|
300
|
+
reasons: readonly PauseReason[];
|
|
301
|
+
paused: boolean;
|
|
302
|
+
resumed: boolean;
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* lifecycle plugin config: none. The plugin has no tunable behaviour.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```ts
|
|
310
|
+
* const config: Config = {};
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
type Config$3 = Record<string, never>;
|
|
314
|
+
/**
|
|
315
|
+
* lifecycle plugin state: the pause reasons in insertion order, no duplicates.
|
|
316
|
+
*/
|
|
317
|
+
type State$3 = {
|
|
318
|
+
reasons: PauseReason[];
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* lifecycle plugin API, `app.lifecycle`. The stack of reasons why the game is paused: the first
|
|
322
|
+
* reason pauses `time`, the last one to leave resumes it, and every real change of the stack
|
|
323
|
+
* emits `lifecycle:changed`.
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```ts
|
|
327
|
+
* // Two reasons hold the pause, so they never cancel each other.
|
|
328
|
+
* app.lifecycle.push("background"); // time pauses
|
|
329
|
+
* app.lifecycle.push("ad"); // still one pause
|
|
330
|
+
* app.lifecycle.pop("background"); // still paused: "ad" holds
|
|
331
|
+
* app.lifecycle.pop("ad"); // time resumes
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
type Api$3 = {
|
|
335
|
+
/**
|
|
336
|
+
* Pushes a reason why the game is paused. A reason already on the stack is ignored, so two
|
|
337
|
+
* plugins pausing for the same reason never pause twice.
|
|
338
|
+
*
|
|
339
|
+
* @param reason - Why the game is paused.
|
|
340
|
+
* @example
|
|
341
|
+
* ```ts
|
|
342
|
+
* // A rewarded ad covers the game: the world stands while it plays.
|
|
343
|
+
* app.lifecycle.push("ad"); // app.time.isPaused() is true, "lifecycle:changed" is emitted
|
|
344
|
+
* app.lifecycle.push("ad"); // already on the stack: no second pause, no event
|
|
345
|
+
* ```
|
|
346
|
+
*/
|
|
347
|
+
push(reason: PauseReason): void;
|
|
348
|
+
/**
|
|
349
|
+
* Pops a reason. The game runs again only when the last reason leaves. A reason that is not
|
|
350
|
+
* on the stack is ignored.
|
|
351
|
+
*
|
|
352
|
+
* @param reason - The reason that no longer holds.
|
|
353
|
+
* @example
|
|
354
|
+
* ```ts
|
|
355
|
+
* // The tab is visible again. The game runs, unless another reason still holds.
|
|
356
|
+
* document.addEventListener("visibilitychange", () => {
|
|
357
|
+
* if (!document.hidden) app.lifecycle.pop("background");
|
|
358
|
+
* });
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
pop(reason: PauseReason): void;
|
|
362
|
+
/**
|
|
363
|
+
* Reads the stack as a frozen copy, in insertion order, so a caller cannot write into it.
|
|
364
|
+
*
|
|
365
|
+
* @returns The pause reasons currently held.
|
|
366
|
+
* @example
|
|
367
|
+
* ```ts
|
|
368
|
+
* // A debug overlay shows why the game stands still.
|
|
369
|
+
* app.lifecycle.push("background");
|
|
370
|
+
* app.lifecycle.push("ad");
|
|
371
|
+
* app.lifecycle.reasons(); // ["background", "ad"]
|
|
372
|
+
* ```
|
|
373
|
+
*/
|
|
374
|
+
reasons(): readonly PauseReason[];
|
|
375
|
+
/**
|
|
376
|
+
* Tells whether the game is paused: true while the stack is not empty.
|
|
377
|
+
*
|
|
378
|
+
* @returns True while at least one reason holds.
|
|
379
|
+
* @example
|
|
380
|
+
* ```ts
|
|
381
|
+
* // The idle hint must not start behind a system dialog.
|
|
382
|
+
* app.lifecycle.push("system-dialog");
|
|
383
|
+
* app.lifecycle.isPaused(); // true
|
|
384
|
+
* ```
|
|
385
|
+
*/
|
|
386
|
+
isPaused(): boolean;
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* Domain context: the kernel context with `require`, used to reach `time`. `emit` is the kernel's:
|
|
390
|
+
* `index.ts` writes `events` with an annotated `register` (core spec `14` row 8), so the own event
|
|
391
|
+
* reaches a factory passed by direct reference.
|
|
392
|
+
*/
|
|
393
|
+
type LifecycleCtx = PluginCtx<Config$3, State$3, Events$2> & {
|
|
394
|
+
readonly require: Require;
|
|
395
|
+
};
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/plugins/model/rng/types.d.ts
|
|
398
|
+
/**
|
|
399
|
+
* @file model/rng — type definitions.
|
|
400
|
+
*/
|
|
401
|
+
/**
|
|
402
|
+
* Persisted randomness: one uint32 per stream id.
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```ts
|
|
406
|
+
* // Seed 42, one draw from the stream "chest:42".
|
|
407
|
+
* const rng: RngState = { seed: 42, streams: { "chest:42": 22541618 } };
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
type RngState = {
|
|
411
|
+
seed: number;
|
|
412
|
+
streams: Record<string, number>;
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* One deterministic stream. Integers only, so every engine produces the same numbers.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* // Inside a node the rng view comes with the context.
|
|
420
|
+
* export const roll = defineNode({
|
|
421
|
+
* outcomes: { done: type() },
|
|
422
|
+
* run: ({ player, rng, out }) => {
|
|
423
|
+
* player.coins += rng.stream("dice").range(1, 6); // 4: first draw of a save with seed 42
|
|
424
|
+
* return out.done();
|
|
425
|
+
* }
|
|
426
|
+
* });
|
|
427
|
+
* ```
|
|
428
|
+
*/
|
|
429
|
+
type RngStream = {
|
|
430
|
+
/**
|
|
431
|
+
* Draws an integer in `[0, maxExclusive)`.
|
|
432
|
+
*
|
|
433
|
+
* @param maxExclusive - Upper bound, excluded.
|
|
434
|
+
* @returns The drawn integer.
|
|
435
|
+
* @throws {Error} When the bound is not a positive integer.
|
|
436
|
+
* @example
|
|
437
|
+
* ```ts
|
|
438
|
+
* // Which of the six board columns gets the new tile.
|
|
439
|
+
* const column = rng.stream("spawn").int(6); // 0 to 5
|
|
440
|
+
* ```
|
|
441
|
+
*/
|
|
442
|
+
int(maxExclusive: number): number;
|
|
443
|
+
/**
|
|
444
|
+
* Draws an integer in `[min, maxInclusive]`.
|
|
445
|
+
*
|
|
446
|
+
* @param min - Lower bound, included.
|
|
447
|
+
* @param maxInclusive - Upper bound, included.
|
|
448
|
+
* @returns The drawn integer.
|
|
449
|
+
* @throws {Error} When the span is empty.
|
|
450
|
+
* @example
|
|
451
|
+
* ```ts
|
|
452
|
+
* // A die.
|
|
453
|
+
* player.lastRoll = rng.stream("dice").range(1, 6); // 4: first draw of a save with seed 42
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
range(min: number, maxInclusive: number): number;
|
|
457
|
+
/**
|
|
458
|
+
* Picks one element of an array.
|
|
459
|
+
*
|
|
460
|
+
* @param items - The array to pick from.
|
|
461
|
+
* @returns The picked element.
|
|
462
|
+
* @throws {Error} When the array is empty or has holes.
|
|
463
|
+
* @example
|
|
464
|
+
* ```ts
|
|
465
|
+
* // The reward of chest 42.
|
|
466
|
+
* rng.stream("chest:42").pick(["coin", "gem", "key"]); // "gem": first draw, seed 42
|
|
467
|
+
* ```
|
|
468
|
+
*/
|
|
469
|
+
pick<Item>(items: readonly Item[]): Item;
|
|
470
|
+
/**
|
|
471
|
+
* Picks one entry of a table by its integer weight. Entries of weight zero never win.
|
|
472
|
+
*
|
|
473
|
+
* @param table - Entries with integer weights.
|
|
474
|
+
* @returns The picked entry.
|
|
475
|
+
* @throws {Error} When no entry has a positive weight.
|
|
476
|
+
* @example
|
|
477
|
+
* ```ts
|
|
478
|
+
* // A coin drops three times as often as a gem.
|
|
479
|
+
* const drop = rng.stream("drop").weighted([
|
|
480
|
+
* { id: "coin", weight: 3 },
|
|
481
|
+
* { id: "gem", weight: 1 }
|
|
482
|
+
* ]); // { id: "gem", weight: 1 }: first draw, seed 42
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
weighted<Entry extends {
|
|
486
|
+
weight: number;
|
|
487
|
+
}>(table: readonly Entry[]): Entry;
|
|
488
|
+
/**
|
|
489
|
+
* Draws a `numerator` in `denominator` chance.
|
|
490
|
+
*
|
|
491
|
+
* @param numerator - How many of the outcomes are a hit.
|
|
492
|
+
* @param denominator - How many outcomes there are.
|
|
493
|
+
* @returns True when the draw is a hit.
|
|
494
|
+
* @throws {Error} When the denominator is not a positive integer.
|
|
495
|
+
* @example
|
|
496
|
+
* ```ts
|
|
497
|
+
* // One merge in twenty gives a bonus tile.
|
|
498
|
+
* const bonus = rng.stream("bonus").chance(1, 20);
|
|
499
|
+
* ```
|
|
500
|
+
*/
|
|
501
|
+
chance(numerator: number, denominator: number): boolean;
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* View over an rng branch, draft or frozen: `rng` of a node context. Every draw advances
|
|
505
|
+
* `streams[id]` of the branch, so draws inside a transaction commit with it and draws inside a
|
|
506
|
+
* discarded transaction are forgotten with it.
|
|
507
|
+
*/
|
|
508
|
+
type RngView = {
|
|
509
|
+
/**
|
|
510
|
+
* Opens one stream. The first draw seeds it from the save's seed and the id, so opening a
|
|
511
|
+
* stream without drawing leaves the branch untouched.
|
|
512
|
+
*
|
|
513
|
+
* @param id - Stream id. One id per source, for example `"chest:42"`.
|
|
514
|
+
* @returns The stream of that id.
|
|
515
|
+
* @example
|
|
516
|
+
* ```ts
|
|
517
|
+
* // One id per source: a kill of the app before the rest node cannot re-roll chest 42.
|
|
518
|
+
* const chest = rng.stream("chest:42");
|
|
519
|
+
* chest.int(6); // 2: first draw of a save with seed 42
|
|
520
|
+
* chest.int(6); // 0
|
|
521
|
+
* ```
|
|
522
|
+
*/
|
|
523
|
+
stream(id: string): RngStream;
|
|
524
|
+
};
|
|
525
|
+
/**
|
|
526
|
+
* rng module API, `app.model.rng`: inspection of stream state in the committed document. Draws
|
|
527
|
+
* never happen here; they happen on the `rng` view of a node context.
|
|
528
|
+
*/
|
|
529
|
+
type RngApi = {
|
|
530
|
+
/**
|
|
531
|
+
* Reads the committed state of one stream, for bookmarks, tools and tests. It never draws.
|
|
532
|
+
*
|
|
533
|
+
* @param id - Stream id.
|
|
534
|
+
* @returns The uint32 state of the stream, or `undefined` when it was never drawn.
|
|
535
|
+
* @example
|
|
536
|
+
* ```ts
|
|
537
|
+
* // A test checks that one roll drew from the dice stream. The save has seed 42.
|
|
538
|
+
* app.model.rng.peek("dice"); // undefined: never drawn
|
|
539
|
+
* await game.walk([{ at: "home", intent: "roll" }]);
|
|
540
|
+
* app.model.rng.peek("dice"); // 1175946015
|
|
541
|
+
* ```
|
|
542
|
+
*/
|
|
543
|
+
peek(id: string): number | undefined;
|
|
544
|
+
};
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region src/plugins/model/store/types.d.ts
|
|
547
|
+
/**
|
|
548
|
+
* JSON patch produced by a commit.
|
|
549
|
+
*
|
|
550
|
+
* @example
|
|
551
|
+
* ```ts
|
|
552
|
+
* const patch: Patch = { op: "replace", path: ["player", "coins"], value: 12 };
|
|
553
|
+
* ```
|
|
554
|
+
*/
|
|
555
|
+
type Patch = {
|
|
556
|
+
op: "add" | "remove" | "replace";
|
|
557
|
+
path: (string | number)[];
|
|
558
|
+
value?: Json;
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
561
|
+
* The persisted document.
|
|
562
|
+
*
|
|
563
|
+
* @example
|
|
564
|
+
* ```ts
|
|
565
|
+
* const doc: SaveDoc = { player: { coins: 0 }, rng: { seed: 42, streams: {} } };
|
|
566
|
+
* ```
|
|
567
|
+
*/
|
|
568
|
+
type SaveDoc = {
|
|
569
|
+
player: Json;
|
|
570
|
+
rng: RngState;
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* The save seam implemented by the application layer. `state` is the whole SaveDoc.
|
|
574
|
+
*
|
|
575
|
+
* @example
|
|
576
|
+
* ```ts
|
|
577
|
+
* // A server save: the server holds the document and applies the patches it is sent.
|
|
578
|
+
* const serverSave = (url: string): PlayerStateProvider => {
|
|
579
|
+
* const queue: { patches: Patch[]; version: number }[] = [];
|
|
580
|
+
*
|
|
581
|
+
* const send = async (batch: typeof queue, txId?: string): Promise<void> => {
|
|
582
|
+
* if (batch.length === 0) return;
|
|
583
|
+
* await fetch(url, { method: "POST", body: JSON.stringify({ batch, txId }) });
|
|
584
|
+
* };
|
|
585
|
+
*
|
|
586
|
+
* return {
|
|
587
|
+
* load: async () => {
|
|
588
|
+
* const response = await fetch(url);
|
|
589
|
+
* return response.status === 404 ? null : await response.json(); // { state, version }
|
|
590
|
+
* },
|
|
591
|
+
* commit: (patches, version) => {
|
|
592
|
+
* queue.push({ patches, version }); // a rest node: accumulate, write at the next flush
|
|
593
|
+
* },
|
|
594
|
+
* commitDurable: async (patches, txId, version) => {
|
|
595
|
+
* queue.push({ patches, version });
|
|
596
|
+
* await send(queue.splice(0), txId); // a barrier node: resolve only when it is stored
|
|
597
|
+
* },
|
|
598
|
+
* flush: () => send(queue.splice(0))
|
|
599
|
+
* };
|
|
600
|
+
* };
|
|
601
|
+
*
|
|
602
|
+
* createApp({ pluginConfigs: { model: { playerProvider: serverSave("/api/save") } } });
|
|
603
|
+
* ```
|
|
604
|
+
*/
|
|
605
|
+
type PlayerStateProvider = {
|
|
606
|
+
/**
|
|
607
|
+
* Reads the save. Called once, by `store.load()`.
|
|
608
|
+
*
|
|
609
|
+
* @returns The stored document and its schema version. `null` means a new player.
|
|
610
|
+
*/
|
|
611
|
+
load(): Promise<{
|
|
612
|
+
state: Json;
|
|
613
|
+
version: number;
|
|
614
|
+
} | null>;
|
|
615
|
+
/**
|
|
616
|
+
* Called at rest nodes. The provider accumulates and debounces writes.
|
|
617
|
+
*
|
|
618
|
+
* @param patches - Doc patches since the last commit.
|
|
619
|
+
* @param version - Schema version this build writes.
|
|
620
|
+
*/
|
|
621
|
+
commit(patches: Patch[], version: number): void;
|
|
622
|
+
/**
|
|
623
|
+
* Called after a barrier node. Resolves when the data is durable.
|
|
624
|
+
*
|
|
625
|
+
* @param patches - Doc patches since the last commit.
|
|
626
|
+
* @param txId - Id of the transaction that left the barrier node.
|
|
627
|
+
* @param version - Schema version this build writes.
|
|
628
|
+
* @returns Resolves when the data is durable.
|
|
629
|
+
*/
|
|
630
|
+
commitDurable(patches: Patch[], txId: string, version: number): Promise<void>;
|
|
631
|
+
/**
|
|
632
|
+
* Writes what was accumulated. Called on a background pause and on stop.
|
|
633
|
+
*
|
|
634
|
+
* @returns Resolves when the provider has written.
|
|
635
|
+
*/
|
|
636
|
+
flush(): Promise<void>;
|
|
637
|
+
};
|
|
638
|
+
/**
|
|
639
|
+
* One recorded call of the in-memory provider.
|
|
640
|
+
*
|
|
641
|
+
* @example
|
|
642
|
+
* ```ts
|
|
643
|
+
* const call: ProviderCall = {
|
|
644
|
+
* method: "commit",
|
|
645
|
+
* patches: [{ op: "replace", path: ["player", "coins"], value: 4 }],
|
|
646
|
+
* version: 1
|
|
647
|
+
* };
|
|
648
|
+
* ```
|
|
649
|
+
*/
|
|
650
|
+
type ProviderCall = {
|
|
651
|
+
method: "load";
|
|
652
|
+
} | {
|
|
653
|
+
method: "commit";
|
|
654
|
+
patches: Patch[];
|
|
655
|
+
version: number;
|
|
656
|
+
} | {
|
|
657
|
+
method: "commitDurable";
|
|
658
|
+
patches: Patch[];
|
|
659
|
+
txId: string;
|
|
660
|
+
version: number;
|
|
661
|
+
} | {
|
|
662
|
+
method: "flush";
|
|
663
|
+
};
|
|
664
|
+
/**
|
|
665
|
+
* One step of the save migration chain. `up` of `from: n` gets the whole save document of version
|
|
666
|
+
* `n` and returns the document of version `n + 1`.
|
|
667
|
+
*
|
|
668
|
+
* @example
|
|
669
|
+
* ```ts
|
|
670
|
+
* // Version 1 saved `gold`. Version 2 calls it `coins`.
|
|
671
|
+
* const renameGold: Migration = {
|
|
672
|
+
* from: 1,
|
|
673
|
+
* up: state => {
|
|
674
|
+
* const doc = state as { player: { gold: number }; rng: RngState };
|
|
675
|
+
*
|
|
676
|
+
* return { player: { coins: doc.player.gold }, rng: doc.rng };
|
|
677
|
+
* }
|
|
678
|
+
* };
|
|
679
|
+
*
|
|
680
|
+
* createApp({ pluginConfigs: { model: { schemaVersion: 2, migrations: [renameGold] } } });
|
|
681
|
+
* ```
|
|
682
|
+
*/
|
|
683
|
+
type Migration = {
|
|
684
|
+
from: number;
|
|
685
|
+
up(state: Json): Json;
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* Frozen view of committed state.
|
|
689
|
+
*
|
|
690
|
+
* @example
|
|
691
|
+
* ```ts
|
|
692
|
+
* const snapshot: Snapshot = {
|
|
693
|
+
* player: { coins: 4 },
|
|
694
|
+
* session: { rolls: 1 },
|
|
695
|
+
* rng: { seed: 42, streams: { dice: 1175946015 } }
|
|
696
|
+
* };
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
type Snapshot = {
|
|
700
|
+
readonly player: Json;
|
|
701
|
+
readonly session: Json;
|
|
702
|
+
readonly rng: Readonly<RngState>;
|
|
703
|
+
};
|
|
704
|
+
/**
|
|
705
|
+
* Result of a commit: the patches split by tree, and the touched roots in the order `player`,
|
|
706
|
+
* `session`, `rng`.
|
|
707
|
+
*
|
|
708
|
+
* @example
|
|
709
|
+
* ```ts
|
|
710
|
+
* const result: CommitResult = {
|
|
711
|
+
* patches: { doc: [{ op: "replace", path: ["player", "coins"], value: 4 }], session: [] },
|
|
712
|
+
* roots: ["player"]
|
|
713
|
+
* };
|
|
714
|
+
* ```
|
|
715
|
+
*/
|
|
716
|
+
type CommitResult = {
|
|
717
|
+
patches: {
|
|
718
|
+
doc: Patch[];
|
|
719
|
+
session: Patch[];
|
|
720
|
+
};
|
|
721
|
+
roots: Root[];
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* Open transaction handed to one node run.
|
|
725
|
+
*
|
|
726
|
+
* @example
|
|
727
|
+
* ```ts
|
|
728
|
+
* // A game never holds a transaction: the node context carries its drafts and its rng view.
|
|
729
|
+
* export const roll = defineNode({
|
|
730
|
+
* outcomes: { done: type() },
|
|
731
|
+
* run: ({ player, rng, out }) => {
|
|
732
|
+
* player.coins += rng.stream("dice").range(1, 6);
|
|
733
|
+
* return out.done();
|
|
734
|
+
* }
|
|
735
|
+
* });
|
|
736
|
+
* ```
|
|
737
|
+
*/
|
|
738
|
+
type Transaction = {
|
|
739
|
+
/** Mutable draft of the player tree. */player: Json; /** Mutable draft of the session tree. */
|
|
740
|
+
session: Json; /** Rng view bound to the draft `doc.rng`. Draws advance the draft. */
|
|
741
|
+
rng: RngView;
|
|
742
|
+
/**
|
|
743
|
+
* Commits the drafts of this transaction on the edge: finishes the drafts, swaps the frozen
|
|
744
|
+
* trees, appends the doc patches to the pending list and emits `model:committed` with cause
|
|
745
|
+
* `"edge"`.
|
|
746
|
+
*
|
|
747
|
+
* @returns The patches split by tree and the touched roots.
|
|
748
|
+
* @throws {Error} When the transaction was already committed or discarded.
|
|
749
|
+
* @example
|
|
750
|
+
* ```ts
|
|
751
|
+
* // A runner plugin commits on the edge, after the node body returned its outcome.
|
|
752
|
+
* const { store } = ctx.require(modelPlugin);
|
|
753
|
+
* const transaction = store.begin();
|
|
754
|
+
* await runNodeBody(transaction); // the node wrote coins into transaction.player
|
|
755
|
+
* transaction.commit().roots; // ["player"]
|
|
756
|
+
* ```
|
|
757
|
+
*/
|
|
758
|
+
commit(): CommitResult;
|
|
759
|
+
/**
|
|
760
|
+
* Drops the drafts of this transaction. Nothing changes and nothing is emitted.
|
|
761
|
+
*
|
|
762
|
+
* @throws {Error} When the transaction was already committed or discarded.
|
|
763
|
+
* @example
|
|
764
|
+
* ```ts
|
|
765
|
+
* // The node threw or was aborted: nothing of its drafts may reach the state.
|
|
766
|
+
* transaction.discard();
|
|
767
|
+
* store.snapshot().player; // as it was before begin()
|
|
768
|
+
* ```
|
|
769
|
+
*/
|
|
770
|
+
discard(): void;
|
|
771
|
+
};
|
|
772
|
+
/**
|
|
773
|
+
* store module state.
|
|
774
|
+
*/
|
|
775
|
+
type StoreState = {
|
|
776
|
+
/** Frozen save document. */doc: SaveDoc; /** Frozen session tree. */
|
|
777
|
+
session: Json; /** Frozen trees of the last rest point. */
|
|
778
|
+
restPoint: {
|
|
779
|
+
doc: SaveDoc;
|
|
780
|
+
session: Json;
|
|
781
|
+
} | undefined; /** Doc patches since the last provider commit. */
|
|
782
|
+
pending: Patch[];
|
|
783
|
+
transaction: Transaction | undefined;
|
|
784
|
+
loaded: boolean;
|
|
785
|
+
provider: PlayerStateProvider;
|
|
786
|
+
};
|
|
787
|
+
/**
|
|
788
|
+
* store module API, `app.model.store`. Logic changes the trees only inside a transaction, and only
|
|
789
|
+
* a rest point hands patches to the provider, so a kill between two rest nodes loses a whole
|
|
790
|
+
* transition and never half of one.
|
|
791
|
+
*
|
|
792
|
+
* @example
|
|
793
|
+
* ```ts
|
|
794
|
+
* // A game only reads the store. The flow runner loads, opens transactions and marks rest points.
|
|
795
|
+
* const { player, session } = app.model.store.snapshot();
|
|
796
|
+
* ```
|
|
797
|
+
*/
|
|
798
|
+
type StoreApi = {
|
|
799
|
+
/**
|
|
800
|
+
* Loads the save. A new player gets the initial player and a seed; a stored save runs through
|
|
801
|
+
* the migration chain. Nothing is written until the document is complete, so a failure leaves
|
|
802
|
+
* the state exactly as it was and the app can show a clear screen. A document the provider has
|
|
803
|
+
* no base for, a new player or a migrated save, is handed over at once: a player who leaves on
|
|
804
|
+
* the first screen is a saved player. Marks the first rest point and emits `model:committed`
|
|
805
|
+
* with cause `"load"`.
|
|
806
|
+
*
|
|
807
|
+
* @returns Resolves when the document is in place.
|
|
808
|
+
* @throws {SaveUnreadableError} When the save cannot be read by this build.
|
|
809
|
+
* @throws {unknown} The error of a provider that fails to load or refuses the first commit.
|
|
810
|
+
* @example
|
|
811
|
+
* ```ts
|
|
812
|
+
* // The flow runner loads the save once, before the first node; a game calls app.flow.run().
|
|
813
|
+
* const { store } = ctx.require(modelPlugin);
|
|
814
|
+
* await store.load(); // model:committed fires with cause "load"
|
|
815
|
+
* store.snapshot().player; // { coins: 0 } for a new player, from config.initialPlayer
|
|
816
|
+
* ```
|
|
817
|
+
*/
|
|
818
|
+
load(): Promise<void>;
|
|
819
|
+
/**
|
|
820
|
+
* Hands out the committed trees. Frozen: this is the only thing a view or a projection sees.
|
|
821
|
+
* Before `load()` it shows the initial player, never a half-read save. The same object comes
|
|
822
|
+
* back while nothing was committed, so a watcher compares identities instead of trees.
|
|
823
|
+
*
|
|
824
|
+
* @returns The frozen player, session and rng trees.
|
|
825
|
+
* @example
|
|
826
|
+
* ```ts
|
|
827
|
+
* // A test plays two rolls headless and reads what was committed.
|
|
828
|
+
* await game.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
|
|
829
|
+
* app.model.store.snapshot().session; // { rolls: 2 }
|
|
830
|
+
*
|
|
831
|
+
* // An editor panel re-reads the model only when a commit replaced the snapshot.
|
|
832
|
+
* app.model.store.snapshot() === app.model.store.snapshot(); // true until the next commit
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
snapshot(): Snapshot;
|
|
836
|
+
/**
|
|
837
|
+
* Opens the drafts of one node run. One transaction at a time: a second `begin` is a bug in the
|
|
838
|
+
* runner, not a state to recover from.
|
|
839
|
+
*
|
|
840
|
+
* @returns The open transaction.
|
|
841
|
+
* @throws {Error} When a transaction is already open.
|
|
842
|
+
* @example
|
|
843
|
+
* ```ts
|
|
844
|
+
* // The flow runner opens one transaction when it enters a node and keeps it until the edge.
|
|
845
|
+
* const transaction = ctx.require(modelPlugin).store.begin();
|
|
846
|
+
* transaction.player; // a draft: writes stay invisible to snapshot() until commit()
|
|
847
|
+
* ```
|
|
848
|
+
*/
|
|
849
|
+
begin(): Transaction;
|
|
850
|
+
/**
|
|
851
|
+
* Marks a rest node: the provider receives everything since the last rest point, and only then
|
|
852
|
+
* does the rest point move. A throwing provider leaves both untouched, so the next attempt
|
|
853
|
+
* still holds the whole transition.
|
|
854
|
+
*
|
|
855
|
+
* @throws {unknown} The error of a failing provider.
|
|
856
|
+
* @example
|
|
857
|
+
* ```ts
|
|
858
|
+
* // The edge led into a node with `rest: true`: the provider gets the whole transition.
|
|
859
|
+
* transaction.commit();
|
|
860
|
+
* store.markRest(); // provider.commit() receives every patch since the last rest point
|
|
861
|
+
* ```
|
|
862
|
+
*/
|
|
863
|
+
markRest(): void;
|
|
864
|
+
/**
|
|
865
|
+
* Marks a barrier node: rollback cannot cross it, so the rest point moves first. The pending
|
|
866
|
+
* patches are dropped only once the provider reports the data as durable.
|
|
867
|
+
*
|
|
868
|
+
* @param txId - Id of the transaction that left the barrier node.
|
|
869
|
+
* @returns Resolves when the data is durable.
|
|
870
|
+
* @throws {unknown} The error of a failing provider.
|
|
871
|
+
* @example
|
|
872
|
+
* ```ts
|
|
873
|
+
* // The edge left a node with `barrier: true`, for example a granted purchase: wait for the disk.
|
|
874
|
+
* // The id is path#journalIndex@now, built by the runner.
|
|
875
|
+
* await store.markBarrier("shop/grant#12@1790000000000");
|
|
876
|
+
* ```
|
|
877
|
+
*/
|
|
878
|
+
markBarrier(txId: string): Promise<void>;
|
|
879
|
+
/**
|
|
880
|
+
* Returns to the last rest point. It discards an open transaction first. A pointer swap to
|
|
881
|
+
* frozen trees: no inverse patch is replayed. Everything in `pending` belongs to the failed
|
|
882
|
+
* transition, because a rest point empties it. Emits `model:committed` with cause `"rollback"`.
|
|
883
|
+
*
|
|
884
|
+
* @example
|
|
885
|
+
* ```ts
|
|
886
|
+
* // A node failed after it wrote into its drafts: back to the last rest point, then retry.
|
|
887
|
+
* store.rollback(); // model:committed fires with cause "rollback"
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
890
|
+
rollback(): void;
|
|
891
|
+
/**
|
|
892
|
+
* Replaces the trees: a bookmark, a repro, a dev restore after a reload. What is omitted stays
|
|
893
|
+
* as it is. The restored trees become the new rest point. The provider receives the whole
|
|
894
|
+
* document at the next rest point, never patches on a base it no longer has. Emits
|
|
895
|
+
* `model:committed` with cause `"restore"`.
|
|
896
|
+
*
|
|
897
|
+
* @param input - The trees to put in place.
|
|
898
|
+
* @param input.player - The player tree.
|
|
899
|
+
* @param input.session - The session tree. Omitted: the current one stays.
|
|
900
|
+
* @param input.rng - The rng branch. Omitted: the current one stays.
|
|
901
|
+
* @throws {Error} When a transaction is open.
|
|
902
|
+
* @example
|
|
903
|
+
* ```ts
|
|
904
|
+
* // The flow runner enters a bookmark: the trees first, then the rest point, then the node.
|
|
905
|
+
* // A game calls app.flow.restore(bookmark), which does all three.
|
|
906
|
+
* store.restore({ player: bookmark.player, session: bookmark.session, rng: bookmark.rng });
|
|
907
|
+
* store.markRest();
|
|
908
|
+
* ```
|
|
909
|
+
*/
|
|
910
|
+
restore(input: {
|
|
911
|
+
player: Json;
|
|
912
|
+
session?: Json;
|
|
913
|
+
rng?: RngState;
|
|
914
|
+
}): void;
|
|
915
|
+
/**
|
|
916
|
+
* Asks the provider to write what it has accumulated. The pending patches stay: between two
|
|
917
|
+
* rest nodes they are an unfinished transition, and a kill must lose all of it, never half.
|
|
918
|
+
* The engine calls it on a background pause and on stop.
|
|
919
|
+
*
|
|
920
|
+
* @returns Resolves when the provider has written.
|
|
921
|
+
* @throws {unknown} The error of a failing provider.
|
|
922
|
+
* @example
|
|
923
|
+
* ```ts
|
|
924
|
+
* // The game leaves the page for a payment screen: write the save first.
|
|
925
|
+
* await app.model.store.flush();
|
|
926
|
+
* window.location.assign("/checkout");
|
|
927
|
+
* ```
|
|
928
|
+
*/
|
|
929
|
+
flush(): Promise<void>;
|
|
930
|
+
};
|
|
931
|
+
/**
|
|
932
|
+
* Thrown by `load()`, and so by `flow.run()`, when the save cannot be read by this build. The
|
|
933
|
+
* save stays untouched, so the app can show a clear screen instead of overwriting it.
|
|
934
|
+
*
|
|
935
|
+
* @example
|
|
936
|
+
* ```ts
|
|
937
|
+
* // The player opens an old build over a newer save: show "update the game", keep the save.
|
|
938
|
+
* app.flow.run().catch((error: unknown) => {
|
|
939
|
+
* if (!(error instanceof SaveUnreadableError)) throw error;
|
|
940
|
+
* showUpdateScreen(error.savedVersion, error.schemaVersion); // 3, 2
|
|
941
|
+
* });
|
|
942
|
+
* ```
|
|
943
|
+
*/
|
|
944
|
+
declare class SaveUnreadableError extends Error {
|
|
945
|
+
/** Version found in the save. */
|
|
946
|
+
readonly savedVersion: number;
|
|
947
|
+
/** Version this build writes. */
|
|
948
|
+
readonly schemaVersion: number;
|
|
949
|
+
/**
|
|
950
|
+
* Creates the error.
|
|
951
|
+
*
|
|
952
|
+
* @param savedVersion - Version found in the save.
|
|
953
|
+
* @param schemaVersion - Version this build writes.
|
|
954
|
+
* @param cause - The underlying failure.
|
|
955
|
+
* @example
|
|
956
|
+
* ```ts
|
|
957
|
+
* const error = new SaveUnreadableError(3, 2, new Error("Written by a newer build."));
|
|
958
|
+
* error.savedVersion; // 3
|
|
959
|
+
* ```
|
|
960
|
+
*/
|
|
961
|
+
constructor(savedVersion: number, schemaVersion: number, cause: unknown);
|
|
962
|
+
}
|
|
963
|
+
declare namespace types_d_exports$2 {
|
|
964
|
+
export { Api$2 as Api, CommitResult, Config$2 as Config, Events$1 as Events, Json, Migration, ModelCtx, Patch, PlayerStateProvider, ProviderCall, RngApi, RngState, RngStream, RngView, Root, SaveDoc, Snapshot, State$2 as State, StoreApi, Transaction };
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Plain JSON value. Everything in state is JSON.
|
|
968
|
+
*
|
|
969
|
+
* @example
|
|
970
|
+
* ```ts
|
|
971
|
+
* const player: Json = { coins: 10, inventory: ["key"] };
|
|
972
|
+
* ```
|
|
973
|
+
*/
|
|
974
|
+
type Json = null | boolean | number | string | Json[] | {
|
|
975
|
+
[key: string]: Json;
|
|
976
|
+
};
|
|
977
|
+
/**
|
|
978
|
+
* State roots reported by `model:committed`.
|
|
979
|
+
*
|
|
980
|
+
* @example
|
|
981
|
+
* ```ts
|
|
982
|
+
* const roots: Root[] = ["player", "rng"];
|
|
983
|
+
* ```
|
|
984
|
+
*/
|
|
985
|
+
type Root = "player" | "session" | "rng";
|
|
986
|
+
/**
|
|
987
|
+
* model plugin events.
|
|
988
|
+
*
|
|
989
|
+
* @example
|
|
990
|
+
* ```ts
|
|
991
|
+
* // One node run changed the coins and drew from a stream.
|
|
992
|
+
* const payload: Events["model:committed"] = { roots: ["player", "rng"], cause: "edge" };
|
|
993
|
+
* ```
|
|
994
|
+
*/
|
|
995
|
+
type Events$1 = {
|
|
996
|
+
/** Committed state changed. Projections reconcile from the snapshot. */"model:committed": {
|
|
997
|
+
roots: readonly Root[];
|
|
998
|
+
cause: "edge" | "rollback" | "restore" | "load";
|
|
999
|
+
};
|
|
1000
|
+
};
|
|
1001
|
+
/**
|
|
1002
|
+
* model plugin config.
|
|
1003
|
+
*
|
|
1004
|
+
* @example
|
|
1005
|
+
* ```ts
|
|
1006
|
+
* createApp({ pluginConfigs: { model: { initialPlayer: { coins: 0 }, seed: 42 } } });
|
|
1007
|
+
* ```
|
|
1008
|
+
*/
|
|
1009
|
+
type Config$2 = {
|
|
1010
|
+
/** The save seam. `undefined`: an in-memory provider, nothing is persisted. */playerProvider: PlayerStateProvider | undefined; /** Player state of a new player. Deep-cloned. */
|
|
1011
|
+
initialPlayer: Json; /** Session state at every start. Deep-cloned. */
|
|
1012
|
+
initialSession: Json; /** "from-save": a new player gets a random seed once; a number fixes it (tests). */
|
|
1013
|
+
seed: "from-save" | number; /** Version of the save schema written by this build. */
|
|
1014
|
+
schemaVersion: number; /** Ordered chain; `up` of `from: n` produces version `n + 1`. */
|
|
1015
|
+
migrations: readonly Migration[];
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* model plugin state: one branch per module.
|
|
1019
|
+
*
|
|
1020
|
+
*/
|
|
1021
|
+
type State$2 = {
|
|
1022
|
+
store: StoreState;
|
|
1023
|
+
rng: Record<string, never>;
|
|
1024
|
+
};
|
|
1025
|
+
/**
|
|
1026
|
+
* model plugin API, `app.model`, grouped by module.
|
|
1027
|
+
*
|
|
1028
|
+
* @example
|
|
1029
|
+
* ```ts
|
|
1030
|
+
* app.model.store.snapshot().player; // { coins: 4 }
|
|
1031
|
+
* app.model.rng.peek("chest:42"); // undefined: this chest was never opened
|
|
1032
|
+
* ```
|
|
1033
|
+
*/
|
|
1034
|
+
type Api$2 = {
|
|
1035
|
+
store: StoreApi;
|
|
1036
|
+
rng: RngApi;
|
|
1037
|
+
};
|
|
1038
|
+
/**
|
|
1039
|
+
* Domain context shared by the modules. `emit` is the kernel's: `index.ts` writes `events` with
|
|
1040
|
+
* an annotated `register` (core spec `14` row 8), so the own event reaches a factory passed by
|
|
1041
|
+
* direct reference.
|
|
1042
|
+
*/
|
|
1043
|
+
type ModelCtx = PluginCtx<Config$2, State$2, Events$1> & {
|
|
1044
|
+
readonly global: object;
|
|
1045
|
+
readonly log: Log.LogApi;
|
|
1046
|
+
};
|
|
1047
|
+
declare namespace types_d_exports$1 {
|
|
1048
|
+
export { Api$1 as Api, Config$1 as Config, FrameCallback, Phase, State$1 as State, Time, TimeCtx };
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Frame phases, in call order.
|
|
1052
|
+
*
|
|
1053
|
+
* @example
|
|
1054
|
+
* ```ts
|
|
1055
|
+
* const phase: Phase = "animate";
|
|
1056
|
+
* ```
|
|
1057
|
+
*/
|
|
1058
|
+
type Phase = "input" | "animate" | "layout" | "sync" | "signals" | "render";
|
|
1059
|
+
/**
|
|
1060
|
+
* The Time resource, in scaled milliseconds. `idle` tells whether the loop currently runs at the
|
|
1061
|
+
* lowered idle cap.
|
|
1062
|
+
*
|
|
1063
|
+
* @example
|
|
1064
|
+
* ```ts
|
|
1065
|
+
* const time: Time = { delta: 16, elapsed: 1600, scale: 1, frame: 100, idle: false };
|
|
1066
|
+
* ```
|
|
1067
|
+
*/
|
|
1068
|
+
type Time = {
|
|
1069
|
+
delta: number;
|
|
1070
|
+
elapsed: number;
|
|
1071
|
+
scale: number;
|
|
1072
|
+
frame: number;
|
|
1073
|
+
idle: boolean;
|
|
1074
|
+
};
|
|
1075
|
+
/**
|
|
1076
|
+
* Callback run once per frame in its phase.
|
|
1077
|
+
*
|
|
1078
|
+
* @example
|
|
1079
|
+
* ```ts
|
|
1080
|
+
* const advanceTweens: FrameCallback = time => tweens.advance(time.delta);
|
|
1081
|
+
* ```
|
|
1082
|
+
*/
|
|
1083
|
+
type FrameCallback = (time: Readonly<Time>) => void;
|
|
1084
|
+
/**
|
|
1085
|
+
* time plugin config.
|
|
1086
|
+
*
|
|
1087
|
+
* @example
|
|
1088
|
+
* ```ts
|
|
1089
|
+
* createApp({ pluginConfigs: { time: { maxFps: 30 } } });
|
|
1090
|
+
* ```
|
|
1091
|
+
*/
|
|
1092
|
+
type Config$1 = {
|
|
1093
|
+
/**
|
|
1094
|
+
* Frame rate cap. The default is 60. 120 is for WebViews that really deliver 120 Hz frames:
|
|
1095
|
+
* Android today; WKWebView on iOS and macOS is capped at 60 by WebKit (bug 294338).
|
|
1096
|
+
*/
|
|
1097
|
+
maxFps: 30 | 60 | 120;
|
|
1098
|
+
/**
|
|
1099
|
+
* Upper bound of one frame's delta in milliseconds.
|
|
1100
|
+
*/
|
|
1101
|
+
maxDeltaMs: number;
|
|
1102
|
+
/**
|
|
1103
|
+
* Frame rate cap of an idle screen, when nothing woke the clock for `idleAfterMs`. The default
|
|
1104
|
+
* is 30; 0 turns the idle cap off and every frame runs at `maxFps`.
|
|
1105
|
+
*/
|
|
1106
|
+
idleFps: 0 | 30;
|
|
1107
|
+
/**
|
|
1108
|
+
* Unscaled milliseconds without a `wake()` after which the loop drops to `idleFps`.
|
|
1109
|
+
*/
|
|
1110
|
+
idleAfterMs: number;
|
|
1111
|
+
};
|
|
1112
|
+
/**
|
|
1113
|
+
* time plugin state.
|
|
1114
|
+
*/
|
|
1115
|
+
type State$1 = {
|
|
1116
|
+
callbacks: Record<Phase, FrameCallback[]>;
|
|
1117
|
+
/**
|
|
1118
|
+
* Scratch array of a frame: the six callback lists as they were when the frame started.
|
|
1119
|
+
*/
|
|
1120
|
+
captured: (readonly FrameCallback[])[];
|
|
1121
|
+
time: Time;
|
|
1122
|
+
paused: boolean;
|
|
1123
|
+
running: boolean;
|
|
1124
|
+
/**
|
|
1125
|
+
* True while a frame runs; guards `step` re-entry.
|
|
1126
|
+
*/
|
|
1127
|
+
stepping: boolean;
|
|
1128
|
+
rafId: number | undefined;
|
|
1129
|
+
lastTimestamp: number | undefined;
|
|
1130
|
+
/**
|
|
1131
|
+
* Elapsed time of the frame source in unscaled milliseconds, the clock of the idle timer: it
|
|
1132
|
+
* keeps running while `scale` is 0.
|
|
1133
|
+
*/
|
|
1134
|
+
unscaledElapsedMs: number;
|
|
1135
|
+
/**
|
|
1136
|
+
* Unscaled elapsed time of the last wake.
|
|
1137
|
+
*/
|
|
1138
|
+
lastWakeMs: number;
|
|
1139
|
+
/**
|
|
1140
|
+
* True while the loop runs at `idleFps`.
|
|
1141
|
+
*/
|
|
1142
|
+
idle: boolean;
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* time plugin API, `app.time`. The one clock of the screen: frame callbacks per phase, the `Time`
|
|
1146
|
+
* resource, the time scale, and `step` for tests and tools.
|
|
1147
|
+
*
|
|
1148
|
+
* @example
|
|
1149
|
+
* ```ts
|
|
1150
|
+
* // Frame work is a callback in a phase. A test has no frame source: it drives the frames itself.
|
|
1151
|
+
* const off = app.time.onFrame("animate", time => counter.advance(time.delta));
|
|
1152
|
+
*
|
|
1153
|
+
* app.time.step(16);
|
|
1154
|
+
* // the callback gets { delta: 16, elapsed: 16, scale: 1, frame: 1, idle: false }
|
|
1155
|
+
* off();
|
|
1156
|
+
* ```
|
|
1157
|
+
*/
|
|
1158
|
+
type Api$1 = {
|
|
1159
|
+
/**
|
|
1160
|
+
* Registers a frame callback. Callbacks of one phase run in registration order; a callback
|
|
1161
|
+
* registered during a frame runs from the next frame on.
|
|
1162
|
+
*
|
|
1163
|
+
* @param phase - Phase the callback belongs to.
|
|
1164
|
+
* @param callback - Function called once per frame with the current `Time`.
|
|
1165
|
+
* @returns Unsubscribe function; calling it twice is a no-op.
|
|
1166
|
+
* @example
|
|
1167
|
+
* ```ts
|
|
1168
|
+
* // The coin counter rolls up while the reward popup is open.
|
|
1169
|
+
* const off = app.time.onFrame("animate", time => counter.advance(time.delta));
|
|
1170
|
+
*
|
|
1171
|
+
* off(); // the popup is closed
|
|
1172
|
+
* ```
|
|
1173
|
+
*/
|
|
1174
|
+
onFrame(phase: Phase, callback: FrameCallback): () => void;
|
|
1175
|
+
/**
|
|
1176
|
+
* Returns a snapshot of the current `Time`, so a caller cannot write into the frame state.
|
|
1177
|
+
*
|
|
1178
|
+
* @returns A copy of the current `Time`.
|
|
1179
|
+
* @example
|
|
1180
|
+
* ```ts
|
|
1181
|
+
* // Stamp the start of a combo window in game time, which stands still during a pause.
|
|
1182
|
+
* const startedAt = app.time.snapshot().elapsed; // 1600
|
|
1183
|
+
* app.time.snapshot(); // { delta: 16, elapsed: 1600, scale: 1, frame: 100, idle: false }
|
|
1184
|
+
* ```
|
|
1185
|
+
*/
|
|
1186
|
+
snapshot(): Readonly<Time>;
|
|
1187
|
+
/**
|
|
1188
|
+
* Sets the time scale. Every frame delta is multiplied by it; 0 freezes the game time
|
|
1189
|
+
* while the frames keep running. A negative scale is clamped to 0.
|
|
1190
|
+
*
|
|
1191
|
+
* @param scale - New time scale, 0 or greater.
|
|
1192
|
+
* @example
|
|
1193
|
+
* ```ts
|
|
1194
|
+
* // Slow motion while the last match of the level resolves.
|
|
1195
|
+
* app.time.setScale(0.5); // a frame of 20 ms now has delta 10
|
|
1196
|
+
* app.time.setScale(1);
|
|
1197
|
+
* ```
|
|
1198
|
+
*/
|
|
1199
|
+
setScale(scale: number): void;
|
|
1200
|
+
/**
|
|
1201
|
+
* Pauses the clock: no phase runs and `elapsed` stops advancing. Called by `lifecycle`.
|
|
1202
|
+
*
|
|
1203
|
+
* @example
|
|
1204
|
+
* ```ts
|
|
1205
|
+
* // The lifecycle plugin owns the pause policy: the first pause reason stops the clock.
|
|
1206
|
+
* // A game pauses through app.lifecycle.push(reason), never here.
|
|
1207
|
+
* const time = ctx.require(timePlugin);
|
|
1208
|
+
* if (reasons.length === 1) time.pause(); // app.time.isPaused() is true, no phase runs
|
|
1209
|
+
* ```
|
|
1210
|
+
*/
|
|
1211
|
+
pause(): void;
|
|
1212
|
+
/**
|
|
1213
|
+
* Resumes the clock and drops the stale timestamp, so the first frame after the pause has
|
|
1214
|
+
* a normal delta instead of the whole pause. Called by `lifecycle`.
|
|
1215
|
+
*
|
|
1216
|
+
* @example
|
|
1217
|
+
* ```ts
|
|
1218
|
+
* // The lifecycle plugin resumes when the last pause reason is gone.
|
|
1219
|
+
* const time = ctx.require(timePlugin);
|
|
1220
|
+
* if (reasons.length === 0) time.resume(); // the next frame has a normal delta, not the whole pause
|
|
1221
|
+
* ```
|
|
1222
|
+
*/
|
|
1223
|
+
resume(): void;
|
|
1224
|
+
/**
|
|
1225
|
+
* Tells whether the clock is paused.
|
|
1226
|
+
*
|
|
1227
|
+
* @returns True while paused.
|
|
1228
|
+
* @example
|
|
1229
|
+
* ```ts
|
|
1230
|
+
* // A test checks that a pause reason really stopped the frames.
|
|
1231
|
+
* app.lifecycle.push("background");
|
|
1232
|
+
* app.time.isPaused(); // true
|
|
1233
|
+
* ```
|
|
1234
|
+
*/
|
|
1235
|
+
isPaused(): boolean;
|
|
1236
|
+
/**
|
|
1237
|
+
* Tells whether a real frame source drives the loop. False in plain Bun, where a test
|
|
1238
|
+
* drives the frames with `step`.
|
|
1239
|
+
*
|
|
1240
|
+
* @returns True while the loop runs.
|
|
1241
|
+
* @example
|
|
1242
|
+
* ```ts
|
|
1243
|
+
* // Without a frame source no frame ever comes: finish the fly-in at once instead of waiting.
|
|
1244
|
+
* if (!app.time.isRunning()) coin.moveTo(target); // false in plain Bun, true in a browser
|
|
1245
|
+
* ```
|
|
1246
|
+
*/
|
|
1247
|
+
isRunning(): boolean;
|
|
1248
|
+
/**
|
|
1249
|
+
* Resets the idle timer: the next frame runs at `maxFps` again, even after a long idle. Cheap,
|
|
1250
|
+
* idempotent and safe inside a frame callback. Every plugin that gives the player something to
|
|
1251
|
+
* look at calls it: a pointer sample, a starting animation, a flow edge, a finished load, a
|
|
1252
|
+
* scene switch, a locale change, a reconcile.
|
|
1253
|
+
*
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```ts
|
|
1256
|
+
* // The flow runner walked an edge, so the screen has work again and the cap goes back up.
|
|
1257
|
+
* const time = ctx.require(timePlugin);
|
|
1258
|
+
*
|
|
1259
|
+
* time.wake(); // app.time.snapshot().idle is false from here on
|
|
1260
|
+
* ```
|
|
1261
|
+
*/
|
|
1262
|
+
wake(): void;
|
|
1263
|
+
/**
|
|
1264
|
+
* Runs exactly one frame with the given unscaled delta, ignoring the fps cap, the pause
|
|
1265
|
+
* flag and `maxDeltaMs`. The time scale still applies. For tests and tools.
|
|
1266
|
+
*
|
|
1267
|
+
* @param deltaMs - Unscaled delta of the frame in milliseconds.
|
|
1268
|
+
* @throws {Error} When called from inside a frame callback.
|
|
1269
|
+
* @example
|
|
1270
|
+
* ```ts
|
|
1271
|
+
* // A test plays two frames without a browser.
|
|
1272
|
+
* app.time.step(16);
|
|
1273
|
+
* app.time.step(4);
|
|
1274
|
+
* app.time.snapshot(); // { delta: 4, elapsed: 20, scale: 1, frame: 2, idle: false }
|
|
1275
|
+
* ```
|
|
1276
|
+
*/
|
|
1277
|
+
step(deltaMs: number): void;
|
|
1278
|
+
};
|
|
1279
|
+
/**
|
|
1280
|
+
* Domain context of the time plugin.
|
|
1281
|
+
*/
|
|
1282
|
+
type TimeCtx = PluginCtx<Config$1, State$1> & {
|
|
1283
|
+
readonly global: object;
|
|
1284
|
+
readonly log: Log.LogApi;
|
|
1285
|
+
};
|
|
1286
|
+
//#endregion
|
|
1287
|
+
//#region src/plugins/flow/gate/types.d.ts
|
|
1288
|
+
/**
|
|
1289
|
+
* One answer of the player, of an agent or of a test.
|
|
1290
|
+
*
|
|
1291
|
+
* @example
|
|
1292
|
+
* ```ts
|
|
1293
|
+
* const answer: Answer = { intent: "merge", payload: { from: "c2", to: "c3" } };
|
|
1294
|
+
* ```
|
|
1295
|
+
*/
|
|
1296
|
+
type Answer = {
|
|
1297
|
+
intent: string;
|
|
1298
|
+
payload?: Json;
|
|
1299
|
+
};
|
|
1300
|
+
/**
|
|
1301
|
+
* The one answer a `guide` lets through. `payload` is compared by deep equality when present.
|
|
1302
|
+
*
|
|
1303
|
+
* @example
|
|
1304
|
+
* ```ts
|
|
1305
|
+
* const allow: Allow = { intent: "merge", payload: { from: "c2", to: "c3" } };
|
|
1306
|
+
* ```
|
|
1307
|
+
*/
|
|
1308
|
+
type Allow = {
|
|
1309
|
+
intent: string;
|
|
1310
|
+
payload?: Json;
|
|
1311
|
+
};
|
|
1312
|
+
/**
|
|
1313
|
+
* What an open gate accepts.
|
|
1314
|
+
*/
|
|
1315
|
+
type GateSpec = {
|
|
1316
|
+
allowed: readonly string[];
|
|
1317
|
+
};
|
|
1318
|
+
/**
|
|
1319
|
+
* gate module state.
|
|
1320
|
+
*/
|
|
1321
|
+
type GateState = {
|
|
1322
|
+
/** The spec of the open gate. `undefined`: the gate is closed. */open: GateSpec | undefined; /** Resolver of the promise returned by `open`. */
|
|
1323
|
+
resolve: ((answer: Answer) => void) | undefined; /** An answer given while the gate was closed, kept for one frame. */
|
|
1324
|
+
held: {
|
|
1325
|
+
answer: Answer;
|
|
1326
|
+
frame: number;
|
|
1327
|
+
} | undefined; /** The one answer a running `guide` lets through. */
|
|
1328
|
+
narrow: Allow | undefined; /** True while a pointer is down. Entering an `over` node waits for false. */
|
|
1329
|
+
pointerActive: boolean; /** Ends the pending pointer wait of the runner. Set while the wait runs, so a stop needs no frame. */
|
|
1330
|
+
wake: (() => void) | undefined;
|
|
1331
|
+
};
|
|
1332
|
+
/**
|
|
1333
|
+
* gate module API, `app.flow.gate`: the single entry of player answers.
|
|
1334
|
+
*
|
|
1335
|
+
* @example
|
|
1336
|
+
* ```ts
|
|
1337
|
+
* // The screen turns a tap into an answer. A double tap is safe: the gate closes before the
|
|
1338
|
+
* // first answer is handed on.
|
|
1339
|
+
* app.flow.gate.answer({ intent: "play" }); // true: "home" rests and lists "play"
|
|
1340
|
+
* app.flow.gate.answer({ intent: "play" }); // false: the second tap hits a closed door
|
|
1341
|
+
* ```
|
|
1342
|
+
*/
|
|
1343
|
+
type GateApi = {
|
|
1344
|
+
/**
|
|
1345
|
+
* Gives one answer. Accepted only while the gate is open and the intent is allowed. An answer
|
|
1346
|
+
* to a closed gate is held for one frame and re-offered when the gate opens within it.
|
|
1347
|
+
*
|
|
1348
|
+
* @param answer - Intent and optional payload.
|
|
1349
|
+
* @returns Whether the answer was accepted.
|
|
1350
|
+
* @example
|
|
1351
|
+
* ```ts
|
|
1352
|
+
* // The player dropped the item of cell c2 onto c3 while the board rests.
|
|
1353
|
+
* app.flow.gate.answer({ intent: "merge", payload: { from: "c2", to: "c3" } }); // true
|
|
1354
|
+
* app.flow.gate.answer({ intent: "quit" }); // false: the resting node does not list "quit"
|
|
1355
|
+
* ```
|
|
1356
|
+
*/
|
|
1357
|
+
answer(answer: Answer): boolean;
|
|
1358
|
+
/**
|
|
1359
|
+
* Records that a pointer is down. While true the runner delays entering an `over` node, so a
|
|
1360
|
+
* popup never appears mid-drag.
|
|
1361
|
+
*
|
|
1362
|
+
* @param active - True while the pointer is down.
|
|
1363
|
+
* @example
|
|
1364
|
+
* ```ts
|
|
1365
|
+
* // The input layer reports a drag on the board.
|
|
1366
|
+
* app.flow.gate.pointer(true); // pointer down: an `over` node that comes next waits
|
|
1367
|
+
* app.flow.gate.pointer(false); // pointer up: the node is entered in the next `signals` phase
|
|
1368
|
+
* ```
|
|
1369
|
+
*/
|
|
1370
|
+
pointer(active: boolean): void;
|
|
1371
|
+
/**
|
|
1372
|
+
* Reads what the gate takes right now.
|
|
1373
|
+
*
|
|
1374
|
+
* @returns Whether the gate is open, the allowed intents and whether a `guide` narrows it.
|
|
1375
|
+
* @example
|
|
1376
|
+
* ```ts
|
|
1377
|
+
* // The screen greys out a button whose intent the resting node does not take.
|
|
1378
|
+
* app.flow.gate.state(); // { open: true, allowed: ["play", "shop"], narrowed: false }
|
|
1379
|
+
* ```
|
|
1380
|
+
*/
|
|
1381
|
+
state(): {
|
|
1382
|
+
open: boolean;
|
|
1383
|
+
allowed: readonly string[];
|
|
1384
|
+
narrowed: boolean;
|
|
1385
|
+
};
|
|
1386
|
+
};
|
|
1387
|
+
//#endregion
|
|
1388
|
+
//#region src/plugins/flow/fx/types.d.ts
|
|
1389
|
+
/**
|
|
1390
|
+
* An effect a node awaits. With `answers` the runner opens the gate for those intents.
|
|
1391
|
+
* `cosmetic: true` swallows a handler error and resolves `undefined`.
|
|
1392
|
+
*
|
|
1393
|
+
* @example
|
|
1394
|
+
* ```ts
|
|
1395
|
+
* const descriptor: Descriptor = { kind: "popup", payload: { name: "Retry" }, answers: ["again"] };
|
|
1396
|
+
* ```
|
|
1397
|
+
*/
|
|
1398
|
+
type Descriptor = {
|
|
1399
|
+
kind: string;
|
|
1400
|
+
payload?: Json;
|
|
1401
|
+
answers?: readonly string[];
|
|
1402
|
+
cosmetic?: boolean;
|
|
1403
|
+
};
|
|
1404
|
+
/**
|
|
1405
|
+
* A fire-and-forget cosmetic effect. Released only after the commit of its transaction.
|
|
1406
|
+
*
|
|
1407
|
+
* @example
|
|
1408
|
+
* ```ts
|
|
1409
|
+
* const sparkle: Hint = { kind: "sparkle", payload: { cell: "c3" }, hint: true };
|
|
1410
|
+
* ```
|
|
1411
|
+
*/
|
|
1412
|
+
type Hint = {
|
|
1413
|
+
kind: string;
|
|
1414
|
+
payload?: Json;
|
|
1415
|
+
hint: true;
|
|
1416
|
+
};
|
|
1417
|
+
/**
|
|
1418
|
+
* Options of the `guide` descriptor: the one allowed answer and its visual part. `target` names
|
|
1419
|
+
* the keyed element the visual hole of `ui` is cut over.
|
|
1420
|
+
*
|
|
1421
|
+
* @example
|
|
1422
|
+
* ```ts
|
|
1423
|
+
* const options: GuideOptions = {
|
|
1424
|
+
* allow: { intent: "merge" },
|
|
1425
|
+
* target: { projection: "board", key: "c3" },
|
|
1426
|
+
* hand: "drag"
|
|
1427
|
+
* };
|
|
1428
|
+
* ```
|
|
1429
|
+
*/
|
|
1430
|
+
type GuideOptions = {
|
|
1431
|
+
allow: Allow;
|
|
1432
|
+
target?: {
|
|
1433
|
+
projection: string;
|
|
1434
|
+
key: string;
|
|
1435
|
+
};
|
|
1436
|
+
highlight?: readonly string[];
|
|
1437
|
+
hand?: "tap" | "drag";
|
|
1438
|
+
text?: string;
|
|
1439
|
+
};
|
|
1440
|
+
/**
|
|
1441
|
+
* Handler of one effect kind, registered by a plugin above `flow`. `signal` is the node's abort
|
|
1442
|
+
* signal, except for a descriptor with `answers` and for `guide`: those get a signal of their own,
|
|
1443
|
+
* aborted when the answer arrives or the narrow is lifted, so the handler unmounts what it showed.
|
|
1444
|
+
*
|
|
1445
|
+
* @example
|
|
1446
|
+
* ```ts
|
|
1447
|
+
* // The handler of "sfx": start the sound the node named, stop it when the node is aborted.
|
|
1448
|
+
* const playSound: FxHandler = (descriptor, { signal }) => {
|
|
1449
|
+
* const sound = sounds.play(descriptor.payload); // payload: { name: "merge" }
|
|
1450
|
+
*
|
|
1451
|
+
* signal.addEventListener("abort", () => sound.stop());
|
|
1452
|
+
* };
|
|
1453
|
+
*
|
|
1454
|
+
* app.flow.fx.handle("sfx", playSound);
|
|
1455
|
+
* ```
|
|
1456
|
+
*/
|
|
1457
|
+
type FxHandler = (descriptor: Descriptor | Hint, ctx: {
|
|
1458
|
+
signal: AbortSignal;
|
|
1459
|
+
mode: "live" | "fast";
|
|
1460
|
+
}) => unknown | Promise<unknown>;
|
|
1461
|
+
/**
|
|
1462
|
+
* Listener of released hints, registered with `fx.onHint`. Every listener hears every hint, so it
|
|
1463
|
+
* reads `kind` itself.
|
|
1464
|
+
*
|
|
1465
|
+
* @example
|
|
1466
|
+
* ```ts
|
|
1467
|
+
* // The world plugin plays a motion for the hints it knows and ignores the rest.
|
|
1468
|
+
* const playMotion: HintListener = released => {
|
|
1469
|
+
* if (released.kind === "merged") playMergeInto(released.payload);
|
|
1470
|
+
* };
|
|
1471
|
+
* ```
|
|
1472
|
+
*/
|
|
1473
|
+
type HintListener = (hint: Hint) => void;
|
|
1474
|
+
/**
|
|
1475
|
+
* fx module state.
|
|
1476
|
+
*/
|
|
1477
|
+
type FxState = {
|
|
1478
|
+
/** One handler per effect kind. */handlers: Map<string, {
|
|
1479
|
+
run: FxHandler;
|
|
1480
|
+
runInFast: boolean;
|
|
1481
|
+
}>; /** Hints of the open transaction. */
|
|
1482
|
+
buffered: Hint[]; /** Listeners of released hints, in registration order. */
|
|
1483
|
+
hintListeners: HintListener[]; /** Completions waiting for the `signals` phase, in start order. */
|
|
1484
|
+
settled: Array<() => void>; /** The child controllers of the `guide` handlers that are showing something right now. */
|
|
1485
|
+
guides: AbortController[];
|
|
1486
|
+
mode: "live" | "fast";
|
|
1487
|
+
};
|
|
1488
|
+
/**
|
|
1489
|
+
* fx module API, `app.flow.fx`: the effects gateway.
|
|
1490
|
+
*
|
|
1491
|
+
* @example
|
|
1492
|
+
* ```ts
|
|
1493
|
+
* // The popup layer owns the kind "popup". It shows the UI; the answer comes through the gate.
|
|
1494
|
+
* app.flow.fx.handle("popup", descriptor => showPopup(descriptor.payload));
|
|
1495
|
+
*
|
|
1496
|
+
* // A node then writes: await fx({ kind: "popup", answers: ["close"] });
|
|
1497
|
+
* ```
|
|
1498
|
+
*/
|
|
1499
|
+
type FxApi = {
|
|
1500
|
+
/**
|
|
1501
|
+
* Registers the single handler of one effect kind. A second handler for the same kind throws:
|
|
1502
|
+
* an effect has exactly one owner.
|
|
1503
|
+
*
|
|
1504
|
+
* @param kind - Effect kind, for example `"sfx"`.
|
|
1505
|
+
* @param handler - Called with the descriptor and `{ signal, mode }`.
|
|
1506
|
+
* @param options - Registration options.
|
|
1507
|
+
* @param options.runInFast - `true` makes the handler run in fast mode too.
|
|
1508
|
+
* @returns The unregister function. It removes only the handler it registered.
|
|
1509
|
+
* @throws {Error} When the kind already has a handler.
|
|
1510
|
+
* @example
|
|
1511
|
+
* ```ts
|
|
1512
|
+
* // Preloading is real work, so it runs in a fast walk too. Sounds and popups do not.
|
|
1513
|
+
* const off = app.flow.fx.handle("load", preload, { runInFast: true });
|
|
1514
|
+
*
|
|
1515
|
+
* off(); // "load" has no handler again: the kind is free for another owner
|
|
1516
|
+
* ```
|
|
1517
|
+
*/
|
|
1518
|
+
handle(kind: string, handler: FxHandler, options?: {
|
|
1519
|
+
runInFast?: boolean;
|
|
1520
|
+
}): () => void;
|
|
1521
|
+
/**
|
|
1522
|
+
* Delivers a descriptor or a released hint to its handler and forgets about it. In fast mode
|
|
1523
|
+
* only a handler registered with `runInFast` is called, exactly as for an awaited effect. A
|
|
1524
|
+
* missing handler is not an error and a failing handler is logged, never thrown.
|
|
1525
|
+
*
|
|
1526
|
+
* @param descriptor - The descriptor or hint to deliver.
|
|
1527
|
+
* @example
|
|
1528
|
+
* ```ts
|
|
1529
|
+
* // The screen wants a haptic tick outside any node. Nothing waits for it.
|
|
1530
|
+
* app.flow.fx.dispatch({ kind: "haptic", payload: { style: "light" } });
|
|
1531
|
+
* ```
|
|
1532
|
+
*/
|
|
1533
|
+
dispatch(descriptor: Descriptor | Hint): void;
|
|
1534
|
+
/**
|
|
1535
|
+
* Registers a listener for every released hint. A hint reaches it after the commit of its edge,
|
|
1536
|
+
* in the order the node emitted them; a discarded transaction sends nothing, and fast mode drops
|
|
1537
|
+
* hints altogether. The handler `handle` registered for the same kind is served as well: a
|
|
1538
|
+
* listener displaces nobody.
|
|
1539
|
+
*
|
|
1540
|
+
* @param listener - Called with each released hint.
|
|
1541
|
+
* @returns The unregister function. It removes only the listener it registered.
|
|
1542
|
+
* @example
|
|
1543
|
+
* ```ts
|
|
1544
|
+
* // The world plugin turns the hints of a committed edge into projection motions.
|
|
1545
|
+
* const off = app.flow.fx.onHint(hint => {
|
|
1546
|
+
* if (hint.kind === "merged") playMergeInto(hint.payload); // payload: { from: "c1", to: "c2" }
|
|
1547
|
+
* });
|
|
1548
|
+
*
|
|
1549
|
+
* off(); // the plugin stopped: no hint reaches it any more
|
|
1550
|
+
* ```
|
|
1551
|
+
*/
|
|
1552
|
+
onHint(listener: HintListener): () => void;
|
|
1553
|
+
};
|
|
1554
|
+
/**
|
|
1555
|
+
* The `fx` of a node context: awaited effects by call, cosmetic hints by `emit`.
|
|
1556
|
+
*
|
|
1557
|
+
* @example
|
|
1558
|
+
* ```ts
|
|
1559
|
+
* // Inside a node body: wait for the player's pick, then sparkle once the edge has committed.
|
|
1560
|
+
* const pick = await fx({ kind: "popup", payload: { name: "Retry" }, answers: ["again", "home"] });
|
|
1561
|
+
* // pick: { intent: "again" }
|
|
1562
|
+
* fx.emit(hint("sparkle", { cell: "c3" }));
|
|
1563
|
+
* ```
|
|
1564
|
+
*/
|
|
1565
|
+
type NodeFx = ((descriptor: Descriptor) => Promise<unknown>) & {
|
|
1566
|
+
emit(hint: Hint): void;
|
|
1567
|
+
};
|
|
1568
|
+
//#endregion
|
|
1569
|
+
//#region src/plugins/flow/inbox/types.d.ts
|
|
1570
|
+
/**
|
|
1571
|
+
* One event of the world: time that passed, a push message, a purchase that arrived.
|
|
1572
|
+
*
|
|
1573
|
+
* @example
|
|
1574
|
+
* ```ts
|
|
1575
|
+
* const event: WorldEvent = { type: "elapsed", payload: { now: 1000 } };
|
|
1576
|
+
* ```
|
|
1577
|
+
*/
|
|
1578
|
+
type WorldEvent = {
|
|
1579
|
+
type: string;
|
|
1580
|
+
payload?: Json;
|
|
1581
|
+
};
|
|
1582
|
+
/**
|
|
1583
|
+
* inbox module state.
|
|
1584
|
+
*/
|
|
1585
|
+
type InboxState = {
|
|
1586
|
+
/** Undelivered events, oldest first. One entry per `type`. */queue: WorldEvent[]; /** Callbacks woken by every `post`. Kept in state so every API instance over this state shares them. */
|
|
1587
|
+
listeners: Array<() => void>;
|
|
1588
|
+
};
|
|
1589
|
+
/**
|
|
1590
|
+
* inbox module API, `app.flow.inbox`: world events wait here until a rest node that lists their
|
|
1591
|
+
* type is active.
|
|
1592
|
+
*
|
|
1593
|
+
* @example
|
|
1594
|
+
* ```ts
|
|
1595
|
+
* // The shop SDK confirms a purchase at any moment. The graph takes it at the next rest node
|
|
1596
|
+
* // that declares `inbox: ["purchased"]`, as the outcome "purchased" of that node.
|
|
1597
|
+
* app.flow.inbox.post({ type: "purchased", payload: { sku: "coins100" } });
|
|
1598
|
+
* ```
|
|
1599
|
+
*/
|
|
1600
|
+
type InboxApi = {
|
|
1601
|
+
/**
|
|
1602
|
+
* Queues one world event. It is delivered only when the active node is a rest node whose
|
|
1603
|
+
* `inbox` lists the type; until then it waits. Duplicates of one type collapse to the latest.
|
|
1604
|
+
*
|
|
1605
|
+
* @param event - Type and optional payload.
|
|
1606
|
+
* @example
|
|
1607
|
+
* ```ts
|
|
1608
|
+
* // Two push messages arrive while a transit node runs. The next rest node gets one event.
|
|
1609
|
+
* app.flow.inbox.post({ type: "gift", payload: { coins: 50 } });
|
|
1610
|
+
* app.flow.inbox.post({ type: "gift", payload: { coins: 75 } }); // delivered: { coins: 75 }
|
|
1611
|
+
* ```
|
|
1612
|
+
*/
|
|
1613
|
+
post(event: WorldEvent): void;
|
|
1614
|
+
};
|
|
1615
|
+
//#endregion
|
|
1616
|
+
//#region src/plugins/flow/runner/types.d.ts
|
|
1617
|
+
/**
|
|
1618
|
+
* A value that carries only a type. Invariant in `Payload`, so `{ stars }` never passes for
|
|
1619
|
+
* `{ stars, moves }` in either direction.
|
|
1620
|
+
*
|
|
1621
|
+
* @example
|
|
1622
|
+
* ```ts
|
|
1623
|
+
* const stars: TypeTag<{ stars: number }> = type<{ stars: number }>();
|
|
1624
|
+
* ```
|
|
1625
|
+
*/
|
|
1626
|
+
type TypeTag<Payload> = {
|
|
1627
|
+
readonly kind: "type";
|
|
1628
|
+
readonly $type?: (value: Payload) => Payload;
|
|
1629
|
+
};
|
|
1630
|
+
/**
|
|
1631
|
+
* Any type tag. The bound of tag records: it names no payload, so `type()` infers nothing from it.
|
|
1632
|
+
*/
|
|
1633
|
+
type AnyTypeTag = {
|
|
1634
|
+
readonly kind: "type";
|
|
1635
|
+
};
|
|
1636
|
+
/**
|
|
1637
|
+
* Outcome name to the type tag of its payload.
|
|
1638
|
+
*
|
|
1639
|
+
* @example
|
|
1640
|
+
* ```ts
|
|
1641
|
+
* const outcomes = { done: type(), failed: type<{ reason: string }>() } satisfies OutcomeTags;
|
|
1642
|
+
* ```
|
|
1643
|
+
*/
|
|
1644
|
+
type OutcomeTags = Readonly<Record<string, AnyTypeTag>>;
|
|
1645
|
+
/**
|
|
1646
|
+
* The payload type carried by a type tag.
|
|
1647
|
+
*
|
|
1648
|
+
* @example
|
|
1649
|
+
* ```ts
|
|
1650
|
+
* type Reason = PayloadOf<TypeTag<{ reason: string }>>; // { reason: string }
|
|
1651
|
+
* ```
|
|
1652
|
+
*/
|
|
1653
|
+
type PayloadOf<Tag> = Tag extends TypeTag<infer Payload> ? Payload : never;
|
|
1654
|
+
/**
|
|
1655
|
+
* The part of the game types a node sees. Inside the engine both trees are `Json`;
|
|
1656
|
+
* `defineGame<Types>()` narrows them for the game at the type level only.
|
|
1657
|
+
*
|
|
1658
|
+
* @example
|
|
1659
|
+
* ```ts
|
|
1660
|
+
* const game: GameState = { player: { lives: 3 }, session: { visits: 0 } };
|
|
1661
|
+
* ```
|
|
1662
|
+
*/
|
|
1663
|
+
type GameState = {
|
|
1664
|
+
player: Json;
|
|
1665
|
+
session: Json;
|
|
1666
|
+
scenes?: string;
|
|
1667
|
+
};
|
|
1668
|
+
/**
|
|
1669
|
+
* The scene ids a game declared, `string` when it declared none.
|
|
1670
|
+
*
|
|
1671
|
+
* @example
|
|
1672
|
+
* ```ts
|
|
1673
|
+
* type Ids = SceneIdOf<{ player: {}; session: {}; scenes: "home" | "board" }>; // "home" | "board"
|
|
1674
|
+
* ```
|
|
1675
|
+
*/
|
|
1676
|
+
type SceneIdOf<Game extends GameState> = Game extends {
|
|
1677
|
+
scenes: infer Ids extends string;
|
|
1678
|
+
} ? Ids : string;
|
|
1679
|
+
/**
|
|
1680
|
+
* Whether a payload or input type is `void`: an outcome without data, a node without input.
|
|
1681
|
+
*
|
|
1682
|
+
* @example
|
|
1683
|
+
* ```ts
|
|
1684
|
+
* type Empty = IsVoid<PayloadOf<TypeTag<void>>>; // true
|
|
1685
|
+
* ```
|
|
1686
|
+
*/
|
|
1687
|
+
type IsVoid<Payload> = [Payload] extends [void] ? true : false;
|
|
1688
|
+
/**
|
|
1689
|
+
* What `out.name(data)` returns. `run` must return one of these; a bare string is not accepted.
|
|
1690
|
+
* `Name` is the union of the node's outcome names; without a type argument it is the widened
|
|
1691
|
+
* result the runner reads.
|
|
1692
|
+
*
|
|
1693
|
+
* @example
|
|
1694
|
+
* ```ts
|
|
1695
|
+
* // What `out.rejected({ reason: "empty" })` returns, and what the journal keeps of the edge.
|
|
1696
|
+
* const result: Result = { outcome: "rejected", payload: { reason: "empty" } };
|
|
1697
|
+
* ```
|
|
1698
|
+
*/
|
|
1699
|
+
type Result<Name extends string = string> = {
|
|
1700
|
+
readonly outcome: Name;
|
|
1701
|
+
readonly payload: Json;
|
|
1702
|
+
};
|
|
1703
|
+
/**
|
|
1704
|
+
* The `out` of a node context: one method per declared outcome, without an argument when the
|
|
1705
|
+
* payload is `void`.
|
|
1706
|
+
*
|
|
1707
|
+
* @example
|
|
1708
|
+
* ```ts
|
|
1709
|
+
* // In a node with outcomes { done: type(), rejected: type<{ reason: string }>() }:
|
|
1710
|
+
* out.done(); // { outcome: "done", payload: null }
|
|
1711
|
+
* out.rejected({ reason: "empty" }); // { outcome: "rejected", payload: { reason: "empty" } }
|
|
1712
|
+
* ```
|
|
1713
|
+
*/
|
|
1714
|
+
type Out<Tags extends OutcomeTags> = { readonly [Name in keyof Tags]: IsVoid<PayloadOf<Tags[Name]>> extends true ? () => Result<keyof Tags & string> : (data: PayloadOf<Tags[Name]>) => Result<keyof Tags & string> };
|
|
1715
|
+
/**
|
|
1716
|
+
* The one object a node body receives. `player` and `session` are drafts of the open
|
|
1717
|
+
* transaction, `rng` is its view, `now` is `clock.now()` read once at node entry.
|
|
1718
|
+
*
|
|
1719
|
+
* @example
|
|
1720
|
+
* ```ts
|
|
1721
|
+
* // The "roll" node of a dice game: the drafts are written in place, the edge commits them.
|
|
1722
|
+
* run: ({ player, session, rng, out }) => {
|
|
1723
|
+
* player.coins += rng.stream("dice").range(1, 6);
|
|
1724
|
+
* session.rolls += 1;
|
|
1725
|
+
* return out.done();
|
|
1726
|
+
* }
|
|
1727
|
+
* ```
|
|
1728
|
+
*/
|
|
1729
|
+
type NodeContext<Game extends GameState = GameState, Input = void, Tags extends OutcomeTags = OutcomeTags> = {
|
|
1730
|
+
input: Input;
|
|
1731
|
+
player: Game["player"];
|
|
1732
|
+
session: Game["session"];
|
|
1733
|
+
rng: RngView;
|
|
1734
|
+
fx: NodeFx;
|
|
1735
|
+
out: Out<Tags>;
|
|
1736
|
+
signal: AbortSignal;
|
|
1737
|
+
now: number;
|
|
1738
|
+
};
|
|
1739
|
+
/**
|
|
1740
|
+
* The body of a node: plain `await` code that ends with `out.name(data)`.
|
|
1741
|
+
*/
|
|
1742
|
+
type NodeRun<Game extends GameState, Input, Tags extends OutcomeTags> = (ctx: NodeContext<Game, Input, Tags>) => Result<keyof Tags & string> | Promise<Result<keyof Tags & string>>;
|
|
1743
|
+
/**
|
|
1744
|
+
* The part of a node, or of a flow used as a node, that an edge table looks at.
|
|
1745
|
+
*/
|
|
1746
|
+
type Wired<Input, Tags extends OutcomeTags> = {
|
|
1747
|
+
readonly input: TypeTag<Input>;
|
|
1748
|
+
readonly outcomes: Tags;
|
|
1749
|
+
};
|
|
1750
|
+
/**
|
|
1751
|
+
* Anything that can sit in the `nodes` of a flow: a node, a sub-flow or a slot.
|
|
1752
|
+
*/
|
|
1753
|
+
type AnyWired = {
|
|
1754
|
+
readonly input: AnyTypeTag;
|
|
1755
|
+
readonly outcomes: OutcomeTags;
|
|
1756
|
+
};
|
|
1757
|
+
/**
|
|
1758
|
+
* Node name to node: the `nodes` of one flow.
|
|
1759
|
+
*/
|
|
1760
|
+
type NodeTable = Readonly<Record<string, AnyWired>>;
|
|
1761
|
+
/**
|
|
1762
|
+
* A node as `defineNode` returns it: plain data plus the optional body.
|
|
1763
|
+
*
|
|
1764
|
+
* @example
|
|
1765
|
+
* ```ts
|
|
1766
|
+
* // What defineNode({ outcomes: { play: type() }, rest: true, checkpoint: true }) returns:
|
|
1767
|
+
* // { kind: "node", input: { kind: "type" }, outcomes: { play: { kind: "type" } }, rest: true,
|
|
1768
|
+
* // over: false, checkpoint: true, barrier: false, inbox: [] }
|
|
1769
|
+
* ```
|
|
1770
|
+
*/
|
|
1771
|
+
type NodeDefinition<Input, Tags extends OutcomeTags, Game extends GameState = GameState> = Wired<Input, Tags> & {
|
|
1772
|
+
readonly kind: "node";
|
|
1773
|
+
readonly rest: boolean;
|
|
1774
|
+
readonly over: boolean;
|
|
1775
|
+
readonly checkpoint: boolean;
|
|
1776
|
+
readonly barrier: boolean;
|
|
1777
|
+
readonly inbox: readonly (keyof Tags & string)[]; /** Id of the scene the node is shown on. Absent: the node keeps the current scene. */
|
|
1778
|
+
readonly scene?: string;
|
|
1779
|
+
readonly run?: NodeRun<Game, Input, Tags>;
|
|
1780
|
+
};
|
|
1781
|
+
/**
|
|
1782
|
+
* What the author passes to `defineNode`. `run` is required unless `rest: true`: a rest node
|
|
1783
|
+
* without a body is a pure wait.
|
|
1784
|
+
*
|
|
1785
|
+
* @example
|
|
1786
|
+
* ```ts
|
|
1787
|
+
* const spec: NodeSpec<Game, void, { play: TypeTag<void> }> = {
|
|
1788
|
+
* rest: true,
|
|
1789
|
+
* checkpoint: true,
|
|
1790
|
+
* scene: "home",
|
|
1791
|
+
* outcomes: { play: type() }
|
|
1792
|
+
* };
|
|
1793
|
+
* ```
|
|
1794
|
+
*/
|
|
1795
|
+
type NodeSpec<Game extends GameState, Input, Tags extends OutcomeTags> = {
|
|
1796
|
+
input?: TypeTag<Input>;
|
|
1797
|
+
outcomes: Tags;
|
|
1798
|
+
over?: boolean;
|
|
1799
|
+
checkpoint?: boolean;
|
|
1800
|
+
barrier?: boolean;
|
|
1801
|
+
inbox?: readonly (keyof Tags & string)[]; /** Id of the scene this node is shown on. Without it the node keeps the current scene. */
|
|
1802
|
+
scene?: SceneIdOf<Game>;
|
|
1803
|
+
} & ({
|
|
1804
|
+
rest: true;
|
|
1805
|
+
run?: NodeRun<Game, Input, Tags>;
|
|
1806
|
+
} | {
|
|
1807
|
+
rest?: false;
|
|
1808
|
+
run: NodeRun<Game, Input, Tags>;
|
|
1809
|
+
});
|
|
1810
|
+
/**
|
|
1811
|
+
* The signature of `defineNode` bound to one game. `defineGame<Types>()` returns it.
|
|
1812
|
+
*
|
|
1813
|
+
* @example
|
|
1814
|
+
* ```ts
|
|
1815
|
+
* const defineGameNode: DefineNode<{ player: Player; session: Session }> = defineNode;
|
|
1816
|
+
* ```
|
|
1817
|
+
*/
|
|
1818
|
+
type DefineNode<Game extends GameState> = <Input = void, const Tags extends OutcomeTags = OutcomeTags>(spec: NodeSpec<Game, Input, Tags>) => NodeDefinition<Input, Tags, Game>;
|
|
1819
|
+
/**
|
|
1820
|
+
* An extension point inside a flow: a node whose body is "run the contributions in order".
|
|
1821
|
+
* Its single outcome is `done`.
|
|
1822
|
+
*
|
|
1823
|
+
* @example
|
|
1824
|
+
* ```ts
|
|
1825
|
+
* const afterOrder: SlotNode = slot("afterOrder");
|
|
1826
|
+
* // { kind: "slot", name: "afterOrder", input: { kind: "type" },
|
|
1827
|
+
* // outcomes: { done: { kind: "type" } } }
|
|
1828
|
+
* ```
|
|
1829
|
+
*/
|
|
1830
|
+
type SlotNode = Wired<void, {
|
|
1831
|
+
readonly done: TypeTag<void>;
|
|
1832
|
+
}> & {
|
|
1833
|
+
readonly kind: "slot";
|
|
1834
|
+
readonly name: string;
|
|
1835
|
+
};
|
|
1836
|
+
/**
|
|
1837
|
+
* Edge target that leaves the sub-flow with this outcome. The payload passes through.
|
|
1838
|
+
*
|
|
1839
|
+
* @example
|
|
1840
|
+
* ```ts
|
|
1841
|
+
* const leave: Exit<"left"> = exit("left"); // { kind: "exit", outcome: "left" }
|
|
1842
|
+
* ```
|
|
1843
|
+
*/
|
|
1844
|
+
type Exit<Name extends string = string> = {
|
|
1845
|
+
readonly kind: "exit";
|
|
1846
|
+
readonly outcome: Name;
|
|
1847
|
+
};
|
|
1848
|
+
/**
|
|
1849
|
+
* Edge target that adapts the payload on the edge. `map` is a property on purpose: the mapper's
|
|
1850
|
+
* parameter annotation is checked strictly against the outcome payload.
|
|
1851
|
+
*
|
|
1852
|
+
* @example
|
|
1853
|
+
* ```ts
|
|
1854
|
+
* const adapt: Mapped<"retry", { reason: string }, { why: string }> = to(
|
|
1855
|
+
* "retry",
|
|
1856
|
+
* (failure: { reason: string }) => ({ why: failure.reason })
|
|
1857
|
+
* );
|
|
1858
|
+
* ```
|
|
1859
|
+
*/
|
|
1860
|
+
type Mapped<TargetName extends string = string, Payload = never, Output = unknown> = {
|
|
1861
|
+
readonly kind: "map";
|
|
1862
|
+
readonly target: TargetName;
|
|
1863
|
+
readonly map: (payload: Payload) => Output;
|
|
1864
|
+
};
|
|
1865
|
+
/**
|
|
1866
|
+
* A mapped target as the runner calls it. `map` is a method on purpose: method parameters are
|
|
1867
|
+
* compared bivariantly, so every checked `Mapped` fits and the runner calls it without a cast.
|
|
1868
|
+
* The result is `unknown`: a mapper into a node without input returns nothing, and the runner
|
|
1869
|
+
* checks that a mapped payload is plain JSON before it becomes the next input.
|
|
1870
|
+
*/
|
|
1871
|
+
type AnyMapped = {
|
|
1872
|
+
readonly kind: "map";
|
|
1873
|
+
readonly target: string;
|
|
1874
|
+
map(payload: unknown): unknown;
|
|
1875
|
+
};
|
|
1876
|
+
/**
|
|
1877
|
+
* Any edge target as the runner reads it: a node name, an exit or a mapped target.
|
|
1878
|
+
*
|
|
1879
|
+
* @example
|
|
1880
|
+
* ```ts
|
|
1881
|
+
* const next: Target = "home"; // a node of the same flow
|
|
1882
|
+
* const leave: Target = exit("left"); // { kind: "exit", outcome: "left" }
|
|
1883
|
+
* ```
|
|
1884
|
+
*/
|
|
1885
|
+
type Target = string | Exit | AnyMapped;
|
|
1886
|
+
/**
|
|
1887
|
+
* Whether a receiver with input `Input` takes `Payload`. A receiver without input ignores the
|
|
1888
|
+
* payload, so it accepts anything; a void outcome into a node with input is an error.
|
|
1889
|
+
*
|
|
1890
|
+
* @example
|
|
1891
|
+
* ```ts
|
|
1892
|
+
* type Fits = Accepts<{ stars: number }, void>; // true
|
|
1893
|
+
* ```
|
|
1894
|
+
*/
|
|
1895
|
+
type Accepts<Payload, Input> = IsVoid<Input> extends true ? true : [Payload] extends [Input] ? true : false;
|
|
1896
|
+
/**
|
|
1897
|
+
* The input type of a node or sub-flow.
|
|
1898
|
+
*
|
|
1899
|
+
* @example
|
|
1900
|
+
* ```ts
|
|
1901
|
+
* type MergeInput = InputOf<typeof merge>; // { from: string; to: string }
|
|
1902
|
+
* ```
|
|
1903
|
+
*/
|
|
1904
|
+
type InputOf<Node extends AnyWired> = PayloadOf<Node["input"]>;
|
|
1905
|
+
/**
|
|
1906
|
+
* Names of the nodes whose input accepts `Payload`.
|
|
1907
|
+
*
|
|
1908
|
+
* @example
|
|
1909
|
+
* ```ts
|
|
1910
|
+
* type Starts = NodeNamesFor<{ boot: typeof boot; merge: typeof merge }, void>; // "boot"
|
|
1911
|
+
* ```
|
|
1912
|
+
*/
|
|
1913
|
+
type NodeNamesFor<Nodes extends NodeTable, Payload> = { [Name in keyof Nodes & string]: Accepts<Payload, InputOf<Nodes[Name]>> extends true ? Name : never }[keyof Nodes & string];
|
|
1914
|
+
/**
|
|
1915
|
+
* Names of the flow outcomes whose payload accepts `Payload`.
|
|
1916
|
+
*
|
|
1917
|
+
* @example
|
|
1918
|
+
* ```ts
|
|
1919
|
+
* type Exits = ExitNamesFor<{ win: TypeTag<{ stars: number }> }, { stars: number }>; // "win"
|
|
1920
|
+
* ```
|
|
1921
|
+
*/
|
|
1922
|
+
type ExitNamesFor<FlowTags extends OutcomeTags, Payload> = { [Name in keyof FlowTags & string]: Accepts<Payload, PayloadOf<FlowTags[Name]>> extends true ? Name : never }[keyof FlowTags & string];
|
|
1923
|
+
/**
|
|
1924
|
+
* Every correctly typed `to(node, map)` for `Payload`.
|
|
1925
|
+
*/
|
|
1926
|
+
type MappedFor<Nodes extends NodeTable, Payload> = { [Name in keyof Nodes & string]: Mapped<Name, Payload, InputOf<Nodes[Name]>> }[keyof Nodes & string];
|
|
1927
|
+
/**
|
|
1928
|
+
* Descriptive type: every legal target of an outcome that carries `Payload`.
|
|
1929
|
+
*/
|
|
1930
|
+
type TargetFor<Nodes extends NodeTable, FlowTags extends OutcomeTags, Payload> = NodeNamesFor<Nodes, Payload> | Exit<ExitNamesFor<FlowTags, Payload>> | MappedFor<Nodes, Payload>;
|
|
1931
|
+
/**
|
|
1932
|
+
* Descriptive type of an edge table: one entry per outcome of every node. Stored on
|
|
1933
|
+
* `FlowDefinition` and read by tools; the check with readable errors is `CheckedEdges`.
|
|
1934
|
+
*
|
|
1935
|
+
* @example
|
|
1936
|
+
* ```ts
|
|
1937
|
+
* const edges: Edges<{ boot: typeof boot; home: typeof home }, Record<never, never>> = {
|
|
1938
|
+
* boot: { ready: "home" },
|
|
1939
|
+
* home: { play: "boot" }
|
|
1940
|
+
* };
|
|
1941
|
+
* ```
|
|
1942
|
+
*/
|
|
1943
|
+
type Edges<Nodes extends NodeTable, FlowTags extends OutcomeTags> = { readonly [Name in keyof Nodes]: { readonly [Outcome in keyof Nodes[Name]["outcomes"]]: TargetFor<Nodes, FlowTags, PayloadOf<Nodes[Name]["outcomes"][Outcome]>> } };
|
|
1944
|
+
/**
|
|
1945
|
+
* A branded sentence. The compiler prints it in the "is not assignable to" position, so a wrong
|
|
1946
|
+
* edge reads as a message with node and outcome names.
|
|
1947
|
+
*
|
|
1948
|
+
* @example
|
|
1949
|
+
* ```ts
|
|
1950
|
+
* type Problem = GraphError<'No node "awaitIntnet" in this flow (edge of outcome "done" of node "merge").'>;
|
|
1951
|
+
* ```
|
|
1952
|
+
*/
|
|
1953
|
+
type GraphError<Message extends string> = {
|
|
1954
|
+
readonly $graphError: Message;
|
|
1955
|
+
};
|
|
1956
|
+
/**
|
|
1957
|
+
* The loosest legal target of a flow: what a missing entry is expected to be.
|
|
1958
|
+
*/
|
|
1959
|
+
type AnyTargetOf<Nodes extends NodeTable, FlowTags extends OutcomeTags> = (keyof Nodes & string) | Exit<keyof FlowTags & string> | Mapped<keyof Nodes & string, never, unknown>;
|
|
1960
|
+
/**
|
|
1961
|
+
* Checks one entry `Value` of the inferred edge table. A legal entry stays itself; a wrong one
|
|
1962
|
+
* becomes a `GraphError` sentence.
|
|
1963
|
+
*/
|
|
1964
|
+
type CheckTarget<Nodes extends NodeTable, FlowTags extends OutcomeTags, NodeName extends string, OutcomeName extends string, Payload, Value> = Value extends string ? Value extends keyof Nodes ? Accepts<Payload, InputOf<Nodes[Value]>> extends true ? Value : GraphError<`Payload of outcome "${OutcomeName}" of node "${NodeName}" does not fit the input of node "${Value}". Use to("${Value}", payload => ...).`> : GraphError<`No node "${Value}" in this flow (edge of outcome "${OutcomeName}" of node "${NodeName}").`> : Value extends Exit<infer ExitName> ? ExitName extends keyof FlowTags ? Accepts<Payload, PayloadOf<FlowTags[ExitName]>> extends true ? Value : GraphError<`Payload of outcome "${OutcomeName}" of node "${NodeName}" does not fit flow outcome "${ExitName}".`> : GraphError<`exit("${ExitName}"): the flow declares no outcome "${ExitName}".`> : Value extends Mapped<infer TargetName, never, unknown> ? TargetName extends keyof Nodes ? Mapped<TargetName, Payload, InputOf<Nodes[TargetName]>> : GraphError<`to("${TargetName}", ...): no node "${TargetName}" in this flow.`> : AnyTargetOf<Nodes, FlowTags>;
|
|
1965
|
+
/**
|
|
1966
|
+
* Checking type: validates the inferred edge table `Table` entry by entry. It reports a missing
|
|
1967
|
+
* edge, an unknown target, a payload that does not fit, an edge of an unknown outcome and an
|
|
1968
|
+
* edge table row of an unknown node.
|
|
1969
|
+
*
|
|
1970
|
+
* @example
|
|
1971
|
+
* ```ts
|
|
1972
|
+
* type Spec = { edges: Table & CheckedEdges<Nodes, FlowTags, Table> };
|
|
1973
|
+
* ```
|
|
1974
|
+
*/
|
|
1975
|
+
type CheckedEdges<Nodes extends NodeTable, FlowTags extends OutcomeTags, Table> = { readonly [Name in keyof Nodes & string]: { readonly [Outcome in keyof Nodes[Name]["outcomes"] & string]: Name extends keyof Table ? Outcome extends keyof Table[Name] ? CheckTarget<Nodes, FlowTags, Name, Outcome, PayloadOf<Nodes[Name]["outcomes"][Outcome]>, Table[Name][Outcome]> : AnyTargetOf<Nodes, FlowTags> : AnyTargetOf<Nodes, FlowTags> } & (Name extends keyof Table ? { readonly [Extra in Exclude<keyof Table[Name], keyof Nodes[Name]["outcomes"]> & string]: GraphError<`Node "${Name}" has no outcome "${Extra}".`> } : unknown) } & { readonly [Extra in Exclude<keyof Table, keyof Nodes> & string]: GraphError<`"${Extra}" is not a node of this flow.`> };
|
|
1976
|
+
/**
|
|
1977
|
+
* A flow as `defineFlow` returns it: plain data. A flow that declares `input` and `outcomes`
|
|
1978
|
+
* is used as a node of another flow.
|
|
1979
|
+
*
|
|
1980
|
+
* @example
|
|
1981
|
+
* ```ts
|
|
1982
|
+
* // What defineFlow("main", { nodes: { boot, home }, start: "boot", edges }) returns:
|
|
1983
|
+
* // { kind: "flow", id: "main", input: { kind: "type" }, outcomes: {}, nodes: { boot, home },
|
|
1984
|
+
* // start: "boot", edges: { boot: { ready: "home" }, home: { play: "boot" } } }
|
|
1985
|
+
* ```
|
|
1986
|
+
*/
|
|
1987
|
+
type FlowDefinition<Input, FlowTags extends OutcomeTags, Nodes extends NodeTable = NodeTable> = Wired<Input, FlowTags> & {
|
|
1988
|
+
readonly kind: "flow";
|
|
1989
|
+
readonly id: string;
|
|
1990
|
+
readonly nodes: Nodes;
|
|
1991
|
+
readonly start: keyof Nodes & string;
|
|
1992
|
+
readonly edges: Edges<Nodes, FlowTags>;
|
|
1993
|
+
};
|
|
1994
|
+
/**
|
|
1995
|
+
* What the author passes to `defineFlow`. `start` must name a node whose input accepts the flow
|
|
1996
|
+
* input; `edges` is inferred as written and validated by `CheckedEdges`.
|
|
1997
|
+
*
|
|
1998
|
+
* @example
|
|
1999
|
+
* ```ts
|
|
2000
|
+
* // The spec of a sub-flow: `outcomes` makes it usable as a node, exit() leaves it.
|
|
2001
|
+
* defineFlow("rewardPopup", {
|
|
2002
|
+
* nodes: { show, grant },
|
|
2003
|
+
* start: "show",
|
|
2004
|
+
* outcomes: { done: type() },
|
|
2005
|
+
* edges: { show: { claim: "grant" }, grant: { done: exit("done") } }
|
|
2006
|
+
* });
|
|
2007
|
+
* ```
|
|
2008
|
+
*/
|
|
2009
|
+
type FlowSpec<Nodes extends NodeTable, Input, FlowTags extends OutcomeTags, Table> = {
|
|
2010
|
+
nodes: Nodes;
|
|
2011
|
+
start: NodeNamesFor<NoInfer<Nodes>, NoInfer<Input>>;
|
|
2012
|
+
edges: Table & CheckedEdges<NoInfer<Nodes>, NoInfer<FlowTags>, NoInfer<Table>>;
|
|
2013
|
+
input?: TypeTag<Input>;
|
|
2014
|
+
outcomes?: FlowTags;
|
|
2015
|
+
};
|
|
2016
|
+
/**
|
|
2017
|
+
* The node context as the runner builds it. Every typed `NodeContext` is assignable to it.
|
|
2018
|
+
*/
|
|
2019
|
+
type AnyNodeContext = {
|
|
2020
|
+
input: unknown;
|
|
2021
|
+
player: unknown;
|
|
2022
|
+
session: unknown;
|
|
2023
|
+
rng: RngView;
|
|
2024
|
+
fx: NodeFx;
|
|
2025
|
+
out: Readonly<Record<string, (payload: never) => Result>>;
|
|
2026
|
+
signal: AbortSignal;
|
|
2027
|
+
now: number;
|
|
2028
|
+
};
|
|
2029
|
+
/**
|
|
2030
|
+
* Any node as the runner holds it. `run` is a method on purpose: method parameters are compared
|
|
2031
|
+
* bivariantly, so every typed `NodeDefinition` fits and the runner calls the body without a cast.
|
|
2032
|
+
*
|
|
2033
|
+
* @example
|
|
2034
|
+
* ```ts
|
|
2035
|
+
* const nodes: readonly AnyNode[] = [catchUp, merge];
|
|
2036
|
+
* ```
|
|
2037
|
+
*/
|
|
2038
|
+
type AnyNode = AnyWired & {
|
|
2039
|
+
readonly kind: "node";
|
|
2040
|
+
readonly rest: boolean;
|
|
2041
|
+
readonly over: boolean;
|
|
2042
|
+
readonly checkpoint: boolean;
|
|
2043
|
+
readonly barrier: boolean;
|
|
2044
|
+
readonly inbox: readonly string[]; /** Id of the scene the node is shown on. Absent: the node keeps the current scene. */
|
|
2045
|
+
readonly scene?: string;
|
|
2046
|
+
run?(ctx: AnyNodeContext): Result | Promise<Result>;
|
|
2047
|
+
};
|
|
2048
|
+
/**
|
|
2049
|
+
* One entry of a flow's `nodes`: a node, a sub-flow or a slot. The `kind` field tells them apart.
|
|
2050
|
+
*/
|
|
2051
|
+
type FlowEntry = AnyNode | AnyFlow | SlotNode;
|
|
2052
|
+
/**
|
|
2053
|
+
* Any flow as the runner holds it. Every typed `FlowDefinition` fits.
|
|
2054
|
+
*
|
|
2055
|
+
* @example
|
|
2056
|
+
* ```ts
|
|
2057
|
+
* const flows: readonly AnyFlow[] = [mainFlow, boardFlow, rewardFlow];
|
|
2058
|
+
* ```
|
|
2059
|
+
*/
|
|
2060
|
+
type AnyFlow = AnyWired & {
|
|
2061
|
+
readonly kind: "flow";
|
|
2062
|
+
readonly id: string;
|
|
2063
|
+
readonly nodes: Readonly<Record<string, FlowEntry>>;
|
|
2064
|
+
readonly start: string;
|
|
2065
|
+
readonly edges: Readonly<Record<string, Readonly<Record<string, Target>>>>;
|
|
2066
|
+
};
|
|
2067
|
+
/**
|
|
2068
|
+
* One level of the position. The path is the frames joined: `"board/awaitIntent"`.
|
|
2069
|
+
*
|
|
2070
|
+
* @example
|
|
2071
|
+
* ```ts
|
|
2072
|
+
* const frame: Frame = { flow: "board", node: "awaitIntent", input: null };
|
|
2073
|
+
* ```
|
|
2074
|
+
*/
|
|
2075
|
+
type Frame = {
|
|
2076
|
+
flow: string;
|
|
2077
|
+
node: string;
|
|
2078
|
+
input: Json;
|
|
2079
|
+
};
|
|
2080
|
+
/**
|
|
2081
|
+
* One taken edge. `hash` covers `path + outcome + next`, so a replay against edited code fails
|
|
2082
|
+
* loudly in dev.
|
|
2083
|
+
*
|
|
2084
|
+
* @example
|
|
2085
|
+
* ```ts
|
|
2086
|
+
* // The play button on "home" led into the board sub-flow.
|
|
2087
|
+
* const entry: JournalEntry = {
|
|
2088
|
+
* index: 1, path: "home", outcome: "play", payload: null,
|
|
2089
|
+
* next: "board/awaitIntent", now: 1_790_000_000_000, hash: "fbeb1a2f"
|
|
2090
|
+
* };
|
|
2091
|
+
* ```
|
|
2092
|
+
*/
|
|
2093
|
+
type JournalEntry = {
|
|
2094
|
+
index: number;
|
|
2095
|
+
path: string;
|
|
2096
|
+
outcome: string;
|
|
2097
|
+
payload: Json;
|
|
2098
|
+
next: string;
|
|
2099
|
+
now: number;
|
|
2100
|
+
hash: string;
|
|
2101
|
+
};
|
|
2102
|
+
/**
|
|
2103
|
+
* One step of a fast walk: a player answer at a rest node, or a substituted sub-flow result.
|
|
2104
|
+
*
|
|
2105
|
+
* @example
|
|
2106
|
+
* ```ts
|
|
2107
|
+
* const route: RouteStep[] = [
|
|
2108
|
+
* { at: "home", intent: "play" }, // the answer "play" at the rest node "home"
|
|
2109
|
+
* { at: "board", result: { outcome: "left" } } // the sub-flow "board" is skipped with "left"
|
|
2110
|
+
* ];
|
|
2111
|
+
* ```
|
|
2112
|
+
*/
|
|
2113
|
+
type RouteStep = {
|
|
2114
|
+
at: string;
|
|
2115
|
+
intent: string;
|
|
2116
|
+
payload?: Json;
|
|
2117
|
+
} | {
|
|
2118
|
+
at: string;
|
|
2119
|
+
result: {
|
|
2120
|
+
outcome: string;
|
|
2121
|
+
payload?: Json;
|
|
2122
|
+
};
|
|
2123
|
+
};
|
|
2124
|
+
/**
|
|
2125
|
+
* A rest node plus a state that really existed there. Serialisable. `graph` is the hash of
|
|
2126
|
+
* `describe()`.
|
|
2127
|
+
*
|
|
2128
|
+
* @example
|
|
2129
|
+
* ```ts
|
|
2130
|
+
* const bookmark: Bookmark = {
|
|
2131
|
+
* path: "board/awaitIntent", input: null,
|
|
2132
|
+
* player: { coins: 7 }, session: { taps: 0 },
|
|
2133
|
+
* rng: { seed: 42, streams: {} }, graph: "fe4d257a"
|
|
2134
|
+
* };
|
|
2135
|
+
* ```
|
|
2136
|
+
*/
|
|
2137
|
+
type Bookmark = {
|
|
2138
|
+
path: string;
|
|
2139
|
+
input: Json;
|
|
2140
|
+
player: Json;
|
|
2141
|
+
session: Json;
|
|
2142
|
+
rng: RngState;
|
|
2143
|
+
graph: string;
|
|
2144
|
+
};
|
|
2145
|
+
/**
|
|
2146
|
+
* What `onEnter` callbacks learn about the node being entered.
|
|
2147
|
+
*
|
|
2148
|
+
* @example
|
|
2149
|
+
* ```ts
|
|
2150
|
+
* // The rest node "awaitIntent" of the sub-flow "board" is being entered.
|
|
2151
|
+
* const node: NodeInfo = {
|
|
2152
|
+
* path: "board/awaitIntent", flow: "board", node: "awaitIntent",
|
|
2153
|
+
* rest: true, over: false, checkpoint: false, barrier: false, scene: "board"
|
|
2154
|
+
* };
|
|
2155
|
+
* ```
|
|
2156
|
+
*/
|
|
2157
|
+
type NodeInfo = {
|
|
2158
|
+
path: string;
|
|
2159
|
+
flow: string;
|
|
2160
|
+
node: string;
|
|
2161
|
+
rest: boolean;
|
|
2162
|
+
over: boolean;
|
|
2163
|
+
checkpoint: boolean;
|
|
2164
|
+
barrier: boolean; /** Id of the scene the node is shown on: `scenes` switches on it, `assets` pins its bundle. */
|
|
2165
|
+
scene?: string;
|
|
2166
|
+
};
|
|
2167
|
+
/**
|
|
2168
|
+
* The whole graph as JSON. Edge targets are rendered as strings: `"node"`, `"exit:win"`,
|
|
2169
|
+
* `"map:node"`.
|
|
2170
|
+
*
|
|
2171
|
+
* @example
|
|
2172
|
+
* ```ts
|
|
2173
|
+
* const graph: FlowGraph = app.flow.describe();
|
|
2174
|
+
*
|
|
2175
|
+
* graph.main; // "main"
|
|
2176
|
+
* graph.flows.board?.edges.awaitIntent?.leave; // "exit:left"; a mapped target reads "map:node"
|
|
2177
|
+
* graph.slots.afterOrder; // [{ feature: "reward", flow: "rewardPopup", order: 10 }]
|
|
2178
|
+
* ```
|
|
2179
|
+
*/
|
|
2180
|
+
type FlowGraph = {
|
|
2181
|
+
main: string;
|
|
2182
|
+
flows: Record<string, {
|
|
2183
|
+
nodes: Record<string, GraphNode>;
|
|
2184
|
+
start: string;
|
|
2185
|
+
edges: Record<string, Record<string, string>>;
|
|
2186
|
+
}>;
|
|
2187
|
+
slots: Record<string, {
|
|
2188
|
+
feature: string;
|
|
2189
|
+
flow: string;
|
|
2190
|
+
order: number;
|
|
2191
|
+
}[]>;
|
|
2192
|
+
};
|
|
2193
|
+
/**
|
|
2194
|
+
* One node of `describe()`: its flags, its scene, its outcome names and, when it is one, the slot
|
|
2195
|
+
* it opens, the sub-flow it enters and the feature that brought it. It has no `path`: a static
|
|
2196
|
+
* description has no runtime position, and `flow` plus `node` address it.
|
|
2197
|
+
*
|
|
2198
|
+
* @example
|
|
2199
|
+
* ```ts
|
|
2200
|
+
* // app.flow.describe().flows.main?.nodes.board: the sub-flow "board" used as a node of "main".
|
|
2201
|
+
* const node: GraphNode = {
|
|
2202
|
+
* flow: "main", node: "board",
|
|
2203
|
+
* rest: false, over: false, checkpoint: false, barrier: false,
|
|
2204
|
+
* outcomes: ["orderComplete", "left"], subFlow: "board"
|
|
2205
|
+
* };
|
|
2206
|
+
* ```
|
|
2207
|
+
*/
|
|
2208
|
+
type GraphNode = Omit<NodeInfo, "path"> & {
|
|
2209
|
+
outcomes: string[];
|
|
2210
|
+
slot?: string;
|
|
2211
|
+
subFlow?: string;
|
|
2212
|
+
owner?: string;
|
|
2213
|
+
};
|
|
2214
|
+
/**
|
|
2215
|
+
* Inspection of the running graph.
|
|
2216
|
+
*
|
|
2217
|
+
* @example
|
|
2218
|
+
* ```ts
|
|
2219
|
+
* // The graph rests at "home" and waits for the intent "play".
|
|
2220
|
+
* const state: FlowState = {
|
|
2221
|
+
* running: true, path: "home", stack: [{ flow: "main", node: "home", input: null }],
|
|
2222
|
+
* pending: { gate: ["play"] }, mode: "live"
|
|
2223
|
+
* };
|
|
2224
|
+
* ```
|
|
2225
|
+
*/
|
|
2226
|
+
type FlowState = {
|
|
2227
|
+
running: boolean;
|
|
2228
|
+
path: string;
|
|
2229
|
+
stack: readonly Frame[];
|
|
2230
|
+
pending: {
|
|
2231
|
+
fx?: string;
|
|
2232
|
+
gate?: readonly string[];
|
|
2233
|
+
};
|
|
2234
|
+
mode: "live" | "fast";
|
|
2235
|
+
};
|
|
2236
|
+
/**
|
|
2237
|
+
* Stages of entering a node: `assets` preloads at `load`, `scenes` switches at `scene`.
|
|
2238
|
+
*
|
|
2239
|
+
* @example
|
|
2240
|
+
* ```ts
|
|
2241
|
+
* const stage: Stage = "load";
|
|
2242
|
+
* ```
|
|
2243
|
+
*/
|
|
2244
|
+
type Stage = "load" | "scene";
|
|
2245
|
+
/**
|
|
2246
|
+
* Callback of `onEnter`. Called before `node.run`, awaited.
|
|
2247
|
+
*
|
|
2248
|
+
* @example
|
|
2249
|
+
* ```ts
|
|
2250
|
+
* // The "load" stage of a game plugin: the loop waits for it before the body of the node runs.
|
|
2251
|
+
* const preloadNode: EnterCallback = async (node, { signal }) => {
|
|
2252
|
+
* if (node.flow === "board") await preloadBoard(signal); // node.path: "board/awaitIntent"
|
|
2253
|
+
* };
|
|
2254
|
+
*
|
|
2255
|
+
* app.flow.onEnter("load", preloadNode);
|
|
2256
|
+
* ```
|
|
2257
|
+
*/
|
|
2258
|
+
type EnterCallback = (node: NodeInfo, ctx: {
|
|
2259
|
+
mode: "live" | "fast";
|
|
2260
|
+
signal: AbortSignal;
|
|
2261
|
+
}) => void | Promise<void>;
|
|
2262
|
+
/**
|
|
2263
|
+
* The seam the fast walk and `restore` steer the running loop with. It stays absent while the
|
|
2264
|
+
* game just runs: `walk.ts` and `restore` create it through `loopSeam` when they need it.
|
|
2265
|
+
*/
|
|
2266
|
+
type LoopSeam = {
|
|
2267
|
+
/** Sub-flow results a walk substitutes, by path. The loop takes each one once. */substitutions: Map<string, Result>; /** Called with the path every time the loop enters a rest node. */
|
|
2268
|
+
rest: ((path: string) => void)[]; /** Called every time the loop opens the gate of a rest node. The walk waits on it. */
|
|
2269
|
+
gateOpen: (() => void)[]; /** The bookmark the loop enters at the next turn. */
|
|
2270
|
+
restoring: Bookmark | undefined;
|
|
2271
|
+
};
|
|
2272
|
+
/**
|
|
2273
|
+
* runner module state.
|
|
2274
|
+
*/
|
|
2275
|
+
type RunnerState = {
|
|
2276
|
+
/** Flush started by a background pause. `onStop` awaits it. */flushing: Promise<void> | undefined; /** Every flow of the graph by id, collected at `run()`. */
|
|
2277
|
+
flows: Map<string, AnyFlow>;
|
|
2278
|
+
enterCallbacks: Record<Stage, EnterCallback[]>; /** The position: one frame per nesting level. */
|
|
2279
|
+
stack: Frame[]; /** The stack of the last rest node: the rollback target. */
|
|
2280
|
+
restFrame: Frame[] | undefined; /** Inside a slot: the contribution the loop just finished. The slot continues after it. */
|
|
2281
|
+
slotAfter: string | undefined; /** Edges since the last checkpoint. */
|
|
2282
|
+
journal: JournalEntry[];
|
|
2283
|
+
journalIndex: number; /** The promise of `run()`. `undefined`: not started. */
|
|
2284
|
+
running: Promise<void> | undefined; /** Aborts the active node. */
|
|
2285
|
+
abort: AbortController | undefined; /** Failed transitions in a row. */
|
|
2286
|
+
failures: number; /** How `walk` and `restore` steer the loop. Absent until one of them needs it. */
|
|
2287
|
+
seam?: LoopSeam; /** The last `state()` and what it was read from. Absent until the first `state()`. */
|
|
2288
|
+
view?: StateView;
|
|
2289
|
+
};
|
|
2290
|
+
/**
|
|
2291
|
+
* The last `flow.state()` and the fields it was built from. `state()` hands the same frozen
|
|
2292
|
+
* object out again while none of them moved, so a watcher compares identities.
|
|
2293
|
+
*/
|
|
2294
|
+
type StateView = {
|
|
2295
|
+
running: boolean;
|
|
2296
|
+
depth: number;
|
|
2297
|
+
top: Frame | undefined;
|
|
2298
|
+
open: GateSpec | undefined;
|
|
2299
|
+
mode: "live" | "fast";
|
|
2300
|
+
journalIndex: number;
|
|
2301
|
+
state: FlowState;
|
|
2302
|
+
};
|
|
2303
|
+
/**
|
|
2304
|
+
* runner module API. Its methods are spread onto the plugin root: `app.flow.run()`. `run` owns
|
|
2305
|
+
* the one loop, `walk` and `restore` enter a position through it, `describe`, `state` and
|
|
2306
|
+
* `history` inspect it.
|
|
2307
|
+
*
|
|
2308
|
+
* @example
|
|
2309
|
+
* ```ts
|
|
2310
|
+
* // A live game starts the graph once and then only reads it: answers go through the gate.
|
|
2311
|
+
* app.flow.run().catch(showFatal);
|
|
2312
|
+
* app.flow.state().path; // "home", once the transit node "boot" was played out
|
|
2313
|
+
* ```
|
|
2314
|
+
*/
|
|
2315
|
+
type RunnerApi = {
|
|
2316
|
+
/**
|
|
2317
|
+
* Validates the graph, seals the features, loads the save and runs the one loop until `onStop`
|
|
2318
|
+
* aborts it. A fatal error rejects: the consumer catches it.
|
|
2319
|
+
*
|
|
2320
|
+
* @returns The promise of the running graph. It resolves when the app stops.
|
|
2321
|
+
* @throws {Error} When `run()` was already called.
|
|
2322
|
+
* @example
|
|
2323
|
+
* ```ts
|
|
2324
|
+
* // The game starts the graph from onStart and does not await it: the loop never ends.
|
|
2325
|
+
* createApp({
|
|
2326
|
+
* pluginConfigs: { flow: { mainFlow, safeNode: "home" } },
|
|
2327
|
+
* onStart: ctx => {
|
|
2328
|
+
* ctx.flow.run().catch(showFatal); // a broken graph or an unreadable save ends up here
|
|
2329
|
+
* }
|
|
2330
|
+
* });
|
|
2331
|
+
* ```
|
|
2332
|
+
*/
|
|
2333
|
+
run(): Promise<void>;
|
|
2334
|
+
/**
|
|
2335
|
+
* Registers a callback run before every node body: `assets` preloads at `load`, `scenes`
|
|
2336
|
+
* switches at `scene`. Every `load` callback runs before the first `scene` callback, in
|
|
2337
|
+
* registration order, each awaited.
|
|
2338
|
+
*
|
|
2339
|
+
* @param stage - `"load"` or `"scene"`.
|
|
2340
|
+
* @param callback - Called with the node and `{ mode, signal }`, awaited.
|
|
2341
|
+
* @returns The unregister function.
|
|
2342
|
+
* @example
|
|
2343
|
+
* ```ts
|
|
2344
|
+
* // A scenes plugin switches the screen when the graph enters a node.
|
|
2345
|
+
* const off = app.flow.onEnter("scene", node => showScene(node.path)); // node.path: "home"
|
|
2346
|
+
*
|
|
2347
|
+
* off(); // the plugin stops: the callback is not called any more
|
|
2348
|
+
* ```
|
|
2349
|
+
*/
|
|
2350
|
+
onEnter(stage: Stage, callback: EnterCallback): () => void;
|
|
2351
|
+
/**
|
|
2352
|
+
* Walks a route in fast mode through the running loop: it answers the gate at each step's `at`
|
|
2353
|
+
* and substitutes the result of every sub-flow node the route skips. A `from` bookmark is
|
|
2354
|
+
* entered through the same check as `restore`. The mode of the caller is put back afterwards.
|
|
2355
|
+
*
|
|
2356
|
+
* @param route - The player's answers and substituted sub-flow results, in order.
|
|
2357
|
+
* @param options - Walk options.
|
|
2358
|
+
* @param options.from - Bookmark restored before the first step.
|
|
2359
|
+
* @returns The state the walk ended in.
|
|
2360
|
+
* @throws {Error} Before `run()` was called, when the bookmark is refused, and when a step's
|
|
2361
|
+
* `at` is never reached.
|
|
2362
|
+
* @example
|
|
2363
|
+
* ```ts
|
|
2364
|
+
* // A test skips the menu and stands on the board, without a screen and without waiting.
|
|
2365
|
+
* const state = await app.flow.walk([{ at: "home", intent: "play" }]);
|
|
2366
|
+
* state.path; // "board/awaitIntent"
|
|
2367
|
+
*
|
|
2368
|
+
* // Devtools jump back to a saved position and leave the board from there.
|
|
2369
|
+
* await app.flow.walk([{ at: "board/awaitIntent", intent: "leave" }], { from: bookmark });
|
|
2370
|
+
* ```
|
|
2371
|
+
*/
|
|
2372
|
+
walk(route: readonly RouteStep[], options?: {
|
|
2373
|
+
from?: Bookmark;
|
|
2374
|
+
}): Promise<FlowState>;
|
|
2375
|
+
/**
|
|
2376
|
+
* Makes a bookmark of the current rest point: the rest node plus the committed state.
|
|
2377
|
+
*
|
|
2378
|
+
* @returns The bookmark, ready for JSON.
|
|
2379
|
+
* @throws {Error} When the graph has no position yet.
|
|
2380
|
+
* @example
|
|
2381
|
+
* ```ts
|
|
2382
|
+
* // A devtools button keeps the position while the board rests.
|
|
2383
|
+
* const bookmark = app.flow.bookmark();
|
|
2384
|
+
* bookmark.path; // "board/awaitIntent"
|
|
2385
|
+
* JSON.stringify(bookmark); // plain data: path, input, player, session, rng and the graph hash
|
|
2386
|
+
* ```
|
|
2387
|
+
*/
|
|
2388
|
+
bookmark(): Bookmark;
|
|
2389
|
+
/**
|
|
2390
|
+
* Replaces the state with the bookmark's and enters its node. A checkpoint is always
|
|
2391
|
+
* accepted; any other rest node only while the graph is unchanged.
|
|
2392
|
+
*
|
|
2393
|
+
* @param bookmark - The bookmark to enter.
|
|
2394
|
+
* @returns A promise that resolves once the graph rests at the bookmark's node.
|
|
2395
|
+
* @throws {Error} When the bookmark names no rest node of this graph, when the graph changed
|
|
2396
|
+
* since a bookmark of a plain rest node, and before `run()`.
|
|
2397
|
+
* @example
|
|
2398
|
+
* ```ts
|
|
2399
|
+
* // The next session opens where the last one stopped: state and position come back together.
|
|
2400
|
+
* await app.flow.restore(bookmark);
|
|
2401
|
+
* app.flow.state().path; // "board/awaitIntent"
|
|
2402
|
+
* ```
|
|
2403
|
+
*/
|
|
2404
|
+
restore(bookmark: Bookmark): Promise<void>;
|
|
2405
|
+
/**
|
|
2406
|
+
* Renders the whole graph as JSON, without running the game. It reads the flows as data, so
|
|
2407
|
+
* it works before `run()`.
|
|
2408
|
+
*
|
|
2409
|
+
* @returns Nodes, flags, outcomes, edges, slots and who contributed.
|
|
2410
|
+
* @example
|
|
2411
|
+
* ```ts
|
|
2412
|
+
* // A devtools panel draws the graph of the merge game.
|
|
2413
|
+
* const graph = app.flow.describe();
|
|
2414
|
+
*
|
|
2415
|
+
* graph.flows.main?.edges.home; // { play: "board" }
|
|
2416
|
+
* graph.flows.board?.nodes.merge?.outcomes; // ["done", "rejected"]
|
|
2417
|
+
* ```
|
|
2418
|
+
*/
|
|
2419
|
+
describe(): FlowGraph;
|
|
2420
|
+
/**
|
|
2421
|
+
* Reads where the graph stands. Frozen, and the same object while the graph did not move: no
|
|
2422
|
+
* edge, no gate opened or closed, no mode switch.
|
|
2423
|
+
*
|
|
2424
|
+
* @returns Whether it runs, the path, the stack, what it waits for and the mode.
|
|
2425
|
+
* @example
|
|
2426
|
+
* ```ts
|
|
2427
|
+
* // The screen enables only the buttons the resting node takes.
|
|
2428
|
+
* const { path, pending } = app.flow.state();
|
|
2429
|
+
* // path: "home", pending: { gate: ["play"] }
|
|
2430
|
+
*
|
|
2431
|
+
* // An editor panel redraws the position only when the graph moved.
|
|
2432
|
+
* app.flow.state() === app.flow.state(); // true while "home" rests
|
|
2433
|
+
* ```
|
|
2434
|
+
*/
|
|
2435
|
+
state(): FlowState;
|
|
2436
|
+
/**
|
|
2437
|
+
* Reads the edges taken since the last checkpoint.
|
|
2438
|
+
*
|
|
2439
|
+
* @returns A copy of the journal.
|
|
2440
|
+
* @example
|
|
2441
|
+
* ```ts
|
|
2442
|
+
* // A bug report says why the last move was refused.
|
|
2443
|
+
* const last = app.flow.history().at(-1);
|
|
2444
|
+
* // last?.path: "board/merge", last?.outcome: "rejected", last?.payload: { reason: "empty" }
|
|
2445
|
+
* ```
|
|
2446
|
+
*/
|
|
2447
|
+
history(): readonly JournalEntry[];
|
|
2448
|
+
/**
|
|
2449
|
+
* Switches between live and fast mode. Legal before `run()` and while the graph rests.
|
|
2450
|
+
*
|
|
2451
|
+
* @param mode - `"live"` or `"fast"`.
|
|
2452
|
+
* @throws {Error} When a transit node is running.
|
|
2453
|
+
* @example
|
|
2454
|
+
* ```ts
|
|
2455
|
+
* // A headless run plays without effects: fast mode before the app starts.
|
|
2456
|
+
* app.flow.setMode("fast");
|
|
2457
|
+
* await app.start();
|
|
2458
|
+
* app.flow.state().mode; // "fast"
|
|
2459
|
+
* ```
|
|
2460
|
+
*/
|
|
2461
|
+
setMode(mode: "live" | "fast"): void;
|
|
2462
|
+
};
|
|
2463
|
+
//#endregion
|
|
2464
|
+
//#region src/plugins/flow/features/types.d.ts
|
|
2465
|
+
/**
|
|
2466
|
+
* One animation of a feature, as `defineAnimation` returns it. Structural on purpose: `flow`
|
|
2467
|
+
* stores what the game brought and never imports `anim`, which reads the rest of the object.
|
|
2468
|
+
*
|
|
2469
|
+
* @example
|
|
2470
|
+
* ```ts
|
|
2471
|
+
* const coinsFly: FeatureAnimation = { id: "coinsFly" };
|
|
2472
|
+
* ```
|
|
2473
|
+
*/
|
|
2474
|
+
type FeatureAnimation = {
|
|
2475
|
+
readonly id: string;
|
|
2476
|
+
};
|
|
2477
|
+
/**
|
|
2478
|
+
* One interface component of a feature, as `defineComponent` returns it. Structural on purpose:
|
|
2479
|
+
* `ui` reads the rest of the object, `flow` only carries it.
|
|
2480
|
+
*
|
|
2481
|
+
* @example
|
|
2482
|
+
* ```ts
|
|
2483
|
+
* const rewardPopup: FeatureComponent = { name: "RewardPopup" };
|
|
2484
|
+
* ```
|
|
2485
|
+
*/
|
|
2486
|
+
type FeatureComponent = {
|
|
2487
|
+
readonly name: string;
|
|
2488
|
+
};
|
|
2489
|
+
/**
|
|
2490
|
+
* The compiled messages of one locale: message key to what `compileStrings` wrote for it. The
|
|
2491
|
+
* value stays opaque here because only `i18n` calls it; `flow` passes the module on untouched.
|
|
2492
|
+
*
|
|
2493
|
+
* @example
|
|
2494
|
+
* ```ts
|
|
2495
|
+
* const en: CompiledMessagesLike = { "board.title": () => [{ kind: "text", text: "Board" }] };
|
|
2496
|
+
* ```
|
|
2497
|
+
*/
|
|
2498
|
+
type CompiledMessagesLike = {
|
|
2499
|
+
readonly [key: string]: unknown;
|
|
2500
|
+
};
|
|
2501
|
+
/**
|
|
2502
|
+
* The text styles of a feature, as `defineTextStyles` returns them. Structural on purpose: `text`
|
|
2503
|
+
* reads the fields of each style, `flow` only carries the map.
|
|
2504
|
+
*
|
|
2505
|
+
* @example
|
|
2506
|
+
* ```ts
|
|
2507
|
+
* const styles: FeatureTextStyles = { kind: "textStyles", map: { title: { size: 24 } } };
|
|
2508
|
+
* ```
|
|
2509
|
+
*/
|
|
2510
|
+
type FeatureTextStyles = {
|
|
2511
|
+
readonly kind: "textStyles";
|
|
2512
|
+
readonly map: Record<string, object>;
|
|
2513
|
+
};
|
|
2514
|
+
/**
|
|
2515
|
+
* What a feature brings to the game. The logic keys are typed here; the keys of later milestones
|
|
2516
|
+
* pass through the index signature until their plugin types them. `flow` stores them untouched,
|
|
2517
|
+
* and `logicOnly` drops them. The V2 screen plugins read five of them through the index signature:
|
|
2518
|
+
* `projections`, `systems` and `components` (`world`), `scenes` (`scenes`, `assets`, and the
|
|
2519
|
+
* scene-id check of `flow`) and `assets` (`assets`). The V3 interface keys are typed below.
|
|
2520
|
+
*
|
|
2521
|
+
* @example
|
|
2522
|
+
* ```ts
|
|
2523
|
+
* const description: FeatureDescription = {
|
|
2524
|
+
* flows: [rewardFlow],
|
|
2525
|
+
* contribute: { afterWin: { flow: rewardFlow, order: 10 } }
|
|
2526
|
+
* };
|
|
2527
|
+
* ```
|
|
2528
|
+
*/
|
|
2529
|
+
type FeatureDescription = {
|
|
2530
|
+
/** Nodes the feature owns. Recorded for `describe()` and hot swap. */nodes?: readonly AnyNode[]; /** Flows the feature owns. */
|
|
2531
|
+
flows?: readonly AnyFlow[]; /** Slot name to the sub-flow run in that slot. */
|
|
2532
|
+
contribute?: Record<string, {
|
|
2533
|
+
flow: AnyFlow;
|
|
2534
|
+
order: number;
|
|
2535
|
+
when?: (snapshot: Snapshot) => boolean;
|
|
2536
|
+
}>; /** Animations the feature owns, read by `anim`. */
|
|
2537
|
+
animations?: readonly FeatureAnimation[]; /** Interface components the feature owns, read by `ui`. `components` stays the ECS key. */
|
|
2538
|
+
ui?: readonly FeatureComponent[]; /** Compiled messages per locale, ready or lazy, read by `i18n`. */
|
|
2539
|
+
strings?: Record<string, CompiledMessagesLike | (() => Promise<unknown>)>; /** Text styles the feature owns, read by `text`. */
|
|
2540
|
+
textStyles?: FeatureTextStyles;
|
|
2541
|
+
[later: string]: unknown;
|
|
2542
|
+
};
|
|
2543
|
+
/**
|
|
2544
|
+
* One sub-flow contributed to a slot.
|
|
2545
|
+
*
|
|
2546
|
+
* @example
|
|
2547
|
+
* ```ts
|
|
2548
|
+
* const contribution: Contribution = { feature: "reward", flow: rewardFlow, order: 10 };
|
|
2549
|
+
* ```
|
|
2550
|
+
*/
|
|
2551
|
+
type Contribution = {
|
|
2552
|
+
feature: string;
|
|
2553
|
+
flow: AnyFlow;
|
|
2554
|
+
order: number;
|
|
2555
|
+
when?: (snapshot: Snapshot) => boolean;
|
|
2556
|
+
};
|
|
2557
|
+
/**
|
|
2558
|
+
* features module state.
|
|
2559
|
+
*/
|
|
2560
|
+
type FeaturesState = {
|
|
2561
|
+
byName: Map<string, FeatureDescription>; /** True after `run()`: `register` throws. */
|
|
2562
|
+
sealed: boolean;
|
|
2563
|
+
};
|
|
2564
|
+
/**
|
|
2565
|
+
* features module API, `app.flow.features`: the registry every feature plugin writes itself into.
|
|
2566
|
+
*
|
|
2567
|
+
* @example
|
|
2568
|
+
* ```ts
|
|
2569
|
+
* // The game composed `rewardFeature`. A plugin above asks in its onStart what the game brought.
|
|
2570
|
+
* app.flow.features.all().map(feature => feature.name); // ["reward"]
|
|
2571
|
+
* ```
|
|
2572
|
+
*/
|
|
2573
|
+
type FeaturesApi = {
|
|
2574
|
+
/**
|
|
2575
|
+
* Records what a feature brings. Called from the feature plugin's `onInit`, so every feature
|
|
2576
|
+
* is known before the graph is validated.
|
|
2577
|
+
*
|
|
2578
|
+
* @param name - Feature name. Shares the namespace with plugin names.
|
|
2579
|
+
* @param description - Nodes, flows and slot contributions of the feature.
|
|
2580
|
+
* @throws {Error} After `run()` sealed the registry, and for a duplicate name.
|
|
2581
|
+
* @example
|
|
2582
|
+
* ```ts
|
|
2583
|
+
* // A hand-written feature plugin registers itself in onInit. `defineFeature` does the same.
|
|
2584
|
+
* const rewardPlugin = createPlugin("reward", {
|
|
2585
|
+
* depends: [flowPlugin],
|
|
2586
|
+
* onInit: ctx => ctx.require(flowPlugin).features.register("reward", { flows: [rewardFlow] })
|
|
2587
|
+
* });
|
|
2588
|
+
* ```
|
|
2589
|
+
*/
|
|
2590
|
+
register(name: string, description: FeatureDescription): void;
|
|
2591
|
+
/**
|
|
2592
|
+
* Lists every registered feature in registration order. Plugins above read it in their
|
|
2593
|
+
* `onStart` to pick up what they own.
|
|
2594
|
+
*
|
|
2595
|
+
* @returns A fresh list of name and description.
|
|
2596
|
+
* @example
|
|
2597
|
+
* ```ts
|
|
2598
|
+
* // A plugin above collects the flows of every feature.
|
|
2599
|
+
* app.flow.features.all(); // [{ name: "reward", description: { flows: [rewardFlow] } }]
|
|
2600
|
+
* ```
|
|
2601
|
+
*/
|
|
2602
|
+
all(): readonly {
|
|
2603
|
+
name: string;
|
|
2604
|
+
description: FeatureDescription;
|
|
2605
|
+
}[];
|
|
2606
|
+
/**
|
|
2607
|
+
* Lists the sub-flows contributed to one slot, lowest `order` first. Two equal orders in one
|
|
2608
|
+
* slot stay in registration order here and are reported by `validate`.
|
|
2609
|
+
*
|
|
2610
|
+
* @param slotName - Name of the slot node.
|
|
2611
|
+
* @returns The contributions of that slot, sorted by `order`.
|
|
2612
|
+
* @example
|
|
2613
|
+
* ```ts
|
|
2614
|
+
* // What runs when the graph enters slot("afterOrder").
|
|
2615
|
+
* app.flow.features.contributions("afterOrder");
|
|
2616
|
+
* // [{ feature: "reward", flow: rewardFlow, order: 10 }]
|
|
2617
|
+
* app.flow.features.contributions("afterLoss"); // []: no feature contributes to this slot
|
|
2618
|
+
* ```
|
|
2619
|
+
*/
|
|
2620
|
+
contributions(slotName: string): readonly Contribution[];
|
|
2621
|
+
};
|
|
2622
|
+
//#endregion
|
|
2623
|
+
//#region src/plugins/flow/runner/define.d.ts
|
|
2624
|
+
/**
|
|
2625
|
+
* Creates a type tag: a value that carries only a payload type. Without a type argument the
|
|
2626
|
+
* payload is `void`. The tag never infers its type from the place it is written in.
|
|
2627
|
+
*
|
|
2628
|
+
* @returns A tag that carries the payload type and no value.
|
|
2629
|
+
* @example
|
|
2630
|
+
* ```ts
|
|
2631
|
+
* // The outcomes of a node: `done` carries nothing, `orderComplete` carries the reward id.
|
|
2632
|
+
* const outcomes = { done: type(), orderComplete: type<{ rewardId: string }>() };
|
|
2633
|
+
* // outcomes.done: { kind: "type" }. The payload type exists for the compiler only.
|
|
2634
|
+
* ```
|
|
2635
|
+
*/
|
|
2636
|
+
declare function type<Payload = void>(): TypeTag<NoInfer<Payload>>;
|
|
2637
|
+
/**
|
|
2638
|
+
* Defines a flow. The edge table is inferred as written and checked entry by entry: a missing
|
|
2639
|
+
* edge, an unknown target and a payload that does not fit are compile errors with a sentence.
|
|
2640
|
+
*
|
|
2641
|
+
* @param id - Flow id, unique in the game.
|
|
2642
|
+
* @param spec - Nodes, start node, edge table, and input and outcomes when used as a node.
|
|
2643
|
+
* @returns The flow as plain data.
|
|
2644
|
+
* @example
|
|
2645
|
+
* ```ts
|
|
2646
|
+
* // The main flow of a dice game. A missing edge or an unknown target is a compile error.
|
|
2647
|
+
* const mainFlow = defineFlow("main", {
|
|
2648
|
+
* nodes: { home, roll },
|
|
2649
|
+
* start: "home",
|
|
2650
|
+
* edges: { home: { roll: "roll" }, roll: { done: "home" } }
|
|
2651
|
+
* });
|
|
2652
|
+
* ```
|
|
2653
|
+
*/
|
|
2654
|
+
declare function defineFlow<Nodes extends NodeTable, Input = void, const FlowTags extends OutcomeTags = Record<never, never>, const Table = Record<never, never>>(id: string, spec: FlowSpec<Nodes, Input, FlowTags, Table>): FlowDefinition<Input, FlowTags, Nodes>;
|
|
2655
|
+
/**
|
|
2656
|
+
* Edge target: leave the sub-flow with this outcome. The payload passes through to the parent.
|
|
2657
|
+
*
|
|
2658
|
+
* @param outcome - Outcome declared by the flow.
|
|
2659
|
+
* @returns The exit target as plain data.
|
|
2660
|
+
* @example
|
|
2661
|
+
* ```ts
|
|
2662
|
+
* // Inside the sub-flow "board": the intent "leave" ends the flow with its outcome "left".
|
|
2663
|
+
* edges: { awaitIntent: { leave: exit("left") } } // the target: { kind: "exit", outcome: "left" }
|
|
2664
|
+
* // The parent flow then follows its own edge: board: { left: "home" }.
|
|
2665
|
+
* ```
|
|
2666
|
+
*/
|
|
2667
|
+
declare function exit<const Name extends string>(outcome: Name): Exit<Name>;
|
|
2668
|
+
/**
|
|
2669
|
+
* Edge target: adapt the payload on the edge. The mapper's parameter is annotated by the author;
|
|
2670
|
+
* the annotation is checked against the outcome payload, the result against the target's input.
|
|
2671
|
+
*
|
|
2672
|
+
* @param target - Name of a node of the same flow.
|
|
2673
|
+
* @param map - Pure function from the outcome payload to the target's input.
|
|
2674
|
+
* @returns The mapped target as plain data.
|
|
2675
|
+
* @example
|
|
2676
|
+
* ```ts
|
|
2677
|
+
* // "loadCore" fails with { reason }, "retry" takes { why }: the edge adapts the payload.
|
|
2678
|
+
* edges: { loadCore: { failed: to("retry", (failure: { reason: string }) => ({ why: failure.reason })) } }
|
|
2679
|
+
* // to("retry", map): { kind: "map", target: "retry", map }
|
|
2680
|
+
* ```
|
|
2681
|
+
*/
|
|
2682
|
+
declare function to<const TargetName extends string, Payload, Output>(target: TargetName, map: (payload: Payload) => Output): Mapped<TargetName, Payload, Output>;
|
|
2683
|
+
/**
|
|
2684
|
+
* Defines an extension point: a node whose body is "run the contributions of this slot in order".
|
|
2685
|
+
* Its single outcome is `done`.
|
|
2686
|
+
*
|
|
2687
|
+
* @param name - Slot name features contribute to.
|
|
2688
|
+
* @returns The slot node as plain data.
|
|
2689
|
+
* @example
|
|
2690
|
+
* ```ts
|
|
2691
|
+
* // The main flow leaves room after a finished order. Features contribute sub-flows to it.
|
|
2692
|
+
* nodes: { boot, home, board: boardFlow, afterOrder: slot("afterOrder") }
|
|
2693
|
+
* // slot("afterOrder"): { kind: "slot", name: "afterOrder", input: …, outcomes: { done: … } }
|
|
2694
|
+
* ```
|
|
2695
|
+
*/
|
|
2696
|
+
declare function slot(name: string): SlotNode;
|
|
2697
|
+
declare namespace types_d_exports {
|
|
2698
|
+
export { Allow, Answer, AnyFlow, AnyNode, AnyNodeContext, Api, Bookmark, BundlesOf, CheckedEdges, Config, Contribution, DefineNode, Deps, Descriptor, Edges, EnterCallback, Events, Exit, FeatureDescription, FeaturePlugin, FeaturesApi, FlowCtx, FlowDefinition, FlowGraph, FlowKit, FlowSpec, FlowState, FxApi, FxHandler, GameState, GameTypes, GateApi, GraphError, GraphNode, GuideOptions, Hint, InboxApi, JournalEntry, KernelSlice, LifecycleChanged, Mapped, NodeContext, NodeDefinition, NodeFx, NodeInfo, NodeSpec, OutcomeTags, Position, Result, RouteStep, RunnerApi, SceneIdOf, SlotNode, Stage, State, Target, TextStylesOf, TypeTag, WorldEvent };
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* flow plugin events.
|
|
2702
|
+
*
|
|
2703
|
+
* @example
|
|
2704
|
+
* ```ts
|
|
2705
|
+
* // A plugin that depends on flowPlugin logs every edge the player takes.
|
|
2706
|
+
* createPlugin("edgeLog", {
|
|
2707
|
+
* depends: [flowPlugin],
|
|
2708
|
+
* hooks: ctx => ({ "flow:edge": ({ node, outcome }) => ctx.log.info("edge", { node, outcome }) })
|
|
2709
|
+
* }); // the play button on "home" logs { node: "home", outcome: "play" }
|
|
2710
|
+
* ```
|
|
2711
|
+
*/
|
|
2712
|
+
type Events = {
|
|
2713
|
+
/** An edge was taken and its state committed. */"flow:edge": {
|
|
2714
|
+
flow: string;
|
|
2715
|
+
node: string;
|
|
2716
|
+
outcome: string;
|
|
2717
|
+
payload: Json;
|
|
2718
|
+
next: string;
|
|
2719
|
+
patches: {
|
|
2720
|
+
doc: Patch[];
|
|
2721
|
+
session: Patch[];
|
|
2722
|
+
};
|
|
2723
|
+
index: number;
|
|
2724
|
+
now: number;
|
|
2725
|
+
}; /** The graph reached a rest node. */
|
|
2726
|
+
"flow:rest": {
|
|
2727
|
+
path: string;
|
|
2728
|
+
checkpoint: boolean;
|
|
2729
|
+
}; /** A node failed and the graph rolled back. */
|
|
2730
|
+
"flow:error": {
|
|
2731
|
+
path: string;
|
|
2732
|
+
error: unknown;
|
|
2733
|
+
rolledBackTo: string;
|
|
2734
|
+
retry: boolean;
|
|
2735
|
+
};
|
|
2736
|
+
};
|
|
2737
|
+
/**
|
|
2738
|
+
* flow plugin config.
|
|
2739
|
+
*
|
|
2740
|
+
* @example
|
|
2741
|
+
* ```ts
|
|
2742
|
+
* createApp({ pluginConfigs: { flow: { mainFlow, safeNode: "home" } } });
|
|
2743
|
+
* ```
|
|
2744
|
+
*/
|
|
2745
|
+
type Config = {
|
|
2746
|
+
/** The top-level flow. Required before `run()`. */mainFlow: AnyFlow | undefined; /** Path of the checkpoint entered after a failed retry. `undefined`: the main flow's `start`. */
|
|
2747
|
+
safeNode: string | undefined; /** Retries of a failed transition before going to `safeNode`. */
|
|
2748
|
+
retries: number; /** How long `onStop` waits for the active node to settle after abort, in real milliseconds. */
|
|
2749
|
+
settleTimeoutMs: number; /** Journal entries kept between checkpoints. */
|
|
2750
|
+
journalLimit: number;
|
|
2751
|
+
};
|
|
2752
|
+
/**
|
|
2753
|
+
* flow plugin state: one branch per module.
|
|
2754
|
+
*/
|
|
2755
|
+
type State = {
|
|
2756
|
+
features: FeaturesState;
|
|
2757
|
+
fx: FxState;
|
|
2758
|
+
gate: GateState;
|
|
2759
|
+
inbox: InboxState;
|
|
2760
|
+
runner: RunnerState;
|
|
2761
|
+
};
|
|
2762
|
+
/**
|
|
2763
|
+
* flow plugin API, `app.flow`: the runner on the root, the other modules grouped.
|
|
2764
|
+
*
|
|
2765
|
+
* @example
|
|
2766
|
+
* ```ts
|
|
2767
|
+
* // The game starts the graph once. After that only answers and world events move it.
|
|
2768
|
+
* app.flow.run().catch(showFatal);
|
|
2769
|
+
*
|
|
2770
|
+
* app.flow.gate.answer({ intent: "play" }); // the play button: true when "home" rests
|
|
2771
|
+
* app.flow.inbox.post({ type: "purchased" }); // the shop SDK: waits for a node that lists it
|
|
2772
|
+
* ```
|
|
2773
|
+
*/
|
|
2774
|
+
type Api = RunnerApi & {
|
|
2775
|
+
gate: GateApi;
|
|
2776
|
+
inbox: InboxApi;
|
|
2777
|
+
fx: FxApi;
|
|
2778
|
+
features: FeaturesApi;
|
|
2779
|
+
};
|
|
2780
|
+
/**
|
|
2781
|
+
* The APIs flow requires from below (`time`, `model`, `clock`), resolved once in `onInit` and
|
|
2782
|
+
* shared by the runner, gate, inbox and fx.
|
|
2783
|
+
*/
|
|
2784
|
+
type Deps = {
|
|
2785
|
+
time: Api$1;
|
|
2786
|
+
model: Api$2;
|
|
2787
|
+
clock: Api$4;
|
|
2788
|
+
};
|
|
2789
|
+
/**
|
|
2790
|
+
* What the kernel context offers before the deps are attached. `emit` is the kernel's: `index.ts`
|
|
2791
|
+
* writes `events` with an annotated `register` (core spec `14` row 8), so the own events reach a
|
|
2792
|
+
* factory passed by direct reference (`api`, `hooks`, `onInit`, `onStart`).
|
|
2793
|
+
*/
|
|
2794
|
+
type KernelSlice = PluginCtx<Config, State, Events> & {
|
|
2795
|
+
readonly global: object;
|
|
2796
|
+
readonly log: Log.LogApi;
|
|
2797
|
+
readonly require: Require;
|
|
2798
|
+
};
|
|
2799
|
+
/**
|
|
2800
|
+
* Domain context shared by the modules.
|
|
2801
|
+
*/
|
|
2802
|
+
type FlowCtx = KernelSlice & {
|
|
2803
|
+
readonly deps: Deps;
|
|
2804
|
+
};
|
|
2805
|
+
/**
|
|
2806
|
+
* Payload of the one event flow listens to.
|
|
2807
|
+
*/
|
|
2808
|
+
type LifecycleChanged = Events$2["lifecycle:changed"];
|
|
2809
|
+
/**
|
|
2810
|
+
* The types of one game. `player` and `session` type the node context; `assets` and `bundles`
|
|
2811
|
+
* are the key unions the asset scanner generates (`string` while a game has none); `strings`
|
|
2812
|
+
* is used from V3. Every plugin binds its own helpers to them through its `…For` binder.
|
|
2813
|
+
*
|
|
2814
|
+
* @example
|
|
2815
|
+
* ```ts
|
|
2816
|
+
* type Types = { player: Player; session: Session; assets: AssetKey; bundles: BundleKey; strings: StringTable };
|
|
2817
|
+
* ```
|
|
2818
|
+
*/
|
|
2819
|
+
type GameTypes = {
|
|
2820
|
+
player: Json;
|
|
2821
|
+
session: Json;
|
|
2822
|
+
assets: string;
|
|
2823
|
+
bundles?: string;
|
|
2824
|
+
scenes?: string;
|
|
2825
|
+
strings: Record<string, unknown>;
|
|
2826
|
+
textStyles?: string;
|
|
2827
|
+
};
|
|
2828
|
+
/**
|
|
2829
|
+
* The text style key union of a game, `string` when the game passed none.
|
|
2830
|
+
*
|
|
2831
|
+
* @example
|
|
2832
|
+
* ```ts
|
|
2833
|
+
* type Keys = TextStylesOf<{ player: {}; session: {}; assets: string; strings: {}; textStyles: "hud.digits" }>; // "hud.digits"
|
|
2834
|
+
* ```
|
|
2835
|
+
*/
|
|
2836
|
+
type TextStylesOf<Types extends GameTypes> = Types extends {
|
|
2837
|
+
textStyles: infer Keys extends string;
|
|
2838
|
+
} ? Keys : string;
|
|
2839
|
+
/**
|
|
2840
|
+
* The bundle key union of a game, `string` when the game passed none.
|
|
2841
|
+
*
|
|
2842
|
+
* @example
|
|
2843
|
+
* ```ts
|
|
2844
|
+
* type Keys = BundlesOf<{ player: {}; session: {}; assets: string; bundles: "board"; strings: {} }>; // "board"
|
|
2845
|
+
* ```
|
|
2846
|
+
*/
|
|
2847
|
+
type BundlesOf<Types extends GameTypes> = Types extends {
|
|
2848
|
+
bundles: infer Keys extends string;
|
|
2849
|
+
} ? Keys : string;
|
|
2850
|
+
/**
|
|
2851
|
+
* A feature is an ordinary plugin with no API of its own; `AnyPluginInstance` is the kernel's
|
|
2852
|
+
* widened type for plugin lists. `logicOnly` is the same plugin reduced to the V1 keys.
|
|
2853
|
+
*
|
|
2854
|
+
* @example
|
|
2855
|
+
* ```ts
|
|
2856
|
+
* createApp({ plugins: [boardFeature.logicOnly] });
|
|
2857
|
+
* ```
|
|
2858
|
+
*/
|
|
2859
|
+
type FeaturePlugin = AnyPluginInstance & {
|
|
2860
|
+
readonly logicOnly: AnyPluginInstance;
|
|
2861
|
+
};
|
|
2862
|
+
/**
|
|
2863
|
+
* What `flowFor` binds to a game: the node helper with the game's `player` and `session`, and
|
|
2864
|
+
* the flow and feature helpers, which take no game types.
|
|
2865
|
+
*
|
|
2866
|
+
* @example
|
|
2867
|
+
* ```ts
|
|
2868
|
+
* const { defineNode } = flowFor<{ player: Player; session: Session }>();
|
|
2869
|
+
* defineNode({ rest: true, outcomes: { play: type() }, run: ({ player }) => player.coins }); // player: Player
|
|
2870
|
+
* ```
|
|
2871
|
+
*/
|
|
2872
|
+
type FlowKit<Game extends GameState> = {
|
|
2873
|
+
defineNode: DefineNode<Game>;
|
|
2874
|
+
defineFlow: typeof defineFlow;
|
|
2875
|
+
defineFeature: (name: string, description: FeatureDescription) => FeaturePlugin;
|
|
2876
|
+
};
|
|
2877
|
+
/**
|
|
2878
|
+
* Where the graph rests, as the `game.position` source reads it: the path, the flow and node on
|
|
2879
|
+
* top of the stack, and the intents the open gate waits for.
|
|
2880
|
+
*
|
|
2881
|
+
* @example
|
|
2882
|
+
* ```ts
|
|
2883
|
+
* // The board rests and waits for a tap or for leaving.
|
|
2884
|
+
* const position: Position = {
|
|
2885
|
+
* path: "board/awaitIntent", flow: "board", node: "awaitIntent", waiting: ["tap", "leave"]
|
|
2886
|
+
* };
|
|
2887
|
+
* ```
|
|
2888
|
+
*/
|
|
2889
|
+
type Position = {
|
|
2890
|
+
path: string;
|
|
2891
|
+
flow: string | undefined;
|
|
2892
|
+
node: string | undefined;
|
|
2893
|
+
waiting: readonly string[];
|
|
2894
|
+
};
|
|
2895
|
+
//#endregion
|
|
2896
|
+
export { PauseReason as $, Config$1 as A, types_d_exports$2 as B, SceneIdOf as C, Hint as D, GuideOptions as E, Config$2 as F, SaveUnreadableError as G, PlayerStateProvider as H, Events$1 as I, RngApi as J, Snapshot as K, Json as L, Time as M, types_d_exports$1 as N, Answer as O, Api$2 as P, Events$2 as Q, Root as R, RouteStep as S, Descriptor as T, ProviderCall as U, Patch as V, SaveDoc as W, Api$3 as X, RngState as Y, Config$3 as Z, DefineNode as _, GameTypes as a, State$4 as at, JournalEntry as b, TextStylesOf as c, Events$3 as ct, exit as d, State$3 as et, slot as f, Bookmark as g, FeatureDescription as h, FeaturePlugin as i, FakeClock as it, State$1 as j, Api$1 as k, types_d_exports as l, Require as lt, type as m, BundlesOf as n, Api$4 as nt, Position as o, types_d_exports$4 as ot, to as p, StoreApi as q, Config as r, Config$4 as rt, State as s, Config$5 as st, Api as t, types_d_exports$3 as tt, defineFlow as u, FlowGraph as v, TypeTag as w, NodeInfo as x, FlowState as y, State$2 as z };
|