@patterkit/runtime 0.1.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/CHANGELOG.md +30 -0
- package/README.md +91 -0
- package/dist/index.cjs +1281 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +626 -0
- package/dist/index.d.ts +626 -0
- package/dist/index.js +1249 -0
- package/dist/index.js.map +1 -0
- package/dist/patterplay.min.js +3 -0
- package/dist/patterplay.min.js.map +1 -0
- package/package.json +36 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Engine: () => Engine,
|
|
24
|
+
Flow: () => Flow,
|
|
25
|
+
buildTagIndex: () => buildTagIndex,
|
|
26
|
+
effectiveGameData: () => effectiveGameData,
|
|
27
|
+
gameDataFields: () => gameDataFields,
|
|
28
|
+
gameDataValue: () => gameDataValue
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/engine.ts
|
|
33
|
+
var import_expr = require("@wildwinter/expr");
|
|
34
|
+
var import_scoperegistry = require("@wildwinter/scoperegistry");
|
|
35
|
+
var import_dialect = require("@patterkit/dialect");
|
|
36
|
+
var import_model = require("@patterkit/model");
|
|
37
|
+
|
|
38
|
+
// src/tags.ts
|
|
39
|
+
function dedupe(tags) {
|
|
40
|
+
const seen = /* @__PURE__ */ new Set();
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const t of tags) if (!seen.has(t)) {
|
|
43
|
+
seen.add(t);
|
|
44
|
+
out.push(t);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function buildTagIndex(bundle) {
|
|
49
|
+
const index = /* @__PURE__ */ new Map();
|
|
50
|
+
const visit = (node, inherited) => {
|
|
51
|
+
const acc = dedupe([...inherited, ...node.tags ?? []]);
|
|
52
|
+
index.set(node.id, acc);
|
|
53
|
+
if (node.type === "group") {
|
|
54
|
+
for (const child of node.children) visit(child, acc);
|
|
55
|
+
} else {
|
|
56
|
+
for (const beat of node.beats ?? []) index.set(beat.id, dedupe([...acc, ...beat.tags ?? []]));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
for (const scene of Object.values(bundle.scenes)) {
|
|
60
|
+
const sceneAcc = dedupe(scene.tags ?? []);
|
|
61
|
+
index.set(scene.id, sceneAcc);
|
|
62
|
+
for (const block of scene.blocks) {
|
|
63
|
+
const blockAcc = dedupe([...sceneAcc, ...block.tags ?? []]);
|
|
64
|
+
index.set(block.id, blockAcc);
|
|
65
|
+
for (const child of block.children) visit(child, blockAcc);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return index;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/engine.ts
|
|
72
|
+
var astCache = /* @__PURE__ */ new WeakMap();
|
|
73
|
+
var Engine = class _Engine {
|
|
74
|
+
host;
|
|
75
|
+
defaultSeed;
|
|
76
|
+
flowsById = /* @__PURE__ */ new Map();
|
|
77
|
+
/** Every locale's string table (the inline `bundle.strings`), kept so the active locale can be swapped
|
|
78
|
+
* live (setLocale) without rebuilding the engine. Reassigned wholesale by `replaceStrings`
|
|
79
|
+
* (live bundle refresh, tier 1), hence not readonly. */
|
|
80
|
+
allStrings;
|
|
81
|
+
/** The currently active locale (string lookups + character names resolve in it). */
|
|
82
|
+
currentLocale;
|
|
83
|
+
/** True for a source-only DEBUG build (`localisation: { mode: "ids", sourceDebug: true }`) - the strings
|
|
84
|
+
* are the source language, embedded only so the build can be played; not a shippable localised build. */
|
|
85
|
+
sourceDebug;
|
|
86
|
+
/** Host-facing addresses (spec §6): scene gameId -> internal id (project-wide), and per-scene
|
|
87
|
+
* block gameId -> internal id. The effective gameId falls back to the name slug when unpinned. */
|
|
88
|
+
sceneGameIdToId = /* @__PURE__ */ new Map();
|
|
89
|
+
blockGameIdToId = /* @__PURE__ */ new Map();
|
|
90
|
+
/** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
|
|
91
|
+
* engine keeps the same world resolver, custom RNG, and diagnostic hooks. */
|
|
92
|
+
creationOptions;
|
|
93
|
+
constructor(bundle, options = {}) {
|
|
94
|
+
this.creationOptions = options;
|
|
95
|
+
const locale = options.locale ?? bundle.locales.default;
|
|
96
|
+
const allStrings = bundle.strings;
|
|
97
|
+
this.allStrings = allStrings;
|
|
98
|
+
this.currentLocale = locale;
|
|
99
|
+
const strings = allStrings[locale] ?? {};
|
|
100
|
+
const defaultStrings = allStrings[bundle.locales.default] ?? {};
|
|
101
|
+
const loc = bundle.localisation;
|
|
102
|
+
const emitIds = loc?.mode === "ids" && !loc.sourceDebug;
|
|
103
|
+
this.sourceDebug = loc?.mode === "ids" && !!loc.sourceDebug;
|
|
104
|
+
if (this.sourceDebug && typeof console !== "undefined") {
|
|
105
|
+
console.warn("[Patterplay] source-only DEBUG build: strings are the source language for debugging, not a shippable localised build.");
|
|
106
|
+
}
|
|
107
|
+
const castDisplay = /* @__PURE__ */ new Map();
|
|
108
|
+
for (const c of bundle.cast ?? []) if (c.displayName) castDisplay.set(c.name, c.displayName);
|
|
109
|
+
this.defaultSeed = (options.seed ?? 2654435769) >>> 0;
|
|
110
|
+
const nodeIndex = /* @__PURE__ */ new Map();
|
|
111
|
+
const blockIndex = /* @__PURE__ */ new Map();
|
|
112
|
+
const blockById = /* @__PURE__ */ new Map();
|
|
113
|
+
for (const [sceneId, scene] of Object.entries(bundle.scenes)) {
|
|
114
|
+
this.sceneGameIdToId.set((0, import_model.effectiveGameId)(scene), sceneId);
|
|
115
|
+
const blockAddrs = /* @__PURE__ */ new Map();
|
|
116
|
+
for (const block of scene.blocks) {
|
|
117
|
+
blockIndex.set(block.id, { sceneId });
|
|
118
|
+
blockById.set(block.id, block);
|
|
119
|
+
blockAddrs.set((0, import_model.effectiveGameId)(block), block.id);
|
|
120
|
+
(0, import_model.walkNodes)(block.children, (n) => nodeIndex.set(n.id, n));
|
|
121
|
+
}
|
|
122
|
+
this.blockGameIdToId.set(sceneId, blockAddrs);
|
|
123
|
+
}
|
|
124
|
+
const props = bundle.properties ?? [];
|
|
125
|
+
const patterSharedDecls = props.filter((p) => p.shared ?? true).map(toDecl);
|
|
126
|
+
const patterLocalDecls = props.filter((p) => !(p.shared ?? true)).map(toDecl);
|
|
127
|
+
const patterSharedNames = new Set(patterSharedDecls.map((d) => d.name.toLowerCase()));
|
|
128
|
+
const shared = new import_scoperegistry.ScopeRegistry().defineOwned("patter", patterSharedDecls);
|
|
129
|
+
const hostBound = /* @__PURE__ */ new Set();
|
|
130
|
+
if (options.world) {
|
|
131
|
+
const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
|
|
132
|
+
const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
|
|
133
|
+
shared.defineForeign("world", options.world, decls, worldSpec?.writable ?? true);
|
|
134
|
+
hostBound.add("world");
|
|
135
|
+
}
|
|
136
|
+
for (const spec of bundle.scopeRegistry?.scopes ?? []) {
|
|
137
|
+
if (hostBound.has(spec.token)) continue;
|
|
138
|
+
const decls = (spec.declarations ?? []).map(toForeignDecl);
|
|
139
|
+
shared.defineForeign(spec.token, selfBackedResolver(spec.declarations ?? []), decls, spec.writable ?? true);
|
|
140
|
+
}
|
|
141
|
+
const sceneSharedNames = /* @__PURE__ */ new Map();
|
|
142
|
+
for (const [sceneId, scene] of Object.entries(bundle.scenes)) {
|
|
143
|
+
const names = new Set((scene.sceneProps ?? []).filter((p) => p.shared ?? false).map((p) => p.name.toLowerCase()));
|
|
144
|
+
sceneSharedNames.set(sceneId, names);
|
|
145
|
+
}
|
|
146
|
+
this.host = {
|
|
147
|
+
bundle,
|
|
148
|
+
emitIds,
|
|
149
|
+
strings,
|
|
150
|
+
defaultStrings,
|
|
151
|
+
castDisplay,
|
|
152
|
+
nodeIndex,
|
|
153
|
+
blockIndex,
|
|
154
|
+
blockById,
|
|
155
|
+
tagIndex: buildTagIndex(bundle),
|
|
156
|
+
shared,
|
|
157
|
+
patterSharedDecls,
|
|
158
|
+
patterLocalDecls,
|
|
159
|
+
patterSharedNames,
|
|
160
|
+
sceneSharedNames,
|
|
161
|
+
sharedVisits: /* @__PURE__ */ new Map(),
|
|
162
|
+
sharedSelectors: /* @__PURE__ */ new Map(),
|
|
163
|
+
stageBags: /* @__PURE__ */ new Map(),
|
|
164
|
+
customRng: options.rng,
|
|
165
|
+
onDryChoice: options.onDryChoice,
|
|
166
|
+
replayPromptOnChoose: options.replayPromptOnChoose ?? false,
|
|
167
|
+
captionsOn: options.closedCaptions ?? true,
|
|
168
|
+
// captions shown by default (full text)
|
|
169
|
+
captionOpen: (bundle.closedCaptions ?? import_model.DEFAULT_CAPTION_DELIMITERS).open,
|
|
170
|
+
captionClose: (bundle.closedCaptions ?? import_model.DEFAULT_CAPTION_DELIMITERS).close,
|
|
171
|
+
captionCharacter: bundle.closedCaptions?.character || import_model.DEFAULT_CAPTION_CHARACTER,
|
|
172
|
+
// absent/empty -> SFX
|
|
173
|
+
refSplitCache: /* @__PURE__ */ new Map()
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** The active locale (string + character-name lookups resolve in it). */
|
|
177
|
+
get locale() {
|
|
178
|
+
return this.currentLocale;
|
|
179
|
+
}
|
|
180
|
+
/** True for a source-only DEBUG build: the embedded strings are the source language (for debugging),
|
|
181
|
+
* not a shippable localised build. An IDs-only ship build is `false`. */
|
|
182
|
+
get isSourceDebug() {
|
|
183
|
+
return this.sourceDebug;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Switch the active locale LIVE - a real game's "language" setting can change mid-session. Subsequent
|
|
187
|
+
* string lookups (new beats, re-resolved character names, `{@ref}` interpolation) render in the new
|
|
188
|
+
* locale; everything else - flow position, `@patter`/`@scene` state, visit counts, the PRNG - is
|
|
189
|
+
* untouched (already-emitted text isn't retro-translated; that's the host's call). A locale with no
|
|
190
|
+
* table resolves every string via the `<Untranslated: {id}>` source fallback. All open flows share the
|
|
191
|
+
* engine's string table, so the swap reaches every flow at once.
|
|
192
|
+
*/
|
|
193
|
+
setLocale(locale) {
|
|
194
|
+
this.currentLocale = locale;
|
|
195
|
+
this.host.strings = this.allStrings[locale] ?? {};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Live bundle refresh, tier 1 (strings only): swap every locale's string table in place from a
|
|
199
|
+
* freshly compiled bundle whose STRUCTURE is unchanged (same `content.structureHash`). Like
|
|
200
|
+
* setLocale, nothing restarts and no flow is touched: the next delivered beat reads the new text,
|
|
201
|
+
* `{@ref}` slots re-interpolate, and beats the host already received keep the words it saw. The
|
|
202
|
+
* swap reaches every open flow at once and is not part of save state. Structural edits need the
|
|
203
|
+
* full save/load hot swap instead (a structure change here simply won't show).
|
|
204
|
+
*/
|
|
205
|
+
replaceStrings(bundle) {
|
|
206
|
+
this.allStrings = bundle.strings;
|
|
207
|
+
this.host.strings = this.allStrings[this.currentLocale] ?? {};
|
|
208
|
+
this.host.defaultStrings = this.allStrings[this.host.bundle.locales.default] ?? {};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Live bundle refresh, tier 2 (full swap): rebuild on an edited bundle with the whole run carried
|
|
212
|
+
* over. Snapshot (`saveGame`), construct a fresh engine on `bundle` with THIS engine's original
|
|
213
|
+
* options (same world resolver, RNG, hooks), restore (`loadGame`), and carry over the presentation
|
|
214
|
+
* state that deliberately isn't save state (active locale, closed-captions toggle). The
|
|
215
|
+
* content-drift policy (§9.8) resolves edits under the cursor: stack frames re-find their next
|
|
216
|
+
* child by id, drifted options drop, a vanished snippet is skipped.
|
|
217
|
+
*
|
|
218
|
+
* Returns the REPLACEMENT engine; this one is left untouched and should be discarded. Hosts
|
|
219
|
+
* re-bind their flow handles via `next.getFlow(id)`. If the restore throws (defensive - §9.8
|
|
220
|
+
* makes this unreachable for ordinary edits), the swap falls back to a cold engine with each
|
|
221
|
+
* saved flow restarted from the top of the scene it was in.
|
|
222
|
+
*/
|
|
223
|
+
hotSwap(bundle) {
|
|
224
|
+
const snapshot = this.saveGame();
|
|
225
|
+
const carryOver = (next2) => {
|
|
226
|
+
next2.setLocale(this.currentLocale);
|
|
227
|
+
next2.setClosedCaptions(this.host.captionsOn);
|
|
228
|
+
return next2;
|
|
229
|
+
};
|
|
230
|
+
const next = new _Engine(bundle, this.creationOptions);
|
|
231
|
+
try {
|
|
232
|
+
next.loadGame(snapshot);
|
|
233
|
+
return carryOver(next);
|
|
234
|
+
} catch {
|
|
235
|
+
const fresh = new _Engine(bundle, this.creationOptions);
|
|
236
|
+
for (const [id, f] of Object.entries(snapshot.flows)) {
|
|
237
|
+
const sceneId = f.cursor.currentSceneId;
|
|
238
|
+
try {
|
|
239
|
+
fresh.openFlow(id, sceneId !== null ? { scene: sceneId } : {});
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return carryOver(fresh);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Whether closed captions are currently shown (full dialogue text). */
|
|
247
|
+
get closedCaptions() {
|
|
248
|
+
return this.host.captionsOn;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Turn closed captions on/off LIVE (#214). When OFF, subsequent dialogue lines have their caption
|
|
252
|
+
* cues (`[sigh]` etc., between the project's delimiters) and the surrounding whitespace stripped;
|
|
253
|
+
* narration, choice prompts, and everything else are untouched. Like setLocale this is a presentation
|
|
254
|
+
* toggle - it reaches every open flow at once and isn't part of save state; already-emitted text is
|
|
255
|
+
* not retro-edited. An IDs-only game applies the same rule itself via `flow.stripCaptions`.
|
|
256
|
+
*/
|
|
257
|
+
setClosedCaptions(on) {
|
|
258
|
+
this.host.captionsOn = on;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Open (and start) a named flow. Each flow has its own cursor, PRNG, and per-flow
|
|
262
|
+
* half of the scopes (not-shared `@patter`/`@scene`); all flows share the shared
|
|
263
|
+
* half. Re-opening an existing id replaces it with a fresh flow.
|
|
264
|
+
*/
|
|
265
|
+
openFlow(id, opts = {}) {
|
|
266
|
+
const sceneId = this.resolveSceneRef(opts.scene);
|
|
267
|
+
const blockId = this.resolveBlockRef(sceneId, opts.block);
|
|
268
|
+
const flow = new Flow(id, this.host, opts.seed ?? this.defaultSeed);
|
|
269
|
+
this.flowsById.set(id, flow);
|
|
270
|
+
flow.start(sceneId, blockId);
|
|
271
|
+
return flow;
|
|
272
|
+
}
|
|
273
|
+
/** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
|
|
274
|
+
resolveSceneRef(ref) {
|
|
275
|
+
if (ref == null) return void 0;
|
|
276
|
+
if (this.host.bundle.scenes[ref]) return ref;
|
|
277
|
+
return this.sceneGameIdToId.get(ref) ?? ref;
|
|
278
|
+
}
|
|
279
|
+
/** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
|
|
280
|
+
resolveBlockRef(sceneId, ref) {
|
|
281
|
+
if (ref == null) return void 0;
|
|
282
|
+
if (this.host.blockById.has(ref)) return ref;
|
|
283
|
+
if (sceneId != null) {
|
|
284
|
+
const id = this.blockGameIdToId.get(sceneId)?.get(ref);
|
|
285
|
+
if (id) return id;
|
|
286
|
+
}
|
|
287
|
+
return ref;
|
|
288
|
+
}
|
|
289
|
+
/** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
|
|
290
|
+
* The inverse of the resolve helpers - for a host that wants to display / log the address. */
|
|
291
|
+
sceneAddress(sceneId) {
|
|
292
|
+
const scene = this.host.bundle.scenes[sceneId];
|
|
293
|
+
return scene ? (0, import_model.effectiveGameId)(scene) : void 0;
|
|
294
|
+
}
|
|
295
|
+
blockAddress(blockId) {
|
|
296
|
+
const block = this.host.blockById.get(blockId);
|
|
297
|
+
return block ? (0, import_model.effectiveGameId)(block) : void 0;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Author tags (#215) accumulated for a beat by id: its own tags unioned with every ancestor's
|
|
301
|
+
* (scene → block → group(s) → snippet → beat), deduped, outermost-first. The same value the beat's
|
|
302
|
+
* delivered step carries. Empty array for an unknown id or a beat with no tags anywhere up the chain.
|
|
303
|
+
*/
|
|
304
|
+
tagsForBeat(beatId) {
|
|
305
|
+
return this.host.tagIndex.get(beatId) ?? [];
|
|
306
|
+
}
|
|
307
|
+
/** A scene's own tags (by internal id or gameId address). Empty when none / unknown. */
|
|
308
|
+
tagsForScene(sceneRef) {
|
|
309
|
+
const id = this.resolveSceneRef(sceneRef);
|
|
310
|
+
return (id != null ? this.host.tagIndex.get(id) : void 0) ?? [];
|
|
311
|
+
}
|
|
312
|
+
/** A block's accumulated tags (scene + block), by scene + block ref (id or gameId). Empty when none / unknown. */
|
|
313
|
+
tagsForBlock(sceneRef, blockRef) {
|
|
314
|
+
const sceneId = this.resolveSceneRef(sceneRef);
|
|
315
|
+
const id = this.resolveBlockRef(sceneId, blockRef);
|
|
316
|
+
return (id != null ? this.host.tagIndex.get(id) : void 0) ?? [];
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The authored structure as a nested tree: scenes -> blocks -> children (groups + snippets, groups
|
|
320
|
+
* preserved) -> a snippet's beats. Static (no flow / play state); per-beat data is read at the source
|
|
321
|
+
* locale. For dev tooling that builds against the writer's structure (see also {@link getBeatSequence}).
|
|
322
|
+
*/
|
|
323
|
+
getOutline() {
|
|
324
|
+
return Object.values(this.host.bundle.scenes).map((scene) => ({
|
|
325
|
+
id: scene.id,
|
|
326
|
+
...(0, import_model.effectiveGameId)(scene) ? { gameId: (0, import_model.effectiveGameId)(scene) } : {},
|
|
327
|
+
name: scene.name,
|
|
328
|
+
...this.tagsField(scene.id),
|
|
329
|
+
blocks: scene.blocks.map((block) => ({
|
|
330
|
+
id: block.id,
|
|
331
|
+
...(0, import_model.effectiveGameId)(block) ? { gameId: (0, import_model.effectiveGameId)(block) } : {},
|
|
332
|
+
name: block.name,
|
|
333
|
+
...this.tagsField(block.id),
|
|
334
|
+
children: block.children.map((n) => this.outlineNode(n))
|
|
335
|
+
}))
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Every beat in document order, flattened (through groups), each with the scene / block / snippet it
|
|
340
|
+
* belongs to and its static data. The linear view of {@link getOutline} - hand it to a tool that lays
|
|
341
|
+
* one item per beat (e.g. an Unreal Sequencer of subsequences).
|
|
342
|
+
*/
|
|
343
|
+
getBeatSequence() {
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const scene of Object.values(this.host.bundle.scenes)) {
|
|
346
|
+
for (const block of scene.blocks) {
|
|
347
|
+
(0, import_model.walkNodes)(block.children, (n) => {
|
|
348
|
+
if (n.type !== "snippet") return;
|
|
349
|
+
for (const beat of n.beats ?? []) {
|
|
350
|
+
out.push({ sceneId: scene.id, blockId: block.id, snippetId: n.id, beat: this.beatInfo(beat) });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
/** A node's outline entry: a group (selector + prompt + children) or a snippet (beats + jump). */
|
|
358
|
+
outlineNode(n) {
|
|
359
|
+
if (n.type === "group") {
|
|
360
|
+
return {
|
|
361
|
+
type: "group",
|
|
362
|
+
id: n.id,
|
|
363
|
+
...this.tagsField(n.id),
|
|
364
|
+
...n.selector ? { selector: n.selector } : {},
|
|
365
|
+
...n.prompt ? { prompt: this.beatInfo(n.prompt) } : {},
|
|
366
|
+
children: n.children.map((c) => this.outlineNode(c))
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
type: "snippet",
|
|
371
|
+
id: n.id,
|
|
372
|
+
...this.tagsField(n.id),
|
|
373
|
+
beats: (n.beats ?? []).map((b) => this.beatInfo(b)),
|
|
374
|
+
...n.jump ? { jumpTo: n.jump.to, ...n.jump.mode ? { jumpMode: n.jump.mode } : {} } : {}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** One beat's static data (source locale), the same shape a delivered step carries. */
|
|
378
|
+
beatInfo(beat) {
|
|
379
|
+
const tags = this.host.tagIndex.get(beat.id);
|
|
380
|
+
const info = { id: beat.id, kind: beat.kind };
|
|
381
|
+
if (beat.kind === "line") {
|
|
382
|
+
if (beat.character !== void 0) {
|
|
383
|
+
info.character = beat.character;
|
|
384
|
+
const name = this.host.defaultStrings[(0, import_model.castStringKey)(beat.character)] ?? this.host.castDisplay.get(beat.character);
|
|
385
|
+
if (name !== void 0) info.characterName = name;
|
|
386
|
+
}
|
|
387
|
+
if (beat.direction !== void 0) info.direction = beat.direction;
|
|
388
|
+
}
|
|
389
|
+
if (beat.kind === "line" || beat.kind === "text") {
|
|
390
|
+
const source = this.host.defaultStrings[beat.id];
|
|
391
|
+
if (source !== void 0) info.text = source;
|
|
392
|
+
}
|
|
393
|
+
if (beat.gameData && Object.keys(beat.gameData).length) info.gameData = beat.gameData;
|
|
394
|
+
if (tags && tags.length) info.tags = tags;
|
|
395
|
+
return info;
|
|
396
|
+
}
|
|
397
|
+
/** A `{ tags }` fragment for an id, present only when the id has accumulated tags (keeps output tidy). */
|
|
398
|
+
tagsField(id) {
|
|
399
|
+
const tags = this.host.tagIndex.get(id);
|
|
400
|
+
return tags && tags.length ? { tags } : {};
|
|
401
|
+
}
|
|
402
|
+
/** Retrieve an open flow by id (undefined if none / closed). */
|
|
403
|
+
getFlow(id) {
|
|
404
|
+
return this.flowsById.get(id);
|
|
405
|
+
}
|
|
406
|
+
/** All currently-open flows. */
|
|
407
|
+
flows() {
|
|
408
|
+
return [...this.flowsById.values()];
|
|
409
|
+
}
|
|
410
|
+
/** Close (remove) a flow. */
|
|
411
|
+
closeFlow(id) {
|
|
412
|
+
this.flowsById.delete(id);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Reset the whole game to its initial state: drop every flow, re-seed the shared
|
|
416
|
+
* `@patter` globals to their declared defaults, and clear all shared state (shared
|
|
417
|
+
* `@scene` bags, world visit counts). World properties are host-owned and untouched.
|
|
418
|
+
* After reset, open fresh flows with `openFlow`.
|
|
419
|
+
*/
|
|
420
|
+
reset() {
|
|
421
|
+
this.flowsById.clear();
|
|
422
|
+
this.host.shared.reseedOwned("patter", this.host.patterSharedDecls);
|
|
423
|
+
this.host.sharedVisits.clear();
|
|
424
|
+
this.host.sharedSelectors.clear();
|
|
425
|
+
this.host.stageBags.clear();
|
|
426
|
+
}
|
|
427
|
+
/** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
|
|
428
|
+
getProperty(ref) {
|
|
429
|
+
const { scope, name } = this.splitShared(ref);
|
|
430
|
+
return this.host.shared.get(scope, name);
|
|
431
|
+
}
|
|
432
|
+
/** Write a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
|
|
433
|
+
setProperty(ref, value) {
|
|
434
|
+
const { scope, name } = this.splitShared(ref);
|
|
435
|
+
this.host.shared.set(scope, name, value);
|
|
436
|
+
}
|
|
437
|
+
/** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
|
|
438
|
+
* value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
|
|
439
|
+
listProperties() {
|
|
440
|
+
return this.host.patterSharedDecls.map((d) => ({
|
|
441
|
+
ref: `@${d.name}`,
|
|
442
|
+
type: d.type,
|
|
443
|
+
values: d.values,
|
|
444
|
+
value: this.getProperty(`@${d.name}`),
|
|
445
|
+
default: declDefault(d)
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
// @scene is scene-namespaced and needs a flow's current scene - silently
|
|
449
|
+
// routing it into the shared bag (as a junk "scene.x" key) was a trap.
|
|
450
|
+
splitShared(ref) {
|
|
451
|
+
let split = this.host.refSplitCache.get(ref);
|
|
452
|
+
if (!split) {
|
|
453
|
+
split = (0, import_dialect.splitRef)(ref, (t) => t === "scene" || this.host.shared.has(t));
|
|
454
|
+
this.host.refSplitCache.set(ref, split);
|
|
455
|
+
}
|
|
456
|
+
if (split.scope === "scene") {
|
|
457
|
+
throw new Error(`'${ref}': @scene properties are scene-scoped - read/write them on a Flow, not the Engine`);
|
|
458
|
+
}
|
|
459
|
+
return split;
|
|
460
|
+
}
|
|
461
|
+
/** Snapshot shared `@patter` state only (for a unified cross-engine save blob, Phase D). */
|
|
462
|
+
save() {
|
|
463
|
+
return this.host.shared.save();
|
|
464
|
+
}
|
|
465
|
+
/** Restore shared `@patter` values (world properties untouched). */
|
|
466
|
+
load(blob) {
|
|
467
|
+
this.host.shared.load(blob);
|
|
468
|
+
}
|
|
469
|
+
/** Snapshot the whole game: shared `@patter` + visit counts + every live flow. */
|
|
470
|
+
saveGame() {
|
|
471
|
+
const flows = {};
|
|
472
|
+
for (const [id, flow] of this.flowsById) flows[id] = flow.snapshot();
|
|
473
|
+
return {
|
|
474
|
+
version: 2,
|
|
475
|
+
shared: this.host.shared.save(),
|
|
476
|
+
sharedVisits: Object.fromEntries(this.host.sharedVisits),
|
|
477
|
+
sharedSelectors: serialiseSelectors(this.host.sharedSelectors),
|
|
478
|
+
stageBags: Object.fromEntries([...this.host.stageBags].map(([s, bag]) => [s, { ...bag }])),
|
|
479
|
+
flows
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/** Restore a `saveGame()`: shared globals + visit counts + shared scene bags + reconstruct every flow. */
|
|
483
|
+
loadGame(save) {
|
|
484
|
+
if (save.version !== 2) throw new Error(`unsupported save version: ${save.version}`);
|
|
485
|
+
this.host.shared.load(save.shared);
|
|
486
|
+
this.host.sharedVisits.clear();
|
|
487
|
+
for (const [id, n] of Object.entries(save.sharedVisits ?? {})) this.host.sharedVisits.set(id, n);
|
|
488
|
+
this.host.sharedSelectors.clear();
|
|
489
|
+
for (const [id, st] of deserialiseSelectors(save.sharedSelectors)) this.host.sharedSelectors.set(id, st);
|
|
490
|
+
this.host.stageBags.clear();
|
|
491
|
+
for (const [s, bag] of Object.entries(save.stageBags ?? {})) this.host.stageBags.set(s, { ...bag });
|
|
492
|
+
this.flowsById.clear();
|
|
493
|
+
for (const [id, snap] of Object.entries(save.flows)) {
|
|
494
|
+
const flow = new Flow(id, this.host, this.defaultSeed);
|
|
495
|
+
flow.restore(snap);
|
|
496
|
+
this.flowsById.set(id, flow);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
var Flow = class {
|
|
501
|
+
id;
|
|
502
|
+
host;
|
|
503
|
+
local;
|
|
504
|
+
// owns "patter" = the NOT-shared globals (this flow's copy)
|
|
505
|
+
rngState;
|
|
506
|
+
// Execution cursor. The `stack` is the continuation stack: each frame is a
|
|
507
|
+
// position within a block's children (the top frame is the active block run;
|
|
508
|
+
// lower frames are pending call-returns). A snippet's beats deliver from
|
|
509
|
+
// `activeSnippet`/`beatIndex`.
|
|
510
|
+
started = false;
|
|
511
|
+
flowEnded = false;
|
|
512
|
+
currentSceneId = null;
|
|
513
|
+
stack = [];
|
|
514
|
+
activeSnippet = null;
|
|
515
|
+
beatIndex = 0;
|
|
516
|
+
pendingChoice = null;
|
|
517
|
+
/** When `replayPromptOnChoose`, the chosen option's prompt beat to deliver before its content. */
|
|
518
|
+
pendingPromptBeat = null;
|
|
519
|
+
/** The chosen option that owns `pendingPromptBeat`, so a save taken between choose() and the next
|
|
520
|
+
* advance() can re-derive the prompt on load (the beat isn't otherwise reachable by id). */
|
|
521
|
+
pendingPromptOwnerId = null;
|
|
522
|
+
selectors = /* @__PURE__ */ new Map();
|
|
523
|
+
/** Per-node entry counts for this flow (node id -> times entered). */
|
|
524
|
+
visitCounts = /* @__PURE__ */ new Map();
|
|
525
|
+
// Per-flow halves of the two scopes. The NOT-shared `@patter` globals live in
|
|
526
|
+
// `local` (owned scope "patter"); the NOT-shared `@scene` props live in
|
|
527
|
+
// `sceneBags` (namespaced per scene; they PERSIST across re-entries, spec §7).
|
|
528
|
+
// The SHARED halves live on the host (`host.shared` / `host.stageBags`). Each
|
|
529
|
+
// resolver presents one merged scope, routing each property to its half by the
|
|
530
|
+
// declared `shared` flag.
|
|
531
|
+
sceneBags = /* @__PURE__ */ new Map();
|
|
532
|
+
patterResolver = {
|
|
533
|
+
get: (n) => this.host.patterSharedNames.has(n) ? this.host.shared.get("patter", n) : this.local.get("patter", n),
|
|
534
|
+
set: (n, v) => {
|
|
535
|
+
if (this.host.patterSharedNames.has(n)) this.host.shared.set("patter", n, v);
|
|
536
|
+
else this.local.set("patter", n, v);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
sceneResolver = {
|
|
540
|
+
get: (n) => {
|
|
541
|
+
const s = this.currentSceneId;
|
|
542
|
+
if (s === null) return void 0;
|
|
543
|
+
const bag = this.host.sceneSharedNames.get(s)?.has(n) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
|
|
544
|
+
return bag?.[n];
|
|
545
|
+
},
|
|
546
|
+
set: (n, v) => {
|
|
547
|
+
const s = this.currentSceneId;
|
|
548
|
+
if (s === null) return;
|
|
549
|
+
const bag = this.host.sceneSharedNames.get(s)?.has(n) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
|
|
550
|
+
if (bag) bag[n] = v;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
// The eval context is built ONCE: every constituent resolves live state at
|
|
554
|
+
// call time (shared bags mutate in place per scoperegistry's contract;
|
|
555
|
+
// patter/scene route through this flow's resolvers, which read the current
|
|
556
|
+
// `local`/`sceneBags`/`currentSceneId`; the host callbacks read current flow
|
|
557
|
+
// fields). Rebuilding it per evaluation was the engine's hottest allocation.
|
|
558
|
+
evalCtx;
|
|
559
|
+
constructor(id, host, seed) {
|
|
560
|
+
this.id = id;
|
|
561
|
+
this.host = host;
|
|
562
|
+
this.rngState = seed >>> 0;
|
|
563
|
+
this.local = this.freshLocal();
|
|
564
|
+
const scopes = { ...host.shared.toEvalContext().scopes };
|
|
565
|
+
scopes["patter"] = this.patterResolver;
|
|
566
|
+
scopes["scene"] = this.sceneResolver;
|
|
567
|
+
this.evalCtx = {
|
|
568
|
+
scopes,
|
|
569
|
+
host: {
|
|
570
|
+
nextRandom: this.rng,
|
|
571
|
+
visits: (id2) => this.visitCounts.get(id2) ?? 0,
|
|
572
|
+
patterVisits: (id2) => this.host.sharedVisits.get(id2) ?? 0
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// -- Host API -------------------------------------------------------------
|
|
577
|
+
/** Begin this flow at a scene (and optionally a specific block within it). */
|
|
578
|
+
start(sceneId, blockId) {
|
|
579
|
+
this.sceneBags.clear();
|
|
580
|
+
this.local = this.freshLocal();
|
|
581
|
+
this.selectors.clear();
|
|
582
|
+
this.visitCounts.clear();
|
|
583
|
+
this.stack = [];
|
|
584
|
+
this.currentSceneId = null;
|
|
585
|
+
this.flowEnded = false;
|
|
586
|
+
this.activeSnippet = null;
|
|
587
|
+
this.beatIndex = 0;
|
|
588
|
+
this.pendingChoice = null;
|
|
589
|
+
this.started = true;
|
|
590
|
+
if (blockId) {
|
|
591
|
+
const loc = this.host.blockIndex.get(blockId);
|
|
592
|
+
if (!loc) throw new Error(`unknown block: ${blockId}`);
|
|
593
|
+
this.enterSceneSetup(loc.sceneId);
|
|
594
|
+
this.stack = [{ sceneId: loc.sceneId, containerId: blockId, index: 0 }];
|
|
595
|
+
this.enter(blockId);
|
|
596
|
+
} else {
|
|
597
|
+
const id = sceneId ?? Object.keys(this.host.bundle.scenes)[0];
|
|
598
|
+
const scene = id ? this.host.bundle.scenes[id] : void 0;
|
|
599
|
+
if (!scene) throw new Error(id ? `unknown scene: ${id}` : "no scenes in bundle");
|
|
600
|
+
this.enterSceneSetup(id);
|
|
601
|
+
const first = scene.blocks[0];
|
|
602
|
+
if (first) {
|
|
603
|
+
this.stack = [{ sceneId: id, containerId: first.id, index: 0 }];
|
|
604
|
+
this.enter(first.id);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
this.settle();
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Forget everything in this flow and begin again - its per-flow state (not-shared
|
|
611
|
+
* `@patter` globals + `@scene` props), cursor, callstack, selector cursors, and
|
|
612
|
+
* visit counts. Shared state (shared `@patter` / `@scene`, world visit counts) is
|
|
613
|
+
* untouched. A clearer-named alias of `start()`.
|
|
614
|
+
*/
|
|
615
|
+
reset(sceneId, blockId) {
|
|
616
|
+
this.start(sceneId, blockId);
|
|
617
|
+
}
|
|
618
|
+
/** The scene the cursor is currently in - set on entry and whenever a jump crosses scenes. Read
|
|
619
|
+
* right after `advance()` to know which scene the just-played beat lives in (tooling that mirrors
|
|
620
|
+
* the playhead, e.g. an editor following a cross-scene jump). `null` before the flow has started. */
|
|
621
|
+
get currentScene() {
|
|
622
|
+
return this.currentSceneId;
|
|
623
|
+
}
|
|
624
|
+
/** Run until the next line, game event, choice, or the end of the flow. */
|
|
625
|
+
advance() {
|
|
626
|
+
if (!this.started) throw new Error("flow has not been started");
|
|
627
|
+
if (this.pendingPromptBeat) {
|
|
628
|
+
const b = this.pendingPromptBeat;
|
|
629
|
+
this.pendingPromptBeat = null;
|
|
630
|
+
this.pendingPromptOwnerId = null;
|
|
631
|
+
return this.beatResult(b);
|
|
632
|
+
}
|
|
633
|
+
this.settle();
|
|
634
|
+
if (this.flowEnded) return { type: "end" };
|
|
635
|
+
if (this.pendingChoice) return { type: "choice", groupId: this.pendingChoice.groupId, options: this.pendingChoice.options };
|
|
636
|
+
if (!this.activeSnippet) {
|
|
637
|
+
this.flowEnded = true;
|
|
638
|
+
return { type: "end" };
|
|
639
|
+
}
|
|
640
|
+
return this.beatResult(this.activeSnippet.beats[this.beatIndex++]);
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Advance repeatedly, collecting every played beat, until a choice or the end - the "play to the
|
|
644
|
+
* next stop" a host's play UI / tooling wants. The terminal `choice` / `end` is returned as `stop`;
|
|
645
|
+
* `played` holds the line / text / game-event results walked on the way to it. Termination is guaranteed
|
|
646
|
+
* (each `advance()` makes progress or `settle()` throws on a contentless jump cycle).
|
|
647
|
+
*/
|
|
648
|
+
advanceToStop() {
|
|
649
|
+
const played = [];
|
|
650
|
+
for (; ; ) {
|
|
651
|
+
const r = this.advance();
|
|
652
|
+
if (r.type === "choice" || r.type === "end") return { played, stop: r };
|
|
653
|
+
played.push(r);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Drive the cursor to the next *deliverable* stop: a beat ready on the active
|
|
658
|
+
* snippet, a pending choice, or the end. Runs onExit/jump seams and walks the
|
|
659
|
+
* block run (sequentially, skipping ineligible children); a finished block pops
|
|
660
|
+
* to its caller (call-return) or ends the flow.
|
|
661
|
+
*/
|
|
662
|
+
settle() {
|
|
663
|
+
let transitions = 0;
|
|
664
|
+
for (; ; ) {
|
|
665
|
+
if (++transitions > 1e4) {
|
|
666
|
+
throw new Error("flow did not settle after 10000 transitions - likely a jump cycle with no deliverable content");
|
|
667
|
+
}
|
|
668
|
+
if (this.flowEnded || this.pendingChoice) return;
|
|
669
|
+
if (this.activeSnippet) {
|
|
670
|
+
if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
|
|
671
|
+
this.runEffects(this.activeSnippet.onExit);
|
|
672
|
+
const jump = this.activeSnippet.jump;
|
|
673
|
+
this.activeSnippet = null;
|
|
674
|
+
this.beatIndex = 0;
|
|
675
|
+
this.resolveJump(jump);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const frame = this.stack[this.stack.length - 1];
|
|
679
|
+
if (!frame) {
|
|
680
|
+
this.flowEnded = true;
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (frame.sceneId !== this.currentSceneId) this.currentSceneId = frame.sceneId;
|
|
684
|
+
const children = this.childrenOf(frame.containerId);
|
|
685
|
+
if (!children) {
|
|
686
|
+
this.stack.pop();
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
|
|
690
|
+
if (frame.index >= children.length) {
|
|
691
|
+
this.stack.pop();
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
this.enterChild(children[frame.index++]);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/** The options of a pending choice (empty when not at a choice point). */
|
|
698
|
+
getChoices() {
|
|
699
|
+
return this.pendingChoice?.options ?? [];
|
|
700
|
+
}
|
|
701
|
+
/** Pick an eligible option by id; the next `advance()` runs it. */
|
|
702
|
+
choose(id) {
|
|
703
|
+
const choice = this.pendingChoice;
|
|
704
|
+
if (!choice) throw new Error("no choice is pending");
|
|
705
|
+
const option = choice.options.find((o) => o.id === id);
|
|
706
|
+
if (!option) throw new Error(`unknown choice option: ${id}`);
|
|
707
|
+
if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
|
|
708
|
+
const node = choice.byId.get(id);
|
|
709
|
+
this.pendingChoice = null;
|
|
710
|
+
this.pendingPromptBeat = this.host.replayPromptOnChoose ? this.promptBeatOf(node) ?? null : null;
|
|
711
|
+
this.pendingPromptOwnerId = this.pendingPromptBeat ? node.id : null;
|
|
712
|
+
this.enterChild(node);
|
|
713
|
+
}
|
|
714
|
+
isEnded() {
|
|
715
|
+
return this.flowEnded;
|
|
716
|
+
}
|
|
717
|
+
/** Read a property by ref - `@patter` / `@scene` (each routed by its `shared` flag) or foreign. */
|
|
718
|
+
getProperty(ref) {
|
|
719
|
+
const { scope, name } = this.splitRef(ref);
|
|
720
|
+
if (scope === "patter") return this.patterResolver.get(name);
|
|
721
|
+
if (scope === "scene") return this.sceneResolver.get(name);
|
|
722
|
+
return this.host.shared.get(scope, name);
|
|
723
|
+
}
|
|
724
|
+
/** Write a property by ref (routed by scope, then by the property's `shared` flag). */
|
|
725
|
+
setProperty(ref, value) {
|
|
726
|
+
const { scope, name } = this.splitRef(ref);
|
|
727
|
+
if (scope === "patter") {
|
|
728
|
+
this.patterResolver.set(name, value);
|
|
729
|
+
} else if (scope === "scene") {
|
|
730
|
+
if (this.currentSceneId === null) throw new Error(`'${ref}': the flow has not entered a scene yet`);
|
|
731
|
+
this.sceneResolver.set(name, value);
|
|
732
|
+
} else {
|
|
733
|
+
this.host.shared.set(scope, name, value);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// -- Save / restore (engine-driven) --------------------------------------
|
|
737
|
+
/** @internal Snapshot this flow's cursor + per-flow scopes (not-shared `@patter`/`@scene`) + PRNG. */
|
|
738
|
+
snapshot() {
|
|
739
|
+
return {
|
|
740
|
+
scopes: this.local.save(),
|
|
741
|
+
// owned scope "patter" = the NOT-shared globals (@scene saved separately)
|
|
742
|
+
sceneBags: Object.fromEntries([...this.sceneBags].map(([s, bag]) => [s, { ...bag }])),
|
|
743
|
+
rngState: this.rngState,
|
|
744
|
+
visits: Object.fromEntries(this.visitCounts),
|
|
745
|
+
cursor: {
|
|
746
|
+
flowEnded: this.flowEnded,
|
|
747
|
+
currentSceneId: this.currentSceneId,
|
|
748
|
+
// Stamp each frame with the id of the child it would run next (nextId), so a restore against
|
|
749
|
+
// an EDITED bundle re-finds the position by id instead of trusting the raw index (§9.8 /
|
|
750
|
+
// live bundle refresh). A frame saved at its container's end has no next child - no stamp.
|
|
751
|
+
stack: this.stack.map((f) => {
|
|
752
|
+
const next = this.childrenOf(f.containerId)?.[f.index];
|
|
753
|
+
return next ? { ...f, nextId: next.id } : { ...f };
|
|
754
|
+
}),
|
|
755
|
+
activeSnippetId: this.activeSnippet?.id ?? null,
|
|
756
|
+
beatIndex: this.beatIndex,
|
|
757
|
+
pendingChoice: this.pendingChoice ? { groupId: this.pendingChoice.groupId, options: this.pendingChoice.options.map((o) => ({ ...o })) } : null,
|
|
758
|
+
pendingPromptOwnerId: this.pendingPromptOwnerId,
|
|
759
|
+
selectors: serialiseSelectors(this.selectors)
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/** @internal Restore this flow from a snapshot. */
|
|
764
|
+
restore(snap) {
|
|
765
|
+
this.rngState = snap.rngState >>> 0;
|
|
766
|
+
this.visitCounts = new Map(Object.entries(snap.visits ?? {}));
|
|
767
|
+
const c = snap.cursor;
|
|
768
|
+
this.started = true;
|
|
769
|
+
this.flowEnded = c.flowEnded;
|
|
770
|
+
this.beatIndex = c.beatIndex;
|
|
771
|
+
this.currentSceneId = c.currentSceneId;
|
|
772
|
+
this.stack = c.stack.map((f) => {
|
|
773
|
+
const { nextId, ...frame } = f;
|
|
774
|
+
if (nextId !== void 0) {
|
|
775
|
+
const at = this.childrenOf(frame.containerId)?.findIndex((ch) => ch.id === nextId) ?? -1;
|
|
776
|
+
if (at >= 0) return { ...frame, index: at };
|
|
777
|
+
}
|
|
778
|
+
return { ...frame };
|
|
779
|
+
});
|
|
780
|
+
this.sceneBags = new Map(Object.entries(snap.sceneBags ?? {}).map(([s, bag]) => [s, { ...bag }]));
|
|
781
|
+
this.local = this.freshLocal();
|
|
782
|
+
this.local.load(snap.scopes);
|
|
783
|
+
this.activeSnippet = null;
|
|
784
|
+
if (c.activeSnippetId !== null) {
|
|
785
|
+
const node = this.host.nodeIndex.get(c.activeSnippetId);
|
|
786
|
+
if (node && node.type === "snippet") this.activeSnippet = node;
|
|
787
|
+
}
|
|
788
|
+
this.selectors = deserialiseSelectors(c.selectors);
|
|
789
|
+
this.pendingChoice = null;
|
|
790
|
+
if (c.pendingChoice !== null) {
|
|
791
|
+
const byId = /* @__PURE__ */ new Map();
|
|
792
|
+
const options = [];
|
|
793
|
+
for (const o of c.pendingChoice.options) {
|
|
794
|
+
const node = this.host.nodeIndex.get(o.id);
|
|
795
|
+
if (!node) continue;
|
|
796
|
+
byId.set(o.id, node);
|
|
797
|
+
options.push({ ...o });
|
|
798
|
+
}
|
|
799
|
+
if (options.length > 0) this.pendingChoice = { groupId: c.pendingChoice.groupId, options, byId };
|
|
800
|
+
}
|
|
801
|
+
this.pendingPromptBeat = null;
|
|
802
|
+
this.pendingPromptOwnerId = c.pendingPromptOwnerId ?? null;
|
|
803
|
+
if (this.pendingPromptOwnerId) {
|
|
804
|
+
const owner = this.host.nodeIndex.get(this.pendingPromptOwnerId);
|
|
805
|
+
this.pendingPromptBeat = owner ? this.promptBeatOf(owner) ?? null : null;
|
|
806
|
+
if (!this.pendingPromptBeat) this.pendingPromptOwnerId = null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// -- Scene / block / node entry ------------------------------------------
|
|
810
|
+
/** Set the current scene, reset its scene-local props, run onEntry. */
|
|
811
|
+
enterSceneSetup(sceneId) {
|
|
812
|
+
const scene = this.host.bundle.scenes[sceneId];
|
|
813
|
+
if (!scene) throw new Error(`unknown scene: ${sceneId}`);
|
|
814
|
+
this.currentSceneId = sceneId;
|
|
815
|
+
this.enter(sceneId);
|
|
816
|
+
this.seedScene(scene);
|
|
817
|
+
this.runEffects(scene.onEntry);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Play one child of the active run. A snippet begins delivering. A group is
|
|
821
|
+
* walked by its selector: the default `run` pushes a nested run (its children
|
|
822
|
+
* play in order, gathering back); `choice` stops for the host; a select-one
|
|
823
|
+
* selector (branch, or a `sequence` in any order x exhaust mode) picks ONE child (recursing
|
|
824
|
+
* to a leaf) - selecting nothing contributes no content and the run continues.
|
|
825
|
+
*/
|
|
826
|
+
enterChild(node) {
|
|
827
|
+
this.enter(node.id);
|
|
828
|
+
if (node.type === "snippet") {
|
|
829
|
+
this.beginSnippet(node);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const selector = node.selector ?? "run";
|
|
833
|
+
if (selector === "run") {
|
|
834
|
+
this.stack.push({ sceneId: this.currentSceneId, containerId: node.id, index: 0 });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (selector === "choice") {
|
|
838
|
+
this.setupChoice(node);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const pick = this.selectChild(node);
|
|
842
|
+
if (pick) this.enterChild(pick);
|
|
843
|
+
}
|
|
844
|
+
/** A container's children, whether it's a block or a run-group; undefined if the id is gone. */
|
|
845
|
+
childrenOf(containerId) {
|
|
846
|
+
const block = this.host.blockById.get(containerId);
|
|
847
|
+
if (block) return block.children;
|
|
848
|
+
const node = this.host.nodeIndex.get(containerId);
|
|
849
|
+
if (node && node.type === "group") return node.children;
|
|
850
|
+
return void 0;
|
|
851
|
+
}
|
|
852
|
+
beginSnippet(snippet) {
|
|
853
|
+
this.runEffects(snippet.onEnter);
|
|
854
|
+
this.activeSnippet = snippet;
|
|
855
|
+
this.beatIndex = 0;
|
|
856
|
+
}
|
|
857
|
+
setupChoice(group) {
|
|
858
|
+
const options = [];
|
|
859
|
+
const byId = /* @__PURE__ */ new Map();
|
|
860
|
+
const fallbacks = [];
|
|
861
|
+
for (const child of group.children) {
|
|
862
|
+
if (child.fallback === true) {
|
|
863
|
+
fallbacks.push(child);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (child.sticky !== true && (this.visitCounts.get(child.id) ?? 0) >= 1) continue;
|
|
867
|
+
const eligible = this.eligible(child);
|
|
868
|
+
const hidden = child.secretUntilEligible === true;
|
|
869
|
+
if (!eligible && hidden) continue;
|
|
870
|
+
options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
|
|
871
|
+
byId.set(child.id, child);
|
|
872
|
+
}
|
|
873
|
+
if (options.length > 0) {
|
|
874
|
+
this.pendingChoice = { groupId: group.id, options, byId };
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const fallback = fallbacks.find((f) => this.eligible(f));
|
|
878
|
+
if (fallback) {
|
|
879
|
+
this.enterChild(fallback);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
this.host.onDryChoice?.(group.id);
|
|
883
|
+
}
|
|
884
|
+
// -- Jumps (jump / call-return) ----------------------------------------
|
|
885
|
+
resolveJump(jump) {
|
|
886
|
+
if (!jump) return;
|
|
887
|
+
this.enterTarget(jump.to, jump.mode === "call" ? "call" : "jump");
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Route to a target (scene / block / `END`). `call` PUSHES a return frame (the
|
|
891
|
+
* caller's block run, already advanced to its next child, stays below); `jump`
|
|
892
|
+
* is absolute - it REPLACES the whole stack, discarding pending returns. `END`
|
|
893
|
+
* hard-ends the flow regardless of the callstack.
|
|
894
|
+
*/
|
|
895
|
+
enterTarget(to, mode) {
|
|
896
|
+
if (to === "END") {
|
|
897
|
+
this.flowEnded = true;
|
|
898
|
+
this.stack = [];
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
let sceneId;
|
|
902
|
+
let containerId;
|
|
903
|
+
const scene = this.host.bundle.scenes[to];
|
|
904
|
+
if (scene) {
|
|
905
|
+
this.enterSceneSetup(to);
|
|
906
|
+
const first = scene.blocks[0];
|
|
907
|
+
if (!first) {
|
|
908
|
+
if (mode === "jump") this.stack = [];
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
sceneId = to;
|
|
912
|
+
containerId = first.id;
|
|
913
|
+
} else {
|
|
914
|
+
const loc = this.host.blockIndex.get(to);
|
|
915
|
+
if (!loc) throw new Error(`jump target not found: ${to}`);
|
|
916
|
+
if (loc.sceneId !== this.currentSceneId) this.enterSceneSetup(loc.sceneId);
|
|
917
|
+
sceneId = loc.sceneId;
|
|
918
|
+
containerId = to;
|
|
919
|
+
}
|
|
920
|
+
this.enter(containerId);
|
|
921
|
+
const frame = { sceneId, containerId, index: 0 };
|
|
922
|
+
if (mode === "call") this.stack.push(frame);
|
|
923
|
+
else this.stack = [frame];
|
|
924
|
+
}
|
|
925
|
+
// -- Selectors ------------------------------------------------------------
|
|
926
|
+
selectChild(group) {
|
|
927
|
+
const eligible = group.children.filter((c) => this.eligible(c));
|
|
928
|
+
if (eligible.length === 0) return null;
|
|
929
|
+
const st = this.selectorState(group);
|
|
930
|
+
switch (group.selector) {
|
|
931
|
+
case "branch":
|
|
932
|
+
return eligible[0];
|
|
933
|
+
case "sequence": {
|
|
934
|
+
const order = group.options?.order ?? "sequential";
|
|
935
|
+
const exhaust = group.options?.exhaust ?? "once";
|
|
936
|
+
return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
|
|
937
|
+
}
|
|
938
|
+
case "run":
|
|
939
|
+
case "choice":
|
|
940
|
+
default:
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
/** `sequence` with `order: "sequential"` - walk children in authored order. */
|
|
945
|
+
pickSequential(eligible, exhaust, st) {
|
|
946
|
+
const len = eligible.length;
|
|
947
|
+
const n = st.seq ?? 0;
|
|
948
|
+
st.seq = n + 1;
|
|
949
|
+
if (exhaust === "repeat") return eligible[n % len];
|
|
950
|
+
if (n < len) return eligible[n];
|
|
951
|
+
if (exhaust === "stick") return eligible[len - 1];
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* `sequence` with `order: "shuffle"` - draw WITHOUT replacement (a bag), never
|
|
956
|
+
* repeating the immediately-previous pick across a reshuffle (no line twice in a
|
|
957
|
+
* row when >=2 are eligible). `stick` holds out the last authored child as the
|
|
958
|
+
* permanent terminal; `once` stops after one pass; `repeat` reshuffles.
|
|
959
|
+
*/
|
|
960
|
+
pickShuffle(eligible, exhaust, st) {
|
|
961
|
+
const len = eligible.length;
|
|
962
|
+
const stick = exhaust === "stick";
|
|
963
|
+
const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
|
|
964
|
+
if (st.bag === void 0) st.bag = fill();
|
|
965
|
+
if (st.bag.length === 0) {
|
|
966
|
+
if (exhaust === "once") return null;
|
|
967
|
+
if (stick) {
|
|
968
|
+
const last = eligible[len - 1];
|
|
969
|
+
st.last = last.id;
|
|
970
|
+
return last;
|
|
971
|
+
}
|
|
972
|
+
st.bag = fill();
|
|
973
|
+
}
|
|
974
|
+
const pool = st.bag;
|
|
975
|
+
const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
|
|
976
|
+
let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
|
|
977
|
+
if (p >= 0 && i >= p) i++;
|
|
978
|
+
const id = pool[i];
|
|
979
|
+
pool.splice(i, 1);
|
|
980
|
+
st.last = id;
|
|
981
|
+
return eligible.find((c) => c.id === id);
|
|
982
|
+
}
|
|
983
|
+
/** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
|
|
984
|
+
selectorState(group) {
|
|
985
|
+
const map = group.shared ? this.host.sharedSelectors : this.selectors;
|
|
986
|
+
let st = map.get(group.id);
|
|
987
|
+
if (!st) {
|
|
988
|
+
st = {};
|
|
989
|
+
map.set(group.id, st);
|
|
990
|
+
}
|
|
991
|
+
return st;
|
|
992
|
+
}
|
|
993
|
+
// -- Effects + expressions ------------------------------------------------
|
|
994
|
+
runEffects(effects) {
|
|
995
|
+
for (const e of effects ?? []) {
|
|
996
|
+
this.setProperty(e.target, this.evalExpr(e.value));
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
eligible(node) {
|
|
1000
|
+
if (!node.condition) return true;
|
|
1001
|
+
return truthy(this.evalExpr(node.condition));
|
|
1002
|
+
}
|
|
1003
|
+
evalExpr(expr) {
|
|
1004
|
+
let ast = astCache.get(expr);
|
|
1005
|
+
if (!ast) {
|
|
1006
|
+
ast = (0, import_expr.deserialiseAst)(expr.ast);
|
|
1007
|
+
astCache.set(expr, ast);
|
|
1008
|
+
}
|
|
1009
|
+
return (0, import_expr.evaluate)(ast, this.evalCtx, import_dialect.patterDialect);
|
|
1010
|
+
}
|
|
1011
|
+
/** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
|
|
1012
|
+
enter(id) {
|
|
1013
|
+
this.visitCounts.set(id, (this.visitCounts.get(id) ?? 0) + 1);
|
|
1014
|
+
this.host.sharedVisits.set(id, (this.host.sharedVisits.get(id) ?? 0) + 1);
|
|
1015
|
+
}
|
|
1016
|
+
/** Next float in [0, 1): the shared custom PRNG, or this flow's serialisable mulberry32. */
|
|
1017
|
+
rng = () => {
|
|
1018
|
+
if (this.host.customRng) return this.host.customRng();
|
|
1019
|
+
const a = this.rngState + 1831565813 | 0;
|
|
1020
|
+
this.rngState = a;
|
|
1021
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
1022
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
1023
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1024
|
+
};
|
|
1025
|
+
// -- Strings / beats ------------------------------------------------------
|
|
1026
|
+
beatResult(beat) {
|
|
1027
|
+
const tags = this.host.tagIndex.get(beat.id);
|
|
1028
|
+
const withTags = tags && tags.length ? { tags } : {};
|
|
1029
|
+
switch (beat.kind) {
|
|
1030
|
+
case "gameEvent":
|
|
1031
|
+
return { type: "gameEvent", id: beat.id, gameData: beat.gameData, ...withTags };
|
|
1032
|
+
case "text":
|
|
1033
|
+
return { type: "text", id: beat.id, text: this.interpolate(this.resolveString(beat.id)), gameData: beat.gameData, ...withTags };
|
|
1034
|
+
case "line": {
|
|
1035
|
+
const raw = this.resolveString(beat.id);
|
|
1036
|
+
const off = !this.host.captionsOn;
|
|
1037
|
+
const captionChar = off && beat.character === this.host.captionCharacter;
|
|
1038
|
+
const text = captionChar ? "" : this.captionLine(this.host.bundle.voiced ? raw : this.interpolate(raw));
|
|
1039
|
+
const silent = off && text.length === 0;
|
|
1040
|
+
return {
|
|
1041
|
+
type: "line",
|
|
1042
|
+
id: beat.id,
|
|
1043
|
+
text,
|
|
1044
|
+
character: silent ? void 0 : beat.character,
|
|
1045
|
+
characterName: silent ? void 0 : this.resolveCharacterName(beat.character),
|
|
1046
|
+
direction: silent ? void 0 : beat.direction,
|
|
1047
|
+
gameData: beat.gameData,
|
|
1048
|
+
...withTags
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Expand inline `{@ref}` slots (spec §16) against this flow's CURRENT property state. Public so an
|
|
1055
|
+
* IDs-only game can apply the same property replacement to a string it looked up in its own loc system:
|
|
1056
|
+
* the engine handed it the beat ID, the game fetched its translation, then calls `flow.interpolate(...)`.
|
|
1057
|
+
*/
|
|
1058
|
+
interpolate(raw) {
|
|
1059
|
+
return (0, import_dialect.interpolate)(raw, (ref) => this.getProperty(ref));
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Apply the project's caption rule to a string UNCONDITIONALLY (#214): remove every cue span between
|
|
1063
|
+
* the project's delimiters and collapse the whitespace. Public so an IDs-only game - which looks up
|
|
1064
|
+
* its own strings - can match the embedded runtime: `flow.stripCaptions(flow.interpolate(text))` when
|
|
1065
|
+
* its own captions setting is off. (Embedded play does this automatically for dialogue lines.)
|
|
1066
|
+
*/
|
|
1067
|
+
stripCaptions(raw) {
|
|
1068
|
+
return (0, import_dialect.stripCaptions)(raw, this.host.captionOpen, this.host.captionClose);
|
|
1069
|
+
}
|
|
1070
|
+
/** Caption-strip a dialogue line ONLY when captions are off; otherwise pass the text through. The
|
|
1071
|
+
* internal gate the engine applies to every `line` beat / line-kind prompt. */
|
|
1072
|
+
captionLine(text) {
|
|
1073
|
+
return this.host.captionsOn ? text : this.stripCaptions(text);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* An option's prompt (spec §5): the Option group's `prompt` beat, resolved + interpolated
|
|
1077
|
+
* (choice labels are on-screen text, so they interpolate, spec §16). For the degenerate
|
|
1078
|
+
* bare-snippet tolerance - or an Option group authored without a prompt - it falls back to the
|
|
1079
|
+
* option's first content line. NO look-ahead. Undefined only when even that is absent.
|
|
1080
|
+
*/
|
|
1081
|
+
promptFor(node) {
|
|
1082
|
+
const beat = this.promptBeatOf(node);
|
|
1083
|
+
if (!beat) return void 0;
|
|
1084
|
+
const text = this.interpolate(this.resolveString(beat.id));
|
|
1085
|
+
return beat.kind === "line" ? { kind: "line", text: this.captionLine(text), character: beat.character, characterName: this.resolveCharacterName(beat.character), direction: beat.direction } : { kind: "text", text };
|
|
1086
|
+
}
|
|
1087
|
+
/** The prompt BEAT of an option: the Option group's `prompt`, else (tolerance) its first content line. */
|
|
1088
|
+
promptBeatOf(node) {
|
|
1089
|
+
if (node.type === "group" && node.prompt) return node.prompt;
|
|
1090
|
+
const snippet = node.type === "snippet" ? node : this.firstTextSnippetIn(node.children);
|
|
1091
|
+
return (snippet?.beats ?? []).find((b) => b.kind === "line" || b.kind === "text");
|
|
1092
|
+
}
|
|
1093
|
+
/** The first snippet with a line/text beat within a child list, depth-first in authored order. */
|
|
1094
|
+
firstTextSnippetIn(children) {
|
|
1095
|
+
let found;
|
|
1096
|
+
(0, import_model.walkNodes)(children, (n) => {
|
|
1097
|
+
if (!found && n.type === "snippet" && (n.beats ?? []).some((b) => b.kind === "line" || b.kind === "text")) {
|
|
1098
|
+
found = n;
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
return found;
|
|
1102
|
+
}
|
|
1103
|
+
resolveString(id) {
|
|
1104
|
+
if (this.host.emitIds) return id;
|
|
1105
|
+
const active = this.host.strings[id];
|
|
1106
|
+
if (active !== void 0) return active;
|
|
1107
|
+
const source = this.host.defaultStrings[id];
|
|
1108
|
+
return source !== void 0 ? `<Untranslated: ${id}> ${source}` : id;
|
|
1109
|
+
}
|
|
1110
|
+
/** A character's player-facing name: the `cast:<name>` string in the active locale, else the default
|
|
1111
|
+
* locale, else the authoring `displayName`. Undefined when the character has no display name at all
|
|
1112
|
+
* (the host falls back to the `character` token itself). */
|
|
1113
|
+
resolveCharacterName(character) {
|
|
1114
|
+
if (character === void 0) return void 0;
|
|
1115
|
+
if (this.host.emitIds) return void 0;
|
|
1116
|
+
const key = (0, import_model.castStringKey)(character);
|
|
1117
|
+
return this.host.strings[key] ?? this.host.defaultStrings[key] ?? this.host.castDisplay.get(character);
|
|
1118
|
+
}
|
|
1119
|
+
/** Split a ref into scope + name. Tokens: `@scene`, foreign tokens, else `@patter` (incl. bare `@name`). */
|
|
1120
|
+
splitRef(ref) {
|
|
1121
|
+
let hit = this.host.refSplitCache.get(ref);
|
|
1122
|
+
if (!hit) {
|
|
1123
|
+
hit = (0, import_dialect.splitRef)(ref, (t) => t === "scene" || this.host.shared.has(t));
|
|
1124
|
+
this.host.refSplitCache.set(ref, hit);
|
|
1125
|
+
}
|
|
1126
|
+
return hit;
|
|
1127
|
+
}
|
|
1128
|
+
/** The per-flow registry: the NOT-shared `@patter` globals (the shared ones live on the host). */
|
|
1129
|
+
freshLocal() {
|
|
1130
|
+
return new import_scoperegistry.ScopeRegistry().defineOwned("patter", this.host.patterLocalDecls);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Seed a scene's `@scene` props (spec §7). The not-shared props seed THIS flow's
|
|
1134
|
+
* bag the first time it enters (persist across re-entries thereafter); the shared
|
|
1135
|
+
* props seed the host's stage bag the first time ANY flow enters the scene (shared
|
|
1136
|
+
* and persistent thereafter - a later flow finds it present and leaves it).
|
|
1137
|
+
* `temporary` props are the exception: reseeded to their default on every entry.
|
|
1138
|
+
*/
|
|
1139
|
+
seedScene(scene) {
|
|
1140
|
+
const shared = this.host.sceneSharedNames.get(scene.id) ?? /* @__PURE__ */ new Set();
|
|
1141
|
+
if (!this.sceneBags.has(scene.id)) {
|
|
1142
|
+
const bag = {};
|
|
1143
|
+
for (const decl of scene.sceneProps ?? []) {
|
|
1144
|
+
const name = decl.name.toLowerCase();
|
|
1145
|
+
if (!shared.has(name)) bag[name] = sceneDefault(decl);
|
|
1146
|
+
}
|
|
1147
|
+
this.sceneBags.set(scene.id, bag);
|
|
1148
|
+
}
|
|
1149
|
+
if (!this.host.stageBags.has(scene.id)) {
|
|
1150
|
+
const bag = {};
|
|
1151
|
+
for (const decl of scene.sceneProps ?? []) {
|
|
1152
|
+
const name = decl.name.toLowerCase();
|
|
1153
|
+
if (shared.has(name)) bag[name] = sceneDefault(decl);
|
|
1154
|
+
}
|
|
1155
|
+
this.host.stageBags.set(scene.id, bag);
|
|
1156
|
+
}
|
|
1157
|
+
for (const decl of scene.sceneProps ?? []) {
|
|
1158
|
+
if (!decl.temporary) continue;
|
|
1159
|
+
const name = decl.name.toLowerCase();
|
|
1160
|
+
const bag = shared.has(name) ? this.host.stageBags.get(scene.id) : this.sceneBags.get(scene.id);
|
|
1161
|
+
if (bag) bag[name] = sceneDefault(decl);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
function serialiseSelectors(map) {
|
|
1166
|
+
const out = {};
|
|
1167
|
+
for (const [id, st] of map) {
|
|
1168
|
+
const v = {};
|
|
1169
|
+
if (st.seq !== void 0) v.seq = st.seq;
|
|
1170
|
+
if (st.bag) v.bag = [...st.bag];
|
|
1171
|
+
if (st.last !== void 0) v.last = st.last;
|
|
1172
|
+
out[id] = v;
|
|
1173
|
+
}
|
|
1174
|
+
return out;
|
|
1175
|
+
}
|
|
1176
|
+
function deserialiseSelectors(rec) {
|
|
1177
|
+
const map = /* @__PURE__ */ new Map();
|
|
1178
|
+
for (const [id, v] of Object.entries(rec ?? {})) {
|
|
1179
|
+
const st = {};
|
|
1180
|
+
if (v.seq !== void 0) st.seq = v.seq;
|
|
1181
|
+
if (v.bag) st.bag = [...v.bag];
|
|
1182
|
+
if (v.last !== void 0) st.last = v.last;
|
|
1183
|
+
map.set(id, st);
|
|
1184
|
+
}
|
|
1185
|
+
return map;
|
|
1186
|
+
}
|
|
1187
|
+
function toDecl(decl) {
|
|
1188
|
+
return { name: decl.name, type: decl.type, values: decl.values, default: decl.default };
|
|
1189
|
+
}
|
|
1190
|
+
function declDefault(d) {
|
|
1191
|
+
if (d.default !== void 0) return d.default;
|
|
1192
|
+
switch (d.type) {
|
|
1193
|
+
case "number":
|
|
1194
|
+
return 0;
|
|
1195
|
+
case "string":
|
|
1196
|
+
return "";
|
|
1197
|
+
case "flags":
|
|
1198
|
+
return [];
|
|
1199
|
+
case "enum":
|
|
1200
|
+
return d.values?.[0] ?? "";
|
|
1201
|
+
default:
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function toForeignDecl(decl) {
|
|
1206
|
+
return { name: decl.name, type: decl.type, values: decl.values, default: decl.default, writable: decl.writable };
|
|
1207
|
+
}
|
|
1208
|
+
function hostScopeDefault(decl) {
|
|
1209
|
+
if (decl.default !== void 0) return decl.default;
|
|
1210
|
+
switch (decl.type) {
|
|
1211
|
+
case "boolean":
|
|
1212
|
+
return false;
|
|
1213
|
+
case "number":
|
|
1214
|
+
return 0;
|
|
1215
|
+
case "string":
|
|
1216
|
+
return "";
|
|
1217
|
+
case "flags":
|
|
1218
|
+
return [];
|
|
1219
|
+
case "enum":
|
|
1220
|
+
return decl.values?.[0] ?? "";
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
function selfBackedResolver(decls) {
|
|
1224
|
+
const bag = /* @__PURE__ */ new Map();
|
|
1225
|
+
for (const d of decls) bag.set(d.name, hostScopeDefault(d));
|
|
1226
|
+
return {
|
|
1227
|
+
get: (name) => bag.get(name),
|
|
1228
|
+
set: (name, value) => {
|
|
1229
|
+
bag.set(name, value);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function sceneDefault(decl) {
|
|
1234
|
+
if (decl.default !== void 0) return decl.default;
|
|
1235
|
+
switch (decl.type) {
|
|
1236
|
+
case "boolean":
|
|
1237
|
+
return false;
|
|
1238
|
+
case "number":
|
|
1239
|
+
return 0;
|
|
1240
|
+
case "string":
|
|
1241
|
+
return "";
|
|
1242
|
+
case "flags":
|
|
1243
|
+
return [];
|
|
1244
|
+
case "enum":
|
|
1245
|
+
return decl.values?.[0] ?? "";
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
function truthy(v) {
|
|
1249
|
+
if (typeof v === "boolean") return v;
|
|
1250
|
+
if (typeof v === "number") return v !== 0;
|
|
1251
|
+
if (typeof v === "string") return v !== "";
|
|
1252
|
+
return v.length > 0;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/gamedata.ts
|
|
1256
|
+
function gameDataFields(bundle, kind) {
|
|
1257
|
+
return bundle.gameDataFields?.[kind] ?? [];
|
|
1258
|
+
}
|
|
1259
|
+
function gameDataValue(fields, node, name) {
|
|
1260
|
+
if (node && Object.prototype.hasOwnProperty.call(node, name)) return node[name];
|
|
1261
|
+
return fields.find((f) => f.name === name)?.default;
|
|
1262
|
+
}
|
|
1263
|
+
function effectiveGameData(fields, node) {
|
|
1264
|
+
const out = {};
|
|
1265
|
+
for (const f of fields) {
|
|
1266
|
+
const v = gameDataValue(fields, node, f.name);
|
|
1267
|
+
if (v !== void 0) out[f.name] = v;
|
|
1268
|
+
}
|
|
1269
|
+
for (const [k, v] of Object.entries(node ?? {})) if (!(k in out)) out[k] = v;
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1273
|
+
0 && (module.exports = {
|
|
1274
|
+
Engine,
|
|
1275
|
+
Flow,
|
|
1276
|
+
buildTagIndex,
|
|
1277
|
+
effectiveGameData,
|
|
1278
|
+
gameDataFields,
|
|
1279
|
+
gameDataValue
|
|
1280
|
+
});
|
|
1281
|
+
//# sourceMappingURL=index.cjs.map
|