@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/CHANGELOG.md CHANGED
@@ -33,6 +33,52 @@ 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
+
36
82
  ## [0.18.0] - 2026-10-05
37
83
 
38
84
  ### Changed
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) {
@@ -1052,11 +1056,11 @@ var Flow = class {
1052
1056
  this.stack = [];
1053
1057
  return true;
1054
1058
  }
1055
- const sceneId = this.host.sceneGameIdToId.get(scene) ?? (this.host.bundle.scenes[scene] ? scene : void 0);
1059
+ const sceneId = resolveScene(this.host, scene);
1056
1060
  if (sceneId === void 0) return false;
1057
1061
  let blockId;
1058
1062
  if (block !== void 0) {
1059
- 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);
1060
1064
  if (blockId === void 0) return false;
1061
1065
  }
1062
1066
  if (!this.started) {
@@ -1166,7 +1170,7 @@ var Flow = class {
1166
1170
  if (this.flowEnded || this.pendingChoice) return;
1167
1171
  if (this.activeSnippet) {
1168
1172
  if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
1169
- this.runEffects(this.activeSnippet.onExit);
1173
+ this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
1170
1174
  const jump = this.activeSnippet.jump;
1171
1175
  this.activeSnippet = null;
1172
1176
  this.beatIndex = 0;
@@ -1382,7 +1386,7 @@ var Flow = class {
1382
1386
  this.currentSceneId = sceneId;
1383
1387
  this.enter(sceneId);
1384
1388
  this.seedScene(scene);
1385
- this.runEffects(scene.onEntry);
1389
+ this.runEffects(scene.onEntry, scene.id);
1386
1390
  }
1387
1391
  /**
1388
1392
  * Play one child of the active run. A snippet begins delivering. A group is
@@ -1418,7 +1422,7 @@ var Flow = class {
1418
1422
  return void 0;
1419
1423
  }
1420
1424
  beginSnippet(snippet) {
1421
- this.runEffects(snippet.onEnter);
1425
+ this.runEffects(snippet.onEnter, snippet.id);
1422
1426
  this.activeSnippet = snippet;
1423
1427
  this.beatIndex = 0;
1424
1428
  }
@@ -1438,7 +1442,7 @@ var Flow = class {
1438
1442
  options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
1439
1443
  byId.set(child.id, child);
1440
1444
  }
1441
- if (options.length > 0) {
1445
+ if (options.some((o) => o.eligible)) {
1442
1446
  this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1443
1447
  this.pendingChoice = { groupId: group.id, options, byId };
1444
1448
  return;
@@ -1496,7 +1500,7 @@ var Flow = class {
1496
1500
  // -- Selectors ------------------------------------------------------------
1497
1501
  selectChild(group) {
1498
1502
  const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
1499
- const eligible = group.children.filter((c) => this.eligible(c));
1503
+ const eligible = group.children.filter((_, i) => verdicts[i].eligible);
1500
1504
  const order = group.options?.order ?? "sequential";
1501
1505
  const exhaust = group.options?.exhaust ?? "once";
1502
1506
  const trace = (picked) => {
@@ -1543,8 +1547,9 @@ var Flow = class {
1543
1547
  const len = eligible.length;
1544
1548
  const stick = exhaust === "stick";
1545
1549
  const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
1550
+ const eligibleIds = new Set(eligible.map((c) => c.id));
1546
1551
  if (st.bag === void 0) st.bag = fill();
1547
- if (st.bag.length === 0) {
1552
+ if (!st.bag.some((id2) => eligibleIds.has(id2))) {
1548
1553
  if (exhaust === "once") return null;
1549
1554
  if (stick) {
1550
1555
  const last = eligible[len - 1];
@@ -1552,13 +1557,14 @@ var Flow = class {
1552
1557
  return last;
1553
1558
  }
1554
1559
  st.bag = fill();
1560
+ if (st.bag.length === 0) return null;
1555
1561
  }
1556
- const pool = st.bag;
1562
+ const pool = st.bag.filter((id2) => eligibleIds.has(id2));
1557
1563
  const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
1558
1564
  let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
1559
1565
  if (p >= 0 && i >= p) i++;
1560
1566
  const id = pool[i];
1561
- pool.splice(i, 1);
1567
+ st.bag.splice(st.bag.indexOf(id), 1);
1562
1568
  st.last = id;
1563
1569
  return eligible.find((c) => c.id === id);
1564
1570
  }
@@ -1607,7 +1613,7 @@ var Flow = class {
1607
1613
  /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1608
1614
  * tier), else the specificity of its (already-passing) condition. */
1609
1615
  specScore(node) {
1610
- return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1616
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
1611
1617
  }
1612
1618
  /**
1613
1619
  * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
@@ -1617,8 +1623,15 @@ var Flow = class {
1617
1623
  * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1618
1624
  * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1619
1625
  */
1620
- matchedSpec(node, want) {
1621
- 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
+ };
1622
1635
  return (0, import_expr_specificity.matchedSpecificity)(node, evalTruthy, { want });
1623
1636
  }
1624
1637
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
@@ -1650,17 +1663,36 @@ var Flow = class {
1650
1663
  return st;
1651
1664
  }
1652
1665
  // -- Effects + expressions ------------------------------------------------
1653
- runEffects(effects) {
1666
+ /** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
1667
+ runEffects(effects, owner) {
1654
1668
  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 } : {} });
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
+ }
1659
1678
  }
1660
1679
  }
1680
+ /** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
1661
1681
  eligible(node) {
1662
1682
  if (!node.condition) return true;
1663
- 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 });
1664
1696
  }
1665
1697
  evalExpr(expr) {
1666
1698
  return (0, import_expr.evaluate)(this.conditionAst(expr), this.context(), import_dialect.patterDialect);
@@ -1856,23 +1888,23 @@ var Flow = class {
1856
1888
  if (!this.sceneBags.has(s)) {
1857
1889
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1858
1890
  const key = keys.flowScene(this.id, s);
1859
- this.host.registry.mountOwned(key, bag, { owner: OWNER });
1891
+ const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
1860
1892
  this.registered.add(key);
1861
1893
  this.sceneBags.set(s, bag);
1862
1894
  if (journal) journal.undo.push(() => {
1863
1895
  if (this.sceneBags.get(s) !== bag) return;
1864
- this.host.registry.remove(key);
1896
+ this.host.registry.remove(key, claimed ? { keep: true } : void 0);
1865
1897
  this.registered.delete(key);
1866
1898
  this.sceneBags.delete(s);
1867
1899
  });
1868
1900
  }
1869
1901
  if (!this.host.stageBags.has(s)) {
1870
1902
  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 });
1903
+ const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
1872
1904
  this.host.stageBags.set(s, bag);
1873
1905
  if (journal) journal.undo.push(() => {
1874
1906
  if (this.host.stageBags.get(s) !== bag) return;
1875
- this.host.registry.remove(keys.stage(s));
1907
+ this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
1876
1908
  this.host.stageBags.delete(s);
1877
1909
  });
1878
1910
  }
@@ -1939,6 +1971,11 @@ function deserialiseSelectors(rec) {
1939
1971
  }
1940
1972
  return map;
1941
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
+ }
1942
1979
  function toDecl(decl) {
1943
1980
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1944
1981
  }
@@ -1950,6 +1987,12 @@ function selfBackedDecl(decl, scopeWritable) {
1950
1987
  const writable = decl.writable ?? scopeWritable;
1951
1988
  return writable === void 0 ? d : { ...d, writable };
1952
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
+ }
1953
1996
  function splitHostRef(host, ref) {
1954
1997
  if (host.refSplitRevision !== host.registry.revision) {
1955
1998
  host.refSplitCache.clear();