@patterkit/runtime 0.18.0 → 0.20.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.js CHANGED
@@ -21,6 +21,7 @@ function buildTagIndex(bundle) {
21
21
  const visit = (node, inherited) => {
22
22
  const acc = dedupe([...inherited, ...node.tags ?? []]);
23
23
  index.set(node.id, acc);
24
+ if (node.type === "group" && node.prompt) index.set(node.prompt.id, dedupe([...acc, ...node.prompt.tags ?? []]));
24
25
  if (node.type === "group") {
25
26
  for (const child of node.children) visit(child, acc);
26
27
  } else {
@@ -80,11 +81,13 @@ var Engine = class _Engine {
80
81
  sceneGameIdToId = /* @__PURE__ */ new Map();
81
82
  blockGameIdToId = /* @__PURE__ */ new Map();
82
83
  /** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
83
- * engine keeps the same world resolver, custom RNG, and diagnostic hooks. */
84
+ * engine keeps the same host scopes, custom RNG, and diagnostic hooks. */
84
85
  creationOptions;
85
86
  /** The run's ordered stream: every flow's events, each naming its flow. Empty and
86
87
  * unwritten unless `options.log` asked for it. */
87
88
  engineLog = [];
89
+ /** The next engine-log `seq`: its own counter, since the log's length restarts after a clear. */
90
+ engineSeq = 0;
88
91
  engineTraceHandlers = /* @__PURE__ */ new Set();
89
92
  constructor(bundle, options = {}) {
90
93
  this.creationOptions = options;
@@ -130,12 +133,12 @@ var Engine = class _Engine {
130
133
  registry.mountOwned("patter", patterBag, { owner: OWNER });
131
134
  registered.push("patter");
132
135
  const hostBound = /* @__PURE__ */ new Set();
133
- if (options.world) {
134
- const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
135
- const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
136
- registry.defineForeign("world", options.world, decls, { writable: worldSpec?.writable ?? true, owner: OWNER });
137
- registered.push("world");
138
- hostBound.add("world");
136
+ for (const [token, resolver] of Object.entries(boundScopes(options))) {
137
+ const spec = bundle.scopeRegistry?.scopes.find((s) => s.token === token);
138
+ const decls = (spec?.declarations ?? []).map(toForeignDecl);
139
+ registry.defineForeign(token, resolver, decls, { writable: spec?.writable ?? true, owner: OWNER });
140
+ registered.push(token);
141
+ hostBound.add(token);
139
142
  }
140
143
  for (const spec of ownsRegistry ? bundle.scopeRegistry?.scopes ?? [] : []) {
141
144
  if (hostBound.has(spec.token) || registry.has(spec.token)) continue;
@@ -155,6 +158,7 @@ var Engine = class _Engine {
155
158
  }
156
159
  this.host = {
157
160
  logEnabled: options.log ?? false,
161
+ tracing: options.log ?? false,
158
162
  emitEngine: (flow, event, scene) => this.emitEngine(flow, event, scene),
159
163
  bundle,
160
164
  emitIds,
@@ -177,11 +181,13 @@ var Engine = class _Engine {
177
181
  patterSharedNames,
178
182
  sceneSharedNames,
179
183
  sharedVisits: /* @__PURE__ */ new Map(),
184
+ qualityLadders: /* @__PURE__ */ new Map(),
180
185
  sharedSelectors: /* @__PURE__ */ new Map(),
181
186
  stageBags: /* @__PURE__ */ new Map(),
182
187
  journal: null,
183
188
  customRng: options.rng,
184
189
  onDryChoice: options.onDryChoice,
190
+ onError: options.onError ?? ((e) => console.warn(`Patterplay: ${e.kind} on ${e.node} in flow '${e.flow}' failed, played through: ${e.message}`)),
185
191
  replayPromptOnChoose: options.replayPromptOnChoose ?? false,
186
192
  captionsOn: options.closedCaptions ?? true,
187
193
  // captions shown by default (full text)
@@ -202,6 +208,11 @@ var Engine = class _Engine {
202
208
  get isSourceDebug() {
203
209
  return this.sourceDebug;
204
210
  }
211
+ /** The bundle's build identity (its content hash), the one a debug link handshakes with, or an empty
212
+ * string for a bundle compiled without one. */
213
+ get buildId() {
214
+ return this.host.bundle.content?.hash ?? "";
215
+ }
205
216
  /**
206
217
  * Switch the active locale LIVE - a real game's "language" setting can change mid-session. Subsequent
207
218
  * string lookups (new beats, re-resolved character names, `{@ref}` interpolation) render in the new
@@ -322,7 +333,7 @@ var Engine = class _Engine {
322
333
  });
323
334
  }
324
335
  this.flowsById.set(id, flow);
325
- flow.start(sceneId, blockId);
336
+ flow.begin(sceneId, blockId);
326
337
  return flow;
327
338
  }
328
339
  /**
@@ -375,10 +386,10 @@ var Engine = class _Engine {
375
386
  */
376
387
  resolveOpenAddress(scene, block) {
377
388
  if (scene !== void 0) {
378
- const sceneId = this.resolveSceneRef(scene);
379
- if (!this.host.bundle.scenes[sceneId]) throw new Error(`unknown scene: ${scene}`);
389
+ const sceneId = resolveScene(this.host, scene);
390
+ if (sceneId === void 0) throw new Error(`unknown scene: ${scene}`);
380
391
  if (block === void 0) return { sceneId };
381
- const blockId = this.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
392
+ const blockId = resolveBlock(this.host, sceneId, block);
382
393
  if (blockId === void 0) throw new Error(`unknown block: ${block}`);
383
394
  return { sceneId, blockId };
384
395
  }
@@ -389,21 +400,19 @@ var Engine = class _Engine {
389
400
  if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
390
401
  return {};
391
402
  }
392
- /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
403
+ /** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
404
+ * unknown one passes through, for the caller to find nothing. */
393
405
  resolveSceneRef(ref) {
394
406
  if (ref == null) return void 0;
395
- if (this.host.bundle.scenes[ref]) return ref;
396
- return this.sceneGameIdToId.get(ref) ?? ref;
407
+ return resolveScene(this.host, ref) ?? ref;
397
408
  }
398
- /** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
409
+ /** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
410
+ * scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
411
+ * since a block's gameId is only unique within its scene. */
399
412
  resolveBlockRef(sceneId, ref) {
400
413
  if (ref == null) return void 0;
401
- if (this.host.blockById.has(ref)) return ref;
402
- if (sceneId != null) {
403
- const id = this.blockGameIdToId.get(sceneId)?.get(ref);
404
- if (id) return id;
405
- }
406
- return ref;
414
+ if (sceneId == null) return ref;
415
+ return resolveBlock(this.host, sceneId, ref);
407
416
  }
408
417
  /** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
409
418
  * The inverse of the resolve helpers - for a host that wants to display / log the address. */
@@ -688,7 +697,7 @@ var Engine = class _Engine {
688
697
  for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep });
689
698
  this.host.stageBags.clear();
690
699
  for (const t of ["patter", ...this.host.hostScopes]) if (reg.has(t)) reg.remove(t, { keep });
691
- if (this.creationOptions.world && reg.has("world")) reg.remove("world");
700
+ for (const t of Object.keys(boundScopes(this.creationOptions))) if (reg.has(t)) reg.remove(t);
692
701
  }
693
702
  /** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
694
703
  getProperty(ref) {
@@ -725,12 +734,21 @@ var Engine = class _Engine {
725
734
  * retained. Returns its own unsubscribe. */
726
735
  onTrace(handler) {
727
736
  this.engineTraceHandlers.add(handler);
728
- return () => this.engineTraceHandlers.delete(handler);
737
+ this.host.tracing = true;
738
+ return () => {
739
+ this.engineTraceHandlers.delete(handler);
740
+ this.host.tracing = this.host.logEnabled || this.engineTraceHandlers.size > 0;
741
+ };
729
742
  }
730
743
  emitEngine(flow, event, scene) {
731
744
  for (const h of this.engineTraceHandlers) h(flow, event);
732
745
  if (!this.host.logEnabled) return;
733
- this.engineLog.push({ ...event, flow, seq: this.engineLog.length, ...scene ? { scene } : {} });
746
+ this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
747
+ }
748
+ /** How many times each node has been entered across every flow, by node id: the shared count. With a
749
+ * flow's own {@link Flow.getVisitCounts}, every visit count the run keeps, read without a save. */
750
+ getVisitCounts() {
751
+ return Object.fromEntries(this.host.sharedVisits);
734
752
  }
735
753
  listProperties() {
736
754
  return this.host.patterSharedDecls.map((d) => ({
@@ -796,6 +814,8 @@ var Engine = class _Engine {
796
814
  this.refuseInCheckpoint("loadGame");
797
815
  const version = save.version;
798
816
  if (version !== 2 && version !== SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
817
+ const flows = save.flows;
818
+ if (typeof flows !== "object" || flows === null || Array.isArray(flows)) throw new Error("malformed save: no flows");
799
819
  this.assertExternalScopes();
800
820
  const reg = this.host.registry;
801
821
  for (const [id, flow] of this.flowsById) {
@@ -934,19 +954,21 @@ var Flow = class {
934
954
  * lowercase, as the compiler emits references (the selfBackedResolver lesson). */
935
955
  stagesFor(scope, name) {
936
956
  const key = name.toLowerCase();
937
- const fromDecls = (decls) => decls?.find((d) => d.name.toLowerCase() === key && d.type === "quality")?.stages;
938
957
  if (scope === "patter") {
939
- return fromDecls(this.host.patterSharedDecls) ?? fromDecls(this.host.patterLocalDecls);
958
+ return ladders(this.host, "patter", () => [...this.host.patterSharedDecls, ...this.host.patterLocalDecls]).get(key);
940
959
  }
941
960
  if (scope === "scene") {
942
- const scene = this.currentSceneId != null ? this.host.bundle.scenes[this.currentSceneId] : void 0;
943
- return fromDecls(scene?.sceneProps);
961
+ const sceneId = this.currentSceneId;
962
+ if (sceneId == null) return void 0;
963
+ return ladders(this.host, `scene/${sceneId}`, () => this.host.bundle.scenes[sceneId]?.sceneProps).get(key);
944
964
  }
945
- return this.registryQualities?.(scope, name) ?? fromDecls(this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations);
965
+ return this.registryQualities?.(scope, name) ?? ladders(this.host, `host/${scope}`, () => this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations).get(key);
946
966
  }
947
967
  // -- Host API -------------------------------------------------------------
948
- /** Begin this flow at a scene (and optionally a specific block within it). */
949
- start(sceneId, blockId) {
968
+ /** Begin this flow at a scene (and optionally a specific block within it). The engine's own entry point:
969
+ * openFlow and a goto on an unstarted flow begin a flow here. A game calls {@link reset}.
970
+ * @internal */
971
+ begin(sceneId, blockId) {
950
972
  if (this.host.journal && !this.host.journal.opened.has(this)) throw new Error("a flow can't be started or reset while a checkpoint is open");
951
973
  this.releaseBags(false);
952
974
  this.host.registry.discardParked(keys.flow(this.id));
@@ -958,7 +980,7 @@ var Flow = class {
958
980
  this.flowEnded = false;
959
981
  this.activeSnippet = null;
960
982
  this.beatIndex = 0;
961
- this.pendingChoice = null;
983
+ this.clearPending();
962
984
  this.started = true;
963
985
  if (blockId) {
964
986
  const loc = this.host.blockIndex.get(blockId);
@@ -983,10 +1005,15 @@ var Flow = class {
983
1005
  * Forget everything in this flow and begin again - its per-flow state (not-shared
984
1006
  * `@patter` globals + `@scene` props), cursor, callstack, selector cursors, and
985
1007
  * visit counts. Shared state (shared `@patter` / `@scene`, world visit counts) is
986
- * untouched. A clearer-named alias of `start()`.
1008
+ * untouched. The one public way to begin a flow again (openFlow begins a new one).
987
1009
  */
988
1010
  reset(sceneId, blockId) {
989
- this.start(sceneId, blockId);
1011
+ this.begin(sceneId, blockId);
1012
+ }
1013
+ /** @deprecated Use {@link reset}, the one public name for beginning a flow again on every runtime. `start`
1014
+ * was the same call under a second name, and goes in a later release. */
1015
+ start(sceneId, blockId) {
1016
+ this.reset(sceneId, blockId);
990
1017
  }
991
1018
  /**
992
1019
  * Send this flow's cursor to an ADDRESS, exactly as an authored `go` jump would: the target scene's
@@ -1010,29 +1037,25 @@ var Flow = class {
1010
1037
  this.touch();
1011
1038
  if (scene === "END") {
1012
1039
  this.started = true;
1013
- this.pendingChoice = null;
1014
- this.pendingPrompt = null;
1015
- this.pendingPromptOwnerId = null;
1040
+ this.clearPending();
1016
1041
  this.activeSnippet = null;
1017
1042
  this.beatIndex = 0;
1018
1043
  this.flowEnded = true;
1019
1044
  this.stack = [];
1020
1045
  return true;
1021
1046
  }
1022
- const sceneId = this.host.sceneGameIdToId.get(scene) ?? (this.host.bundle.scenes[scene] ? scene : void 0);
1047
+ const sceneId = resolveScene(this.host, scene);
1023
1048
  if (sceneId === void 0) return false;
1024
1049
  let blockId;
1025
1050
  if (block !== void 0) {
1026
- blockId = this.host.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
1051
+ blockId = resolveBlock(this.host, sceneId, block);
1027
1052
  if (blockId === void 0) return false;
1028
1053
  }
1029
1054
  if (!this.started) {
1030
- this.start(sceneId, blockId);
1055
+ this.begin(sceneId, blockId);
1031
1056
  return true;
1032
1057
  }
1033
- this.pendingChoice = null;
1034
- this.pendingPrompt = null;
1035
- this.pendingPromptOwnerId = null;
1058
+ this.clearPending();
1036
1059
  this.activeSnippet = null;
1037
1060
  this.beatIndex = 0;
1038
1061
  this.flowEnded = false;
@@ -1057,6 +1080,15 @@ var Flow = class {
1057
1080
  this.stack = [];
1058
1081
  this.activeSnippet = null;
1059
1082
  this.beatIndex = 0;
1083
+ this.clearPending();
1084
+ }
1085
+ /**
1086
+ * Drop everything waiting to be delivered: an open choice, and a chosen option's prompt still to be
1087
+ * replayed. Every move that abandons the flow's place (start and reset, goto, close) does this, so none
1088
+ * leaves a stale prompt behind. start() cleared only the choice, so a reset between choose() and the
1089
+ * next advance() replayed the abandoned run's prompt as the new run's first beat.
1090
+ */
1091
+ clearPending() {
1060
1092
  this.pendingChoice = null;
1061
1093
  this.pendingPrompt = null;
1062
1094
  this.pendingPromptOwnerId = null;
@@ -1133,7 +1165,7 @@ var Flow = class {
1133
1165
  if (this.flowEnded || this.pendingChoice) return;
1134
1166
  if (this.activeSnippet) {
1135
1167
  if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
1136
- this.runEffects(this.activeSnippet.onExit);
1168
+ this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
1137
1169
  const jump = this.activeSnippet.jump;
1138
1170
  this.activeSnippet = null;
1139
1171
  this.beatIndex = 0;
@@ -1153,7 +1185,7 @@ var Flow = class {
1153
1185
  }
1154
1186
  const from = frame.index;
1155
1187
  while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
1156
- if (this.host.logEnabled && frame.index !== from) {
1188
+ if (this.host.tracing && frame.index !== from) {
1157
1189
  this.emit({
1158
1190
  type: "select",
1159
1191
  group: frame.containerId,
@@ -1189,14 +1221,18 @@ var Flow = class {
1189
1221
  clearLog() {
1190
1222
  this.flowLog.length = 0;
1191
1223
  }
1192
- /** Record one decision, on this flow's log and the engine's. Cheap to call with logging
1193
- * off: the entry is never built. */
1224
+ /** Record one decision, on this flow's log and the engine's, and hand it to any `onTrace`. Each call
1225
+ * site checks `host.tracing` first, so with nothing logging or tracing the entry is never built. */
1194
1226
  emit(event) {
1195
1227
  const scene = this.currentSceneId ?? void 0;
1196
1228
  this.host.emitEngine(this.id, event, scene);
1197
1229
  if (!this.host.logEnabled) return;
1198
1230
  this.flowLog.push({ ...event, seq: this.flowSeq++, ...scene ? { scene } : {} });
1199
1231
  }
1232
+ /** How many times this flow has entered each node, by node id. */
1233
+ getVisitCounts() {
1234
+ return Object.fromEntries(this.visitCounts);
1235
+ }
1200
1236
  /** The options of a pending choice (empty when not at a choice point). */
1201
1237
  getChoices() {
1202
1238
  return this.pendingChoice?.options ?? [];
@@ -1210,7 +1246,7 @@ var Flow = class {
1210
1246
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1211
1247
  this.touch();
1212
1248
  const node = choice.byId.get(id);
1213
- this.emit({ type: "chose", group: choice.groupId, option: id });
1249
+ if (this.host.tracing) this.emit({ type: "chose", group: choice.groupId, option: id });
1214
1250
  this.pendingChoice = null;
1215
1251
  const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
1216
1252
  this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
@@ -1349,7 +1385,7 @@ var Flow = class {
1349
1385
  this.currentSceneId = sceneId;
1350
1386
  this.enter(sceneId);
1351
1387
  this.seedScene(scene);
1352
- this.runEffects(scene.onEntry);
1388
+ this.runEffects(scene.onEntry, scene.id);
1353
1389
  }
1354
1390
  /**
1355
1391
  * Play one child of the active run. A snippet begins delivering. A group is
@@ -1385,7 +1421,7 @@ var Flow = class {
1385
1421
  return void 0;
1386
1422
  }
1387
1423
  beginSnippet(snippet) {
1388
- this.runEffects(snippet.onEnter);
1424
+ this.runEffects(snippet.onEnter, snippet.id);
1389
1425
  this.activeSnippet = snippet;
1390
1426
  this.beatIndex = 0;
1391
1427
  }
@@ -1405,8 +1441,8 @@ var Flow = class {
1405
1441
  options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
1406
1442
  byId.set(child.id, child);
1407
1443
  }
1408
- if (options.length > 0) {
1409
- this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1444
+ if (options.some((o) => o.eligible)) {
1445
+ if (this.host.tracing) this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1410
1446
  this.pendingChoice = { groupId: group.id, options, byId };
1411
1447
  return;
1412
1448
  }
@@ -1415,7 +1451,7 @@ var Flow = class {
1415
1451
  this.enterChild(fallback);
1416
1452
  return;
1417
1453
  }
1418
- this.emit({ type: "dry", group: group.id });
1454
+ if (this.host.tracing) this.emit({ type: "dry", group: group.id });
1419
1455
  this.host.onDryChoice?.(group.id);
1420
1456
  }
1421
1457
  // -- Jumps (jump / call-return) ----------------------------------------
@@ -1430,7 +1466,7 @@ var Flow = class {
1430
1466
  * hard-ends the flow regardless of the callstack.
1431
1467
  */
1432
1468
  enterTarget(to, mode) {
1433
- this.emit({ type: "jump", to, mode });
1469
+ if (this.host.tracing) this.emit({ type: "jump", to, mode });
1434
1470
  if (to === "END") {
1435
1471
  this.flowEnded = true;
1436
1472
  this.stack = [];
@@ -1463,11 +1499,11 @@ var Flow = class {
1463
1499
  // -- Selectors ------------------------------------------------------------
1464
1500
  selectChild(group) {
1465
1501
  const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
1466
- const eligible = group.children.filter((c) => this.eligible(c));
1502
+ const eligible = group.children.filter((_, i) => verdicts[i].eligible);
1467
1503
  const order = group.options?.order ?? "sequential";
1468
1504
  const exhaust = group.options?.exhaust ?? "once";
1469
1505
  const trace = (picked) => {
1470
- this.emit({
1506
+ if (this.host.tracing) this.emit({
1471
1507
  type: "select",
1472
1508
  group: group.id,
1473
1509
  selector: group.selector ?? "default",
@@ -1510,8 +1546,9 @@ var Flow = class {
1510
1546
  const len = eligible.length;
1511
1547
  const stick = exhaust === "stick";
1512
1548
  const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
1549
+ const eligibleIds = new Set(eligible.map((c) => c.id));
1513
1550
  if (st.bag === void 0) st.bag = fill();
1514
- if (st.bag.length === 0) {
1551
+ if (!st.bag.some((id2) => eligibleIds.has(id2))) {
1515
1552
  if (exhaust === "once") return null;
1516
1553
  if (stick) {
1517
1554
  const last = eligible[len - 1];
@@ -1519,13 +1556,14 @@ var Flow = class {
1519
1556
  return last;
1520
1557
  }
1521
1558
  st.bag = fill();
1559
+ if (st.bag.length === 0) return null;
1522
1560
  }
1523
- const pool = st.bag;
1561
+ const pool = st.bag.filter((id2) => eligibleIds.has(id2));
1524
1562
  const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
1525
1563
  let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
1526
1564
  if (p >= 0 && i >= p) i++;
1527
1565
  const id = pool[i];
1528
- pool.splice(i, 1);
1566
+ st.bag.splice(st.bag.indexOf(id), 1);
1529
1567
  st.last = id;
1530
1568
  return eligible.find((c) => c.id === id);
1531
1569
  }
@@ -1574,7 +1612,7 @@ var Flow = class {
1574
1612
  /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1575
1613
  * tier), else the specificity of its (already-passing) condition. */
1576
1614
  specScore(node) {
1577
- return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1615
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
1578
1616
  }
1579
1617
  /**
1580
1618
  * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
@@ -1584,8 +1622,15 @@ var Flow = class {
1584
1622
  * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1585
1623
  * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1586
1624
  */
1587
- matchedSpec(node, want) {
1588
- const evalTruthy = (n) => truthy(evaluate(n, this.context(), patterDialect));
1625
+ matchedSpec(node, want, owner) {
1626
+ const evalTruthy = (n) => {
1627
+ try {
1628
+ return truthy(evaluate(n, this.context(), patterDialect));
1629
+ } catch (err) {
1630
+ this.reportError("best-match", owner.id, owner.condition, err);
1631
+ return false;
1632
+ }
1633
+ };
1589
1634
  return scoreSpecificity(node, evalTruthy, { want });
1590
1635
  }
1591
1636
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
@@ -1617,17 +1662,36 @@ var Flow = class {
1617
1662
  return st;
1618
1663
  }
1619
1664
  // -- Effects + expressions ------------------------------------------------
1620
- runEffects(effects) {
1665
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
1666
+ runEffects(effects, owner) {
1621
1667
  for (const e of effects ?? []) {
1622
- const value = this.evalExpr(e.value);
1623
- const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1624
- this.writeProperty(e.target, value, false);
1625
- this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1668
+ let value;
1669
+ try {
1670
+ value = this.evalExpr(e.value);
1671
+ const prev = this.host.tracing ? this.getProperty(e.target) : void 0;
1672
+ this.writeProperty(e.target, value, false);
1673
+ if (this.host.tracing) this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1674
+ } catch (err) {
1675
+ this.reportError("effect", owner, e.value, err);
1676
+ }
1626
1677
  }
1627
1678
  }
1679
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
1628
1680
  eligible(node) {
1629
1681
  if (!node.condition) return true;
1630
- return truthy(this.evalExpr(node.condition));
1682
+ try {
1683
+ return truthy(this.evalExpr(node.condition));
1684
+ } catch (err) {
1685
+ this.reportError("condition", node.id, node.condition, err);
1686
+ return false;
1687
+ }
1688
+ }
1689
+ /** Report a content error the engine is playing through: to the host's onError, and to the log. */
1690
+ reportError(kind, node, expr, err) {
1691
+ const message = err instanceof Error ? err.message : String(err);
1692
+ const source = expr?.src;
1693
+ this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
1694
+ if (this.host.tracing) this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1631
1695
  }
1632
1696
  evalExpr(expr) {
1633
1697
  return evaluate(this.conditionAst(expr), this.context(), patterDialect);
@@ -1823,23 +1887,23 @@ var Flow = class {
1823
1887
  if (!this.sceneBags.has(s)) {
1824
1888
  const bag = new PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1825
1889
  const key = keys.flowScene(this.id, s);
1826
- this.host.registry.mountOwned(key, bag, { owner: OWNER });
1890
+ const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
1827
1891
  this.registered.add(key);
1828
1892
  this.sceneBags.set(s, bag);
1829
1893
  if (journal) journal.undo.push(() => {
1830
1894
  if (this.sceneBags.get(s) !== bag) return;
1831
- this.host.registry.remove(key);
1895
+ this.host.registry.remove(key, claimed ? { keep: true } : void 0);
1832
1896
  this.registered.delete(key);
1833
1897
  this.sceneBags.delete(s);
1834
1898
  });
1835
1899
  }
1836
1900
  if (!this.host.stageBags.has(s)) {
1837
1901
  const bag = new PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1838
- this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1902
+ const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
1839
1903
  this.host.stageBags.set(s, bag);
1840
1904
  if (journal) journal.undo.push(() => {
1841
1905
  if (this.host.stageBags.get(s) !== bag) return;
1842
- this.host.registry.remove(keys.stage(s));
1906
+ this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
1843
1907
  this.host.stageBags.delete(s);
1844
1908
  });
1845
1909
  }
@@ -1906,9 +1970,29 @@ function deserialiseSelectors(rec) {
1906
1970
  }
1907
1971
  return map;
1908
1972
  }
1973
+ function mountClaims(registry, key, bag, ask) {
1974
+ const before = ask ? JSON.stringify(bag.save()) : "";
1975
+ registry.mountOwned(key, bag, { owner: OWNER });
1976
+ return ask && JSON.stringify(bag.save()) !== before;
1977
+ }
1909
1978
  function toDecl(decl) {
1910
1979
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1911
1980
  }
1981
+ function boundScopes(options) {
1982
+ return { ...options.world ? { world: options.world } : {}, ...options.hostScopes };
1983
+ }
1984
+ function ladders(host, cacheKey, decls) {
1985
+ let found = host.qualityLadders.get(cacheKey);
1986
+ if (!found) {
1987
+ found = /* @__PURE__ */ new Map();
1988
+ for (const d of decls() ?? []) {
1989
+ const k = d.name.toLowerCase();
1990
+ if (d.type === "quality" && d.stages && !found.has(k)) found.set(k, d.stages);
1991
+ }
1992
+ host.qualityLadders.set(cacheKey, found);
1993
+ }
1994
+ return found;
1995
+ }
1912
1996
  function toForeignDecl(decl) {
1913
1997
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
1914
1998
  }
@@ -1917,6 +2001,12 @@ function selfBackedDecl(decl, scopeWritable) {
1917
2001
  const writable = decl.writable ?? scopeWritable;
1918
2002
  return writable === void 0 ? d : { ...d, writable };
1919
2003
  }
2004
+ function resolveScene(host, ref) {
2005
+ return host.sceneGameIdToId.get(ref) ?? (host.bundle.scenes[ref] ? ref : void 0);
2006
+ }
2007
+ function resolveBlock(host, sceneId, ref) {
2008
+ return host.blockGameIdToId.get(sceneId)?.get(ref) ?? (host.blockIndex.get(ref)?.sceneId === sceneId ? ref : void 0);
2009
+ }
1920
2010
  function splitHostRef(host, ref) {
1921
2011
  if (host.refSplitRevision !== host.registry.revision) {
1922
2012
  host.refSplitCache.clear();