@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/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