@patterkit/runtime 0.18.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/dist/index.d.cts CHANGED
@@ -194,12 +194,34 @@ type TraceEvent = {
194
194
  value: ScalarValue;
195
195
  prev?: ScalarValue;
196
196
  }
197
- /** An expression that would not evaluate: never a silent pass, always visible. */
197
+ /** Content the engine could not evaluate and played through (see {@link PlayError}): a condition
198
+ * counted as false, an effect skipped, or a Best-match part scored as false. */
198
199
  | {
199
200
  type: "diagnostic";
200
- where: string;
201
+ kind: PlayError["kind"];
202
+ node: string;
203
+ source?: string;
201
204
  message: string;
202
205
  };
206
+ /**
207
+ * A content error the engine played through. Content can fail at run time in ways the compiler cannot
208
+ * see: a division by zero, a host value of the wrong type, a story write to a read-only `@world` value.
209
+ * The story never stops for one. A condition that fails counts as false; an effect that fails is skipped
210
+ * and the rest of its list still runs; a part of a Best-match condition that fails scores as false. Each
211
+ * is reported, through `EngineOptions.onError` and as a `diagnostic` entry in the decision log. The same
212
+ * rule holds on all four runtimes, including the two whose languages cannot throw.
213
+ */
214
+ interface PlayError {
215
+ /** The flow it happened in. */
216
+ flow: string;
217
+ /** What failed: a condition, an effect, or a part of a condition being scored for Best match. */
218
+ kind: "condition" | "effect" | "best-match";
219
+ /** The snippet, group, or option whose condition failed, or that owns the effect (a scene, for its onEntry). */
220
+ node: string;
221
+ /** The expression's source text, when the bundle carries it. */
222
+ source?: string;
223
+ message: string;
224
+ }
203
225
  type TraceHandler = (event: TraceEvent) => void;
204
226
  /** The engine-level tap: every flow's events, tagged with the flow id - one stream for tools. */
205
227
  type EngineTraceHandler = (flow: string, event: TraceEvent) => void;
@@ -256,6 +278,10 @@ interface EngineOptions {
256
278
  * unchanged; this only makes the fall-through observable. The coverage harness uses it to flag choices
257
279
  * that ran dry. Leave it unset in shipped games (zero cost). */
258
280
  onDryChoice?: (groupId: string) => void;
281
+ /** Called with each content error the engine played through (see {@link PlayError}): a condition
282
+ * that failed and counted as false, an effect that failed and was skipped. Unset, each is written
283
+ * to `console.warn`, so a content bug is never silent. */
284
+ onError?: (error: PlayError) => void;
259
285
  /** Retain a trace of the engine's DECISIONS (what it chose and why), readable through
260
286
  * `engine.log()` and `flow.log()`. Off by default: a shipped game pays nothing for a
261
287
  * debugging surface it never reads. `onDryChoice` above is unaffected and stays useful
@@ -365,6 +391,8 @@ interface FlowHost {
365
391
  * fallback - so it falls through and the flow continues past it. Zero cost when unset; the coverage
366
392
  * harness passes it to surface silent fall-throughs. Not a gameplay signal (the behaviour is unchanged). */
367
393
  onDryChoice?: (groupId: string) => void;
394
+ /** Where a content error the engine played through is reported (see PlayError). */
395
+ onError: (error: PlayError) => void;
368
396
  /** Memoised `splitRef` results (ref string -> {scope,name}). The split depends only on the registry's
369
397
  * scope set, so the cache is dropped whenever that moves (`refSplitRevision`). */
370
398
  refSplitCache: Map<string, {
@@ -396,6 +424,8 @@ declare class Engine {
396
424
  /** The run's ordered stream: every flow's events, each naming its flow. Empty and
397
425
  * unwritten unless `options.log` asked for it. */
398
426
  private readonly engineLog;
427
+ /** The next engine-log `seq`: its own counter, since the log's length restarts after a clear. */
428
+ private engineSeq;
399
429
  private readonly engineTraceHandlers;
400
430
  constructor(bundle: Bundle, options?: EngineOptions);
401
431
  /** The active locale (string + character-name lookups resolve in it). */
@@ -501,9 +531,12 @@ declare class Engine {
501
531
  * is an internal id from any scene. Neither: the bundle's first scene. The same rule on every runtime.
502
532
  */
503
533
  private resolveOpenAddress;
504
- /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
534
+ /** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
535
+ * unknown one passes through, for the caller to find nothing. */
505
536
  private resolveSceneRef;
506
- /** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
537
+ /** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
538
+ * scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
539
+ * since a block's gameId is only unique within its scene. */
507
540
  private resolveBlockRef;
508
541
  /** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
509
542
  * The inverse of the resolve helpers - for a host that wants to display / log the address. */
@@ -870,8 +903,12 @@ declare class Flow {
870
903
  private matchedSpec;
871
904
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
872
905
  private selectorState;
906
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
873
907
  private runEffects;
908
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
874
909
  private eligible;
910
+ /** Report a content error the engine is playing through: to the host's onError, and to the log. */
911
+ private reportError;
875
912
  private evalExpr;
876
913
  /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
877
914
  * evaluator and the Best-match specificity walker so both work off one parse. */
@@ -1095,11 +1132,11 @@ declare function gameDataValue(fields: GameDataField[], node: GameData | undefin
1095
1132
  declare function effectiveGameData(fields: GameDataField[], node: GameData | undefined): GameData;
1096
1133
 
1097
1134
  /**
1098
- * Map every node id (scene / block / group / snippet / beat) to its accumulated
1135
+ * Map every node id (scene / block / group / snippet / beat, prompt beats included) to its accumulated
1099
1136
  * tags. Node ids are globally unique within a project (the validator enforces
1100
1137
  * it), so one flat map suffices. Nodes with no tags anywhere up the chain map to
1101
1138
  * an empty array.
1102
1139
  */
1103
1140
  declare function buildTagIndex(bundle: Bundle): Map<string, string[]>;
1104
1141
 
1105
- export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, Checkpoint, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
1142
+ export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, Checkpoint, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PlayError, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
package/dist/index.d.ts CHANGED
@@ -194,12 +194,34 @@ type TraceEvent = {
194
194
  value: ScalarValue;
195
195
  prev?: ScalarValue;
196
196
  }
197
- /** An expression that would not evaluate: never a silent pass, always visible. */
197
+ /** Content the engine could not evaluate and played through (see {@link PlayError}): a condition
198
+ * counted as false, an effect skipped, or a Best-match part scored as false. */
198
199
  | {
199
200
  type: "diagnostic";
200
- where: string;
201
+ kind: PlayError["kind"];
202
+ node: string;
203
+ source?: string;
201
204
  message: string;
202
205
  };
206
+ /**
207
+ * A content error the engine played through. Content can fail at run time in ways the compiler cannot
208
+ * see: a division by zero, a host value of the wrong type, a story write to a read-only `@world` value.
209
+ * The story never stops for one. A condition that fails counts as false; an effect that fails is skipped
210
+ * and the rest of its list still runs; a part of a Best-match condition that fails scores as false. Each
211
+ * is reported, through `EngineOptions.onError` and as a `diagnostic` entry in the decision log. The same
212
+ * rule holds on all four runtimes, including the two whose languages cannot throw.
213
+ */
214
+ interface PlayError {
215
+ /** The flow it happened in. */
216
+ flow: string;
217
+ /** What failed: a condition, an effect, or a part of a condition being scored for Best match. */
218
+ kind: "condition" | "effect" | "best-match";
219
+ /** The snippet, group, or option whose condition failed, or that owns the effect (a scene, for its onEntry). */
220
+ node: string;
221
+ /** The expression's source text, when the bundle carries it. */
222
+ source?: string;
223
+ message: string;
224
+ }
203
225
  type TraceHandler = (event: TraceEvent) => void;
204
226
  /** The engine-level tap: every flow's events, tagged with the flow id - one stream for tools. */
205
227
  type EngineTraceHandler = (flow: string, event: TraceEvent) => void;
@@ -256,6 +278,10 @@ interface EngineOptions {
256
278
  * unchanged; this only makes the fall-through observable. The coverage harness uses it to flag choices
257
279
  * that ran dry. Leave it unset in shipped games (zero cost). */
258
280
  onDryChoice?: (groupId: string) => void;
281
+ /** Called with each content error the engine played through (see {@link PlayError}): a condition
282
+ * that failed and counted as false, an effect that failed and was skipped. Unset, each is written
283
+ * to `console.warn`, so a content bug is never silent. */
284
+ onError?: (error: PlayError) => void;
259
285
  /** Retain a trace of the engine's DECISIONS (what it chose and why), readable through
260
286
  * `engine.log()` and `flow.log()`. Off by default: a shipped game pays nothing for a
261
287
  * debugging surface it never reads. `onDryChoice` above is unaffected and stays useful
@@ -365,6 +391,8 @@ interface FlowHost {
365
391
  * fallback - so it falls through and the flow continues past it. Zero cost when unset; the coverage
366
392
  * harness passes it to surface silent fall-throughs. Not a gameplay signal (the behaviour is unchanged). */
367
393
  onDryChoice?: (groupId: string) => void;
394
+ /** Where a content error the engine played through is reported (see PlayError). */
395
+ onError: (error: PlayError) => void;
368
396
  /** Memoised `splitRef` results (ref string -> {scope,name}). The split depends only on the registry's
369
397
  * scope set, so the cache is dropped whenever that moves (`refSplitRevision`). */
370
398
  refSplitCache: Map<string, {
@@ -396,6 +424,8 @@ declare class Engine {
396
424
  /** The run's ordered stream: every flow's events, each naming its flow. Empty and
397
425
  * unwritten unless `options.log` asked for it. */
398
426
  private readonly engineLog;
427
+ /** The next engine-log `seq`: its own counter, since the log's length restarts after a clear. */
428
+ private engineSeq;
399
429
  private readonly engineTraceHandlers;
400
430
  constructor(bundle: Bundle, options?: EngineOptions);
401
431
  /** The active locale (string + character-name lookups resolve in it). */
@@ -501,9 +531,12 @@ declare class Engine {
501
531
  * is an internal id from any scene. Neither: the bundle's first scene. The same rule on every runtime.
502
532
  */
503
533
  private resolveOpenAddress;
504
- /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
534
+ /** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
535
+ * unknown one passes through, for the caller to find nothing. */
505
536
  private resolveSceneRef;
506
- /** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
537
+ /** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
538
+ * scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
539
+ * since a block's gameId is only unique within its scene. */
507
540
  private resolveBlockRef;
508
541
  /** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
509
542
  * The inverse of the resolve helpers - for a host that wants to display / log the address. */
@@ -870,8 +903,12 @@ declare class Flow {
870
903
  private matchedSpec;
871
904
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
872
905
  private selectorState;
906
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
873
907
  private runEffects;
908
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
874
909
  private eligible;
910
+ /** Report a content error the engine is playing through: to the host's onError, and to the log. */
911
+ private reportError;
875
912
  private evalExpr;
876
913
  /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
877
914
  * evaluator and the Best-match specificity walker so both work off one parse. */
@@ -1095,11 +1132,11 @@ declare function gameDataValue(fields: GameDataField[], node: GameData | undefin
1095
1132
  declare function effectiveGameData(fields: GameDataField[], node: GameData | undefined): GameData;
1096
1133
 
1097
1134
  /**
1098
- * Map every node id (scene / block / group / snippet / beat) to its accumulated
1135
+ * Map every node id (scene / block / group / snippet / beat, prompt beats included) to its accumulated
1099
1136
  * tags. Node ids are globally unique within a project (the validator enforces
1100
1137
  * it), so one flat map suffices. Nodes with no tags anywhere up the chain map to
1101
1138
  * an empty array.
1102
1139
  */
1103
1140
  declare function buildTagIndex(bundle: Bundle): Map<string, string[]>;
1104
1141
 
1105
- export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, Checkpoint, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
1142
+ export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, Checkpoint, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PlayError, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
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 {
@@ -85,6 +86,8 @@ var Engine = class _Engine {
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;
@@ -182,6 +185,7 @@ var Engine = class _Engine {
182
185
  journal: null,
183
186
  customRng: options.rng,
184
187
  onDryChoice: options.onDryChoice,
188
+ onError: options.onError ?? ((e) => console.warn(`Patterplay: ${e.kind} on ${e.node} in flow '${e.flow}' failed, played through: ${e.message}`)),
185
189
  replayPromptOnChoose: options.replayPromptOnChoose ?? false,
186
190
  captionsOn: options.closedCaptions ?? true,
187
191
  // captions shown by default (full text)
@@ -375,10 +379,10 @@ var Engine = class _Engine {
375
379
  */
376
380
  resolveOpenAddress(scene, block) {
377
381
  if (scene !== void 0) {
378
- const sceneId = this.resolveSceneRef(scene);
379
- if (!this.host.bundle.scenes[sceneId]) throw new Error(`unknown scene: ${scene}`);
382
+ const sceneId = resolveScene(this.host, scene);
383
+ if (sceneId === void 0) throw new Error(`unknown scene: ${scene}`);
380
384
  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);
385
+ const blockId = resolveBlock(this.host, sceneId, block);
382
386
  if (blockId === void 0) throw new Error(`unknown block: ${block}`);
383
387
  return { sceneId, blockId };
384
388
  }
@@ -389,21 +393,19 @@ var Engine = class _Engine {
389
393
  if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
390
394
  return {};
391
395
  }
392
- /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
396
+ /** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
397
+ * unknown one passes through, for the caller to find nothing. */
393
398
  resolveSceneRef(ref) {
394
399
  if (ref == null) return void 0;
395
- if (this.host.bundle.scenes[ref]) return ref;
396
- return this.sceneGameIdToId.get(ref) ?? ref;
400
+ return resolveScene(this.host, ref) ?? ref;
397
401
  }
398
- /** Resolve a block reference (a scene-scoped gameId OR an internal id) to its internal id. */
402
+ /** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
403
+ * scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
404
+ * since a block's gameId is only unique within its scene. */
399
405
  resolveBlockRef(sceneId, ref) {
400
406
  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;
407
+ if (sceneId == null) return ref;
408
+ return resolveBlock(this.host, sceneId, ref);
407
409
  }
408
410
  /** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
409
411
  * The inverse of the resolve helpers - for a host that wants to display / log the address. */
@@ -730,7 +732,7 @@ var Engine = class _Engine {
730
732
  emitEngine(flow, event, scene) {
731
733
  for (const h of this.engineTraceHandlers) h(flow, event);
732
734
  if (!this.host.logEnabled) return;
733
- this.engineLog.push({ ...event, flow, seq: this.engineLog.length, ...scene ? { scene } : {} });
735
+ this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
734
736
  }
735
737
  listProperties() {
736
738
  return this.host.patterSharedDecls.map((d) => ({
@@ -796,6 +798,8 @@ var Engine = class _Engine {
796
798
  this.refuseInCheckpoint("loadGame");
797
799
  const version = save.version;
798
800
  if (version !== 2 && version !== SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
801
+ const flows = save.flows;
802
+ if (typeof flows !== "object" || flows === null || Array.isArray(flows)) throw new Error("malformed save: no flows");
799
803
  this.assertExternalScopes();
800
804
  const reg = this.host.registry;
801
805
  for (const [id, flow] of this.flowsById) {
@@ -1019,11 +1023,11 @@ var Flow = class {
1019
1023
  this.stack = [];
1020
1024
  return true;
1021
1025
  }
1022
- const sceneId = this.host.sceneGameIdToId.get(scene) ?? (this.host.bundle.scenes[scene] ? scene : void 0);
1026
+ const sceneId = resolveScene(this.host, scene);
1023
1027
  if (sceneId === void 0) return false;
1024
1028
  let blockId;
1025
1029
  if (block !== void 0) {
1026
- blockId = this.host.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
1030
+ blockId = resolveBlock(this.host, sceneId, block);
1027
1031
  if (blockId === void 0) return false;
1028
1032
  }
1029
1033
  if (!this.started) {
@@ -1133,7 +1137,7 @@ var Flow = class {
1133
1137
  if (this.flowEnded || this.pendingChoice) return;
1134
1138
  if (this.activeSnippet) {
1135
1139
  if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
1136
- this.runEffects(this.activeSnippet.onExit);
1140
+ this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
1137
1141
  const jump = this.activeSnippet.jump;
1138
1142
  this.activeSnippet = null;
1139
1143
  this.beatIndex = 0;
@@ -1349,7 +1353,7 @@ var Flow = class {
1349
1353
  this.currentSceneId = sceneId;
1350
1354
  this.enter(sceneId);
1351
1355
  this.seedScene(scene);
1352
- this.runEffects(scene.onEntry);
1356
+ this.runEffects(scene.onEntry, scene.id);
1353
1357
  }
1354
1358
  /**
1355
1359
  * Play one child of the active run. A snippet begins delivering. A group is
@@ -1385,7 +1389,7 @@ var Flow = class {
1385
1389
  return void 0;
1386
1390
  }
1387
1391
  beginSnippet(snippet) {
1388
- this.runEffects(snippet.onEnter);
1392
+ this.runEffects(snippet.onEnter, snippet.id);
1389
1393
  this.activeSnippet = snippet;
1390
1394
  this.beatIndex = 0;
1391
1395
  }
@@ -1405,7 +1409,7 @@ var Flow = class {
1405
1409
  options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
1406
1410
  byId.set(child.id, child);
1407
1411
  }
1408
- if (options.length > 0) {
1412
+ if (options.some((o) => o.eligible)) {
1409
1413
  this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1410
1414
  this.pendingChoice = { groupId: group.id, options, byId };
1411
1415
  return;
@@ -1463,7 +1467,7 @@ var Flow = class {
1463
1467
  // -- Selectors ------------------------------------------------------------
1464
1468
  selectChild(group) {
1465
1469
  const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
1466
- const eligible = group.children.filter((c) => this.eligible(c));
1470
+ const eligible = group.children.filter((_, i) => verdicts[i].eligible);
1467
1471
  const order = group.options?.order ?? "sequential";
1468
1472
  const exhaust = group.options?.exhaust ?? "once";
1469
1473
  const trace = (picked) => {
@@ -1510,8 +1514,9 @@ var Flow = class {
1510
1514
  const len = eligible.length;
1511
1515
  const stick = exhaust === "stick";
1512
1516
  const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
1517
+ const eligibleIds = new Set(eligible.map((c) => c.id));
1513
1518
  if (st.bag === void 0) st.bag = fill();
1514
- if (st.bag.length === 0) {
1519
+ if (!st.bag.some((id2) => eligibleIds.has(id2))) {
1515
1520
  if (exhaust === "once") return null;
1516
1521
  if (stick) {
1517
1522
  const last = eligible[len - 1];
@@ -1519,13 +1524,14 @@ var Flow = class {
1519
1524
  return last;
1520
1525
  }
1521
1526
  st.bag = fill();
1527
+ if (st.bag.length === 0) return null;
1522
1528
  }
1523
- const pool = st.bag;
1529
+ const pool = st.bag.filter((id2) => eligibleIds.has(id2));
1524
1530
  const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
1525
1531
  let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
1526
1532
  if (p >= 0 && i >= p) i++;
1527
1533
  const id = pool[i];
1528
- pool.splice(i, 1);
1534
+ st.bag.splice(st.bag.indexOf(id), 1);
1529
1535
  st.last = id;
1530
1536
  return eligible.find((c) => c.id === id);
1531
1537
  }
@@ -1574,7 +1580,7 @@ var Flow = class {
1574
1580
  /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1575
1581
  * tier), else the specificity of its (already-passing) condition. */
1576
1582
  specScore(node) {
1577
- return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1583
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
1578
1584
  }
1579
1585
  /**
1580
1586
  * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
@@ -1584,8 +1590,15 @@ var Flow = class {
1584
1590
  * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1585
1591
  * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1586
1592
  */
1587
- matchedSpec(node, want) {
1588
- const evalTruthy = (n) => truthy(evaluate(n, this.context(), patterDialect));
1593
+ matchedSpec(node, want, owner) {
1594
+ const evalTruthy = (n) => {
1595
+ try {
1596
+ return truthy(evaluate(n, this.context(), patterDialect));
1597
+ } catch (err) {
1598
+ this.reportError("best-match", owner.id, owner.condition, err);
1599
+ return false;
1600
+ }
1601
+ };
1589
1602
  return scoreSpecificity(node, evalTruthy, { want });
1590
1603
  }
1591
1604
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
@@ -1617,17 +1630,36 @@ var Flow = class {
1617
1630
  return st;
1618
1631
  }
1619
1632
  // -- Effects + expressions ------------------------------------------------
1620
- runEffects(effects) {
1633
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
1634
+ runEffects(effects, owner) {
1621
1635
  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 } : {} });
1636
+ let value;
1637
+ try {
1638
+ value = this.evalExpr(e.value);
1639
+ const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1640
+ this.writeProperty(e.target, value, false);
1641
+ this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1642
+ } catch (err) {
1643
+ this.reportError("effect", owner, e.value, err);
1644
+ }
1626
1645
  }
1627
1646
  }
1647
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
1628
1648
  eligible(node) {
1629
1649
  if (!node.condition) return true;
1630
- return truthy(this.evalExpr(node.condition));
1650
+ try {
1651
+ return truthy(this.evalExpr(node.condition));
1652
+ } catch (err) {
1653
+ this.reportError("condition", node.id, node.condition, err);
1654
+ return false;
1655
+ }
1656
+ }
1657
+ /** Report a content error the engine is playing through: to the host's onError, and to the log. */
1658
+ reportError(kind, node, expr, err) {
1659
+ const message = err instanceof Error ? err.message : String(err);
1660
+ const source = expr?.src;
1661
+ this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
1662
+ this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1631
1663
  }
1632
1664
  evalExpr(expr) {
1633
1665
  return evaluate(this.conditionAst(expr), this.context(), patterDialect);
@@ -1823,23 +1855,23 @@ var Flow = class {
1823
1855
  if (!this.sceneBags.has(s)) {
1824
1856
  const bag = new PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1825
1857
  const key = keys.flowScene(this.id, s);
1826
- this.host.registry.mountOwned(key, bag, { owner: OWNER });
1858
+ const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
1827
1859
  this.registered.add(key);
1828
1860
  this.sceneBags.set(s, bag);
1829
1861
  if (journal) journal.undo.push(() => {
1830
1862
  if (this.sceneBags.get(s) !== bag) return;
1831
- this.host.registry.remove(key);
1863
+ this.host.registry.remove(key, claimed ? { keep: true } : void 0);
1832
1864
  this.registered.delete(key);
1833
1865
  this.sceneBags.delete(s);
1834
1866
  });
1835
1867
  }
1836
1868
  if (!this.host.stageBags.has(s)) {
1837
1869
  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 });
1870
+ const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
1839
1871
  this.host.stageBags.set(s, bag);
1840
1872
  if (journal) journal.undo.push(() => {
1841
1873
  if (this.host.stageBags.get(s) !== bag) return;
1842
- this.host.registry.remove(keys.stage(s));
1874
+ this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
1843
1875
  this.host.stageBags.delete(s);
1844
1876
  });
1845
1877
  }
@@ -1906,6 +1938,11 @@ function deserialiseSelectors(rec) {
1906
1938
  }
1907
1939
  return map;
1908
1940
  }
1941
+ function mountClaims(registry, key, bag, ask) {
1942
+ const before = ask ? JSON.stringify(bag.save()) : "";
1943
+ registry.mountOwned(key, bag, { owner: OWNER });
1944
+ return ask && JSON.stringify(bag.save()) !== before;
1945
+ }
1909
1946
  function toDecl(decl) {
1910
1947
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1911
1948
  }
@@ -1917,6 +1954,12 @@ function selfBackedDecl(decl, scopeWritable) {
1917
1954
  const writable = decl.writable ?? scopeWritable;
1918
1955
  return writable === void 0 ? d : { ...d, writable };
1919
1956
  }
1957
+ function resolveScene(host, ref) {
1958
+ return host.sceneGameIdToId.get(ref) ?? (host.bundle.scenes[ref] ? ref : void 0);
1959
+ }
1960
+ function resolveBlock(host, sceneId, ref) {
1961
+ return host.blockGameIdToId.get(sceneId)?.get(ref) ?? (host.blockIndex.get(ref)?.sceneId === sceneId ? ref : void 0);
1962
+ }
1920
1963
  function splitHostRef(host, ref) {
1921
1964
  if (host.refSplitRevision !== host.registry.revision) {
1922
1965
  host.refSplitCache.clear();