@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
package/dist/control.mjs
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import { n as reproBookmark } from "./headless-CvamCUcR.mjs";
|
|
2
|
+
import { a as defineCommand, i as inputOrEmpty, n as isTainted, r as recordCheat } from "./session-DmAxY6Ll.mjs";
|
|
3
|
+
//#region src/plugins/flow/doors/dev.ts
|
|
4
|
+
/**
|
|
5
|
+
* Reads the dev flag at call time. Undefined means off: production is the default.
|
|
6
|
+
*
|
|
7
|
+
* @returns True only when `__MOKU_GAME_DEV__` is exactly `true`.
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // The dev page set the flag in web/dev.ts before the engine started.
|
|
11
|
+
* globalThis.__MOKU_GAME_DEV__ = true;
|
|
12
|
+
* isDev(); // true
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
function isDev() {
|
|
16
|
+
return typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ === true;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Builds the error a control command throws outside a dev build.
|
|
20
|
+
*
|
|
21
|
+
* @returns The error, ready to throw.
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // A production build called a /control command.
|
|
25
|
+
* controlRefused().message; // "[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build."
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
function controlRefused() {
|
|
29
|
+
return /* @__PURE__ */ new Error("[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build.");
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/plugins/anim/control.ts
|
|
33
|
+
/**
|
|
34
|
+
* @file anim plugin — the anim command of the `/control` door: switch reduced motion. Dev builds
|
|
35
|
+
* only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
|
|
36
|
+
* logs the `moku:dev` marker.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Switches reduced motion on or off, the way the player's "Less motion" setting does. Answers
|
|
40
|
+
* the switch as it is afterwards.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* // Check the popups of the settings screen with no motion at all.
|
|
45
|
+
* (await run(app, commands.reducedMotion, { on: true })).value; // true
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
const reducedMotionCommand = defineCommand({
|
|
49
|
+
id: "game.reducedMotion",
|
|
50
|
+
title: "Reduced motion",
|
|
51
|
+
input: { on: "boolean" },
|
|
52
|
+
effect: "cosmetic",
|
|
53
|
+
run: (app, { on }) => {
|
|
54
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
55
|
+
app.log.debug("moku:dev", {
|
|
56
|
+
command: "game.reducedMotion",
|
|
57
|
+
on
|
|
58
|
+
});
|
|
59
|
+
app.anim.setReducedMotion(on);
|
|
60
|
+
return app.anim.reducedMotion();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/plugins/input/control.ts
|
|
65
|
+
/**
|
|
66
|
+
* @file input plugin — the input commands of the `/control` door: a drag from one view onto
|
|
67
|
+
* another and a key press. Each goes through `app.input`, so the gate decides and the session
|
|
68
|
+
* stays clean. Dev builds only: every body starts with the inline dev guard, so a bundler
|
|
69
|
+
* `define` of `false` drops it, and logs the `moku:dev` marker. `readTarget` is shared with the
|
|
70
|
+
* `game.tap` command of ui, which taps a view by the same projection key.
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* Tells whether the JSON a command got names a view by its projection key.
|
|
74
|
+
*
|
|
75
|
+
* @param value - The JSON.
|
|
76
|
+
* @returns True for an object with a string `projection` and a string `key`.
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* isProjectionTarget({ projection: "board.items", key: "i5" }); // true
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
function isProjectionTarget(value) {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.projection === "string" && typeof value.key === "string";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Reads the projection key of a view out of the JSON a command got.
|
|
87
|
+
*
|
|
88
|
+
* @param value - The JSON: `{ projection, key }`.
|
|
89
|
+
* @returns The target, a fresh object.
|
|
90
|
+
* @throws {Error} When the JSON is not an object with a string `projection` and a string `key`.
|
|
91
|
+
*/
|
|
92
|
+
function readTarget(value) {
|
|
93
|
+
if (isProjectionTarget(value)) return {
|
|
94
|
+
projection: value.projection,
|
|
95
|
+
key: value.key
|
|
96
|
+
};
|
|
97
|
+
throw new Error("[game] The target is not a projection key.\n Pass a view like { projection: \"board.items\", key: \"i5\" }.");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drags one view onto another, both by their projection keys. Answers what `input.drag`
|
|
101
|
+
* answers: whether the gate took the answer.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* // Merge two logs of level 1 on the board.
|
|
106
|
+
* const from = { projection: "board.items", key: "i5" };
|
|
107
|
+
* (await run(app, commands.drag, { from, to: { projection: "board.items", key: "i7" } })).value; // true
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
const dragCommand = defineCommand({
|
|
111
|
+
id: "game.drag",
|
|
112
|
+
title: "Drag",
|
|
113
|
+
input: {
|
|
114
|
+
from: "json",
|
|
115
|
+
to: "json"
|
|
116
|
+
},
|
|
117
|
+
effect: "route",
|
|
118
|
+
run: (app, { from, to }) => {
|
|
119
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
120
|
+
app.log.debug("moku:dev", { command: "game.drag" });
|
|
121
|
+
return app.input.drag(readTarget(from), readTarget(to));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Presses a key without a keyboard, with or without Shift. Answers whether a listener handled
|
|
126
|
+
* it.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```ts
|
|
130
|
+
* // Close the settings popup the way Escape does.
|
|
131
|
+
* (await run(app, commands.key, { key: "Escape" })).value; // true
|
|
132
|
+
* (await run(app, commands.key, { key: "Tab", shift: true })).value; // true: the focus moved back
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
const keyCommand = defineCommand({
|
|
136
|
+
id: "game.key",
|
|
137
|
+
title: "Press a key",
|
|
138
|
+
input: {
|
|
139
|
+
key: "string",
|
|
140
|
+
shift: "boolean?"
|
|
141
|
+
},
|
|
142
|
+
effect: "route",
|
|
143
|
+
run: (app, { key, shift = false }) => {
|
|
144
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
145
|
+
app.log.debug("moku:dev", {
|
|
146
|
+
command: "game.key",
|
|
147
|
+
key
|
|
148
|
+
});
|
|
149
|
+
return app.input.pressKey(key, { shift });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/plugins/lifecycle/control.ts
|
|
154
|
+
/**
|
|
155
|
+
* @file lifecycle plugin — the lifecycle commands of the `/control` door: pause and resume the
|
|
156
|
+
* game for the editor, through the `devtools` pause reason, so another reason keeps its hold.
|
|
157
|
+
* Dev builds only: every body starts with the inline dev guard, so a bundler `define` of `false`
|
|
158
|
+
* drops it, and logs the `moku:dev` marker.
|
|
159
|
+
*/
|
|
160
|
+
/**
|
|
161
|
+
* Pauses the game with the pause reason `devtools`. Answers whether the game is paused.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* // The editor's pause button: the world stands, and `game.step` moves it frame by frame.
|
|
166
|
+
* (await run(app, commands.pause)).value; // true
|
|
167
|
+
* app.lifecycle.reasons(); // ["devtools"]
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
const pauseCommand = defineCommand({
|
|
171
|
+
id: "game.pause",
|
|
172
|
+
title: "Pause",
|
|
173
|
+
input: {},
|
|
174
|
+
effect: "cosmetic",
|
|
175
|
+
run: (app) => {
|
|
176
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
177
|
+
app.log.debug("moku:dev", { command: "game.pause" });
|
|
178
|
+
app.lifecycle.push("devtools");
|
|
179
|
+
return app.lifecycle.isPaused();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
* Takes the pause reason `devtools` back. Answers whether the game is still paused: another
|
|
184
|
+
* reason, such as a hidden tab, keeps its hold.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```ts
|
|
188
|
+
* // The editor's play button while the tab is visible.
|
|
189
|
+
* (await run(app, commands.resume)).value; // false: the game runs again
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
const resumeCommand = defineCommand({
|
|
193
|
+
id: "game.resume",
|
|
194
|
+
title: "Resume",
|
|
195
|
+
input: {},
|
|
196
|
+
effect: "cosmetic",
|
|
197
|
+
run: (app) => {
|
|
198
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
199
|
+
app.log.debug("moku:dev", { command: "game.resume" });
|
|
200
|
+
app.lifecycle.pop("devtools");
|
|
201
|
+
return app.lifecycle.isPaused();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/plugins/renderer/control.ts
|
|
206
|
+
/**
|
|
207
|
+
* @file renderer plugin — the renderer commands of the `/control` door: take a picture of the
|
|
208
|
+
* canvas and switch the debug drawing. Dev builds only: every body starts with the inline dev
|
|
209
|
+
* guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker.
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* A PNG of the whole canvas taken at the end of the next drawn frame, as a data URL.
|
|
213
|
+
* `undefined` while the renderer is inert, as in a headless test.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* // The editor attaches the screen to a bug report.
|
|
218
|
+
* (await run(app, commands.capture)).value; // "data:image/png;base64,iVBORw0KGgo…"
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
const captureCommand = defineCommand({
|
|
222
|
+
id: "game.capture",
|
|
223
|
+
title: "Capture",
|
|
224
|
+
input: {},
|
|
225
|
+
effect: "read",
|
|
226
|
+
run: (app) => {
|
|
227
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
228
|
+
app.log.debug("moku:dev", { command: "game.capture" });
|
|
229
|
+
return app.renderer.capture();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
/**
|
|
233
|
+
* Switches the debug drawing: `nineSlice` outlines every nine-slice and the lines Pixi cuts its
|
|
234
|
+
* texture at. Answers the switches as they are afterwards.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* // Check where the settings popup cuts its parchment.
|
|
239
|
+
* (await run(app, commands.debug, { nineSlice: true })).value; // { nineSlice: true }
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
const debugCommand = defineCommand({
|
|
243
|
+
id: "game.debug",
|
|
244
|
+
title: "Debug drawing",
|
|
245
|
+
input: { nineSlice: "boolean" },
|
|
246
|
+
effect: "cosmetic",
|
|
247
|
+
run: (app, { nineSlice }) => {
|
|
248
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
249
|
+
app.log.debug("moku:dev", {
|
|
250
|
+
command: "game.debug",
|
|
251
|
+
nineSlice
|
|
252
|
+
});
|
|
253
|
+
app.renderer.sync.debug.nineSlice(nineSlice);
|
|
254
|
+
return app.renderer.sync.debug.state();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/plugins/time/control.ts
|
|
259
|
+
/**
|
|
260
|
+
* @file time plugin — the time command of the `/control` door: step frames by hand. Dev builds
|
|
261
|
+
* only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
|
|
262
|
+
* logs the `moku:dev` marker.
|
|
263
|
+
*/
|
|
264
|
+
/** The length of one frame at 60 fps, the step a frame count takes by default. */
|
|
265
|
+
const FRAME_MS = 1e3 / 60;
|
|
266
|
+
/**
|
|
267
|
+
* Refuses a frame count that is not a whole number of zero or more.
|
|
268
|
+
*
|
|
269
|
+
* @param frames - The frame count.
|
|
270
|
+
* @throws {Error} When it is negative, fractional or not finite.
|
|
271
|
+
*/
|
|
272
|
+
function checkFrames(frames) {
|
|
273
|
+
if (Number.isInteger(frames) && frames >= 0) return;
|
|
274
|
+
throw new Error(`[game] game.step takes a whole number of frames.\n Pass 0 or a positive whole number, not ${String(frames)}.`);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Runs frames by hand, one `time.step` each, also while the game is paused, and answers the time
|
|
278
|
+
* after them. A frame lasts 1000/60 ms unless `deltaMs` says otherwise.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* // The editor paused the game on a merge; step three frames to watch the item land.
|
|
283
|
+
* const ran = await run(app, commands.step, { frames: 3 });
|
|
284
|
+
* ran.value; // { delta: 16.666…, elapsed: 50, scale: 1, frame: 3, idle: false }
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
const stepCommand = defineCommand({
|
|
288
|
+
id: "game.step",
|
|
289
|
+
title: "Step frames",
|
|
290
|
+
input: {
|
|
291
|
+
frames: "number",
|
|
292
|
+
deltaMs: "number?"
|
|
293
|
+
},
|
|
294
|
+
effect: "cosmetic",
|
|
295
|
+
run: (app, { frames, deltaMs = FRAME_MS }) => {
|
|
296
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
297
|
+
app.log.debug("moku:dev", {
|
|
298
|
+
command: "game.step",
|
|
299
|
+
frames
|
|
300
|
+
});
|
|
301
|
+
checkFrames(frames);
|
|
302
|
+
for (let frame = 0; frame < frames; frame += 1) app.time.step(deltaMs);
|
|
303
|
+
return app.time.snapshot();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/plugins/ui/control.ts
|
|
308
|
+
/**
|
|
309
|
+
* @file ui plugin — the ui command of the `/control` door: a tap on a keyed element or on a
|
|
310
|
+
* view. It goes through `app.input`, so the gate decides and the session stays clean. Dev builds
|
|
311
|
+
* only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it,
|
|
312
|
+
* and logs the `moku:dev` marker.
|
|
313
|
+
*/
|
|
314
|
+
/**
|
|
315
|
+
* Finds the entity of a keyed ui element on screen.
|
|
316
|
+
*
|
|
317
|
+
* @param ui - The ui API.
|
|
318
|
+
* @param key - The `key` prop of the element.
|
|
319
|
+
* @returns The entity.
|
|
320
|
+
* @throws {Error} When no element with the key is on screen.
|
|
321
|
+
*/
|
|
322
|
+
function elementOf(ui, key) {
|
|
323
|
+
const entity = ui.find(key);
|
|
324
|
+
if (entity !== void 0) return entity;
|
|
325
|
+
throw new Error(`[game] No element with the key "${key}" is on screen.\n Read sources.ui for the keys on screen.`);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Taps a ui element by its key, or a view by its projection key: exactly one of the two. Answers
|
|
329
|
+
* what `input.tap` answers: whether the gate took the answer.
|
|
330
|
+
*
|
|
331
|
+
* @example
|
|
332
|
+
* ```ts
|
|
333
|
+
* // Home rests: tap the Play plank, then the first generator of the board.
|
|
334
|
+
* (await run(app, commands.tap, { key: "play" })).value; // true
|
|
335
|
+
* await run(app, commands.tap, { target: { projection: "board.generators", key: "g1" } });
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
const tapCommand = defineCommand({
|
|
339
|
+
id: "game.tap",
|
|
340
|
+
title: "Tap",
|
|
341
|
+
input: {
|
|
342
|
+
key: "string?",
|
|
343
|
+
target: "json?"
|
|
344
|
+
},
|
|
345
|
+
effect: "route",
|
|
346
|
+
run: (app, { key, target }) => {
|
|
347
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
348
|
+
app.log.debug("moku:dev", {
|
|
349
|
+
command: "game.tap",
|
|
350
|
+
key
|
|
351
|
+
});
|
|
352
|
+
if (key !== void 0 && target === void 0) return app.input.tap(elementOf(app.ui, key));
|
|
353
|
+
if (target !== void 0 && key === void 0) return app.input.tap(readTarget(target));
|
|
354
|
+
throw new Error("[game] game.tap takes a key or a target.\n Pass exactly one of { key } and { target }.");
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/plugins/flow/json.ts
|
|
359
|
+
/**
|
|
360
|
+
* Tells whether a JSON value is an object.
|
|
361
|
+
*
|
|
362
|
+
* @param value - The value.
|
|
363
|
+
* @returns True for an object that is neither null nor an array.
|
|
364
|
+
* @example
|
|
365
|
+
* ```ts
|
|
366
|
+
* isRecord({ at: "home" }); // true
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
function isRecord(value) {
|
|
370
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Builds the error of a route that is not a list of steps.
|
|
374
|
+
*
|
|
375
|
+
* @returns The error, ready to throw.
|
|
376
|
+
* @example
|
|
377
|
+
* ```ts
|
|
378
|
+
* invalidRoute().message.startsWith("[game] The route is not a list of route steps."); // true
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
function invalidRoute() {
|
|
382
|
+
return /* @__PURE__ */ new Error("[game] The route is not a list of route steps.\n Pass steps like { at: \"home\", intent: \"play\" } or { at: \"board\", result: { outcome: \"left\" } }.");
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Reads the result of a skipped sub-flow.
|
|
386
|
+
*
|
|
387
|
+
* @param result - The `result` field of a step.
|
|
388
|
+
* @returns The outcome and its payload, if any.
|
|
389
|
+
* @throws {Error} When the result has no outcome name.
|
|
390
|
+
* @example
|
|
391
|
+
* ```ts
|
|
392
|
+
* readResult({ outcome: "won", payload: { stars: 3 } }); // { outcome: "won", payload: { stars: 3 } }
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
function readResult(result) {
|
|
396
|
+
if (!isRecord(result) || typeof result.outcome !== "string") throw invalidRoute();
|
|
397
|
+
const { outcome, payload } = result;
|
|
398
|
+
return payload === void 0 ? { outcome } : {
|
|
399
|
+
outcome,
|
|
400
|
+
payload
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Reads one step: an answer at a rest node, or a substituted sub-flow result.
|
|
405
|
+
*
|
|
406
|
+
* @param step - One element of the route.
|
|
407
|
+
* @returns The step.
|
|
408
|
+
* @throws {Error} When the step has no `at`, or neither an intent nor a result.
|
|
409
|
+
* @example
|
|
410
|
+
* ```ts
|
|
411
|
+
* readStep({ at: "home", intent: "play" }); // { at: "home", intent: "play" }
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
function readStep(step) {
|
|
415
|
+
if (!isRecord(step) || typeof step.at !== "string") throw invalidRoute();
|
|
416
|
+
const { at, intent, payload, result } = step;
|
|
417
|
+
if (typeof intent === "string") return payload === void 0 ? {
|
|
418
|
+
at,
|
|
419
|
+
intent
|
|
420
|
+
} : {
|
|
421
|
+
at,
|
|
422
|
+
intent,
|
|
423
|
+
payload
|
|
424
|
+
};
|
|
425
|
+
if (result !== void 0) return {
|
|
426
|
+
at,
|
|
427
|
+
result: readResult(result)
|
|
428
|
+
};
|
|
429
|
+
throw invalidRoute();
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Reads a route out of JSON.
|
|
433
|
+
*
|
|
434
|
+
* @param value - The JSON the editor sent.
|
|
435
|
+
* @returns The route steps, in order.
|
|
436
|
+
* @throws {Error} When the value is not a list of steps.
|
|
437
|
+
* @example
|
|
438
|
+
* ```ts
|
|
439
|
+
* readRoute([{ at: "home", intent: "play" }]); // [{ at: "home", intent: "play" }]
|
|
440
|
+
* ```
|
|
441
|
+
*/
|
|
442
|
+
function readRoute(value) {
|
|
443
|
+
if (!Array.isArray(value)) throw invalidRoute();
|
|
444
|
+
return value.map((step) => readStep(step));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Reads an rng state: a seed and a number per stream.
|
|
448
|
+
*
|
|
449
|
+
* @param value - The JSON value.
|
|
450
|
+
* @returns The rng state, or `undefined` when the value is not one.
|
|
451
|
+
* @example
|
|
452
|
+
* ```ts
|
|
453
|
+
* readRng({ seed: 42, streams: { chest: 3 } }); // { seed: 42, streams: { chest: 3 } }
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
function readRng(value) {
|
|
457
|
+
if (!isRecord(value) || typeof value.seed !== "number" || !isRecord(value.streams)) return;
|
|
458
|
+
const streams = {};
|
|
459
|
+
for (const [name, draws] of Object.entries(value.streams)) {
|
|
460
|
+
if (typeof draws !== "number") return void 0;
|
|
461
|
+
streams[name] = draws;
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
seed: value.seed,
|
|
465
|
+
streams
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Builds the error of a value that is not a bookmark.
|
|
470
|
+
*
|
|
471
|
+
* @returns The error, ready to throw.
|
|
472
|
+
* @example
|
|
473
|
+
* ```ts
|
|
474
|
+
* invalidBookmark().message.startsWith("[game] The bookmark is not"); // true
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
function invalidBookmark() {
|
|
478
|
+
return /* @__PURE__ */ new Error("[game] The bookmark is not a flow.bookmark() value.\n Pass the JSON that game.bookmark returned.");
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Reads a bookmark out of JSON, the value `flow.bookmark()` made and the editor kept.
|
|
482
|
+
*
|
|
483
|
+
* @param value - The JSON the editor sent.
|
|
484
|
+
* @returns The bookmark.
|
|
485
|
+
* @throws {Error} When a field is missing or has the wrong type.
|
|
486
|
+
* @example
|
|
487
|
+
* ```ts
|
|
488
|
+
* readBookmark(JSON.parse(saved)).path; // "board/awaitIntent"
|
|
489
|
+
* ```
|
|
490
|
+
*/
|
|
491
|
+
function readBookmark(value) {
|
|
492
|
+
if (!isRecord(value)) throw invalidBookmark();
|
|
493
|
+
const { path, input, player, session, graph } = value;
|
|
494
|
+
const rng = readRng(value.rng);
|
|
495
|
+
if (typeof path !== "string" || typeof graph !== "string" || !(input !== void 0 && player !== void 0 && session !== void 0) || rng === void 0) throw invalidBookmark();
|
|
496
|
+
return {
|
|
497
|
+
path,
|
|
498
|
+
input,
|
|
499
|
+
player,
|
|
500
|
+
session,
|
|
501
|
+
rng,
|
|
502
|
+
graph
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Builds the error of a value that is not a repro.
|
|
507
|
+
*
|
|
508
|
+
* @returns The error, ready to throw.
|
|
509
|
+
* @example
|
|
510
|
+
* ```ts
|
|
511
|
+
* invalidRepro().message.startsWith("[game] The repro is not a Repro."); // true
|
|
512
|
+
* ```
|
|
513
|
+
*/
|
|
514
|
+
function invalidRepro() {
|
|
515
|
+
return /* @__PURE__ */ new Error("[game] The repro is not a Repro.\n Pass { player, route } with an optional session, rng and checkpoint.");
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Reads the optional fields of a repro onto it.
|
|
519
|
+
*
|
|
520
|
+
* @param value - The JSON object of the repro.
|
|
521
|
+
* @param repro - The repro with its player and route.
|
|
522
|
+
* @returns The repro with its session, rng and checkpoint.
|
|
523
|
+
* @throws {Error} When the rng or the checkpoint has the wrong type.
|
|
524
|
+
*/
|
|
525
|
+
function readReproOptions(value, repro) {
|
|
526
|
+
const { session, checkpoint } = value;
|
|
527
|
+
if (session !== void 0) repro.session = session;
|
|
528
|
+
if (value.rng !== void 0) {
|
|
529
|
+
const rng = readRng(value.rng);
|
|
530
|
+
if (rng === void 0) throw invalidRepro();
|
|
531
|
+
repro.rng = rng;
|
|
532
|
+
}
|
|
533
|
+
if (checkpoint !== void 0) {
|
|
534
|
+
if (typeof checkpoint !== "string") throw invalidRepro();
|
|
535
|
+
repro.checkpoint = checkpoint;
|
|
536
|
+
}
|
|
537
|
+
return repro;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Reads a repro out of JSON: the `/testing` repro of a bug report.
|
|
541
|
+
*
|
|
542
|
+
* @param value - The JSON the editor sent.
|
|
543
|
+
* @returns The repro.
|
|
544
|
+
* @throws {Error} When the player is missing, a field has the wrong type or the route is broken.
|
|
545
|
+
* @example
|
|
546
|
+
* ```ts
|
|
547
|
+
* readRepro({ player: { coins: 7 }, checkpoint: "home", route: [] }).checkpoint; // "home"
|
|
548
|
+
* ```
|
|
549
|
+
*/
|
|
550
|
+
function readRepro(value) {
|
|
551
|
+
if (!isRecord(value) || value.player === void 0) throw invalidRepro();
|
|
552
|
+
return readReproOptions(value, {
|
|
553
|
+
player: value.player,
|
|
554
|
+
route: readRoute(value.route)
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/plugins/flow/control.ts
|
|
559
|
+
/**
|
|
560
|
+
* Enters a bookmark, or a repro's bookmark followed by its route.
|
|
561
|
+
*
|
|
562
|
+
* @param app - The app.
|
|
563
|
+
* @param input - Exactly one of a bookmark and a repro, as JSON.
|
|
564
|
+
* @param input.bookmark - A `flow.bookmark()` value.
|
|
565
|
+
* @param input.repro - A `/testing` repro.
|
|
566
|
+
* @returns The state the game rests in afterwards.
|
|
567
|
+
* @throws {Error} When both or neither are given, or the JSON is not of its shape.
|
|
568
|
+
*/
|
|
569
|
+
async function restoreFrom(app, input) {
|
|
570
|
+
const { bookmark, repro } = input;
|
|
571
|
+
if (bookmark !== void 0 && repro === void 0) {
|
|
572
|
+
await app.flow.restore(readBookmark(bookmark));
|
|
573
|
+
return app.flow.state();
|
|
574
|
+
}
|
|
575
|
+
if (repro !== void 0 && bookmark === void 0) {
|
|
576
|
+
const parsed = readRepro(repro);
|
|
577
|
+
await app.flow.restore(reproBookmark(app, parsed));
|
|
578
|
+
return app.flow.walk(parsed.route);
|
|
579
|
+
}
|
|
580
|
+
throw new Error("[game] game.restore takes a bookmark or a repro.\n Pass exactly one of { bookmark } and { repro }.");
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Answers the gate, the way a tap on a button does. Goes through the graph: the session stays
|
|
584
|
+
* clean.
|
|
585
|
+
*
|
|
586
|
+
* @example
|
|
587
|
+
* ```ts
|
|
588
|
+
* // The home screen rests: tap Play without touching the screen.
|
|
589
|
+
* const ran = await run(app, commands.answer, { intent: "play" });
|
|
590
|
+
* ran.value; // true: "home" waits for "play"
|
|
591
|
+
* ran.state.tainted; // false
|
|
592
|
+
* ```
|
|
593
|
+
*/
|
|
594
|
+
const answerCommand = defineCommand({
|
|
595
|
+
id: "game.answer",
|
|
596
|
+
title: "Answer",
|
|
597
|
+
input: {
|
|
598
|
+
intent: "string",
|
|
599
|
+
payload: "json?"
|
|
600
|
+
},
|
|
601
|
+
effect: "route",
|
|
602
|
+
run: (app, { intent, payload }) => {
|
|
603
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
604
|
+
app.log.debug("moku:dev", {
|
|
605
|
+
command: "game.answer",
|
|
606
|
+
intent
|
|
607
|
+
});
|
|
608
|
+
return app.flow.gate.answer(payload === void 0 ? { intent } : {
|
|
609
|
+
intent,
|
|
610
|
+
payload
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
/**
|
|
615
|
+
* Walks a route in fast mode through the graph. The session stays clean.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* ```ts
|
|
619
|
+
* // Skip the menu and stand on the board.
|
|
620
|
+
* const ran = await run(app, commands.walk, { route: [{ at: "home", intent: "play" }] });
|
|
621
|
+
* ran.value.path; // "board/awaitIntent"
|
|
622
|
+
* ```
|
|
623
|
+
*/
|
|
624
|
+
const walkCommand = defineCommand({
|
|
625
|
+
id: "game.walk",
|
|
626
|
+
title: "Walk a route",
|
|
627
|
+
input: { route: "json" },
|
|
628
|
+
effect: "route",
|
|
629
|
+
run: (app, { route }) => {
|
|
630
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
631
|
+
app.log.debug("moku:dev", { command: "game.walk" });
|
|
632
|
+
return app.flow.walk(readRoute(route));
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
/**
|
|
636
|
+
* Takes a bookmark of the rest point: the node plus the committed state, plain JSON.
|
|
637
|
+
*
|
|
638
|
+
* @example
|
|
639
|
+
* ```ts
|
|
640
|
+
* // The editor keeps the position before a risky test.
|
|
641
|
+
* const { value } = await run(app, commands.bookmark);
|
|
642
|
+
* value.path; // "board/awaitIntent"
|
|
643
|
+
* ```
|
|
644
|
+
*/
|
|
645
|
+
const bookmarkCommand = defineCommand({
|
|
646
|
+
id: "game.bookmark",
|
|
647
|
+
title: "Bookmark",
|
|
648
|
+
input: {},
|
|
649
|
+
effect: "read",
|
|
650
|
+
run: (app) => {
|
|
651
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
652
|
+
app.log.debug("moku:dev", { command: "game.bookmark" });
|
|
653
|
+
return app.flow.bookmark();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
/**
|
|
657
|
+
* Restores a bookmark, or a repro: its state at its checkpoint, then its route. Replaces the
|
|
658
|
+
* state outside the graph, so the session is tainted and the command journaled.
|
|
659
|
+
*
|
|
660
|
+
* @example
|
|
661
|
+
* ```ts
|
|
662
|
+
* // Load the bug report "the order stays after delivery".
|
|
663
|
+
* const repro = { player: { coins: 40 }, checkpoint: "home", route: [{ at: "home", intent: "play" }] };
|
|
664
|
+
* const ran = await run(app, commands.restore, { repro });
|
|
665
|
+
* ran.state; // { path: "board/awaitIntent", frame: 12, tainted: true }
|
|
666
|
+
* ```
|
|
667
|
+
*/
|
|
668
|
+
const restoreCommand = defineCommand({
|
|
669
|
+
id: "game.restore",
|
|
670
|
+
title: "Restore",
|
|
671
|
+
input: {
|
|
672
|
+
bookmark: "json?",
|
|
673
|
+
repro: "json?"
|
|
674
|
+
},
|
|
675
|
+
effect: "raw",
|
|
676
|
+
run: (app, input) => {
|
|
677
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
678
|
+
app.log.debug("moku:dev", { command: "game.restore" });
|
|
679
|
+
return restoreFrom(app, input);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/plugins/flow/doors/commands.ts
|
|
684
|
+
/**
|
|
685
|
+
* @file flow/doors — the base commands: every command of the engine, keyed by a short name. Each
|
|
686
|
+
* descriptor lives with the plugin that owns its data; this module only gathers them. Imported
|
|
687
|
+
* by the door `src/control.ts` only, so no plugin reaches it and no import cycle forms.
|
|
688
|
+
*/
|
|
689
|
+
/**
|
|
690
|
+
* Every command of the engine, keyed by its short name: what an editor lists as buttons and MCP
|
|
691
|
+
* tools. Frozen. Each runs in dev builds only.
|
|
692
|
+
*
|
|
693
|
+
* @example
|
|
694
|
+
* ```ts
|
|
695
|
+
* // An e2e script taps Play, and the editor registers every command.
|
|
696
|
+
* (await run(app, commands.tap, { key: "play" })).state.path; // "board/awaitIntent"
|
|
697
|
+
* for (const command of Object.values(commands)) registry.add(command);
|
|
698
|
+
* ```
|
|
699
|
+
*/
|
|
700
|
+
const commands = Object.freeze({
|
|
701
|
+
answer: answerCommand,
|
|
702
|
+
tap: tapCommand,
|
|
703
|
+
drag: dragCommand,
|
|
704
|
+
key: keyCommand,
|
|
705
|
+
walk: walkCommand,
|
|
706
|
+
bookmark: bookmarkCommand,
|
|
707
|
+
restore: restoreCommand,
|
|
708
|
+
step: stepCommand,
|
|
709
|
+
pause: pauseCommand,
|
|
710
|
+
resume: resumeCommand,
|
|
711
|
+
capture: captureCommand,
|
|
712
|
+
debug: debugCommand,
|
|
713
|
+
reducedMotion: reducedMotionCommand
|
|
714
|
+
});
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/plugins/flow/doors/run.ts
|
|
717
|
+
/**
|
|
718
|
+
* Reads where the game stands after a command.
|
|
719
|
+
*
|
|
720
|
+
* @param app - The app.
|
|
721
|
+
* @returns The graph path, the frame and the taint.
|
|
722
|
+
*/
|
|
723
|
+
function envelopeOf(app) {
|
|
724
|
+
return {
|
|
725
|
+
path: app.flow.state().path,
|
|
726
|
+
frame: app.time.snapshot().frame,
|
|
727
|
+
tainted: isTainted(app)
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Runs a command in a dev build. A `cheat` or `raw` command taints the session and is journaled
|
|
732
|
+
* before it runs, so a failing one still counts.
|
|
733
|
+
*
|
|
734
|
+
* @param app - The app the command acts on.
|
|
735
|
+
* @param command - The command.
|
|
736
|
+
* @param input - Its input; left out when every field of its schema is optional.
|
|
737
|
+
* @returns The command's value and the envelope read after it.
|
|
738
|
+
* @throws {Error} Outside a dev build, and whatever the command throws.
|
|
739
|
+
* @example
|
|
740
|
+
* ```ts
|
|
741
|
+
* // An e2e script: load the bug report, then tap Play.
|
|
742
|
+
* await run(app, commands.restore, { repro }); // state: { path: "home", frame: 12, tainted: true }
|
|
743
|
+
* const ran = await run(app, commands.answer, { intent: "play" });
|
|
744
|
+
* ran.value; // true
|
|
745
|
+
* ran.state; // { path: "board/awaitIntent", frame: 14, tainted: true }
|
|
746
|
+
* ```
|
|
747
|
+
*/
|
|
748
|
+
async function run(app, command, ...input) {
|
|
749
|
+
if (!isDev()) throw controlRefused();
|
|
750
|
+
const given = inputOrEmpty(input[0]);
|
|
751
|
+
if (command.effect === "cheat" || command.effect === "raw") recordCheat(app, command.id, given, app.time.snapshot().frame);
|
|
752
|
+
return {
|
|
753
|
+
value: await command.run(app, given),
|
|
754
|
+
state: envelopeOf(app)
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
export { commands, controlRefused, defineCommand, run };
|