@patterkit/runtime 0.8.0 → 0.9.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
@@ -2,6 +2,7 @@
2
2
  import { evaluate, deserialiseAst, makePrng, toUint32 } from "@wildwinter/expr";
3
3
  import { matchedSpecificity as scoreSpecificity } from "@wildwinter/expr-specificity";
4
4
  import { ScopeRegistry } from "@wildwinter/scoperegistry";
5
+ import { defaultFor, PropertyBag } from "@wildwinter/scoperegistry";
5
6
  import { patterDialect, interpolate, splitRef, stripCaptions } from "@patterkit/dialect";
6
7
  import { walkNodes, effectiveGameId, castStringKey, DEFAULT_CAPTION_DELIMITERS, DEFAULT_CAPTION_CHARACTER } from "@patterkit/model";
7
8
 
@@ -60,6 +61,10 @@ var Engine = class _Engine {
60
61
  /** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
61
62
  * engine keeps the same world resolver, custom RNG, and diagnostic hooks. */
62
63
  creationOptions;
64
+ /** The run's ordered stream: every flow's events, each naming its flow. Empty and
65
+ * unwritten unless `options.log` asked for it. */
66
+ engineLog = [];
67
+ engineTraceHandlers = /* @__PURE__ */ new Set();
63
68
  constructor(bundle, options = {}) {
64
69
  this.creationOptions = options;
65
70
  const locale = options.locale ?? bundle.locales.default;
@@ -95,7 +100,7 @@ var Engine = class _Engine {
95
100
  const patterSharedDecls = props.filter((p) => p.shared ?? true).map(toDecl);
96
101
  const patterLocalDecls = props.filter((p) => !(p.shared ?? true)).map(toDecl);
97
102
  const patterSharedNames = new Set(patterSharedDecls.map((d) => d.name.toLowerCase()));
98
- const shared = new ScopeRegistry().defineOwned("patter", patterSharedDecls);
103
+ const shared = new ScopeRegistry().defineOwned("patter", patterSharedDecls, "@patter.");
99
104
  const hostBound = /* @__PURE__ */ new Set();
100
105
  if (options.world) {
101
106
  const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
@@ -114,6 +119,8 @@ var Engine = class _Engine {
114
119
  sceneSharedNames.set(sceneId, names);
115
120
  }
116
121
  this.host = {
122
+ logEnabled: options.log ?? false,
123
+ emitEngine: (flow, event, scene) => this.emitEngine(flow, event, scene),
117
124
  bundle,
118
125
  emitIds,
119
126
  strings,
@@ -488,14 +495,44 @@ var Engine = class _Engine {
488
495
  }
489
496
  /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
490
497
  * value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
498
+ /** The run's decisions, in order, each naming the flow it happened in. Empty unless the
499
+ * run was opened with `log: true`. A flow's own log stays flow-local; this is the only
500
+ * place a story spanning several flows reads as one sequence. */
501
+ log() {
502
+ return this.engineLog;
503
+ }
504
+ /** Drop the retained entries. `seq` does NOT restart: two reads of a log either side of a
505
+ * clear still agree about what came first. */
506
+ clearLog() {
507
+ this.engineLog.length = 0;
508
+ }
509
+ /** Live tap on the run's decisions, for tooling that wants them as they happen rather than
510
+ * retained. Returns its own unsubscribe. */
511
+ onTrace(handler) {
512
+ this.engineTraceHandlers.add(handler);
513
+ return () => this.engineTraceHandlers.delete(handler);
514
+ }
515
+ emitEngine(flow, event, scene) {
516
+ for (const h of this.engineTraceHandlers) h(flow, event);
517
+ if (!this.host.logEnabled) return;
518
+ this.engineLog.push({ ...event, flow, seq: this.engineLog.length, ...scene ? { scene } : {} });
519
+ }
491
520
  listProperties() {
492
521
  return this.host.patterSharedDecls.map((d) => ({
493
- ref: `@${d.name}`,
522
+ name: d.name,
523
+ // The qualified address, matching what the bag composes for every other scope.
524
+ // `@gold` still resolves on input; it is the shorthand, not the address.
525
+ path: `@patter.${d.name}`,
494
526
  type: d.type,
495
527
  values: d.values,
496
528
  stages: d.stages,
497
529
  value: this.getProperty(`@${d.name}`),
498
- default: declDefault(d)
530
+ default: defaultFor(d),
531
+ // Part of the shared row. Always true here today: `toDecl` never sets it, because
532
+ // Patter has no read-only shared property. The Storylet Engine does declare them
533
+ // and its panels disable the editor accordingly, so the field is carried rather
534
+ // than dropped - and the day a read-only @patter property exists, the row says so.
535
+ writable: d.writable ?? true
499
536
  }));
500
537
  }
501
538
  // @scene is scene-namespaced and needs a flow's current scene - silently
@@ -528,7 +565,7 @@ var Engine = class _Engine {
528
565
  shared: this.host.shared.save(),
529
566
  sharedVisits: Object.fromEntries(this.host.sharedVisits),
530
567
  sharedSelectors: serialiseSelectors(this.host.sharedSelectors),
531
- stageBags: Object.fromEntries([...this.host.stageBags].map(([s, bag]) => [s, { ...bag }])),
568
+ stageBags: Object.fromEntries([...this.host.stageBags].map(([s, bag]) => [s, bag.save()])),
532
569
  flows
533
570
  };
534
571
  }
@@ -541,7 +578,13 @@ var Engine = class _Engine {
541
578
  this.host.sharedSelectors.clear();
542
579
  for (const [id, st] of deserialiseSelectors(save.sharedSelectors)) this.host.sharedSelectors.set(id, st);
543
580
  this.host.stageBags.clear();
544
- for (const [s, bag] of Object.entries(save.stageBags ?? {})) this.host.stageBags.set(s, { ...bag });
581
+ for (const [s, values] of Object.entries(save.stageBags ?? {})) {
582
+ const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
583
+ const decls = (this.host.bundle.scenes[s]?.sceneProps ?? []).filter((d) => shared.has(d.name.toLowerCase()));
584
+ const bag = new PropertyBag(decls);
585
+ bag.load(values);
586
+ this.host.stageBags.set(s, bag);
587
+ }
545
588
  this.flowsById.clear();
546
589
  for (const [id, snap] of Object.entries(save.flows)) {
547
590
  const flow = new Flow(id, this.host, this.defaultSeed);
@@ -584,6 +627,7 @@ var Flow = class {
584
627
  // The SHARED halves live on the host (`host.shared` / `host.stageBags`). Each
585
628
  // resolver presents one merged scope, routing each property to its half by the
586
629
  // declared `shared` flag.
630
+ /** This flow's per-scene LOCAL scene props; see FlowHost.stageBags. */
587
631
  sceneBags = /* @__PURE__ */ new Map();
588
632
  patterResolver = {
589
633
  get: (n) => this.host.patterSharedNames.has(n) ? this.host.shared.get("patter", n) : this.local.get("patter", n),
@@ -597,13 +641,13 @@ var Flow = class {
597
641
  const s = this.currentSceneId;
598
642
  if (s === null) return void 0;
599
643
  const bag = this.host.sceneSharedNames.get(s)?.has(n) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
600
- return bag?.[n];
644
+ return bag?.get(n);
601
645
  },
602
646
  set: (n, v) => {
603
647
  const s = this.currentSceneId;
604
648
  if (s === null) return;
605
649
  const bag = this.host.sceneSharedNames.get(s)?.has(n) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
606
- if (bag) bag[n] = v;
650
+ if (bag) bag.set(n, v);
607
651
  }
608
652
  };
609
653
  // The eval context is built ONCE: every constituent resolves live state at
@@ -612,6 +656,9 @@ var Flow = class {
612
656
  // `local`/`sceneBags`/`currentSceneId`; the host callbacks read current flow
613
657
  // fields). Rebuilding it per evaluation was the engine's hottest allocation.
614
658
  evalCtx;
659
+ flowLog = [];
660
+ /** Monotonic across the flow's life; survives clearLog so two reads agree on order. */
661
+ flowSeq = 0;
615
662
  constructor(id, host, seed) {
616
663
  this.id = id;
617
664
  this.host = host;
@@ -837,7 +884,17 @@ var Flow = class {
837
884
  this.stack.pop();
838
885
  continue;
839
886
  }
887
+ const from = frame.index;
840
888
  while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
889
+ if (this.host.logEnabled && frame.index !== from) {
890
+ this.emit({
891
+ type: "select",
892
+ group: frame.containerId,
893
+ selector: "run",
894
+ children: children.slice(from, frame.index + 1).map((c, i) => ({ id: c.id, eligible: from + i === frame.index })),
895
+ picked: children[frame.index]?.id ?? null
896
+ });
897
+ }
841
898
  if (frame.index >= children.length) {
842
899
  this.stack.pop();
843
900
  continue;
@@ -846,6 +903,24 @@ var Flow = class {
846
903
  }
847
904
  }
848
905
  /** The options of a pending choice (empty when not at a choice point). */
906
+ /** This flow's decisions, in order. Empty unless the run was opened with `log: true`.
907
+ * The engine's log carries the same events tagged with the flow; this one is what a
908
+ * single conversation reads as. */
909
+ log() {
910
+ return this.flowLog;
911
+ }
912
+ /** Drop the retained entries. `seq` keeps counting, so order survives a clear. */
913
+ clearLog() {
914
+ this.flowLog.length = 0;
915
+ }
916
+ /** Record one decision, on this flow's log and the engine's. Cheap to call with logging
917
+ * off: the entry is never built. */
918
+ emit(event) {
919
+ const scene = this.currentSceneId ?? void 0;
920
+ this.host.emitEngine(this.id, event, scene);
921
+ if (!this.host.logEnabled) return;
922
+ this.flowLog.push({ ...event, seq: this.flowSeq++, ...scene ? { scene } : {} });
923
+ }
849
924
  getChoices() {
850
925
  return this.pendingChoice?.options ?? [];
851
926
  }
@@ -857,6 +932,7 @@ var Flow = class {
857
932
  if (!option) throw new Error(`unknown choice option: ${id}`);
858
933
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
859
934
  const node = choice.byId.get(id);
935
+ this.emit({ type: "chose", group: choice.groupId, option: id });
860
936
  this.pendingChoice = null;
861
937
  this.pendingPromptBeat = this.host.replayPromptOnChoose ? this.promptBeatOf(node) ?? null : null;
862
938
  this.pendingPromptOwnerId = this.pendingPromptBeat ? node.id : null;
@@ -890,7 +966,7 @@ var Flow = class {
890
966
  return {
891
967
  scopes: this.local.save(),
892
968
  // owned scope "patter" = the NOT-shared globals (@scene saved separately)
893
- sceneBags: Object.fromEntries([...this.sceneBags].map(([s, bag]) => [s, { ...bag }])),
969
+ sceneBags: Object.fromEntries([...this.sceneBags].map(([s, bag]) => [s, bag.save()])),
894
970
  rngState: this.rngState,
895
971
  visits: Object.fromEntries(this.visitCounts),
896
972
  cursor: {
@@ -928,7 +1004,13 @@ var Flow = class {
928
1004
  }
929
1005
  return { ...frame };
930
1006
  });
931
- this.sceneBags = new Map(Object.entries(snap.sceneBags ?? {}).map(([s, bag]) => [s, { ...bag }]));
1007
+ this.sceneBags = new Map(Object.entries(snap.sceneBags ?? {}).map(([s, values]) => {
1008
+ const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
1009
+ const decls = (this.host.bundle.scenes[s]?.sceneProps ?? []).filter((d) => !shared.has(d.name.toLowerCase()));
1010
+ const bag = new PropertyBag(decls);
1011
+ bag.load(values);
1012
+ return [s, bag];
1013
+ }));
932
1014
  this.local = this.freshLocal();
933
1015
  this.local.load(snap.scopes);
934
1016
  this.activeSnippet = null;
@@ -1022,6 +1104,7 @@ var Flow = class {
1022
1104
  byId.set(child.id, child);
1023
1105
  }
1024
1106
  if (options.length > 0) {
1107
+ this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
1025
1108
  this.pendingChoice = { groupId: group.id, options, byId };
1026
1109
  return;
1027
1110
  }
@@ -1030,6 +1113,7 @@ var Flow = class {
1030
1113
  this.enterChild(fallback);
1031
1114
  return;
1032
1115
  }
1116
+ this.emit({ type: "dry", group: group.id });
1033
1117
  this.host.onDryChoice?.(group.id);
1034
1118
  }
1035
1119
  // -- Jumps (jump / call-return) ----------------------------------------
@@ -1044,6 +1128,7 @@ var Flow = class {
1044
1128
  * hard-ends the flow regardless of the callstack.
1045
1129
  */
1046
1130
  enterTarget(to, mode) {
1131
+ this.emit({ type: "jump", to, mode });
1047
1132
  if (to === "END") {
1048
1133
  this.flowEnded = true;
1049
1134
  this.stack = [];
@@ -1075,17 +1160,28 @@ var Flow = class {
1075
1160
  }
1076
1161
  // -- Selectors ------------------------------------------------------------
1077
1162
  selectChild(group) {
1163
+ const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
1078
1164
  const eligible = group.children.filter((c) => this.eligible(c));
1079
- if (eligible.length === 0) return null;
1165
+ const order = group.options?.order ?? "sequential";
1166
+ const exhaust = group.options?.exhaust ?? "once";
1167
+ const trace = (picked) => {
1168
+ this.emit({
1169
+ type: "select",
1170
+ group: group.id,
1171
+ selector: group.selector ?? "default",
1172
+ ...group.selector === "sequence" ? { order, exhaust } : {},
1173
+ children: verdicts,
1174
+ picked: picked?.id ?? null
1175
+ });
1176
+ return picked;
1177
+ };
1178
+ if (eligible.length === 0) return trace(null);
1080
1179
  const st = this.selectorState(group);
1081
1180
  switch (group.selector) {
1082
1181
  case "branch":
1083
- return eligible[0];
1084
- case "sequence": {
1085
- const order = group.options?.order ?? "sequential";
1086
- const exhaust = group.options?.exhaust ?? "once";
1087
- return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : order === "specificity" ? this.pickSpecificity(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
1088
- }
1182
+ return trace(eligible[0]);
1183
+ case "sequence":
1184
+ return trace(order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : order === "specificity" ? this.pickSpecificity(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st));
1089
1185
  case "run":
1090
1186
  case "choice":
1091
1187
  default:
@@ -1203,7 +1299,10 @@ var Flow = class {
1203
1299
  // -- Effects + expressions ------------------------------------------------
1204
1300
  runEffects(effects) {
1205
1301
  for (const e of effects ?? []) {
1206
- this.setProperty(e.target, this.evalExpr(e.value));
1302
+ const value = this.evalExpr(e.value);
1303
+ const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
1304
+ this.setProperty(e.target, value);
1305
+ this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
1207
1306
  }
1208
1307
  }
1209
1308
  eligible(node) {
@@ -1347,7 +1446,7 @@ var Flow = class {
1347
1446
  }
1348
1447
  /** The per-flow registry: the NOT-shared `@patter` globals (the shared ones live on the host). */
1349
1448
  freshLocal() {
1350
- return new ScopeRegistry().defineOwned("patter", this.host.patterLocalDecls);
1449
+ return new ScopeRegistry().defineOwned("patter", this.host.patterLocalDecls, "@patter.");
1351
1450
  }
1352
1451
  /**
1353
1452
  * Seed a scene's `@scene` props (spec §7). The not-shared props seed THIS flow's
@@ -1358,27 +1457,18 @@ var Flow = class {
1358
1457
  */
1359
1458
  seedScene(scene) {
1360
1459
  const shared = this.host.sceneSharedNames.get(scene.id) ?? /* @__PURE__ */ new Set();
1460
+ const props = scene.sceneProps ?? [];
1361
1461
  if (!this.sceneBags.has(scene.id)) {
1362
- const bag = {};
1363
- for (const decl of scene.sceneProps ?? []) {
1364
- const name = decl.name.toLowerCase();
1365
- if (!shared.has(name)) bag[name] = sceneDefault(decl);
1366
- }
1367
- this.sceneBags.set(scene.id, bag);
1462
+ this.sceneBags.set(scene.id, new PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase()))));
1368
1463
  }
1369
1464
  if (!this.host.stageBags.has(scene.id)) {
1370
- const bag = {};
1371
- for (const decl of scene.sceneProps ?? []) {
1372
- const name = decl.name.toLowerCase();
1373
- if (shared.has(name)) bag[name] = sceneDefault(decl);
1374
- }
1375
- this.host.stageBags.set(scene.id, bag);
1465
+ this.host.stageBags.set(scene.id, new PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase()))));
1376
1466
  }
1377
1467
  for (const decl of scene.sceneProps ?? []) {
1378
1468
  if (!decl.temporary) continue;
1379
1469
  const name = decl.name.toLowerCase();
1380
1470
  const bag = shared.has(name) ? this.host.stageBags.get(scene.id) : this.sceneBags.get(scene.id);
1381
- if (bag) bag[name] = sceneDefault(decl);
1471
+ if (bag) bag.set(name, defaultFor(decl));
1382
1472
  }
1383
1473
  }
1384
1474
  };
@@ -1416,47 +1506,13 @@ function deserialiseSelectors(rec) {
1416
1506
  function toDecl(decl) {
1417
1507
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
1418
1508
  }
1419
- function declDefault(d) {
1420
- if (d.default !== void 0) return d.default;
1421
- switch (d.type) {
1422
- case "number":
1423
- return 0;
1424
- case "string":
1425
- return "";
1426
- case "flags":
1427
- return [];
1428
- case "enum":
1429
- return d.values?.[0] ?? "";
1430
- case "quality":
1431
- return d.stages?.[0] ?? "";
1432
- default:
1433
- return false;
1434
- }
1435
- }
1436
1509
  function toForeignDecl(decl) {
1437
1510
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
1438
1511
  }
1439
- function hostScopeDefault(decl) {
1440
- if (decl.default !== void 0) return decl.default;
1441
- switch (decl.type) {
1442
- case "boolean":
1443
- return false;
1444
- case "number":
1445
- return 0;
1446
- case "string":
1447
- return "";
1448
- case "flags":
1449
- return [];
1450
- case "enum":
1451
- return decl.values?.[0] ?? "";
1452
- case "quality":
1453
- return decl.stages?.[0] ?? "";
1454
- }
1455
- }
1456
1512
  function selfBackedResolver(decls) {
1457
1513
  const key = (name) => name.toLowerCase();
1458
1514
  const bag = /* @__PURE__ */ new Map();
1459
- for (const d of decls) bag.set(key(d.name), hostScopeDefault(d));
1515
+ for (const d of decls) bag.set(key(d.name), defaultFor(d));
1460
1516
  return {
1461
1517
  get: (name) => bag.get(key(name)),
1462
1518
  set: (name, value) => {
@@ -1464,23 +1520,6 @@ function selfBackedResolver(decls) {
1464
1520
  }
1465
1521
  };
1466
1522
  }
1467
- function sceneDefault(decl) {
1468
- if (decl.default !== void 0) return decl.default;
1469
- switch (decl.type) {
1470
- case "boolean":
1471
- return false;
1472
- case "number":
1473
- return 0;
1474
- case "string":
1475
- return "";
1476
- case "flags":
1477
- return [];
1478
- case "enum":
1479
- return decl.values?.[0] ?? "";
1480
- case "quality":
1481
- return decl.stages?.[0] ?? "";
1482
- }
1483
- }
1484
1523
  function truthy(v) {
1485
1524
  if (typeof v === "boolean") return v;
1486
1525
  if (typeof v === "number") return v !== 0;