@patterkit/runtime 0.19.0 → 0.21.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.js CHANGED
@@ -43,6 +43,18 @@ function buildTagIndex(bundle) {
43
43
  // src/engine.ts
44
44
  import { SAVE_VERSION } from "@patterkit/model";
45
45
  var astCache = /* @__PURE__ */ new WeakMap();
46
+ var tagIndexCache = /* @__PURE__ */ new WeakMap();
47
+ function tagIndexFor(bundle) {
48
+ let index = tagIndexCache.get(bundle);
49
+ if (!index) {
50
+ index = buildTagIndex(bundle);
51
+ tagIndexCache.set(bundle, index);
52
+ }
53
+ return index;
54
+ }
55
+ function tagsOf(host, id) {
56
+ return host.tagIndex.get(id)?.slice();
57
+ }
46
58
  var OWNER = "Patter";
47
59
  var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
48
60
  var OWNS_REGISTRY = /* @__PURE__ */ Symbol("ownsRegistry");
@@ -81,7 +93,7 @@ var Engine = class _Engine {
81
93
  sceneGameIdToId = /* @__PURE__ */ new Map();
82
94
  blockGameIdToId = /* @__PURE__ */ new Map();
83
95
  /** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
84
- * engine keeps the same world resolver, custom RNG, and diagnostic hooks. */
96
+ * engine keeps the same host scopes, custom RNG, and diagnostic hooks. */
85
97
  creationOptions;
86
98
  /** The run's ordered stream: every flow's events, each naming its flow. Empty and
87
99
  * unwritten unless `options.log` asked for it. */
@@ -133,12 +145,12 @@ var Engine = class _Engine {
133
145
  registry.mountOwned("patter", patterBag, { owner: OWNER });
134
146
  registered.push("patter");
135
147
  const hostBound = /* @__PURE__ */ new Set();
136
- if (options.world) {
137
- const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
138
- const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
139
- registry.defineForeign("world", options.world, decls, { writable: worldSpec?.writable ?? true, owner: OWNER });
140
- registered.push("world");
141
- hostBound.add("world");
148
+ for (const [token, resolver] of Object.entries(boundScopes(options))) {
149
+ const spec = bundle.scopeRegistry?.scopes.find((s) => s.token === token);
150
+ const decls = (spec?.declarations ?? []).map(toForeignDecl);
151
+ registry.defineForeign(token, resolver, decls, { writable: spec?.writable ?? true, owner: OWNER });
152
+ registered.push(token);
153
+ hostBound.add(token);
142
154
  }
143
155
  for (const spec of ownsRegistry ? bundle.scopeRegistry?.scopes ?? [] : []) {
144
156
  if (hostBound.has(spec.token) || registry.has(spec.token)) continue;
@@ -158,6 +170,7 @@ var Engine = class _Engine {
158
170
  }
159
171
  this.host = {
160
172
  logEnabled: options.log ?? false,
173
+ tracing: options.log ?? false,
161
174
  emitEngine: (flow, event, scene) => this.emitEngine(flow, event, scene),
162
175
  bundle,
163
176
  emitIds,
@@ -170,7 +183,7 @@ var Engine = class _Engine {
170
183
  sceneGameIdToId: this.sceneGameIdToId,
171
184
  blockGameIdToId: this.blockGameIdToId,
172
185
  // same instances the engine resolves with
173
- tagIndex: buildTagIndex(bundle),
186
+ tagIndex: tagIndexFor(bundle),
174
187
  registry,
175
188
  ownsRegistry,
176
189
  patterBag,
@@ -180,6 +193,7 @@ var Engine = class _Engine {
180
193
  patterSharedNames,
181
194
  sceneSharedNames,
182
195
  sharedVisits: /* @__PURE__ */ new Map(),
196
+ qualityLadders: /* @__PURE__ */ new Map(),
183
197
  sharedSelectors: /* @__PURE__ */ new Map(),
184
198
  stageBags: /* @__PURE__ */ new Map(),
185
199
  journal: null,
@@ -206,6 +220,11 @@ var Engine = class _Engine {
206
220
  get isSourceDebug() {
207
221
  return this.sourceDebug;
208
222
  }
223
+ /** The bundle's build identity (its content hash), the one a debug link handshakes with, or an empty
224
+ * string for a bundle compiled without one. */
225
+ get buildId() {
226
+ return this.host.bundle.content?.hash ?? "";
227
+ }
209
228
  /**
210
229
  * Switch the active locale LIVE - a real game's "language" setting can change mid-session. Subsequent
211
230
  * string lookups (new beats, re-resolved character names, `{@ref}` interpolation) render in the new
@@ -326,7 +345,7 @@ var Engine = class _Engine {
326
345
  });
327
346
  }
328
347
  this.flowsById.set(id, flow);
329
- flow.start(sceneId, blockId);
348
+ flow.begin(sceneId, blockId);
330
349
  return flow;
331
350
  }
332
351
  /**
@@ -423,18 +442,18 @@ var Engine = class _Engine {
423
442
  * delivered step carries. Empty array for an unknown id or a beat with no tags anywhere up the chain.
424
443
  */
425
444
  tagsForBeat(beatId) {
426
- return this.host.tagIndex.get(beatId) ?? [];
445
+ return tagsOf(this.host, beatId) ?? [];
427
446
  }
428
447
  /** A scene's own tags (by internal id or gameId address). Empty when none / unknown. */
429
448
  tagsForScene(sceneRef) {
430
449
  const id = this.resolveSceneRef(sceneRef);
431
- return (id != null ? this.host.tagIndex.get(id) : void 0) ?? [];
450
+ return (id != null ? tagsOf(this.host, id) : void 0) ?? [];
432
451
  }
433
452
  /** A block's accumulated tags (scene + block), by scene + block ref (id or gameId). Empty when none / unknown. */
434
453
  tagsForBlock(sceneRef, blockRef) {
435
454
  const sceneId = this.resolveSceneRef(sceneRef);
436
455
  const id = this.resolveBlockRef(sceneId, blockRef);
437
- return (id != null ? this.host.tagIndex.get(id) : void 0) ?? [];
456
+ return (id != null ? tagsOf(this.host, id) : void 0) ?? [];
438
457
  }
439
458
  /**
440
459
  * A scene's own author gameData (by internal id or gameId address): the RAW sparse overrides, exactly
@@ -553,7 +572,7 @@ var Engine = class _Engine {
553
572
  }
554
573
  /** One beat's static data (source locale), the same shape a delivered step carries. */
555
574
  beatInfo(beat) {
556
- const tags = this.host.tagIndex.get(beat.id);
575
+ const tags = tagsOf(this.host, beat.id);
557
576
  const info = { id: beat.id, kind: beat.kind };
558
577
  if (beat.kind === "line") {
559
578
  if (beat.character !== void 0) {
@@ -573,7 +592,7 @@ var Engine = class _Engine {
573
592
  }
574
593
  /** A `{ tags }` fragment for an id, present only when the id has accumulated tags (keeps output tidy). */
575
594
  tagsField(id) {
576
- const tags = this.host.tagIndex.get(id);
595
+ const tags = tagsOf(this.host, id);
577
596
  return tags && tags.length ? { tags } : {};
578
597
  }
579
598
  /** Retrieve an open flow by id (undefined if none / closed). */
@@ -690,7 +709,7 @@ var Engine = class _Engine {
690
709
  for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep });
691
710
  this.host.stageBags.clear();
692
711
  for (const t of ["patter", ...this.host.hostScopes]) if (reg.has(t)) reg.remove(t, { keep });
693
- if (this.creationOptions.world && reg.has("world")) reg.remove("world");
712
+ for (const t of Object.keys(boundScopes(this.creationOptions))) if (reg.has(t)) reg.remove(t);
694
713
  }
695
714
  /** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
696
715
  getProperty(ref) {
@@ -710,8 +729,6 @@ var Engine = class _Engine {
710
729
  this.host.registry.set(scope, name, value, { host: true });
711
730
  if (this.host.journal && prev !== void 0) this.host.journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
712
731
  }
713
- /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
714
- * value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
715
732
  /** The run's decisions, in order, each naming the flow it happened in. Empty unless the
716
733
  * run was opened with `log: true`. A flow's own log stays flow-local; this is the only
717
734
  * place a story spanning several flows reads as one sequence. */
@@ -727,13 +744,24 @@ var Engine = class _Engine {
727
744
  * retained. Returns its own unsubscribe. */
728
745
  onTrace(handler) {
729
746
  this.engineTraceHandlers.add(handler);
730
- return () => this.engineTraceHandlers.delete(handler);
747
+ this.host.tracing = true;
748
+ return () => {
749
+ this.engineTraceHandlers.delete(handler);
750
+ this.host.tracing = this.host.logEnabled || this.engineTraceHandlers.size > 0;
751
+ };
731
752
  }
732
753
  emitEngine(flow, event, scene) {
733
754
  for (const h of this.engineTraceHandlers) h(flow, event);
734
755
  if (!this.host.logEnabled) return;
735
756
  this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
736
757
  }
758
+ /** How many times each node has been entered across every flow, by node id: the shared count. With a
759
+ * flow's own {@link Flow.getVisitCounts}, every visit count the run keeps, read without a save. */
760
+ getVisitCounts() {
761
+ return Object.fromEntries(this.host.sharedVisits);
762
+ }
763
+ /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
764
+ * value, declared default (for reset), and enum options. Every runtime has the same call. */
737
765
  listProperties() {
738
766
  return this.host.patterSharedDecls.map((d) => ({
739
767
  name: d.name,
@@ -938,19 +966,21 @@ var Flow = class {
938
966
  * lowercase, as the compiler emits references (the selfBackedResolver lesson). */
939
967
  stagesFor(scope, name) {
940
968
  const key = name.toLowerCase();
941
- const fromDecls = (decls) => decls?.find((d) => d.name.toLowerCase() === key && d.type === "quality")?.stages;
942
969
  if (scope === "patter") {
943
- return fromDecls(this.host.patterSharedDecls) ?? fromDecls(this.host.patterLocalDecls);
970
+ return ladders(this.host, "patter", () => [...this.host.patterSharedDecls, ...this.host.patterLocalDecls]).get(key);
944
971
  }
945
972
  if (scope === "scene") {
946
- const scene = this.currentSceneId != null ? this.host.bundle.scenes[this.currentSceneId] : void 0;
947
- return fromDecls(scene?.sceneProps);
973
+ const sceneId = this.currentSceneId;
974
+ if (sceneId == null) return void 0;
975
+ return ladders(this.host, `scene/${sceneId}`, () => this.host.bundle.scenes[sceneId]?.sceneProps).get(key);
948
976
  }
949
- return this.registryQualities?.(scope, name) ?? fromDecls(this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations);
977
+ return this.registryQualities?.(scope, name) ?? ladders(this.host, `host/${scope}`, () => this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations).get(key);
950
978
  }
951
979
  // -- Host API -------------------------------------------------------------
952
- /** Begin this flow at a scene (and optionally a specific block within it). */
953
- start(sceneId, blockId) {
980
+ /** Begin this flow at a scene (and optionally a specific block within it). The engine's own entry point:
981
+ * openFlow and a goto on an unstarted flow begin a flow here. A game calls {@link reset}.
982
+ * @internal */
983
+ begin(sceneId, blockId) {
954
984
  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");
955
985
  this.releaseBags(false);
956
986
  this.host.registry.discardParked(keys.flow(this.id));
@@ -962,7 +992,7 @@ var Flow = class {
962
992
  this.flowEnded = false;
963
993
  this.activeSnippet = null;
964
994
  this.beatIndex = 0;
965
- this.pendingChoice = null;
995
+ this.clearPending();
966
996
  this.started = true;
967
997
  if (blockId) {
968
998
  const loc = this.host.blockIndex.get(blockId);
@@ -987,10 +1017,15 @@ var Flow = class {
987
1017
  * Forget everything in this flow and begin again - its per-flow state (not-shared
988
1018
  * `@patter` globals + `@scene` props), cursor, callstack, selector cursors, and
989
1019
  * visit counts. Shared state (shared `@patter` / `@scene`, world visit counts) is
990
- * untouched. A clearer-named alias of `start()`.
1020
+ * untouched. The one public way to begin a flow again (openFlow begins a new one).
991
1021
  */
992
1022
  reset(sceneId, blockId) {
993
- this.start(sceneId, blockId);
1023
+ this.begin(sceneId, blockId);
1024
+ }
1025
+ /** @deprecated Use {@link reset}, the one public name for beginning a flow again on every runtime. `start`
1026
+ * was the same call under a second name, and goes in a later release. */
1027
+ start(sceneId, blockId) {
1028
+ this.reset(sceneId, blockId);
994
1029
  }
995
1030
  /**
996
1031
  * Send this flow's cursor to an ADDRESS, exactly as an authored `go` jump would: the target scene's
@@ -1014,9 +1049,7 @@ var Flow = class {
1014
1049
  this.touch();
1015
1050
  if (scene === "END") {
1016
1051
  this.started = true;
1017
- this.pendingChoice = null;
1018
- this.pendingPrompt = null;
1019
- this.pendingPromptOwnerId = null;
1052
+ this.clearPending();
1020
1053
  this.activeSnippet = null;
1021
1054
  this.beatIndex = 0;
1022
1055
  this.flowEnded = true;
@@ -1031,12 +1064,10 @@ var Flow = class {
1031
1064
  if (blockId === void 0) return false;
1032
1065
  }
1033
1066
  if (!this.started) {
1034
- this.start(sceneId, blockId);
1067
+ this.begin(sceneId, blockId);
1035
1068
  return true;
1036
1069
  }
1037
- this.pendingChoice = null;
1038
- this.pendingPrompt = null;
1039
- this.pendingPromptOwnerId = null;
1070
+ this.clearPending();
1040
1071
  this.activeSnippet = null;
1041
1072
  this.beatIndex = 0;
1042
1073
  this.flowEnded = false;
@@ -1061,6 +1092,15 @@ var Flow = class {
1061
1092
  this.stack = [];
1062
1093
  this.activeSnippet = null;
1063
1094
  this.beatIndex = 0;
1095
+ this.clearPending();
1096
+ }
1097
+ /**
1098
+ * Drop everything waiting to be delivered: an open choice, and a chosen option's prompt still to be
1099
+ * replayed. Every move that abandons the flow's place (start and reset, goto, close) does this, so none
1100
+ * leaves a stale prompt behind. start() cleared only the choice, so a reset between choose() and the
1101
+ * next advance() replayed the abandoned run's prompt as the new run's first beat.
1102
+ */
1103
+ clearPending() {
1064
1104
  this.pendingChoice = null;
1065
1105
  this.pendingPrompt = null;
1066
1106
  this.pendingPromptOwnerId = null;
@@ -1157,7 +1197,7 @@ var Flow = class {
1157
1197
  }
1158
1198
  const from = frame.index;
1159
1199
  while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
1160
- if (this.host.logEnabled && frame.index !== from) {
1200
+ if (this.host.tracing && frame.index !== from) {
1161
1201
  this.emit({
1162
1202
  type: "select",
1163
1203
  group: frame.containerId,
@@ -1193,14 +1233,18 @@ var Flow = class {
1193
1233
  clearLog() {
1194
1234
  this.flowLog.length = 0;
1195
1235
  }
1196
- /** Record one decision, on this flow's log and the engine's. Cheap to call with logging
1197
- * off: the entry is never built. */
1236
+ /** Record one decision, on this flow's log and the engine's, and hand it to any `onTrace`. Each call
1237
+ * site checks `host.tracing` first, so with nothing logging or tracing the entry is never built. */
1198
1238
  emit(event) {
1199
1239
  const scene = this.currentSceneId ?? void 0;
1200
1240
  this.host.emitEngine(this.id, event, scene);
1201
1241
  if (!this.host.logEnabled) return;
1202
1242
  this.flowLog.push({ ...event, seq: this.flowSeq++, ...scene ? { scene } : {} });
1203
1243
  }
1244
+ /** How many times this flow has entered each node, by node id. */
1245
+ getVisitCounts() {
1246
+ return Object.fromEntries(this.visitCounts);
1247
+ }
1204
1248
  /** The options of a pending choice (empty when not at a choice point). */
1205
1249
  getChoices() {
1206
1250
  return this.pendingChoice?.options ?? [];
@@ -1214,7 +1258,7 @@ var Flow = class {
1214
1258
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1215
1259
  this.touch();
1216
1260
  const node = choice.byId.get(id);
1217
- this.emit({ type: "chose", group: choice.groupId, option: id });
1261
+ if (this.host.tracing) this.emit({ type: "chose", group: choice.groupId, option: id });
1218
1262
  this.pendingChoice = null;
1219
1263
  const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
1220
1264
  this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
@@ -1410,7 +1454,7 @@ var Flow = class {
1410
1454
  byId.set(child.id, child);
1411
1455
  }
1412
1456
  if (options.some((o) => o.eligible)) {
1413
- this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1457
+ if (this.host.tracing) this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1414
1458
  this.pendingChoice = { groupId: group.id, options, byId };
1415
1459
  return;
1416
1460
  }
@@ -1419,7 +1463,7 @@ var Flow = class {
1419
1463
  this.enterChild(fallback);
1420
1464
  return;
1421
1465
  }
1422
- this.emit({ type: "dry", group: group.id });
1466
+ if (this.host.tracing) this.emit({ type: "dry", group: group.id });
1423
1467
  this.host.onDryChoice?.(group.id);
1424
1468
  }
1425
1469
  // -- Jumps (jump / call-return) ----------------------------------------
@@ -1434,7 +1478,7 @@ var Flow = class {
1434
1478
  * hard-ends the flow regardless of the callstack.
1435
1479
  */
1436
1480
  enterTarget(to, mode) {
1437
- this.emit({ type: "jump", to, mode });
1481
+ if (this.host.tracing) this.emit({ type: "jump", to, mode });
1438
1482
  if (to === "END") {
1439
1483
  this.flowEnded = true;
1440
1484
  this.stack = [];
@@ -1471,7 +1515,7 @@ var Flow = class {
1471
1515
  const order = group.options?.order ?? "sequential";
1472
1516
  const exhaust = group.options?.exhaust ?? "once";
1473
1517
  const trace = (picked) => {
1474
- this.emit({
1518
+ if (this.host.tracing) this.emit({
1475
1519
  type: "select",
1476
1520
  group: group.id,
1477
1521
  selector: group.selector ?? "default",
@@ -1636,9 +1680,9 @@ var Flow = class {
1636
1680
  let value;
1637
1681
  try {
1638
1682
  value = this.evalExpr(e.value);
1639
- const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1683
+ const prev = this.host.tracing ? this.getProperty(e.target) : void 0;
1640
1684
  this.writeProperty(e.target, value, false);
1641
- this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1685
+ if (this.host.tracing) this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1642
1686
  } catch (err) {
1643
1687
  this.reportError("effect", owner, e.value, err);
1644
1688
  }
@@ -1659,7 +1703,7 @@ var Flow = class {
1659
1703
  const message = err instanceof Error ? err.message : String(err);
1660
1704
  const source = expr?.src;
1661
1705
  this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
1662
- this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1706
+ if (this.host.tracing) this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
1663
1707
  }
1664
1708
  evalExpr(expr) {
1665
1709
  return evaluate(this.conditionAst(expr), this.context(), patterDialect);
@@ -1705,7 +1749,7 @@ var Flow = class {
1705
1749
  };
1706
1750
  // -- Strings / beats ------------------------------------------------------
1707
1751
  beatResult(beat) {
1708
- const tags = this.host.tagIndex.get(beat.id);
1752
+ const tags = tagsOf(this.host, beat.id);
1709
1753
  const withTags = tags && tags.length ? { tags } : {};
1710
1754
  switch (beat.kind) {
1711
1755
  case "gameEvent":
@@ -1772,7 +1816,7 @@ var Flow = class {
1772
1816
  /** A replayed prompt as a step: the beat's id, gameData, and tags, and the text and speaker fields the
1773
1817
  * choice showed. */
1774
1818
  promptResult(beat, shown) {
1775
- const tags = this.host.tagIndex.get(beat.id);
1819
+ const tags = tagsOf(this.host, beat.id);
1776
1820
  const withTags = tags && tags.length ? { tags } : {};
1777
1821
  if (shown.kind === "text") return { type: "text", id: beat.id, text: shown.text, gameData: beat.gameData, ...withTags };
1778
1822
  return {
@@ -1946,6 +1990,21 @@ function mountClaims(registry, key, bag, ask) {
1946
1990
  function toDecl(decl) {
1947
1991
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1948
1992
  }
1993
+ function boundScopes(options) {
1994
+ return { ...options.world ? { world: options.world } : {}, ...options.hostScopes };
1995
+ }
1996
+ function ladders(host, cacheKey, decls) {
1997
+ let found = host.qualityLadders.get(cacheKey);
1998
+ if (!found) {
1999
+ found = /* @__PURE__ */ new Map();
2000
+ for (const d of decls() ?? []) {
2001
+ const k = d.name.toLowerCase();
2002
+ if (d.type === "quality" && d.stages && !found.has(k)) found.set(k, d.stages);
2003
+ }
2004
+ host.qualityLadders.set(cacheKey, found);
2005
+ }
2006
+ return found;
2007
+ }
1949
2008
  function toForeignDecl(decl) {
1950
2009
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
1951
2010
  }