@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/CHANGELOG.md CHANGED
@@ -33,6 +33,92 @@ version number always means the same runtime behaviour. This package is versione
33
33
 
34
34
  ## [Unreleased]
35
35
 
36
+ ## [0.20.0] - 2026-10-06
37
+
38
+ ### Added
39
+
40
+ - **`hostScopes`, the engine option every other runtime takes**: the scopes the game binds, by token, each a `{
41
+ get, set? }` over values it owns, as in `hostScopes: { world: { get, set } }`. A resolver's type is
42
+ `HostScope`.
43
+ - **`engine.buildId`**, as the other runtimes have: the bundle's content hash, the build a debug link handshakes
44
+ with.
45
+ - **A bundle-description section in the conformance corpus.** What `describeBundle` says about one bundle, field
46
+ for field, held on every runtime.
47
+ - **`getVisitCounts()` on the engine and on a flow**, on every runtime: how many times each node has been
48
+ entered, across every flow or in this one, read without a save.
49
+
50
+ ### Changed
51
+
52
+ - **Less work per step.** A decision's log entry is built only while the log is on or something is tracing, and
53
+ a quality's stage ladder is looked up once per set of declarations rather than scanned for on every
54
+ comparison.
55
+
56
+ ### Deprecated
57
+
58
+ - **`flow.start()` is deprecated: use `flow.reset()`.** They were the same call under two names. `flow.reset()`
59
+ is the one public name for beginning a flow again on every runtime (opening a flow begins a new one);
60
+ `flow.start()` goes in a later release.
61
+ - **The `world` option and the `WorldResolver` type: use `hostScopes: { world }` and `HostScope`.** `world` is
62
+ still the world scope, and goes in a later release.
63
+ - **The `TraceHandler` type.** Nothing takes one: `onTrace` takes an `EngineTraceHandler`. It goes in a later
64
+ release.
65
+
66
+ ### Fixed
67
+
68
+ - **`reset()` no longer replays a prompt from the run it abandoned.** With prompt replay on, `choose()` leaves
69
+ the chosen option's prompt waiting for the next `advance()`. `reset()` in between cleared only the choice, so
70
+ the old prompt played as the restarted run's first beat. Every move that leaves the flow's place (`reset()`,
71
+ `goto`, `close`) now drops both, through one helper.
72
+ - **`onTrace` hears every decision with the log off.** A `run` walk past a snippet whose condition failed, and a
73
+ write's previous value, were only worked out when the log was on, so a tool tracing a run without the log
74
+ missed the one and got a write without the other. They are now worked out whenever anything is tracing.
75
+
76
+ ## [0.19.0] - 2026-10-06
77
+
78
+ ### Added
79
+
80
+ - **`EngineOptions.onError` and `PlayError`.** Each condition or effect that fails while the story plays is
81
+ reported to `onError` with the flow, what failed (`condition`, `effect`, or `best-match`), the node, the
82
+ expression's source, and the error. With no `onError`, each goes to `console.warn`. With the log on, each is
83
+ also a `diagnostic` entry in the decision log.
84
+
85
+ ### Changed
86
+
87
+ - **A condition or effect that fails no longer stops the story.** A failing condition counts as false, and a
88
+ failing effect is skipped while the rest of its list still runs, including a story write a read-only `@world`
89
+ value refuses. This is the same on all four runtimes; JS used to throw out of `advance()`.
90
+ - **A part of a Best-match condition that fails scores as false**, as on the other three runtimes. JS threw
91
+ while scoring.
92
+ - **A selector evaluates each condition once.** It evaluated each child's condition twice, so a condition
93
+ calling `random(a, b)` drew twice and the logged verdict could disagree with the pick.
94
+ - **A shuffle draws only from the children in its bag that are still eligible.** A child whose condition went
95
+ false after the bag was filled could be drawn, and the group then played nothing. When no child left in the
96
+ bag is eligible, the pass ends as if the bag were empty. Seeded results do not change while every child in the
97
+ bag stays eligible.
98
+ - **`openFlow`, `goto` and the engine's lookups read an address the same way:** a scene's game id first, then
99
+ its internal id, and a block only within its scene. `openFlow` tried the internal id first, so the two could
100
+ land in different scenes when one scene's id was another's game id.
101
+ - **A choice whose every remaining option is greyed out runs dry**, as a choice with no options does: the
102
+ fallback follows if there is one, otherwise the flow moves on and `onDryChoice` fires. It used to be offered
103
+ with nothing the player could take.
104
+ - **An option's prompt beat carries tags**: its own and the option's. A replayed prompt and the outline lost
105
+ them.
106
+ - **`loadGame` checks a save before changing anything.** A save missing its flows used to close every flow and
107
+ load the registry before failing, leaving the engine half-loaded.
108
+
109
+ ### Fixed
110
+
111
+ - **`@wildwinter/expr` 0.5.1 and `@wildwinter/scoperegistry` 0.8.1 (kernel `k492cf234`):** a listener's own
112
+ error is no longer reported as a read-only refusal, every listener registered when a write starts hears it
113
+ exactly once, and the hot path allocates less, with no leaks.
114
+ - **A rollback after a load keeps a scene's saved `@scene` values.** After a load, a scene the flow is not
115
+ standing in keeps its saved values waiting until the flow enters it. Entering it inside a checkpoint and then
116
+ rolling back used to drop those values, so the next real entry found the defaults and the next save left them
117
+ out. A new corpus case pins this on all four runtimes.
118
+ - **The decision log's `seq` keeps counting after a clear.** The engine's log numbered entries by its length, so
119
+ after `clearLog()` the numbers started again at 0 and entries read either side of the clear could not be put
120
+ in order.
121
+
36
122
  ## [0.18.0] - 2026-10-05
37
123
 
38
124
  ### Changed
package/README.md CHANGED
@@ -44,9 +44,10 @@ for (;;) {
44
44
  }
45
45
  ```
46
46
 
47
- `new Engine(bundle, options)` takes `{ rng?, seed?, locale?, world?, registry?, replayPromptOnChoose?, closedCaptions? }`:
48
- - `world` is the host's resolver for `@world` properties (World Properties): `{ get(name), set?(name, value) }`.
49
- Omit it and the runtime self-backs `@world` from the declared defaults, as a property it saves.
47
+ `new Engine(bundle, options)` takes `{ rng?, seed?, locale?, hostScopes?, registry?, replayPromptOnChoose?, closedCaptions? }`:
48
+ - `hostScopes` binds the scopes your game owns, by token, each `{ get(name), set?(name, value) }`: for World
49
+ Properties, `hostScopes: { world: { get, set } }`. Leave `world` unbound and the runtime self-backs `@world`
50
+ from the declared defaults, as a property it saves.
50
51
  - `registry` is the game's one `ScopeRegistry` (from `@wildwinter/scoperegistry`), holding every
51
52
  engine's properties. Omit it and the engine makes its own. See *One registry per game* below.
52
53
  - `locale` plays a non-default language (embedded localisation; an IDs-only bundle ignores it).
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 {
@@ -113,11 +114,13 @@ var Engine = class _Engine {
113
114
  sceneGameIdToId = /* @__PURE__ */ new Map();
114
115
  blockGameIdToId = /* @__PURE__ */ new Map();
115
116
  /** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
116
- * engine keeps the same world resolver, custom RNG, and diagnostic hooks. */
117
+ * engine keeps the same host scopes, custom RNG, and diagnostic hooks. */
117
118
  creationOptions;
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;
@@ -163,12 +166,12 @@ var Engine = class _Engine {
163
166
  registry.mountOwned("patter", patterBag, { owner: OWNER });
164
167
  registered.push("patter");
165
168
  const hostBound = /* @__PURE__ */ new Set();
166
- if (options.world) {
167
- const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
168
- const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
169
- registry.defineForeign("world", options.world, decls, { writable: worldSpec?.writable ?? true, owner: OWNER });
170
- registered.push("world");
171
- hostBound.add("world");
169
+ for (const [token, resolver] of Object.entries(boundScopes(options))) {
170
+ const spec = bundle.scopeRegistry?.scopes.find((s) => s.token === token);
171
+ const decls = (spec?.declarations ?? []).map(toForeignDecl);
172
+ registry.defineForeign(token, resolver, decls, { writable: spec?.writable ?? true, owner: OWNER });
173
+ registered.push(token);
174
+ hostBound.add(token);
172
175
  }
173
176
  for (const spec of ownsRegistry ? bundle.scopeRegistry?.scopes ?? [] : []) {
174
177
  if (hostBound.has(spec.token) || registry.has(spec.token)) continue;
@@ -188,6 +191,7 @@ var Engine = class _Engine {
188
191
  }
189
192
  this.host = {
190
193
  logEnabled: options.log ?? false,
194
+ tracing: options.log ?? false,
191
195
  emitEngine: (flow, event, scene) => this.emitEngine(flow, event, scene),
192
196
  bundle,
193
197
  emitIds,
@@ -210,11 +214,13 @@ var Engine = class _Engine {
210
214
  patterSharedNames,
211
215
  sceneSharedNames,
212
216
  sharedVisits: /* @__PURE__ */ new Map(),
217
+ qualityLadders: /* @__PURE__ */ new Map(),
213
218
  sharedSelectors: /* @__PURE__ */ new Map(),
214
219
  stageBags: /* @__PURE__ */ new Map(),
215
220
  journal: null,
216
221
  customRng: options.rng,
217
222
  onDryChoice: options.onDryChoice,
223
+ onError: options.onError ?? ((e) => console.warn(`Patterplay: ${e.kind} on ${e.node} in flow '${e.flow}' failed, played through: ${e.message}`)),
218
224
  replayPromptOnChoose: options.replayPromptOnChoose ?? false,
219
225
  captionsOn: options.closedCaptions ?? true,
220
226
  // captions shown by default (full text)
@@ -235,6 +241,11 @@ var Engine = class _Engine {
235
241
  get isSourceDebug() {
236
242
  return this.sourceDebug;
237
243
  }
244
+ /** The bundle's build identity (its content hash), the one a debug link handshakes with, or an empty
245
+ * string for a bundle compiled without one. */
246
+ get buildId() {
247
+ return this.host.bundle.content?.hash ?? "";
248
+ }
238
249
  /**
239
250
  * Switch the active locale LIVE - a real game's "language" setting can change mid-session. Subsequent
240
251
  * string lookups (new beats, re-resolved character names, `{@ref}` interpolation) render in the new
@@ -355,7 +366,7 @@ var Engine = class _Engine {
355
366
  });
356
367
  }
357
368
  this.flowsById.set(id, flow);
358
- flow.start(sceneId, blockId);
369
+ flow.begin(sceneId, blockId);
359
370
  return flow;
360
371
  }
361
372
  /**
@@ -408,10 +419,10 @@ var Engine = class _Engine {
408
419
  */
409
420
  resolveOpenAddress(scene, block) {
410
421
  if (scene !== void 0) {
411
- const sceneId = this.resolveSceneRef(scene);
412
- if (!this.host.bundle.scenes[sceneId]) throw new Error(`unknown scene: ${scene}`);
422
+ const sceneId = resolveScene(this.host, scene);
423
+ if (sceneId === void 0) throw new Error(`unknown scene: ${scene}`);
413
424
  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);
425
+ const blockId = resolveBlock(this.host, sceneId, block);
415
426
  if (blockId === void 0) throw new Error(`unknown block: ${block}`);
416
427
  return { sceneId, blockId };
417
428
  }
@@ -422,21 +433,19 @@ var Engine = class _Engine {
422
433
  if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
423
434
  return {};
424
435
  }
425
- /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
436
+ /** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
437
+ * unknown one passes through, for the caller to find nothing. */
426
438
  resolveSceneRef(ref) {
427
439
  if (ref == null) return void 0;
428
- if (this.host.bundle.scenes[ref]) return ref;
429
- return this.sceneGameIdToId.get(ref) ?? ref;
440
+ return resolveScene(this.host, ref) ?? ref;
430
441
  }
431
- /** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
442
+ /** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
443
+ * scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
444
+ * since a block's gameId is only unique within its scene. */
432
445
  resolveBlockRef(sceneId, ref) {
433
446
  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;
447
+ if (sceneId == null) return ref;
448
+ return resolveBlock(this.host, sceneId, ref);
440
449
  }
441
450
  /** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
442
451
  * The inverse of the resolve helpers - for a host that wants to display / log the address. */
@@ -721,7 +730,7 @@ var Engine = class _Engine {
721
730
  for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep });
722
731
  this.host.stageBags.clear();
723
732
  for (const t of ["patter", ...this.host.hostScopes]) if (reg.has(t)) reg.remove(t, { keep });
724
- if (this.creationOptions.world && reg.has("world")) reg.remove("world");
733
+ for (const t of Object.keys(boundScopes(this.creationOptions))) if (reg.has(t)) reg.remove(t);
725
734
  }
726
735
  /** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
727
736
  getProperty(ref) {
@@ -758,12 +767,21 @@ var Engine = class _Engine {
758
767
  * retained. Returns its own unsubscribe. */
759
768
  onTrace(handler) {
760
769
  this.engineTraceHandlers.add(handler);
761
- return () => this.engineTraceHandlers.delete(handler);
770
+ this.host.tracing = true;
771
+ return () => {
772
+ this.engineTraceHandlers.delete(handler);
773
+ this.host.tracing = this.host.logEnabled || this.engineTraceHandlers.size > 0;
774
+ };
762
775
  }
763
776
  emitEngine(flow, event, scene) {
764
777
  for (const h of this.engineTraceHandlers) h(flow, event);
765
778
  if (!this.host.logEnabled) return;
766
- this.engineLog.push({ ...event, flow, seq: this.engineLog.length, ...scene ? { scene } : {} });
779
+ this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
780
+ }
781
+ /** How many times each node has been entered across every flow, by node id: the shared count. With a
782
+ * flow's own {@link Flow.getVisitCounts}, every visit count the run keeps, read without a save. */
783
+ getVisitCounts() {
784
+ return Object.fromEntries(this.host.sharedVisits);
767
785
  }
768
786
  listProperties() {
769
787
  return this.host.patterSharedDecls.map((d) => ({
@@ -829,6 +847,8 @@ var Engine = class _Engine {
829
847
  this.refuseInCheckpoint("loadGame");
830
848
  const version = save.version;
831
849
  if (version !== 2 && version !== import_model2.SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
850
+ const flows = save.flows;
851
+ if (typeof flows !== "object" || flows === null || Array.isArray(flows)) throw new Error("malformed save: no flows");
832
852
  this.assertExternalScopes();
833
853
  const reg = this.host.registry;
834
854
  for (const [id, flow] of this.flowsById) {
@@ -967,19 +987,21 @@ var Flow = class {
967
987
  * lowercase, as the compiler emits references (the selfBackedResolver lesson). */
968
988
  stagesFor(scope, name) {
969
989
  const key = name.toLowerCase();
970
- const fromDecls = (decls) => decls?.find((d) => d.name.toLowerCase() === key && d.type === "quality")?.stages;
971
990
  if (scope === "patter") {
972
- return fromDecls(this.host.patterSharedDecls) ?? fromDecls(this.host.patterLocalDecls);
991
+ return ladders(this.host, "patter", () => [...this.host.patterSharedDecls, ...this.host.patterLocalDecls]).get(key);
973
992
  }
974
993
  if (scope === "scene") {
975
- const scene = this.currentSceneId != null ? this.host.bundle.scenes[this.currentSceneId] : void 0;
976
- return fromDecls(scene?.sceneProps);
994
+ const sceneId = this.currentSceneId;
995
+ if (sceneId == null) return void 0;
996
+ return ladders(this.host, `scene/${sceneId}`, () => this.host.bundle.scenes[sceneId]?.sceneProps).get(key);
977
997
  }
978
- return this.registryQualities?.(scope, name) ?? fromDecls(this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations);
998
+ return this.registryQualities?.(scope, name) ?? ladders(this.host, `host/${scope}`, () => this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations).get(key);
979
999
  }
980
1000
  // -- Host API -------------------------------------------------------------
981
- /** Begin this flow at a scene (and optionally a specific block within it). */
982
- start(sceneId, blockId) {
1001
+ /** Begin this flow at a scene (and optionally a specific block within it). The engine's own entry point:
1002
+ * openFlow and a goto on an unstarted flow begin a flow here. A game calls {@link reset}.
1003
+ * @internal */
1004
+ begin(sceneId, blockId) {
983
1005
  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");
984
1006
  this.releaseBags(false);
985
1007
  this.host.registry.discardParked(keys.flow(this.id));
@@ -991,7 +1013,7 @@ var Flow = class {
991
1013
  this.flowEnded = false;
992
1014
  this.activeSnippet = null;
993
1015
  this.beatIndex = 0;
994
- this.pendingChoice = null;
1016
+ this.clearPending();
995
1017
  this.started = true;
996
1018
  if (blockId) {
997
1019
  const loc = this.host.blockIndex.get(blockId);
@@ -1016,10 +1038,15 @@ var Flow = class {
1016
1038
  * Forget everything in this flow and begin again - its per-flow state (not-shared
1017
1039
  * `@patter` globals + `@scene` props), cursor, callstack, selector cursors, and
1018
1040
  * visit counts. Shared state (shared `@patter` / `@scene`, world visit counts) is
1019
- * untouched. A clearer-named alias of `start()`.
1041
+ * untouched. The one public way to begin a flow again (openFlow begins a new one).
1020
1042
  */
1021
1043
  reset(sceneId, blockId) {
1022
- this.start(sceneId, blockId);
1044
+ this.begin(sceneId, blockId);
1045
+ }
1046
+ /** @deprecated Use {@link reset}, the one public name for beginning a flow again on every runtime. `start`
1047
+ * was the same call under a second name, and goes in a later release. */
1048
+ start(sceneId, blockId) {
1049
+ this.reset(sceneId, blockId);
1023
1050
  }
1024
1051
  /**
1025
1052
  * Send this flow's cursor to an ADDRESS, exactly as an authored `go` jump would: the target scene's
@@ -1043,29 +1070,25 @@ var Flow = class {
1043
1070
  this.touch();
1044
1071
  if (scene === "END") {
1045
1072
  this.started = true;
1046
- this.pendingChoice = null;
1047
- this.pendingPrompt = null;
1048
- this.pendingPromptOwnerId = null;
1073
+ this.clearPending();
1049
1074
  this.activeSnippet = null;
1050
1075
  this.beatIndex = 0;
1051
1076
  this.flowEnded = true;
1052
1077
  this.stack = [];
1053
1078
  return true;
1054
1079
  }
1055
- const sceneId = this.host.sceneGameIdToId.get(scene) ?? (this.host.bundle.scenes[scene] ? scene : void 0);
1080
+ const sceneId = resolveScene(this.host, scene);
1056
1081
  if (sceneId === void 0) return false;
1057
1082
  let blockId;
1058
1083
  if (block !== void 0) {
1059
- blockId = this.host.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
1084
+ blockId = resolveBlock(this.host, sceneId, block);
1060
1085
  if (blockId === void 0) return false;
1061
1086
  }
1062
1087
  if (!this.started) {
1063
- this.start(sceneId, blockId);
1088
+ this.begin(sceneId, blockId);
1064
1089
  return true;
1065
1090
  }
1066
- this.pendingChoice = null;
1067
- this.pendingPrompt = null;
1068
- this.pendingPromptOwnerId = null;
1091
+ this.clearPending();
1069
1092
  this.activeSnippet = null;
1070
1093
  this.beatIndex = 0;
1071
1094
  this.flowEnded = false;
@@ -1090,6 +1113,15 @@ var Flow = class {
1090
1113
  this.stack = [];
1091
1114
  this.activeSnippet = null;
1092
1115
  this.beatIndex = 0;
1116
+ this.clearPending();
1117
+ }
1118
+ /**
1119
+ * Drop everything waiting to be delivered: an open choice, and a chosen option's prompt still to be
1120
+ * replayed. Every move that abandons the flow's place (start and reset, goto, close) does this, so none
1121
+ * leaves a stale prompt behind. start() cleared only the choice, so a reset between choose() and the
1122
+ * next advance() replayed the abandoned run's prompt as the new run's first beat.
1123
+ */
1124
+ clearPending() {
1093
1125
  this.pendingChoice = null;
1094
1126
  this.pendingPrompt = null;
1095
1127
  this.pendingPromptOwnerId = null;
@@ -1166,7 +1198,7 @@ var Flow = class {
1166
1198
  if (this.flowEnded || this.pendingChoice) return;
1167
1199
  if (this.activeSnippet) {
1168
1200
  if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
1169
- this.runEffects(this.activeSnippet.onExit);
1201
+ this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
1170
1202
  const jump = this.activeSnippet.jump;
1171
1203
  this.activeSnippet = null;
1172
1204
  this.beatIndex = 0;
@@ -1186,7 +1218,7 @@ var Flow = class {
1186
1218
  }
1187
1219
  const from = frame.index;
1188
1220
  while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
1189
- if (this.host.logEnabled && frame.index !== from) {
1221
+ if (this.host.tracing && frame.index !== from) {
1190
1222
  this.emit({
1191
1223
  type: "select",
1192
1224
  group: frame.containerId,
@@ -1222,14 +1254,18 @@ var Flow = class {
1222
1254
  clearLog() {
1223
1255
  this.flowLog.length = 0;
1224
1256
  }
1225
- /** Record one decision, on this flow's log and the engine's. Cheap to call with logging
1226
- * off: the entry is never built. */
1257
+ /** Record one decision, on this flow's log and the engine's, and hand it to any `onTrace`. Each call
1258
+ * site checks `host.tracing` first, so with nothing logging or tracing the entry is never built. */
1227
1259
  emit(event) {
1228
1260
  const scene = this.currentSceneId ?? void 0;
1229
1261
  this.host.emitEngine(this.id, event, scene);
1230
1262
  if (!this.host.logEnabled) return;
1231
1263
  this.flowLog.push({ ...event, seq: this.flowSeq++, ...scene ? { scene } : {} });
1232
1264
  }
1265
+ /** How many times this flow has entered each node, by node id. */
1266
+ getVisitCounts() {
1267
+ return Object.fromEntries(this.visitCounts);
1268
+ }
1233
1269
  /** The options of a pending choice (empty when not at a choice point). */
1234
1270
  getChoices() {
1235
1271
  return this.pendingChoice?.options ?? [];
@@ -1243,7 +1279,7 @@ var Flow = class {
1243
1279
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1244
1280
  this.touch();
1245
1281
  const node = choice.byId.get(id);
1246
- this.emit({ type: "chose", group: choice.groupId, option: id });
1282
+ if (this.host.tracing) this.emit({ type: "chose", group: choice.groupId, option: id });
1247
1283
  this.pendingChoice = null;
1248
1284
  const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
1249
1285
  this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
@@ -1382,7 +1418,7 @@ var Flow = class {
1382
1418
  this.currentSceneId = sceneId;
1383
1419
  this.enter(sceneId);
1384
1420
  this.seedScene(scene);
1385
- this.runEffects(scene.onEntry);
1421
+ this.runEffects(scene.onEntry, scene.id);
1386
1422
  }
1387
1423
  /**
1388
1424
  * Play one child of the active run. A snippet begins delivering. A group is
@@ -1418,7 +1454,7 @@ var Flow = class {
1418
1454
  return void 0;
1419
1455
  }
1420
1456
  beginSnippet(snippet) {
1421
- this.runEffects(snippet.onEnter);
1457
+ this.runEffects(snippet.onEnter, snippet.id);
1422
1458
  this.activeSnippet = snippet;
1423
1459
  this.beatIndex = 0;
1424
1460
  }
@@ -1438,8 +1474,8 @@ var Flow = class {
1438
1474
  options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
1439
1475
  byId.set(child.id, child);
1440
1476
  }
1441
- if (options.length > 0) {
1442
- this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1477
+ if (options.some((o) => o.eligible)) {
1478
+ if (this.host.tracing) this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1443
1479
  this.pendingChoice = { groupId: group.id, options, byId };
1444
1480
  return;
1445
1481
  }
@@ -1448,7 +1484,7 @@ var Flow = class {
1448
1484
  this.enterChild(fallback);
1449
1485
  return;
1450
1486
  }
1451
- this.emit({ type: "dry", group: group.id });
1487
+ if (this.host.tracing) this.emit({ type: "dry", group: group.id });
1452
1488
  this.host.onDryChoice?.(group.id);
1453
1489
  }
1454
1490
  // -- Jumps (jump / call-return) ----------------------------------------
@@ -1463,7 +1499,7 @@ var Flow = class {
1463
1499
  * hard-ends the flow regardless of the callstack.
1464
1500
  */
1465
1501
  enterTarget(to, mode) {
1466
- this.emit({ type: "jump", to, mode });
1502
+ if (this.host.tracing) this.emit({ type: "jump", to, mode });
1467
1503
  if (to === "END") {
1468
1504
  this.flowEnded = true;
1469
1505
  this.stack = [];
@@ -1496,11 +1532,11 @@ var Flow = class {
1496
1532
  // -- Selectors ------------------------------------------------------------
1497
1533
  selectChild(group) {
1498
1534
  const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
1499
- const eligible = group.children.filter((c) => this.eligible(c));
1535
+ const eligible = group.children.filter((_, i) => verdicts[i].eligible);
1500
1536
  const order = group.options?.order ?? "sequential";
1501
1537
  const exhaust = group.options?.exhaust ?? "once";
1502
1538
  const trace = (picked) => {
1503
- this.emit({
1539
+ if (this.host.tracing) this.emit({
1504
1540
  type: "select",
1505
1541
  group: group.id,
1506
1542
  selector: group.selector ?? "default",
@@ -1543,8 +1579,9 @@ var Flow = class {
1543
1579
  const len = eligible.length;
1544
1580
  const stick = exhaust === "stick";
1545
1581
  const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
1582
+ const eligibleIds = new Set(eligible.map((c) => c.id));
1546
1583
  if (st.bag === void 0) st.bag = fill();
1547
- if (st.bag.length === 0) {
1584
+ if (!st.bag.some((id2) => eligibleIds.has(id2))) {
1548
1585
  if (exhaust === "once") return null;
1549
1586
  if (stick) {
1550
1587
  const last = eligible[len - 1];
@@ -1552,13 +1589,14 @@ var Flow = class {
1552
1589
  return last;
1553
1590
  }
1554
1591
  st.bag = fill();
1592
+ if (st.bag.length === 0) return null;
1555
1593
  }
1556
- const pool = st.bag;
1594
+ const pool = st.bag.filter((id2) => eligibleIds.has(id2));
1557
1595
  const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
1558
1596
  let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
1559
1597
  if (p >= 0 && i >= p) i++;
1560
1598
  const id = pool[i];
1561
- pool.splice(i, 1);
1599
+ st.bag.splice(st.bag.indexOf(id), 1);
1562
1600
  st.last = id;
1563
1601
  return eligible.find((c) => c.id === id);
1564
1602
  }
@@ -1607,7 +1645,7 @@ var Flow = class {
1607
1645
  /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1608
1646
  * tier), else the specificity of its (already-passing) condition. */
1609
1647
  specScore(node) {
1610
- return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1648
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
1611
1649
  }
1612
1650
  /**
1613
1651
  * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
@@ -1617,8 +1655,15 @@ var Flow = class {
1617
1655
  * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1618
1656
  * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1619
1657
  */
1620
- matchedSpec(node, want) {
1621
- const evalTruthy = (n) => truthy((0, import_expr.evaluate)(n, this.context(), import_dialect.patterDialect));
1658
+ matchedSpec(node, want, owner) {
1659
+ const evalTruthy = (n) => {
1660
+ try {
1661
+ return truthy((0, import_expr.evaluate)(n, this.context(), import_dialect.patterDialect));
1662
+ } catch (err) {
1663
+ this.reportError("best-match", owner.id, owner.condition, err);
1664
+ return false;
1665
+ }
1666
+ };
1622
1667
  return (0, import_expr_specificity.matchedSpecificity)(node, evalTruthy, { want });
1623
1668
  }
1624
1669
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
@@ -1650,17 +1695,36 @@ var Flow = class {
1650
1695
  return st;
1651
1696
  }
1652
1697
  // -- Effects + expressions ------------------------------------------------
1653
- runEffects(effects) {
1698
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
1699
+ runEffects(effects, owner) {
1654
1700
  for (const e of effects ?? []) {
1655
- const value = this.evalExpr(e.value);
1656
- const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1657
- this.writeProperty(e.target, value, false);
1658
- this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1701
+ let value;
1702
+ try {
1703
+ value = this.evalExpr(e.value);
1704
+ const prev = this.host.tracing ? this.getProperty(e.target) : void 0;
1705
+ this.writeProperty(e.target, value, false);
1706
+ if (this.host.tracing) this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1707
+ } catch (err) {
1708
+ this.reportError("effect", owner, e.value, err);
1709
+ }
1659
1710
  }
1660
1711
  }
1712
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
1661
1713
  eligible(node) {
1662
1714
  if (!node.condition) return true;
1663
- return truthy(this.evalExpr(node.condition));
1715
+ try {
1716
+ return truthy(this.evalExpr(node.condition));
1717
+ } catch (err) {
1718
+ this.reportError("condition", node.id, node.condition, err);
1719
+ return false;
1720
+ }
1721
+ }
1722
+ /** Report a content error the engine is playing through: to the host's onError, and to the log. */
1723
+ reportError(kind, node, expr, err) {
1724
+ const message = err instanceof Error ? err.message : String(err);
1725
+ const source = expr?.src;
1726
+ this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
1727
+ if (this.host.tracing) this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1664
1728
  }
1665
1729
  evalExpr(expr) {
1666
1730
  return (0, import_expr.evaluate)(this.conditionAst(expr), this.context(), import_dialect.patterDialect);
@@ -1856,23 +1920,23 @@ var Flow = class {
1856
1920
  if (!this.sceneBags.has(s)) {
1857
1921
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1858
1922
  const key = keys.flowScene(this.id, s);
1859
- this.host.registry.mountOwned(key, bag, { owner: OWNER });
1923
+ const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
1860
1924
  this.registered.add(key);
1861
1925
  this.sceneBags.set(s, bag);
1862
1926
  if (journal) journal.undo.push(() => {
1863
1927
  if (this.sceneBags.get(s) !== bag) return;
1864
- this.host.registry.remove(key);
1928
+ this.host.registry.remove(key, claimed ? { keep: true } : void 0);
1865
1929
  this.registered.delete(key);
1866
1930
  this.sceneBags.delete(s);
1867
1931
  });
1868
1932
  }
1869
1933
  if (!this.host.stageBags.has(s)) {
1870
1934
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1871
- this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1935
+ const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
1872
1936
  this.host.stageBags.set(s, bag);
1873
1937
  if (journal) journal.undo.push(() => {
1874
1938
  if (this.host.stageBags.get(s) !== bag) return;
1875
- this.host.registry.remove(keys.stage(s));
1939
+ this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
1876
1940
  this.host.stageBags.delete(s);
1877
1941
  });
1878
1942
  }
@@ -1939,9 +2003,29 @@ function deserialiseSelectors(rec) {
1939
2003
  }
1940
2004
  return map;
1941
2005
  }
2006
+ function mountClaims(registry, key, bag, ask) {
2007
+ const before = ask ? JSON.stringify(bag.save()) : "";
2008
+ registry.mountOwned(key, bag, { owner: OWNER });
2009
+ return ask && JSON.stringify(bag.save()) !== before;
2010
+ }
1942
2011
  function toDecl(decl) {
1943
2012
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1944
2013
  }
2014
+ function boundScopes(options) {
2015
+ return { ...options.world ? { world: options.world } : {}, ...options.hostScopes };
2016
+ }
2017
+ function ladders(host, cacheKey, decls) {
2018
+ let found = host.qualityLadders.get(cacheKey);
2019
+ if (!found) {
2020
+ found = /* @__PURE__ */ new Map();
2021
+ for (const d of decls() ?? []) {
2022
+ const k = d.name.toLowerCase();
2023
+ if (d.type === "quality" && d.stages && !found.has(k)) found.set(k, d.stages);
2024
+ }
2025
+ host.qualityLadders.set(cacheKey, found);
2026
+ }
2027
+ return found;
2028
+ }
1945
2029
  function toForeignDecl(decl) {
1946
2030
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
1947
2031
  }
@@ -1950,6 +2034,12 @@ function selfBackedDecl(decl, scopeWritable) {
1950
2034
  const writable = decl.writable ?? scopeWritable;
1951
2035
  return writable === void 0 ? d : { ...d, writable };
1952
2036
  }
2037
+ function resolveScene(host, ref) {
2038
+ return host.sceneGameIdToId.get(ref) ?? (host.bundle.scenes[ref] ? ref : void 0);
2039
+ }
2040
+ function resolveBlock(host, sceneId, ref) {
2041
+ return host.blockGameIdToId.get(sceneId)?.get(ref) ?? (host.blockIndex.get(ref)?.sceneId === sceneId ? ref : void 0);
2042
+ }
1953
2043
  function splitHostRef(host, ref) {
1954
2044
  if (host.refSplitRevision !== host.registry.revision) {
1955
2045
  host.refSplitCache.clear();