@patterkit/runtime 0.19.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,46 @@ 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
+
36
76
  ## [0.19.0] - 2026-10-06
37
77
 
38
78
  ### Added
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
@@ -114,7 +114,7 @@ var Engine = class _Engine {
114
114
  sceneGameIdToId = /* @__PURE__ */ new Map();
115
115
  blockGameIdToId = /* @__PURE__ */ new Map();
116
116
  /** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
117
- * engine keeps the same world resolver, custom RNG, and diagnostic hooks. */
117
+ * engine keeps the same host scopes, custom RNG, and diagnostic hooks. */
118
118
  creationOptions;
119
119
  /** The run's ordered stream: every flow's events, each naming its flow. Empty and
120
120
  * unwritten unless `options.log` asked for it. */
@@ -166,12 +166,12 @@ var Engine = class _Engine {
166
166
  registry.mountOwned("patter", patterBag, { owner: OWNER });
167
167
  registered.push("patter");
168
168
  const hostBound = /* @__PURE__ */ new Set();
169
- if (options.world) {
170
- const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
171
- const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
172
- registry.defineForeign("world", options.world, decls, { writable: worldSpec?.writable ?? true, owner: OWNER });
173
- registered.push("world");
174
- 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);
175
175
  }
176
176
  for (const spec of ownsRegistry ? bundle.scopeRegistry?.scopes ?? [] : []) {
177
177
  if (hostBound.has(spec.token) || registry.has(spec.token)) continue;
@@ -191,6 +191,7 @@ var Engine = class _Engine {
191
191
  }
192
192
  this.host = {
193
193
  logEnabled: options.log ?? false,
194
+ tracing: options.log ?? false,
194
195
  emitEngine: (flow, event, scene) => this.emitEngine(flow, event, scene),
195
196
  bundle,
196
197
  emitIds,
@@ -213,6 +214,7 @@ var Engine = class _Engine {
213
214
  patterSharedNames,
214
215
  sceneSharedNames,
215
216
  sharedVisits: /* @__PURE__ */ new Map(),
217
+ qualityLadders: /* @__PURE__ */ new Map(),
216
218
  sharedSelectors: /* @__PURE__ */ new Map(),
217
219
  stageBags: /* @__PURE__ */ new Map(),
218
220
  journal: null,
@@ -239,6 +241,11 @@ var Engine = class _Engine {
239
241
  get isSourceDebug() {
240
242
  return this.sourceDebug;
241
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
+ }
242
249
  /**
243
250
  * Switch the active locale LIVE - a real game's "language" setting can change mid-session. Subsequent
244
251
  * string lookups (new beats, re-resolved character names, `{@ref}` interpolation) render in the new
@@ -359,7 +366,7 @@ var Engine = class _Engine {
359
366
  });
360
367
  }
361
368
  this.flowsById.set(id, flow);
362
- flow.start(sceneId, blockId);
369
+ flow.begin(sceneId, blockId);
363
370
  return flow;
364
371
  }
365
372
  /**
@@ -723,7 +730,7 @@ var Engine = class _Engine {
723
730
  for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep });
724
731
  this.host.stageBags.clear();
725
732
  for (const t of ["patter", ...this.host.hostScopes]) if (reg.has(t)) reg.remove(t, { keep });
726
- 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);
727
734
  }
728
735
  /** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
729
736
  getProperty(ref) {
@@ -760,13 +767,22 @@ var Engine = class _Engine {
760
767
  * retained. Returns its own unsubscribe. */
761
768
  onTrace(handler) {
762
769
  this.engineTraceHandlers.add(handler);
763
- 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
+ };
764
775
  }
765
776
  emitEngine(flow, event, scene) {
766
777
  for (const h of this.engineTraceHandlers) h(flow, event);
767
778
  if (!this.host.logEnabled) return;
768
779
  this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
769
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);
785
+ }
770
786
  listProperties() {
771
787
  return this.host.patterSharedDecls.map((d) => ({
772
788
  name: d.name,
@@ -971,19 +987,21 @@ var Flow = class {
971
987
  * lowercase, as the compiler emits references (the selfBackedResolver lesson). */
972
988
  stagesFor(scope, name) {
973
989
  const key = name.toLowerCase();
974
- const fromDecls = (decls) => decls?.find((d) => d.name.toLowerCase() === key && d.type === "quality")?.stages;
975
990
  if (scope === "patter") {
976
- return fromDecls(this.host.patterSharedDecls) ?? fromDecls(this.host.patterLocalDecls);
991
+ return ladders(this.host, "patter", () => [...this.host.patterSharedDecls, ...this.host.patterLocalDecls]).get(key);
977
992
  }
978
993
  if (scope === "scene") {
979
- const scene = this.currentSceneId != null ? this.host.bundle.scenes[this.currentSceneId] : void 0;
980
- 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);
981
997
  }
982
- 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);
983
999
  }
984
1000
  // -- Host API -------------------------------------------------------------
985
- /** Begin this flow at a scene (and optionally a specific block within it). */
986
- 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) {
987
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");
988
1006
  this.releaseBags(false);
989
1007
  this.host.registry.discardParked(keys.flow(this.id));
@@ -995,7 +1013,7 @@ var Flow = class {
995
1013
  this.flowEnded = false;
996
1014
  this.activeSnippet = null;
997
1015
  this.beatIndex = 0;
998
- this.pendingChoice = null;
1016
+ this.clearPending();
999
1017
  this.started = true;
1000
1018
  if (blockId) {
1001
1019
  const loc = this.host.blockIndex.get(blockId);
@@ -1020,10 +1038,15 @@ var Flow = class {
1020
1038
  * Forget everything in this flow and begin again - its per-flow state (not-shared
1021
1039
  * `@patter` globals + `@scene` props), cursor, callstack, selector cursors, and
1022
1040
  * visit counts. Shared state (shared `@patter` / `@scene`, world visit counts) is
1023
- * untouched. A clearer-named alias of `start()`.
1041
+ * untouched. The one public way to begin a flow again (openFlow begins a new one).
1024
1042
  */
1025
1043
  reset(sceneId, blockId) {
1026
- 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);
1027
1050
  }
1028
1051
  /**
1029
1052
  * Send this flow's cursor to an ADDRESS, exactly as an authored `go` jump would: the target scene's
@@ -1047,9 +1070,7 @@ var Flow = class {
1047
1070
  this.touch();
1048
1071
  if (scene === "END") {
1049
1072
  this.started = true;
1050
- this.pendingChoice = null;
1051
- this.pendingPrompt = null;
1052
- this.pendingPromptOwnerId = null;
1073
+ this.clearPending();
1053
1074
  this.activeSnippet = null;
1054
1075
  this.beatIndex = 0;
1055
1076
  this.flowEnded = true;
@@ -1064,12 +1085,10 @@ var Flow = class {
1064
1085
  if (blockId === void 0) return false;
1065
1086
  }
1066
1087
  if (!this.started) {
1067
- this.start(sceneId, blockId);
1088
+ this.begin(sceneId, blockId);
1068
1089
  return true;
1069
1090
  }
1070
- this.pendingChoice = null;
1071
- this.pendingPrompt = null;
1072
- this.pendingPromptOwnerId = null;
1091
+ this.clearPending();
1073
1092
  this.activeSnippet = null;
1074
1093
  this.beatIndex = 0;
1075
1094
  this.flowEnded = false;
@@ -1094,6 +1113,15 @@ var Flow = class {
1094
1113
  this.stack = [];
1095
1114
  this.activeSnippet = null;
1096
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() {
1097
1125
  this.pendingChoice = null;
1098
1126
  this.pendingPrompt = null;
1099
1127
  this.pendingPromptOwnerId = null;
@@ -1190,7 +1218,7 @@ var Flow = class {
1190
1218
  }
1191
1219
  const from = frame.index;
1192
1220
  while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
1193
- if (this.host.logEnabled && frame.index !== from) {
1221
+ if (this.host.tracing && frame.index !== from) {
1194
1222
  this.emit({
1195
1223
  type: "select",
1196
1224
  group: frame.containerId,
@@ -1226,14 +1254,18 @@ var Flow = class {
1226
1254
  clearLog() {
1227
1255
  this.flowLog.length = 0;
1228
1256
  }
1229
- /** Record one decision, on this flow's log and the engine's. Cheap to call with logging
1230
- * 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. */
1231
1259
  emit(event) {
1232
1260
  const scene = this.currentSceneId ?? void 0;
1233
1261
  this.host.emitEngine(this.id, event, scene);
1234
1262
  if (!this.host.logEnabled) return;
1235
1263
  this.flowLog.push({ ...event, seq: this.flowSeq++, ...scene ? { scene } : {} });
1236
1264
  }
1265
+ /** How many times this flow has entered each node, by node id. */
1266
+ getVisitCounts() {
1267
+ return Object.fromEntries(this.visitCounts);
1268
+ }
1237
1269
  /** The options of a pending choice (empty when not at a choice point). */
1238
1270
  getChoices() {
1239
1271
  return this.pendingChoice?.options ?? [];
@@ -1247,7 +1279,7 @@ var Flow = class {
1247
1279
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1248
1280
  this.touch();
1249
1281
  const node = choice.byId.get(id);
1250
- this.emit({ type: "chose", group: choice.groupId, option: id });
1282
+ if (this.host.tracing) this.emit({ type: "chose", group: choice.groupId, option: id });
1251
1283
  this.pendingChoice = null;
1252
1284
  const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
1253
1285
  this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
@@ -1443,7 +1475,7 @@ var Flow = class {
1443
1475
  byId.set(child.id, child);
1444
1476
  }
1445
1477
  if (options.some((o) => o.eligible)) {
1446
- this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1478
+ if (this.host.tracing) this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1447
1479
  this.pendingChoice = { groupId: group.id, options, byId };
1448
1480
  return;
1449
1481
  }
@@ -1452,7 +1484,7 @@ var Flow = class {
1452
1484
  this.enterChild(fallback);
1453
1485
  return;
1454
1486
  }
1455
- this.emit({ type: "dry", group: group.id });
1487
+ if (this.host.tracing) this.emit({ type: "dry", group: group.id });
1456
1488
  this.host.onDryChoice?.(group.id);
1457
1489
  }
1458
1490
  // -- Jumps (jump / call-return) ----------------------------------------
@@ -1467,7 +1499,7 @@ var Flow = class {
1467
1499
  * hard-ends the flow regardless of the callstack.
1468
1500
  */
1469
1501
  enterTarget(to, mode) {
1470
- this.emit({ type: "jump", to, mode });
1502
+ if (this.host.tracing) this.emit({ type: "jump", to, mode });
1471
1503
  if (to === "END") {
1472
1504
  this.flowEnded = true;
1473
1505
  this.stack = [];
@@ -1504,7 +1536,7 @@ var Flow = class {
1504
1536
  const order = group.options?.order ?? "sequential";
1505
1537
  const exhaust = group.options?.exhaust ?? "once";
1506
1538
  const trace = (picked) => {
1507
- this.emit({
1539
+ if (this.host.tracing) this.emit({
1508
1540
  type: "select",
1509
1541
  group: group.id,
1510
1542
  selector: group.selector ?? "default",
@@ -1669,9 +1701,9 @@ var Flow = class {
1669
1701
  let value;
1670
1702
  try {
1671
1703
  value = this.evalExpr(e.value);
1672
- const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1704
+ const prev = this.host.tracing ? this.getProperty(e.target) : void 0;
1673
1705
  this.writeProperty(e.target, value, false);
1674
- this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1706
+ if (this.host.tracing) this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1675
1707
  } catch (err) {
1676
1708
  this.reportError("effect", owner, e.value, err);
1677
1709
  }
@@ -1692,7 +1724,7 @@ var Flow = class {
1692
1724
  const message = err instanceof Error ? err.message : String(err);
1693
1725
  const source = expr?.src;
1694
1726
  this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
1695
- this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1727
+ if (this.host.tracing) this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1696
1728
  }
1697
1729
  evalExpr(expr) {
1698
1730
  return (0, import_expr.evaluate)(this.conditionAst(expr), this.context(), import_dialect.patterDialect);
@@ -1979,6 +2011,21 @@ function mountClaims(registry, key, bag, ask) {
1979
2011
  function toDecl(decl) {
1980
2012
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1981
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
+ }
1982
2029
  function toForeignDecl(decl) {
1983
2030
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
1984
2031
  }