@patterkit/runtime 0.17.0 → 0.19.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 CHANGED
@@ -33,6 +33,72 @@ version number always means the same runtime behaviour. This package is versione
33
33
 
34
34
  ## [Unreleased]
35
35
 
36
+ ## [0.19.0] - 2026-10-06
37
+
38
+ ### Added
39
+
40
+ - **`EngineOptions.onError` and `PlayError`.** Each condition or effect that fails while the story plays is
41
+ reported to `onError` with the flow, what failed (`condition`, `effect`, or `best-match`), the node, the
42
+ expression's source, and the error. With no `onError`, each goes to `console.warn`. With the log on, each is
43
+ also a `diagnostic` entry in the decision log.
44
+
45
+ ### Changed
46
+
47
+ - **A condition or effect that fails no longer stops the story.** A failing condition counts as false, and a
48
+ failing effect is skipped while the rest of its list still runs, including a story write a read-only `@world`
49
+ value refuses. This is the same on all four runtimes; JS used to throw out of `advance()`.
50
+ - **A part of a Best-match condition that fails scores as false**, as on the other three runtimes. JS threw
51
+ while scoring.
52
+ - **A selector evaluates each condition once.** It evaluated each child's condition twice, so a condition
53
+ calling `random(a, b)` drew twice and the logged verdict could disagree with the pick.
54
+ - **A shuffle draws only from the children in its bag that are still eligible.** A child whose condition went
55
+ false after the bag was filled could be drawn, and the group then played nothing. When no child left in the
56
+ bag is eligible, the pass ends as if the bag were empty. Seeded results do not change while every child in the
57
+ bag stays eligible.
58
+ - **`openFlow`, `goto` and the engine's lookups read an address the same way:** a scene's game id first, then
59
+ its internal id, and a block only within its scene. `openFlow` tried the internal id first, so the two could
60
+ land in different scenes when one scene's id was another's game id.
61
+ - **A choice whose every remaining option is greyed out runs dry**, as a choice with no options does: the
62
+ fallback follows if there is one, otherwise the flow moves on and `onDryChoice` fires. It used to be offered
63
+ with nothing the player could take.
64
+ - **An option's prompt beat carries tags**: its own and the option's. A replayed prompt and the outline lost
65
+ them.
66
+ - **`loadGame` checks a save before changing anything.** A save missing its flows used to close every flow and
67
+ load the registry before failing, leaving the engine half-loaded.
68
+
69
+ ### Fixed
70
+
71
+ - **`@wildwinter/expr` 0.5.1 and `@wildwinter/scoperegistry` 0.8.1 (kernel `k492cf234`):** a listener's own
72
+ error is no longer reported as a read-only refusal, every listener registered when a write starts hears it
73
+ exactly once, and the hot path allocates less, with no leaks.
74
+ - **A rollback after a load keeps a scene's saved `@scene` values.** After a load, a scene the flow is not
75
+ standing in keeps its saved values waiting until the flow enters it. Entering it inside a checkpoint and then
76
+ rolling back used to drop those values, so the next real entry found the defaults and the next save left them
77
+ out. A new corpus case pins this on all four runtimes.
78
+ - **The decision log's `seq` keeps counting after a clear.** The engine's log numbered entries by its length, so
79
+ after `clearLog()` the numbers started again at 0 and entries read either side of the clear could not be put
80
+ in order.
81
+
82
+ ## [0.18.0] - 2026-10-05
83
+
84
+ ### Changed
85
+
86
+ - **`replayPromptOnChoose` speaks back only an authored prompt, and exactly what the choice showed.** A game that
87
+ turns the option on will see the difference, which is why this is a minor release rather than a patch:
88
+ - An option whose prompt the choice borrowed from its own first content line (a bare-snippet option,
89
+ or an Option group with no prompt of its own) is no longer spoken back. That line used to play twice
90
+ in a row, once as the replay and once as content. It now plays once.
91
+ - The replayed prompt carries the text, `character`, `characterName`, and `direction` the choice
92
+ displayed, instead of resolving the prompt again when it is delivered. So if choosing runs effects
93
+ first (an Option group that picks a snippet runs its onEnter at once), or the language changes
94
+ between `choose()` and the next `advance()`, the replay still says what the player saw. In a voiced
95
+ project a replayed line prompt is now interpolated, as the choice showed it.
96
+ - A save taken between `choose()` and the next `advance()` carries that prompt as shown, in a new
97
+ optional `pendingPrompt` on the flow's cursor, so the replay after a load is the same. Every
98
+ Patterplay runtime writes and reads it. A save from an earlier version, without it, still loads and
99
+ resolves the prompt when it is delivered, as before.
100
+ The save shape's `FlowCursor.pendingPrompt` is typed in `@patterkit/model`.
101
+
36
102
  ## [0.17.0] - 2026-10-05
37
103
 
38
104
  ### Fixed
package/dist/index.cjs CHANGED
@@ -54,6 +54,7 @@ function buildTagIndex(bundle) {
54
54
  const visit = (node, inherited) => {
55
55
  const acc = dedupe([...inherited, ...node.tags ?? []]);
56
56
  index.set(node.id, acc);
57
+ if (node.type === "group" && node.prompt) index.set(node.prompt.id, dedupe([...acc, ...node.prompt.tags ?? []]));
57
58
  if (node.type === "group") {
58
59
  for (const child of node.children) visit(child, acc);
59
60
  } else {
@@ -118,6 +119,8 @@ var Engine = class _Engine {
118
119
  /** The run's ordered stream: every flow's events, each naming its flow. Empty and
119
120
  * unwritten unless `options.log` asked for it. */
120
121
  engineLog = [];
122
+ /** The next engine-log `seq`: its own counter, since the log's length restarts after a clear. */
123
+ engineSeq = 0;
121
124
  engineTraceHandlers = /* @__PURE__ */ new Set();
122
125
  constructor(bundle, options = {}) {
123
126
  this.creationOptions = options;
@@ -215,6 +218,7 @@ var Engine = class _Engine {
215
218
  journal: null,
216
219
  customRng: options.rng,
217
220
  onDryChoice: options.onDryChoice,
221
+ onError: options.onError ?? ((e) => console.warn(`Patterplay: ${e.kind} on ${e.node} in flow '${e.flow}' failed, played through: ${e.message}`)),
218
222
  replayPromptOnChoose: options.replayPromptOnChoose ?? false,
219
223
  captionsOn: options.closedCaptions ?? true,
220
224
  // captions shown by default (full text)
@@ -408,10 +412,10 @@ var Engine = class _Engine {
408
412
  */
409
413
  resolveOpenAddress(scene, block) {
410
414
  if (scene !== void 0) {
411
- const sceneId = this.resolveSceneRef(scene);
412
- if (!this.host.bundle.scenes[sceneId]) throw new Error(`unknown scene: ${scene}`);
415
+ const sceneId = resolveScene(this.host, scene);
416
+ if (sceneId === void 0) throw new Error(`unknown scene: ${scene}`);
413
417
  if (block === void 0) return { sceneId };
414
- const blockId = this.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
418
+ const blockId = resolveBlock(this.host, sceneId, block);
415
419
  if (blockId === void 0) throw new Error(`unknown block: ${block}`);
416
420
  return { sceneId, blockId };
417
421
  }
@@ -422,21 +426,19 @@ var Engine = class _Engine {
422
426
  if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
423
427
  return {};
424
428
  }
425
- /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
429
+ /** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
430
+ * unknown one passes through, for the caller to find nothing. */
426
431
  resolveSceneRef(ref) {
427
432
  if (ref == null) return void 0;
428
- if (this.host.bundle.scenes[ref]) return ref;
429
- return this.sceneGameIdToId.get(ref) ?? ref;
433
+ return resolveScene(this.host, ref) ?? ref;
430
434
  }
431
- /** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
435
+ /** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
436
+ * scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
437
+ * since a block's gameId is only unique within its scene. */
432
438
  resolveBlockRef(sceneId, ref) {
433
439
  if (ref == null) return void 0;
434
- if (this.host.blockById.has(ref)) return ref;
435
- if (sceneId != null) {
436
- const id = this.blockGameIdToId.get(sceneId)?.get(ref);
437
- if (id) return id;
438
- }
439
- return ref;
440
+ if (sceneId == null) return ref;
441
+ return resolveBlock(this.host, sceneId, ref);
440
442
  }
441
443
  /** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
442
444
  * The inverse of the resolve helpers - for a host that wants to display / log the address. */
@@ -763,7 +765,7 @@ var Engine = class _Engine {
763
765
  emitEngine(flow, event, scene) {
764
766
  for (const h of this.engineTraceHandlers) h(flow, event);
765
767
  if (!this.host.logEnabled) return;
766
- this.engineLog.push({ ...event, flow, seq: this.engineLog.length, ...scene ? { scene } : {} });
768
+ this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
767
769
  }
768
770
  listProperties() {
769
771
  return this.host.patterSharedDecls.map((d) => ({
@@ -829,6 +831,8 @@ var Engine = class _Engine {
829
831
  this.refuseInCheckpoint("loadGame");
830
832
  const version = save.version;
831
833
  if (version !== 2 && version !== import_model2.SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
834
+ const flows = save.flows;
835
+ if (typeof flows !== "object" || flows === null || Array.isArray(flows)) throw new Error("malformed save: no flows");
832
836
  this.assertExternalScopes();
833
837
  const reg = this.host.registry;
834
838
  for (const [id, flow] of this.flowsById) {
@@ -876,10 +880,13 @@ var Flow = class {
876
880
  activeSnippet = null;
877
881
  beatIndex = 0;
878
882
  pendingChoice = null;
879
- /** When `replayPromptOnChoose`, the chosen option's prompt beat to deliver before its content. */
880
- pendingPromptBeat = null;
881
- /** The chosen option that owns `pendingPromptBeat`, so a save taken between choose() and the next
882
- * advance() can re-derive the prompt on load (the beat isn't otherwise reachable by id). */
883
+ /** When `replayPromptOnChoose`, the chosen option's AUTHORED prompt to deliver before its content: its
884
+ * beat (id, gameData, tags) and the prompt exactly as the choice showed it (text and speaker fields).
885
+ * `shown` is missing only after loading a save written before saves carried it: the beat is then
886
+ * resolved when it is delivered, as it always was. */
887
+ pendingPrompt = null;
888
+ /** The chosen option that owns `pendingPrompt`, so a save taken between choose() and the next
889
+ * advance() can find the prompt beat again on load (it isn't otherwise reachable by id). */
883
890
  pendingPromptOwnerId = null;
884
891
  selectors = /* @__PURE__ */ new Map();
885
892
  /** Per-node entry counts for this flow (node id -> times entered). */
@@ -1041,7 +1048,7 @@ var Flow = class {
1041
1048
  if (scene === "END") {
1042
1049
  this.started = true;
1043
1050
  this.pendingChoice = null;
1044
- this.pendingPromptBeat = null;
1051
+ this.pendingPrompt = null;
1045
1052
  this.pendingPromptOwnerId = null;
1046
1053
  this.activeSnippet = null;
1047
1054
  this.beatIndex = 0;
@@ -1049,11 +1056,11 @@ var Flow = class {
1049
1056
  this.stack = [];
1050
1057
  return true;
1051
1058
  }
1052
- const sceneId = this.host.sceneGameIdToId.get(scene) ?? (this.host.bundle.scenes[scene] ? scene : void 0);
1059
+ const sceneId = resolveScene(this.host, scene);
1053
1060
  if (sceneId === void 0) return false;
1054
1061
  let blockId;
1055
1062
  if (block !== void 0) {
1056
- blockId = this.host.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
1063
+ blockId = resolveBlock(this.host, sceneId, block);
1057
1064
  if (blockId === void 0) return false;
1058
1065
  }
1059
1066
  if (!this.started) {
@@ -1061,7 +1068,7 @@ var Flow = class {
1061
1068
  return true;
1062
1069
  }
1063
1070
  this.pendingChoice = null;
1064
- this.pendingPromptBeat = null;
1071
+ this.pendingPrompt = null;
1065
1072
  this.pendingPromptOwnerId = null;
1066
1073
  this.activeSnippet = null;
1067
1074
  this.beatIndex = 0;
@@ -1088,7 +1095,7 @@ var Flow = class {
1088
1095
  this.activeSnippet = null;
1089
1096
  this.beatIndex = 0;
1090
1097
  this.pendingChoice = null;
1091
- this.pendingPromptBeat = null;
1098
+ this.pendingPrompt = null;
1092
1099
  this.pendingPromptOwnerId = null;
1093
1100
  }
1094
1101
  /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
@@ -1098,10 +1105,10 @@ var Flow = class {
1098
1105
  const journal = this.host.journal;
1099
1106
  if (!journal || journal.flows.has(this)) return;
1100
1107
  journal.flows.add(this);
1101
- const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId } = this;
1108
+ const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPrompt, pendingPromptOwnerId } = this;
1102
1109
  const stack = this.stack.map((f) => ({ ...f }));
1103
1110
  journal.undo.push(() => {
1104
- Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId, stack });
1111
+ Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPrompt, pendingPromptOwnerId, stack });
1105
1112
  });
1106
1113
  }
1107
1114
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
@@ -1119,11 +1126,11 @@ var Flow = class {
1119
1126
  if (this.closed) return { type: "end" };
1120
1127
  if (!this.started) throw new Error("flow has not been started");
1121
1128
  this.touch();
1122
- if (this.pendingPromptBeat) {
1123
- const b = this.pendingPromptBeat;
1124
- this.pendingPromptBeat = null;
1129
+ if (this.pendingPrompt) {
1130
+ const p = this.pendingPrompt;
1131
+ this.pendingPrompt = null;
1125
1132
  this.pendingPromptOwnerId = null;
1126
- return this.beatResult(b);
1133
+ return p.shown ? this.promptResult(p.beat, p.shown) : this.beatResult(p.beat);
1127
1134
  }
1128
1135
  this.settle();
1129
1136
  if (this.flowEnded) return { type: "end" };
@@ -1163,7 +1170,7 @@ var Flow = class {
1163
1170
  if (this.flowEnded || this.pendingChoice) return;
1164
1171
  if (this.activeSnippet) {
1165
1172
  if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
1166
- this.runEffects(this.activeSnippet.onExit);
1173
+ this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
1167
1174
  const jump = this.activeSnippet.jump;
1168
1175
  this.activeSnippet = null;
1169
1176
  this.beatIndex = 0;
@@ -1242,8 +1249,9 @@ var Flow = class {
1242
1249
  const node = choice.byId.get(id);
1243
1250
  this.emit({ type: "chose", group: choice.groupId, option: id });
1244
1251
  this.pendingChoice = null;
1245
- this.pendingPromptBeat = this.host.replayPromptOnChoose ? this.promptBeatOf(node) ?? null : null;
1246
- this.pendingPromptOwnerId = this.pendingPromptBeat ? node.id : null;
1252
+ const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
1253
+ this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
1254
+ this.pendingPromptOwnerId = this.pendingPrompt ? node.id : null;
1247
1255
  this.enterChild(node);
1248
1256
  }
1249
1257
  isEnded() {
@@ -1309,6 +1317,8 @@ var Flow = class {
1309
1317
  beatIndex: this.beatIndex,
1310
1318
  pendingChoice: this.pendingChoice ? { groupId: this.pendingChoice.groupId, options: this.pendingChoice.options.map((o) => ({ ...o })) } : null,
1311
1319
  pendingPromptOwnerId: this.pendingPromptOwnerId,
1320
+ // The prompt still to be replayed, as the choice showed it: present only in the choose -> advance window.
1321
+ ...this.pendingPrompt?.shown ? { pendingPrompt: savedPrompt(this.pendingPrompt.shown) } : {},
1312
1322
  selectors: serialiseSelectors(this.selectors)
1313
1323
  }
1314
1324
  };
@@ -1359,12 +1369,13 @@ var Flow = class {
1359
1369
  }
1360
1370
  if (options.length > 0) this.pendingChoice = { groupId: c.pendingChoice.groupId, options, byId };
1361
1371
  }
1362
- this.pendingPromptBeat = null;
1372
+ this.pendingPrompt = null;
1363
1373
  this.pendingPromptOwnerId = c.pendingPromptOwnerId ?? null;
1364
1374
  if (this.pendingPromptOwnerId) {
1365
1375
  const owner = this.host.nodeIndex.get(this.pendingPromptOwnerId);
1366
- this.pendingPromptBeat = owner ? this.promptBeatOf(owner) ?? null : null;
1367
- if (!this.pendingPromptBeat) this.pendingPromptOwnerId = null;
1376
+ const beat = owner ? this.authoredPromptOf(owner) : void 0;
1377
+ if (beat) this.pendingPrompt = { beat, ...c.pendingPrompt ? { shown: { ...c.pendingPrompt } } : {} };
1378
+ else this.pendingPromptOwnerId = null;
1368
1379
  }
1369
1380
  }
1370
1381
  // -- Scene / block / node entry ------------------------------------------
@@ -1375,7 +1386,7 @@ var Flow = class {
1375
1386
  this.currentSceneId = sceneId;
1376
1387
  this.enter(sceneId);
1377
1388
  this.seedScene(scene);
1378
- this.runEffects(scene.onEntry);
1389
+ this.runEffects(scene.onEntry, scene.id);
1379
1390
  }
1380
1391
  /**
1381
1392
  * Play one child of the active run. A snippet begins delivering. A group is
@@ -1411,7 +1422,7 @@ var Flow = class {
1411
1422
  return void 0;
1412
1423
  }
1413
1424
  beginSnippet(snippet) {
1414
- this.runEffects(snippet.onEnter);
1425
+ this.runEffects(snippet.onEnter, snippet.id);
1415
1426
  this.activeSnippet = snippet;
1416
1427
  this.beatIndex = 0;
1417
1428
  }
@@ -1431,7 +1442,7 @@ var Flow = class {
1431
1442
  options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
1432
1443
  byId.set(child.id, child);
1433
1444
  }
1434
- if (options.length > 0) {
1445
+ if (options.some((o) => o.eligible)) {
1435
1446
  this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1436
1447
  this.pendingChoice = { groupId: group.id, options, byId };
1437
1448
  return;
@@ -1489,7 +1500,7 @@ var Flow = class {
1489
1500
  // -- Selectors ------------------------------------------------------------
1490
1501
  selectChild(group) {
1491
1502
  const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
1492
- const eligible = group.children.filter((c) => this.eligible(c));
1503
+ const eligible = group.children.filter((_, i) => verdicts[i].eligible);
1493
1504
  const order = group.options?.order ?? "sequential";
1494
1505
  const exhaust = group.options?.exhaust ?? "once";
1495
1506
  const trace = (picked) => {
@@ -1536,8 +1547,9 @@ var Flow = class {
1536
1547
  const len = eligible.length;
1537
1548
  const stick = exhaust === "stick";
1538
1549
  const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
1550
+ const eligibleIds = new Set(eligible.map((c) => c.id));
1539
1551
  if (st.bag === void 0) st.bag = fill();
1540
- if (st.bag.length === 0) {
1552
+ if (!st.bag.some((id2) => eligibleIds.has(id2))) {
1541
1553
  if (exhaust === "once") return null;
1542
1554
  if (stick) {
1543
1555
  const last = eligible[len - 1];
@@ -1545,13 +1557,14 @@ var Flow = class {
1545
1557
  return last;
1546
1558
  }
1547
1559
  st.bag = fill();
1560
+ if (st.bag.length === 0) return null;
1548
1561
  }
1549
- const pool = st.bag;
1562
+ const pool = st.bag.filter((id2) => eligibleIds.has(id2));
1550
1563
  const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
1551
1564
  let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
1552
1565
  if (p >= 0 && i >= p) i++;
1553
1566
  const id = pool[i];
1554
- pool.splice(i, 1);
1567
+ st.bag.splice(st.bag.indexOf(id), 1);
1555
1568
  st.last = id;
1556
1569
  return eligible.find((c) => c.id === id);
1557
1570
  }
@@ -1600,7 +1613,7 @@ var Flow = class {
1600
1613
  /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1601
1614
  * tier), else the specificity of its (already-passing) condition. */
1602
1615
  specScore(node) {
1603
- return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1616
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
1604
1617
  }
1605
1618
  /**
1606
1619
  * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
@@ -1610,8 +1623,15 @@ var Flow = class {
1610
1623
  * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1611
1624
  * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1612
1625
  */
1613
- matchedSpec(node, want) {
1614
- const evalTruthy = (n) => truthy((0, import_expr.evaluate)(n, this.context(), import_dialect.patterDialect));
1626
+ matchedSpec(node, want, owner) {
1627
+ const evalTruthy = (n) => {
1628
+ try {
1629
+ return truthy((0, import_expr.evaluate)(n, this.context(), import_dialect.patterDialect));
1630
+ } catch (err) {
1631
+ this.reportError("best-match", owner.id, owner.condition, err);
1632
+ return false;
1633
+ }
1634
+ };
1615
1635
  return (0, import_expr_specificity.matchedSpecificity)(node, evalTruthy, { want });
1616
1636
  }
1617
1637
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
@@ -1643,17 +1663,36 @@ var Flow = class {
1643
1663
  return st;
1644
1664
  }
1645
1665
  // -- Effects + expressions ------------------------------------------------
1646
- runEffects(effects) {
1666
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
1667
+ runEffects(effects, owner) {
1647
1668
  for (const e of effects ?? []) {
1648
- const value = this.evalExpr(e.value);
1649
- const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1650
- this.writeProperty(e.target, value, false);
1651
- this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1669
+ let value;
1670
+ try {
1671
+ value = this.evalExpr(e.value);
1672
+ const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1673
+ this.writeProperty(e.target, value, false);
1674
+ this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1675
+ } catch (err) {
1676
+ this.reportError("effect", owner, e.value, err);
1677
+ }
1652
1678
  }
1653
1679
  }
1680
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
1654
1681
  eligible(node) {
1655
1682
  if (!node.condition) return true;
1656
- return truthy(this.evalExpr(node.condition));
1683
+ try {
1684
+ return truthy(this.evalExpr(node.condition));
1685
+ } catch (err) {
1686
+ this.reportError("condition", node.id, node.condition, err);
1687
+ return false;
1688
+ }
1689
+ }
1690
+ /** Report a content error the engine is playing through: to the host's onError, and to the log. */
1691
+ reportError(kind, node, expr, err) {
1692
+ const message = err instanceof Error ? err.message : String(err);
1693
+ const source = expr?.src;
1694
+ this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
1695
+ this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1657
1696
  }
1658
1697
  evalExpr(expr) {
1659
1698
  return (0, import_expr.evaluate)(this.conditionAst(expr), this.context(), import_dialect.patterDialect);
@@ -1759,6 +1798,27 @@ var Flow = class {
1759
1798
  const text = this.interpolate(this.resolveString(beat.id));
1760
1799
  return beat.kind === "line" ? { kind: "line", text: this.captionLine(text), character: beat.character, characterName: this.resolveCharacterName(beat.character), direction: beat.direction } : { kind: "text", text };
1761
1800
  }
1801
+ /** An option's AUTHORED prompt beat: an Option group's own `prompt`. The only prompt a replay speaks. */
1802
+ authoredPromptOf(node) {
1803
+ return node.type === "group" ? node.prompt : void 0;
1804
+ }
1805
+ /** A replayed prompt as a step: the beat's id, gameData, and tags, and the text and speaker fields the
1806
+ * choice showed. */
1807
+ promptResult(beat, shown) {
1808
+ const tags = this.host.tagIndex.get(beat.id);
1809
+ const withTags = tags && tags.length ? { tags } : {};
1810
+ if (shown.kind === "text") return { type: "text", id: beat.id, text: shown.text, gameData: beat.gameData, ...withTags };
1811
+ return {
1812
+ type: "line",
1813
+ id: beat.id,
1814
+ text: shown.text,
1815
+ character: shown.character,
1816
+ characterName: shown.characterName,
1817
+ direction: shown.direction,
1818
+ gameData: beat.gameData,
1819
+ ...withTags
1820
+ };
1821
+ }
1762
1822
  /** The prompt BEAT of an option: the Option group's `prompt`, else (tolerance) its first content line. */
1763
1823
  promptBeatOf(node) {
1764
1824
  if (node.type === "group" && node.prompt) return node.prompt;
@@ -1828,23 +1888,23 @@ var Flow = class {
1828
1888
  if (!this.sceneBags.has(s)) {
1829
1889
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1830
1890
  const key = keys.flowScene(this.id, s);
1831
- this.host.registry.mountOwned(key, bag, { owner: OWNER });
1891
+ const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
1832
1892
  this.registered.add(key);
1833
1893
  this.sceneBags.set(s, bag);
1834
1894
  if (journal) journal.undo.push(() => {
1835
1895
  if (this.sceneBags.get(s) !== bag) return;
1836
- this.host.registry.remove(key);
1896
+ this.host.registry.remove(key, claimed ? { keep: true } : void 0);
1837
1897
  this.registered.delete(key);
1838
1898
  this.sceneBags.delete(s);
1839
1899
  });
1840
1900
  }
1841
1901
  if (!this.host.stageBags.has(s)) {
1842
1902
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1843
- this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1903
+ const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
1844
1904
  this.host.stageBags.set(s, bag);
1845
1905
  if (journal) journal.undo.push(() => {
1846
1906
  if (this.host.stageBags.get(s) !== bag) return;
1847
- this.host.registry.remove(keys.stage(s));
1907
+ this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
1848
1908
  this.host.stageBags.delete(s);
1849
1909
  });
1850
1910
  }
@@ -1882,6 +1942,13 @@ function collectCast(nodes, out) {
1882
1942
  function gameDataField(gameData) {
1883
1943
  return gameData && Object.keys(gameData).length ? { gameData } : {};
1884
1944
  }
1945
+ function savedPrompt(p) {
1946
+ const out = { kind: p.kind, text: p.text };
1947
+ if (p.character !== void 0) out.character = p.character;
1948
+ if (p.characterName !== void 0) out.characterName = p.characterName;
1949
+ if (p.direction !== void 0) out.direction = p.direction;
1950
+ return out;
1951
+ }
1885
1952
  function serialiseSelectors(map) {
1886
1953
  const out = {};
1887
1954
  for (const [id, st] of map) {
@@ -1904,6 +1971,11 @@ function deserialiseSelectors(rec) {
1904
1971
  }
1905
1972
  return map;
1906
1973
  }
1974
+ function mountClaims(registry, key, bag, ask) {
1975
+ const before = ask ? JSON.stringify(bag.save()) : "";
1976
+ registry.mountOwned(key, bag, { owner: OWNER });
1977
+ return ask && JSON.stringify(bag.save()) !== before;
1978
+ }
1907
1979
  function toDecl(decl) {
1908
1980
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1909
1981
  }
@@ -1915,6 +1987,12 @@ function selfBackedDecl(decl, scopeWritable) {
1915
1987
  const writable = decl.writable ?? scopeWritable;
1916
1988
  return writable === void 0 ? d : { ...d, writable };
1917
1989
  }
1990
+ function resolveScene(host, ref) {
1991
+ return host.sceneGameIdToId.get(ref) ?? (host.bundle.scenes[ref] ? ref : void 0);
1992
+ }
1993
+ function resolveBlock(host, sceneId, ref) {
1994
+ return host.blockGameIdToId.get(sceneId)?.get(ref) ?? (host.blockIndex.get(ref)?.sceneId === sceneId ? ref : void 0);
1995
+ }
1918
1996
  function splitHostRef(host, ref) {
1919
1997
  if (host.refSplitRevision !== host.registry.revision) {
1920
1998
  host.refSplitCache.clear();