@moku-labs/game 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/assets.d.mts +1 -1
- package/dist/{capture-fpd83Mi5.mjs → capture-BX5Gfoty.mjs} +1 -384
- package/dist/{commands-mzE9PX0H.d.mts → commands-C1kIfOG5.d.mts} +6 -4
- package/dist/control.d.mts +4 -3
- package/dist/control.mjs +2 -2
- package/dist/{define-BbgXv4cy.d.mts → define-BPpZcpxW.d.mts} +4 -3
- package/dist/headless-CvamCUcR.mjs +186 -0
- package/dist/headless-q98SwADR.d.mts +181 -0
- package/dist/index.d.mts +23 -22
- package/dist/index.mjs +15 -6
- package/dist/{input-CK4nxBO1.mjs → input-z_35Jwfn.mjs} +2 -2
- package/dist/inspect.d.mts +8 -6
- package/dist/inspect.mjs +2 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/project.d.mts +28 -28
- package/dist/project.mjs +7 -7
- package/dist/registry-DlpRCibU.mjs +384 -0
- package/dist/{run-Bcyg32M5.mjs → run-CUPSFM85.mjs} +4 -187
- package/dist/{runtime-HTlmRb81.d.mts → runtime-CvDZZg1S.d.mts} +1 -1
- package/dist/{sources-DVWJ8nfr.mjs → sources-BBUd15vQ.mjs} +1 -1
- package/dist/testing.d.mts +3 -328
- package/dist/testing.mjs +2 -2142
- package/dist/{types-BMEn0tSC.d.mts → types-BWzCSRM2.d.mts} +12 -2974
- package/dist/{types-CYYKjvLn.d.mts → types-CDSKa0vl.d.mts} +5 -182
- package/dist/{types-ClF8say0.d.mts → types-DF5kkW-C.d.mts} +5 -4
- package/dist/{types-BaUUcIaN.d.mts → types-DgdMLZnV.d.mts} +8 -7
- package/dist/{types-CXyipKhE.d.mts → types-DlgsfOmW.d.mts} +10 -9
- package/dist/types-ERb2Y9U5.d.mts +2967 -0
- package/dist/visual.d.mts +331 -0
- package/dist/visual.mjs +2142 -0
- package/llms.txt +6 -5
- package/package.json +7 -8
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ await game.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]
|
|
|
115
115
|
app.model.store.snapshot().session; // { rolls: 2 }
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
The full version, with a reset node and a vitest file, is in [docs/quick-start.md](./docs/quick-start.md). A bigger game lives in [
|
|
118
|
+
The full version, with a reset node and a vitest file, is in [docs/quick-start.md](./docs/quick-start.md). A bigger game, the merge game, lives in [moku-labs/demos](https://github.com/moku-labs/demos) with its tests.
|
|
119
119
|
|
|
120
120
|
A game with a screen gets two more things from the package. The body font `fonts/font-body.*` (Pangolin, SIL OFL 1.1) is the default of the built-in text style `body` once copied into `src/features/ui/assets/`. The bin `moku-game-assets` writes the typed asset keys, the manifest and the strings. It runs under bun; with node only, run `bun node_modules/@moku-labs/game/dist/assets.mjs`. Both are in [docs/quick-start.md](./docs/quick-start.md#a-screen-the-body-font-and-the-asset-keys).
|
|
121
121
|
|
|
@@ -188,7 +188,8 @@ createApp({ plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin] });
|
|
|
188
188
|
| Import | Runs in | For |
|
|
189
189
|
|---|---|---|
|
|
190
190
|
| `@moku-labs/game` | anywhere | The engine: plugins, helpers, components |
|
|
191
|
-
| `@moku-labs/game/testing` |
|
|
191
|
+
| `@moku-labs/game/testing` | anywhere | Headless games, the fake clock, the in-memory save |
|
|
192
|
+
| `@moku-labs/game/visual` | Node and Bun | Visual tests: baselines of state, describe and pixels |
|
|
192
193
|
| `@moku-labs/game/assets` | Node and Bun | Asset keys, the manifest, strings, the pack |
|
|
193
194
|
| `@moku-labs/game/inspect` | anywhere | Read a running game |
|
|
194
195
|
| `@moku-labs/game/control` | dev builds | Drive a running game |
|
|
@@ -246,8 +247,8 @@ bun run lint # biome check . && eslint .
|
|
|
246
247
|
bun run test # vitest, unit and integration
|
|
247
248
|
bun run test:coverage # with the 90% threshold
|
|
248
249
|
bun run validate # publint and attw
|
|
249
|
-
bun run
|
|
250
|
-
bun run
|
|
250
|
+
bun run mini:pack # pack the mini game of tests/fixtures
|
|
251
|
+
bun run mini:visual # visual tests of the mini game
|
|
251
252
|
bun run release # moku-release
|
|
252
253
|
```
|
|
253
254
|
|
package/dist/assets.d.mts
CHANGED
|
@@ -1,386 +1,3 @@
|
|
|
1
|
-
//#region src/plugins/flow/runner/journal.ts
|
|
2
|
-
/** FNV-1a 32-bit offset basis. */
|
|
3
|
-
const OFFSET_BASIS = 2166136261;
|
|
4
|
-
/** FNV-1a 32-bit prime. */
|
|
5
|
-
const PRIME = 16777619;
|
|
6
|
-
/** Separates the parts of a hashed edge, so "a|b" and "a" + "b" never collide. */
|
|
7
|
-
const SEPARATOR = "\0";
|
|
8
|
-
/**
|
|
9
|
-
* Hashes text with FNV-1a, 32 bit. Pure and deterministic: the same text always gives the same
|
|
10
|
-
* eight hexadecimal characters, in every runtime.
|
|
11
|
-
*
|
|
12
|
-
* @param text - The text to hash.
|
|
13
|
-
* @returns Eight lowercase hexadecimal characters.
|
|
14
|
-
* @example
|
|
15
|
-
* ```ts
|
|
16
|
-
* hashText("board/merge"); // "53ceca70"
|
|
17
|
-
* ```
|
|
18
|
-
*/
|
|
19
|
-
function hashText(text) {
|
|
20
|
-
let hash = OFFSET_BASIS;
|
|
21
|
-
for (const character of text) hash = Math.imul(hash ^ (character.codePointAt(0) ?? 0), PRIME);
|
|
22
|
-
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Hashes one edge, so a replay against edited code fails loudly in dev.
|
|
26
|
-
*
|
|
27
|
-
* @param path - Path of the node that produced the outcome.
|
|
28
|
-
* @param outcome - Outcome name.
|
|
29
|
-
* @param next - Rendered edge target.
|
|
30
|
-
* @returns The digest of the three parts together.
|
|
31
|
-
* @example
|
|
32
|
-
* ```ts
|
|
33
|
-
* entryHash("board/merge", "done", "board/awaitIntent"); // "e881176b"
|
|
34
|
-
* ```
|
|
35
|
-
*/
|
|
36
|
-
function entryHash(path, outcome, next) {
|
|
37
|
-
return hashText([
|
|
38
|
-
path,
|
|
39
|
-
outcome,
|
|
40
|
-
next
|
|
41
|
-
].join(SEPARATOR));
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Appends an entry with the next index and its hash, and drops the oldest entries above the limit.
|
|
45
|
-
* The index keeps counting while entries are dropped, so two entries never share a number.
|
|
46
|
-
*
|
|
47
|
-
* @param state - Runner state that holds the journal.
|
|
48
|
-
* @param entry - The taken edge without `index` and `hash`.
|
|
49
|
-
* @param limit - Journal entries kept between checkpoints.
|
|
50
|
-
* @returns The stored entry.
|
|
51
|
-
*/
|
|
52
|
-
function pushEntry(state, entry, limit) {
|
|
53
|
-
const stored = {
|
|
54
|
-
...entry,
|
|
55
|
-
index: state.journalIndex,
|
|
56
|
-
hash: entryHash(entry.path, entry.outcome, entry.next)
|
|
57
|
-
};
|
|
58
|
-
state.journalIndex += 1;
|
|
59
|
-
state.journal.push(stored);
|
|
60
|
-
if (state.journal.length > limit) state.journal.splice(0, state.journal.length - limit);
|
|
61
|
-
return stored;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Clears the journal at a checkpoint. The running index is kept, and so is the array itself: a
|
|
65
|
-
* reader that holds `history()` sees the compaction.
|
|
66
|
-
*
|
|
67
|
-
* @param state - Runner state that holds the journal.
|
|
68
|
-
*/
|
|
69
|
-
function compact(state) {
|
|
70
|
-
state.journal.length = 0;
|
|
71
|
-
}
|
|
72
|
-
//#endregion
|
|
73
|
-
//#region src/plugins/flow/runner/registry.ts
|
|
74
|
-
/**
|
|
75
|
-
* Creates the empty flow map. It lives in its own function because the plugin's lint rule L5
|
|
76
|
-
* refuses a collection built inside an exported declaration.
|
|
77
|
-
*
|
|
78
|
-
* @returns An empty map of flow id to flow.
|
|
79
|
-
* @example
|
|
80
|
-
* ```ts
|
|
81
|
-
* emptyFlows().size; // 0
|
|
82
|
-
* ```
|
|
83
|
-
*/
|
|
84
|
-
function emptyFlows() {
|
|
85
|
-
return /* @__PURE__ */ new Map();
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Adds a flow and every flow it reaches by reference to the map. The first flow under an id wins,
|
|
89
|
-
* so a collision is visible to `validateGraph` instead of silently replacing a flow.
|
|
90
|
-
*
|
|
91
|
-
* @param flow - The flow to add.
|
|
92
|
-
* @param flows - The map being filled.
|
|
93
|
-
*/
|
|
94
|
-
function addFlow(flow, flows) {
|
|
95
|
-
if (flows.has(flow.id)) return;
|
|
96
|
-
flows.set(flow.id, flow);
|
|
97
|
-
for (const entry of Object.values(flow.nodes)) if (entry.kind === "flow") addFlow(entry, flows);
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Collects every flow reachable from the main flow by reference, plus the registered extras.
|
|
101
|
-
* The main flow is the first entry: `describeGraph` reads it as the entry point of the graph.
|
|
102
|
-
*
|
|
103
|
-
* @param main - The top-level flow.
|
|
104
|
-
* @param extra - Flows added with `register`, not reachable by reference.
|
|
105
|
-
* @returns Flow id to flow, main first.
|
|
106
|
-
* @example
|
|
107
|
-
* ```ts
|
|
108
|
-
* [...collectFlows(mainFlow, []).keys()]; // ["main", "board"]: the main flow first
|
|
109
|
-
* ```
|
|
110
|
-
*/
|
|
111
|
-
function collectFlows(main, extra) {
|
|
112
|
-
const flows = emptyFlows();
|
|
113
|
-
addFlow(main, flows);
|
|
114
|
-
for (const flow of extra) addFlow(flow, flows);
|
|
115
|
-
return flows;
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Renders a position as a path: the node of every frame, outermost first.
|
|
119
|
-
*
|
|
120
|
-
* @param stack - One frame per nesting level.
|
|
121
|
-
* @returns The path, for example `"board/awaitIntent"`; empty without a position.
|
|
122
|
-
* @example
|
|
123
|
-
* ```ts
|
|
124
|
-
* const board = { flow: "main", node: "board", input: null };
|
|
125
|
-
* framePath([board, { flow: "board", node: "merge", input: null }]); // "board/merge"
|
|
126
|
-
* ```
|
|
127
|
-
*/
|
|
128
|
-
function framePath(stack) {
|
|
129
|
-
return stack.map((frame) => frame.node).join("/");
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Picks the flow a path continues in after one entry: the sub-flow itself, or, behind a slot, the
|
|
133
|
-
* first contribution in order that has the next segment as a node. A path keeps node names only,
|
|
134
|
-
* so two contributions of one slot with the same node name cannot be told apart; `validateGraph`
|
|
135
|
-
* warns about that.
|
|
136
|
-
*
|
|
137
|
-
* @param entry - The entry the path just passed.
|
|
138
|
-
* @param next - The next segment of the path.
|
|
139
|
-
* @param contributionsOf - Reads the contributions of a slot, in order.
|
|
140
|
-
* @returns The flow to go on in, or `undefined` when the path ends here.
|
|
141
|
-
*/
|
|
142
|
-
function flowBehind(entry, next, contributionsOf) {
|
|
143
|
-
if (entry.kind === "flow") return entry;
|
|
144
|
-
if (entry.kind !== "slot") return void 0;
|
|
145
|
-
return contributionsOf(entry.name).find((item) => Object.hasOwn(item.flow.nodes, next))?.flow;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Reads no contributions: the default of `findNode` for a caller that has no features API.
|
|
149
|
-
*
|
|
150
|
-
* @returns An empty list.
|
|
151
|
-
* @example
|
|
152
|
-
* ```ts
|
|
153
|
-
* noContributions(); // []
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
|
-
function noContributions() {
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Finds the node, sub-flow or slot a path names, starting at the main flow. A path descends one
|
|
161
|
-
* sub-flow per segment, and through a slot into the contribution that has the next segment; a
|
|
162
|
-
* segment after a plain node finds nothing.
|
|
163
|
-
*
|
|
164
|
-
* @param main - The top-level flow.
|
|
165
|
-
* @param path - A path as `framePath` renders it.
|
|
166
|
-
* @param contributionsOf - Reads the contributions of a slot, in order. Without it a path ends at
|
|
167
|
-
* a slot.
|
|
168
|
-
* @returns Where the path leads, or `undefined` when no such node exists.
|
|
169
|
-
* @example
|
|
170
|
-
* ```ts
|
|
171
|
-
* findNode(mainFlow, "board/awaitIntent")?.trail;
|
|
172
|
-
* // [{ flow: "main", node: "board" }, { flow: "board", node: "awaitIntent" }]
|
|
173
|
-
* findNode(mainFlow, "afterOrder/show", () => [{ flow: rewardFlow }])?.trail;
|
|
174
|
-
* // [{ flow: "main", node: "afterOrder" }, { flow: "rewardPopup", node: "show" }]
|
|
175
|
-
* ```
|
|
176
|
-
*/
|
|
177
|
-
function findNode(main, path, contributionsOf = noContributions) {
|
|
178
|
-
const names = path.split("/").filter(Boolean);
|
|
179
|
-
const trail = [];
|
|
180
|
-
let flow = main;
|
|
181
|
-
for (const [step, name] of names.entries()) {
|
|
182
|
-
const entry = flow.nodes[name];
|
|
183
|
-
if (!entry) return void 0;
|
|
184
|
-
trail.push({
|
|
185
|
-
flow: flow.id,
|
|
186
|
-
node: name
|
|
187
|
-
});
|
|
188
|
-
if (step === names.length - 1) return {
|
|
189
|
-
flow,
|
|
190
|
-
name,
|
|
191
|
-
entry,
|
|
192
|
-
trail
|
|
193
|
-
};
|
|
194
|
-
const behind = flowBehind(entry, names[step + 1] ?? "", contributionsOf);
|
|
195
|
-
if (behind === void 0) return void 0;
|
|
196
|
-
flow = behind;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Renders an edge target as a string: a node name, `"exit:win"` or `"map:node"`.
|
|
201
|
-
*
|
|
202
|
-
* @param target - The target as the edge table holds it.
|
|
203
|
-
* @returns The rendered target.
|
|
204
|
-
* @example
|
|
205
|
-
* ```ts
|
|
206
|
-
* renderTarget("home"); // "home"
|
|
207
|
-
* renderTarget({ kind: "exit", outcome: "win" }); // "exit:win"
|
|
208
|
-
* ```
|
|
209
|
-
*/
|
|
210
|
-
function renderTarget(target) {
|
|
211
|
-
if (typeof target === "string") return target;
|
|
212
|
-
return target.kind === "exit" ? `exit:${target.outcome}` : `map:${target.target}`;
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Renders the edge table of one flow.
|
|
216
|
-
*
|
|
217
|
-
* @param flow - The flow to render.
|
|
218
|
-
* @returns Node name to outcome name to rendered target.
|
|
219
|
-
* @example
|
|
220
|
-
* ```ts
|
|
221
|
-
* renderEdges(boardFlow).awaitIntent?.leave; // "exit:left"
|
|
222
|
-
* ```
|
|
223
|
-
*/
|
|
224
|
-
function renderEdges(flow) {
|
|
225
|
-
const edges = {};
|
|
226
|
-
for (const [name, row] of Object.entries(flow.edges)) {
|
|
227
|
-
const rendered = {};
|
|
228
|
-
for (const [outcome, target] of Object.entries(row)) rendered[outcome] = renderTarget(target);
|
|
229
|
-
edges[name] = rendered;
|
|
230
|
-
}
|
|
231
|
-
return edges;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Maps every node and flow a feature brought to the name of that feature.
|
|
235
|
-
*
|
|
236
|
-
* @param features - Features API.
|
|
237
|
-
* @returns Node or flow object to feature name.
|
|
238
|
-
*/
|
|
239
|
-
function collectOwners(features) {
|
|
240
|
-
const owners = /* @__PURE__ */ new Map();
|
|
241
|
-
for (const { name, description } of features.all()) {
|
|
242
|
-
for (const node of description.nodes ?? []) owners.set(node, name);
|
|
243
|
-
for (const flow of description.flows ?? []) owners.set(flow, name);
|
|
244
|
-
}
|
|
245
|
-
return owners;
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Describes one entry of a flow: its flags, its scene, its outcome names, and the slot, sub-flow
|
|
249
|
-
* or owning feature when it has one.
|
|
250
|
-
*
|
|
251
|
-
* @param flow - The flow that holds the entry.
|
|
252
|
-
* @param name - Name of the entry inside that flow.
|
|
253
|
-
* @param entry - The node, sub-flow or slot.
|
|
254
|
-
* @param owner - Name of the feature that brought it, when a feature did.
|
|
255
|
-
* @returns The entry as JSON.
|
|
256
|
-
* @example
|
|
257
|
-
* ```ts
|
|
258
|
-
* describeNode(mainFlow, "home", home, undefined).rest; // true
|
|
259
|
-
* describeNode(mainFlow, "board", boardFlow, "board").subFlow; // "board"
|
|
260
|
-
* ```
|
|
261
|
-
*/
|
|
262
|
-
function describeNode(flow, name, entry, owner) {
|
|
263
|
-
const node = entry.kind === "node" ? entry : void 0;
|
|
264
|
-
return {
|
|
265
|
-
flow: flow.id,
|
|
266
|
-
node: name,
|
|
267
|
-
rest: node?.rest ?? false,
|
|
268
|
-
over: node?.over ?? false,
|
|
269
|
-
checkpoint: node?.checkpoint ?? false,
|
|
270
|
-
barrier: node?.barrier ?? false,
|
|
271
|
-
outcomes: Object.keys(entry.outcomes),
|
|
272
|
-
...node?.scene === void 0 ? {} : { scene: node.scene },
|
|
273
|
-
...entry.kind === "slot" ? { slot: entry.name } : {},
|
|
274
|
-
...entry.kind === "flow" ? { subFlow: entry.id } : {},
|
|
275
|
-
...owner === void 0 ? {} : { owner }
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Describes every entry of one flow.
|
|
280
|
-
*
|
|
281
|
-
* @param flow - The flow to describe.
|
|
282
|
-
* @param owners - Node or flow object to feature name.
|
|
283
|
-
* @returns Node name to description.
|
|
284
|
-
* @example
|
|
285
|
-
* ```ts
|
|
286
|
-
* describeNodes(boardFlow, new Map()).merge?.outcomes; // ["done", "rejected"]
|
|
287
|
-
* ```
|
|
288
|
-
*/
|
|
289
|
-
function describeNodes(flow, owners) {
|
|
290
|
-
const nodes = {};
|
|
291
|
-
for (const [name, entry] of Object.entries(flow.nodes)) nodes[name] = describeNode(flow, name, entry, owners.get(entry) ?? owners.get(flow));
|
|
292
|
-
return nodes;
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Collects the slot names of every flow of the graph. `describe()` and `validate` both read the
|
|
296
|
-
* contributions of exactly these slots.
|
|
297
|
-
*
|
|
298
|
-
* @param flows - Every collected flow by id.
|
|
299
|
-
* @returns The slot names, in the order the graph declares them, each once.
|
|
300
|
-
* @example
|
|
301
|
-
* ```ts
|
|
302
|
-
* slotNames(collectFlows(mainFlow, [])); // ["afterOrder"]
|
|
303
|
-
* ```
|
|
304
|
-
*/
|
|
305
|
-
function slotNames(flows) {
|
|
306
|
-
const names = [];
|
|
307
|
-
for (const flow of flows.values()) for (const entry of Object.values(flow.nodes)) if (entry.kind === "slot" && !names.includes(entry.name)) names.push(entry.name);
|
|
308
|
-
return names;
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Renders the whole graph as JSON without running the game: nodes, flags, outcomes, edges,
|
|
312
|
-
* slots and who contributed. Edge targets become `"node"`, `"exit:win"`, `"map:node"`.
|
|
313
|
-
*
|
|
314
|
-
* @param flows - Every collected flow by id, the main flow first, as `collectFlows` returns them.
|
|
315
|
-
* @param features - Features API: owners and slot contributions.
|
|
316
|
-
* @returns The graph as plain JSON.
|
|
317
|
-
*/
|
|
318
|
-
function describeGraph(flows, features) {
|
|
319
|
-
const owners = collectOwners(features);
|
|
320
|
-
const described = {};
|
|
321
|
-
const slots = {};
|
|
322
|
-
for (const flow of flows.values()) described[flow.id] = {
|
|
323
|
-
nodes: describeNodes(flow, owners),
|
|
324
|
-
start: flow.start,
|
|
325
|
-
edges: renderEdges(flow)
|
|
326
|
-
};
|
|
327
|
-
for (const name of slotNames(flows)) slots[name] = features.contributions(name).map(({ feature, flow, order }) => ({
|
|
328
|
-
feature,
|
|
329
|
-
flow: flow.id,
|
|
330
|
-
order
|
|
331
|
-
}));
|
|
332
|
-
return {
|
|
333
|
-
main: [...flows.keys()][0] ?? "",
|
|
334
|
-
flows: described,
|
|
335
|
-
slots
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Compares two object keys without a locale, so the rendering is the same everywhere.
|
|
340
|
-
*
|
|
341
|
-
* @param first - First key.
|
|
342
|
-
* @param second - Second key.
|
|
343
|
-
* @returns A negative number, zero or a positive number.
|
|
344
|
-
* @example
|
|
345
|
-
* ```ts
|
|
346
|
-
* compareKeys("edges", "nodes"); // -1
|
|
347
|
-
* ```
|
|
348
|
-
*/
|
|
349
|
-
function compareKeys(first, second) {
|
|
350
|
-
if (first === second) return 0;
|
|
351
|
-
return first < second ? -1 : 1;
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Renders a JSON value with its object keys sorted, so two equal graphs written in a different
|
|
355
|
-
* order hash the same.
|
|
356
|
-
*
|
|
357
|
-
* @param value - Any JSON value.
|
|
358
|
-
* @returns The value as text.
|
|
359
|
-
* @example
|
|
360
|
-
* ```ts
|
|
361
|
-
* stableJson({ b: 1, a: 2 }); // '{"a":2,"b":1}'
|
|
362
|
-
* ```
|
|
363
|
-
*/
|
|
364
|
-
function stableJson(value) {
|
|
365
|
-
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
366
|
-
if (typeof value !== "object" || !value) return JSON.stringify(value) ?? "";
|
|
367
|
-
return `{${Object.entries(value).filter(([, item]) => item !== void 0).toSorted(([first], [second]) => compareKeys(first, second)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* Hashes a graph description. A bookmark of a plain rest node is accepted only while this hash
|
|
371
|
-
* is unchanged.
|
|
372
|
-
*
|
|
373
|
-
* @param graph - Result of `describeGraph`.
|
|
374
|
-
* @returns Eight lowercase hexadecimal characters.
|
|
375
|
-
* @example
|
|
376
|
-
* ```ts
|
|
377
|
-
* graphHash({ main: "main", flows: {}, slots: {} }); // "cfb16c03"
|
|
378
|
-
* ```
|
|
379
|
-
*/
|
|
380
|
-
function graphHash(graph) {
|
|
381
|
-
return hashText(stableJson(graph));
|
|
382
|
-
}
|
|
383
|
-
//#endregion
|
|
384
1
|
//#region src/plugins/renderer/monitor/picture.ts
|
|
385
2
|
/** Size of the badge number in CSS pixels; the picture draws it times the resolution. */
|
|
386
3
|
const BADGE_FONT_PX = 12;
|
|
@@ -1151,4 +768,4 @@ async function captureFrame(mctx, options) {
|
|
|
1151
768
|
return shot === void 0 ? void 0 : answerOf(mctx, shot, options.against);
|
|
1152
769
|
}
|
|
1153
770
|
//#endregion
|
|
1154
|
-
export { sheetAlone as a,
|
|
771
|
+
export { sheetAlone as a, servePaused as i, captureFrame as n, serveCaptures as r, cancelCaptures as t };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { N as Time, Z as Api, _ as Bookmark, b as FlowState } from "./types-ERb2Y9U5.mjs";
|
|
2
|
+
import { C as Captured, T as DebugSwitches, k as Api$2, m as Api$1 } from "./types-BWzCSRM2.mjs";
|
|
3
|
+
import { t as HeadlessApp } from "./headless-q98SwADR.mjs";
|
|
4
|
+
import { n as Command, r as ControlApp } from "./types-CDSKa0vl.mjs";
|
|
5
|
+
import { H as InputApi } from "./types-DgdMLZnV.mjs";
|
|
6
|
+
import { b as AnimApi, p as ScenesApi, t as AudioApi, v as UiApi } from "./types-DlgsfOmW.mjs";
|
|
5
7
|
|
|
6
8
|
//#region src/plugins/flow/doors/commands.d.ts
|
|
7
9
|
/**
|
package/dist/control.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { t as
|
|
1
|
+
import { t as HeadlessApp } from "./headless-q98SwADR.mjs";
|
|
2
|
+
import { i as InputArguments, n as Command, o as InputSchema, s as Ran } from "./types-CDSKa0vl.mjs";
|
|
3
|
+
import { t as commands } from "./commands-C1kIfOG5.mjs";
|
|
4
|
+
import { t as defineCommand } from "./define-BPpZcpxW.mjs";
|
|
4
5
|
|
|
5
6
|
//#region src/plugins/flow/doors/dev.d.ts
|
|
6
7
|
/**
|
package/dist/control.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { t as controlRefused } from "./dev-wKLgOw7c.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { s as defineCommand } from "./input-z_35Jwfn.mjs";
|
|
3
|
+
import { n as commands, t as run } from "./run-CUPSFM85.mjs";
|
|
4
4
|
export { commands, controlRefused, defineCommand, run };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t as HeadlessApp } from "./headless-q98SwADR.mjs";
|
|
2
|
+
import { c as Source, n as Command, o as InputSchema } from "./types-CDSKa0vl.mjs";
|
|
2
3
|
|
|
3
4
|
//#region src/plugins/flow/doors/define.d.ts
|
|
4
5
|
/**
|
|
@@ -13,7 +14,7 @@ import { c as Source, d as HeadlessApp, n as Command, o as InputSchema } from ".
|
|
|
13
14
|
* ```ts
|
|
14
15
|
* // A game's .dev module: the orders on the board, re-read after every edge.
|
|
15
16
|
* export const orders = defineSource({
|
|
16
|
-
* id: "
|
|
17
|
+
* id: "mini.orders",
|
|
17
18
|
* title: "Orders",
|
|
18
19
|
* input: {},
|
|
19
20
|
* changes: "edge",
|
|
@@ -34,7 +35,7 @@ declare function defineSource<S extends InputSchema, O, App = HeadlessApp>(sourc
|
|
|
34
35
|
* ```ts
|
|
35
36
|
* // A game's .dev module: open the shop from anywhere, through the graph.
|
|
36
37
|
* export const openShop = defineCommand({
|
|
37
|
-
* id: "
|
|
38
|
+
* id: "mini.openShop",
|
|
38
39
|
* title: "Open the shop",
|
|
39
40
|
* input: {},
|
|
40
41
|
* effect: "route",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { a as graphHash } from "./registry-DlpRCibU.mjs";
|
|
2
|
+
//#region src/plugins/flow/headless.ts
|
|
3
|
+
const noPayload = null;
|
|
4
|
+
/** Seed of a repro that pins no rng state, the seed `saveOf` writes. */
|
|
5
|
+
const defaultSeed = 1;
|
|
6
|
+
/**
|
|
7
|
+
* Turns whatever `run()` rejected with into an error that can be thrown again.
|
|
8
|
+
*
|
|
9
|
+
* @param value - The rejection value.
|
|
10
|
+
* @returns The value itself when it is an error, a wrapped one otherwise.
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* asError("offline").message; // "[game] The headless run failed.\n offline."
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
function asError(value) {
|
|
17
|
+
if (value instanceof Error) return value;
|
|
18
|
+
return /* @__PURE__ */ new Error(`[game] The headless run failed.\n ${String(value)}.`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Starts the one loop unless the app's own `onStart` already did, and keeps a fatal error until
|
|
22
|
+
* someone asks for it. `run()` rejects only on a fatal error, and nobody awaits it here.
|
|
23
|
+
*
|
|
24
|
+
* @param app - The started app.
|
|
25
|
+
* @returns The holder of the fatal error.
|
|
26
|
+
*/
|
|
27
|
+
function startLoop(app) {
|
|
28
|
+
const fatal = { error: void 0 };
|
|
29
|
+
if (app.flow.state().running) return fatal;
|
|
30
|
+
app.flow.run().catch((error) => {
|
|
31
|
+
fatal.error = error;
|
|
32
|
+
});
|
|
33
|
+
return fatal;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Waits until the loop rests for the first time, or ends. An empty route walks nowhere: it only
|
|
37
|
+
* waits for the gate the loop opens at the node it enters, so a start that is a chain of transit
|
|
38
|
+
* nodes is played out before anything reads the game.
|
|
39
|
+
*
|
|
40
|
+
* @param app - The started app whose loop is running.
|
|
41
|
+
* @param fatal - Holder of the fatal error of `run()`.
|
|
42
|
+
* @returns Resolves once the loop rests at its first rest node.
|
|
43
|
+
* @throws {Error} When the loop failed fatally instead of reaching a rest node.
|
|
44
|
+
*/
|
|
45
|
+
async function settleAtFirstRest(app, fatal) {
|
|
46
|
+
await app.flow.walk([]).catch((error) => {
|
|
47
|
+
throw asError(fatal.error ?? error);
|
|
48
|
+
});
|
|
49
|
+
if (fatal.error !== void 0) throw asError(fatal.error);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Starts an app headless: sets flow mode `"fast"`, awaits `app.start()`, starts `flow.run()`
|
|
53
|
+
* unless the app's own `onStart` already did, and waits until the loop rests for the first time.
|
|
54
|
+
* `game.state().path` therefore names the first rest node of the graph as soon as the call
|
|
55
|
+
* resolves. A fatal error of the loop rejects this call, and a later one is re-thrown by `walk`
|
|
56
|
+
* and by `stop`, so a headless test never loses it.
|
|
57
|
+
*
|
|
58
|
+
* @param app - An app that is not started yet.
|
|
59
|
+
* @returns The game: walk it, answer it, read it, stop it.
|
|
60
|
+
* @throws {Error} When the loop failed fatally before it reached its first rest node.
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* // A test starts the merge game without a screen. The call resolves at the first rest node.
|
|
64
|
+
* const game = await createHeadless(app);
|
|
65
|
+
*
|
|
66
|
+
* game.state().path; // "home": the transit node "boot" was played out
|
|
67
|
+
* game.state().mode; // "fast"
|
|
68
|
+
* await game.stop();
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
async function createHeadless(app) {
|
|
72
|
+
app.flow.setMode("fast");
|
|
73
|
+
await app.start();
|
|
74
|
+
const fatal = startLoop(app);
|
|
75
|
+
await settleAtFirstRest(app, fatal);
|
|
76
|
+
return {
|
|
77
|
+
walk: async (route) => {
|
|
78
|
+
const state = await app.flow.walk(route).catch((error) => {
|
|
79
|
+
throw asError(fatal.error ?? error);
|
|
80
|
+
});
|
|
81
|
+
if (fatal.error !== void 0) throw asError(fatal.error);
|
|
82
|
+
return state;
|
|
83
|
+
},
|
|
84
|
+
answer: (answer) => app.flow.gate.answer(answer),
|
|
85
|
+
state: () => app.flow.state(),
|
|
86
|
+
history: () => app.flow.history(),
|
|
87
|
+
stop: async () => {
|
|
88
|
+
await app.stop();
|
|
89
|
+
if (fatal.error !== void 0) throw asError(fatal.error);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Reads the rest node a repro starts at: its checkpoint, or the start of the main flow.
|
|
95
|
+
*
|
|
96
|
+
* @param app - The started app.
|
|
97
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
98
|
+
* @returns The path of the node the repro enters.
|
|
99
|
+
* @throws {Error} When the graph has no main flow to start from.
|
|
100
|
+
*/
|
|
101
|
+
function reproPath(app, repro) {
|
|
102
|
+
if (repro.checkpoint !== void 0) return repro.checkpoint;
|
|
103
|
+
const graph = app.flow.describe();
|
|
104
|
+
const start = graph.flows[graph.main]?.start;
|
|
105
|
+
if (start === void 0) throw new Error(`[game] runRepro() found no flow "${graph.main}" to start from.\n Name the checkpoint the repro was taken at.`);
|
|
106
|
+
return start;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Builds the bookmark a repro is entered with: its state, its checkpoint and the hash of the
|
|
110
|
+
* graph that runs now, so a checkpoint is accepted and any other rest node is checked. The route
|
|
111
|
+
* is not part of it: the caller walks it after the restore.
|
|
112
|
+
*
|
|
113
|
+
* @param app - The started app.
|
|
114
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
115
|
+
* @returns The bookmark to restore.
|
|
116
|
+
* @throws {Error} When the repro names no checkpoint and the graph has no main flow.
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* // The game.restore command loads a bug report taken at the "home" checkpoint.
|
|
120
|
+
* const bookmark = reproBookmark(app, { player: { coins: 7 }, checkpoint: "home", route: [] });
|
|
121
|
+
* bookmark.path; // "home"
|
|
122
|
+
* bookmark.rng; // { seed: 1, streams: {} }: the repro pinned no rng
|
|
123
|
+
* await app.flow.restore(bookmark);
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
function reproBookmark(app, repro) {
|
|
127
|
+
return {
|
|
128
|
+
path: reproPath(app, repro),
|
|
129
|
+
input: noPayload,
|
|
130
|
+
player: repro.player,
|
|
131
|
+
session: repro.session ?? {},
|
|
132
|
+
rng: repro.rng ?? {
|
|
133
|
+
seed: defaultSeed,
|
|
134
|
+
streams: {}
|
|
135
|
+
},
|
|
136
|
+
graph: graphHash(app.flow.describe())
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Plays a repro: starts the app headless, restores the bookmark built from its state and
|
|
141
|
+
* checkpoint, then walks its route. The app keeps running, so the caller can read more than the
|
|
142
|
+
* result and stops it itself.
|
|
143
|
+
*
|
|
144
|
+
* @param app - An app that is not started yet.
|
|
145
|
+
* @param repro - Starting state, optional checkpoint and the route.
|
|
146
|
+
* @returns The path the run ended at, and the committed state.
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* // A bug report on the dice game: "reset keeps my coins". Replay it from the "home" checkpoint.
|
|
150
|
+
* const reset = { at: "home", intent: "reset" };
|
|
151
|
+
* const result = await runRepro(app, { player: { coins: 7 }, checkpoint: "home", route: [reset] });
|
|
152
|
+
*
|
|
153
|
+
* result.player; // { coins: 0 }; result.path is ["home"]
|
|
154
|
+
* await app.stop(); // the app keeps running after the run
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
async function runRepro(app, repro) {
|
|
158
|
+
const game = await createHeadless(app);
|
|
159
|
+
await app.flow.restore(reproBookmark(app, repro));
|
|
160
|
+
const state = await game.walk(repro.route);
|
|
161
|
+
const ended = app.flow.bookmark();
|
|
162
|
+
return {
|
|
163
|
+
path: state.stack.map((frame) => frame.node),
|
|
164
|
+
player: ended.player,
|
|
165
|
+
session: ended.session
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Steps the frame loop by hand: `count` calls of `app.time.step(deltaMs)`. A headless game has no
|
|
170
|
+
* frame source, so this is the only thing that moves the frame phases.
|
|
171
|
+
*
|
|
172
|
+
* @param app - A started app.
|
|
173
|
+
* @param count - Number of frames.
|
|
174
|
+
* @param deltaMs - Milliseconds per frame.
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* // A headless game has no frame source: the test moves the six frame phases by hand.
|
|
178
|
+
* stepFrames(app, 2, 16);
|
|
179
|
+
* app.time.snapshot(); // frame: 2, delta: 16, elapsed: 32
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
function stepFrames(app, count, deltaMs) {
|
|
183
|
+
for (let frame = 0; frame < count; frame += 1) app.time.step(deltaMs);
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { stepFrames as i, reproBookmark as n, runRepro as r, createHeadless as t };
|