@patterkit/runtime 0.15.0 → 0.17.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,39 @@ version number always means the same runtime behaviour. This package is versione
33
33
 
34
34
  ## [Unreleased]
35
35
 
36
+ ## [0.17.0] - 2026-10-05
37
+
38
+ ### Fixed
39
+
40
+ - **`openFlow` at an address that does not resolve changes nothing.** It threw, but only after closing
41
+ any flow already open under that name and putting a broken one in its place. It now throws before
42
+ anything changes. The block must be in the scene you named, as `goto` has always required: a block
43
+ from another scene used to open there, and now throws. Held across all four runtimes by the
44
+ conformance corpus, which now also pins a choice step whole (its `groupId` and each option's
45
+ `prompt`), as this runtime already gave it.
46
+
47
+ ## [0.16.0] - 2026-10-04
48
+
49
+ ### Added
50
+
51
+ - **Checkpoints: try something, then undo it.** `engine.checkpoint()` opens a checkpoint; `engine.rollback(checkpoint)` puts the game back exactly
52
+ as it was then, and `engine.commit(checkpoint)` keeps everything. A rollback undoes every change the story made in between:
53
+ property values in every scope (a game's own `@world` store is written back through the game), visit
54
+ counts, shuffle and sequence positions, `@scene` bags made since, and every flow's position and random
55
+ state; a flow opened since is closed, and its name is free again. `engine.inCheckpoint` says whether one is open.
56
+ The use is asking "would this say anything?" without consequences: move a flow to an address, step
57
+ it, and roll back if it had nothing to give, so the scene's on-entry effects, the visits, and the
58
+ shuffle draws never happened. Each change records how to undo itself as it happens, so a checkpoint
59
+ costs what the steps inside it do, not what the game has built up, and the record is let go when it
60
+ closes. One is open at a time; while it is, the calls a rollback couldn't undo are refused
61
+ (`reset`, `loadGame`, `hotSwap`, `closeFlow`, `openFlow` over an open flow, and a flow's own `start`, `reset`, and `restore`). Held across all four runtimes by the conformance corpus.
62
+
63
+ ### Fixed
64
+
65
+ - **`Flow.restore()` on a running flow keeps that flow's own property values.** It used to drop them back to
66
+ their defaults, since a snapshot holds a flow's position and memory, not its property values. Loading
67
+ a save is unchanged.
68
+
36
69
  ## [0.15.0] - 2026-10-03
37
70
 
38
71
  ### Added
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ Checkpoint: () => Checkpoint,
23
24
  Engine: () => Engine,
24
25
  Flow: () => Flow,
25
26
  buildTagIndex: () => buildTagIndex,
@@ -87,6 +88,13 @@ var keys = {
87
88
  /** A flow's NOT-shared `@scene` props for one scene. */
88
89
  flowScene: (flowId, sceneId) => `patter/flow/${esc(flowId)}/scene/${esc(sceneId)}`
89
90
  };
91
+ var Checkpoint = class {
92
+ /** @internal */
93
+ constructor(journal) {
94
+ this.journal = journal;
95
+ }
96
+ journal;
97
+ };
90
98
  var Engine = class _Engine {
91
99
  host;
92
100
  defaultSeed;
@@ -204,6 +212,7 @@ var Engine = class _Engine {
204
212
  sharedVisits: /* @__PURE__ */ new Map(),
205
213
  sharedSelectors: /* @__PURE__ */ new Map(),
206
214
  stageBags: /* @__PURE__ */ new Map(),
215
+ journal: null,
207
216
  customRng: options.rng,
208
217
  onDryChoice: options.onDryChoice,
209
218
  replayPromptOnChoose: options.replayPromptOnChoose ?? false,
@@ -267,6 +276,7 @@ var Engine = class _Engine {
267
276
  * top of the scene it was in; the shared properties carry over.
268
277
  */
269
278
  hotSwap(bundle) {
279
+ this.refuseInCheckpoint("hotSwap");
270
280
  const snapshot = this.saveGame();
271
281
  const carryOver = (next2) => {
272
282
  next2.setLocale(this.currentLocale);
@@ -322,13 +332,28 @@ var Engine = class _Engine {
322
332
  *
323
333
  * Contrast {@link runFlow}, which REUSES a flow of the same name instead of replacing it - that is the
324
334
  * call to reach for when you want a speaker's variation state to carry on.
335
+ *
336
+ * THROWS on an address that does not resolve (an unknown scene, or a block that is not in the named
337
+ * scene), before anything changes: nothing is opened, and a flow already open under `id` carries on
338
+ * exactly as it was.
325
339
  */
326
340
  openFlow(id, opts = {}) {
327
341
  this.assertExternalScopes();
328
- const sceneId = this.resolveSceneRef(opts.scene);
329
- const blockId = this.resolveBlockRef(sceneId, opts.block);
330
- this.flowsById.get(id)?.close();
342
+ const { sceneId, blockId } = this.resolveOpenAddress(opts.scene, opts.block);
343
+ const prior = this.flowsById.get(id);
344
+ const journal = this.host.journal;
345
+ if (journal && prior && !prior.isClosed) throw new Error(`openFlow would replace the open flow '${id}', which can't be undone while a checkpoint is open`);
346
+ prior?.close();
331
347
  const flow = new Flow(id, this.host, opts.seed ?? this.defaultSeed);
348
+ if (journal) {
349
+ journal.flows.add(flow);
350
+ journal.opened.add(flow);
351
+ journal.undo.push(() => {
352
+ flow.close();
353
+ if (prior) this.flowsById.set(id, prior);
354
+ else this.flowsById.delete(id);
355
+ });
356
+ }
332
357
  this.flowsById.set(id, flow);
333
358
  flow.start(sceneId, blockId);
334
359
  return flow;
@@ -375,6 +400,28 @@ var Engine = class _Engine {
375
400
  }
376
401
  }
377
402
  }
403
+ /**
404
+ * `openFlow`'s address as internal ids, or a throw when it does not resolve. With a scene, the block is
405
+ * scene-scoped exactly as {@link Flow.goto} resolves it (a gameId address in that scene, or the internal
406
+ * id of a block in that scene), so a block from another scene does not resolve. With no scene, the block
407
+ * is an internal id from any scene. Neither: the bundle's first scene. The same rule on every runtime.
408
+ */
409
+ resolveOpenAddress(scene, block) {
410
+ if (scene !== void 0) {
411
+ const sceneId = this.resolveSceneRef(scene);
412
+ if (!this.host.bundle.scenes[sceneId]) throw new Error(`unknown scene: ${scene}`);
413
+ 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);
415
+ if (blockId === void 0) throw new Error(`unknown block: ${block}`);
416
+ return { sceneId, blockId };
417
+ }
418
+ if (block !== void 0) {
419
+ if (!this.host.blockIndex.has(block)) throw new Error(`unknown block: ${block}`);
420
+ return { blockId: block };
421
+ }
422
+ if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
423
+ return {};
424
+ }
378
425
  /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
379
426
  resolveSceneRef(ref) {
380
427
  if (ref == null) return void 0;
@@ -584,9 +631,64 @@ var Engine = class _Engine {
584
631
  for (const [sceneId, bag] of this.host.stageBags) mounts.push({ bag, pathPrefix: `@scene:${sceneId}.` });
585
632
  return mounts;
586
633
  }
634
+ /**
635
+ * Open a checkpoint: from here until `rollback` or `commit`, the engine keeps what it needs to undo
636
+ * every change the story makes. `rollback` puts the game back exactly as it was at the checkpoint:
637
+ * property values (every scope, including a host's `@world`, which is written back through the
638
+ * game), visit counts, shuffle and sequence positions, `@scene` bags made since, every flow's cursor
639
+ * and PRNG, and any flow opened since (closed and forgotten). `commit` keeps everything and stops
640
+ * recording.
641
+ *
642
+ * The use is asking "would this say anything?" without consequences: move a flow to an address,
643
+ * advance it, and roll back if it had nothing to give, so its scene's `onEntry` effects, its visits,
644
+ * and its shuffle draws never happened.
645
+ *
646
+ * One checkpoint at a time. Inside one, the calls that replace or drop flows or their bags wholesale
647
+ * (`reset`, `loadGame`, `hotSwap`, `closeFlow`, `openFlow` on the name of an open flow, and a flow's own
648
+ * `start`, `reset`, and `restore`) throw. Not undone:
649
+ * trace and log events already emitted, and draws from a custom `rng` the game supplied.
650
+ */
651
+ checkpoint() {
652
+ if (this.host.journal) throw new Error("a checkpoint is already open: roll it back or commit it first");
653
+ const journal = { undo: [], flows: /* @__PURE__ */ new Set(), opened: /* @__PURE__ */ new Set(), selectors: /* @__PURE__ */ new Set(), flowSelectors: /* @__PURE__ */ new Map() };
654
+ this.host.journal = journal;
655
+ return new Checkpoint(journal);
656
+ }
657
+ /** Undo everything since `checkpoint` (see there), and close it. */
658
+ rollback(checkpoint) {
659
+ this.endCheckpoint(checkpoint);
660
+ const undo = checkpoint.journal.undo;
661
+ for (let i = undo.length - 1; i >= 0; i--) undo[i]();
662
+ _Engine.forget(checkpoint.journal);
663
+ }
664
+ /** Keep everything since `checkpoint`, and close it. */
665
+ commit(checkpoint) {
666
+ this.endCheckpoint(checkpoint);
667
+ _Engine.forget(checkpoint.journal);
668
+ }
669
+ /** True while a checkpoint is open. */
670
+ get inCheckpoint() {
671
+ return this.host.journal !== null;
672
+ }
673
+ endCheckpoint(checkpoint) {
674
+ if (this.host.journal !== checkpoint.journal) throw new Error("that checkpoint is not the open one");
675
+ this.host.journal = null;
676
+ }
677
+ /** Let go of what a closed checkpoint recorded, so a handle the game keeps holds nothing. */
678
+ static forget(journal) {
679
+ journal.undo = [];
680
+ journal.flows.clear();
681
+ journal.opened.clear();
682
+ journal.selectors.clear();
683
+ journal.flowSelectors.clear();
684
+ }
685
+ refuseInCheckpoint(what) {
686
+ if (this.host.journal) throw new Error(`${what} can't be undone, so it isn't allowed while a checkpoint is open`);
687
+ }
587
688
  /** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
588
689
  * holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
589
690
  closeFlow(id) {
691
+ this.refuseInCheckpoint("closeFlow");
590
692
  this.flowsById.get(id)?.close();
591
693
  this.flowsById.delete(id);
592
694
  }
@@ -597,6 +699,7 @@ var Engine = class _Engine {
597
699
  * After reset, open fresh flows with `openFlow`.
598
700
  */
599
701
  reset() {
702
+ this.refuseInCheckpoint("reset");
600
703
  for (const flow of this.flowsById.values()) flow.close();
601
704
  this.flowsById.clear();
602
705
  this.host.patterBag.reseed(this.host.patterSharedDecls);
@@ -634,7 +737,9 @@ var Engine = class _Engine {
634
737
  * were each refused the one property they existed to move.) */
635
738
  setProperty(ref, value) {
636
739
  const { scope, name } = this.splitShared(ref);
740
+ const prev = this.host.journal ? this.host.registry.get(scope, name) : void 0;
637
741
  this.host.registry.set(scope, name, value, { host: true });
742
+ if (this.host.journal && prev !== void 0) this.host.journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
638
743
  }
639
744
  /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
640
745
  * value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
@@ -721,6 +826,7 @@ var Engine = class _Engine {
721
826
  * to the registry (values kept) and the restored flows claim them as they register.
722
827
  */
723
828
  loadGame(save) {
829
+ this.refuseInCheckpoint("loadGame");
724
830
  const version = save.version;
725
831
  if (version !== 2 && version !== import_model2.SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
726
832
  this.assertExternalScopes();
@@ -871,6 +977,7 @@ var Flow = class {
871
977
  // -- Host API -------------------------------------------------------------
872
978
  /** Begin this flow at a scene (and optionally a specific block within it). */
873
979
  start(sceneId, blockId) {
980
+ 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");
874
981
  this.releaseBags(false);
875
982
  this.host.registry.discardParked(keys.flow(this.id));
876
983
  this.mountLocal();
@@ -930,6 +1037,7 @@ var Flow = class {
930
1037
  */
931
1038
  goto(scene, block) {
932
1039
  if (this.closed) return false;
1040
+ this.touch();
933
1041
  if (scene === "END") {
934
1042
  this.started = true;
935
1043
  this.pendingChoice = null;
@@ -983,6 +1091,19 @@ var Flow = class {
983
1091
  this.pendingPromptBeat = null;
984
1092
  this.pendingPromptOwnerId = null;
985
1093
  }
1094
+ /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
1095
+ * put them back: a handful of fields and a copy of the call stack (a few frames), never its history.
1096
+ * Its visits, selector cursors, and property values are recorded change by change as they happen. */
1097
+ touch() {
1098
+ const journal = this.host.journal;
1099
+ if (!journal || journal.flows.has(this)) return;
1100
+ journal.flows.add(this);
1101
+ const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId } = this;
1102
+ const stack = this.stack.map((f) => ({ ...f }));
1103
+ journal.undo.push(() => {
1104
+ Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId, stack });
1105
+ });
1106
+ }
986
1107
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
987
1108
  get isClosed() {
988
1109
  return this.closed;
@@ -997,6 +1118,7 @@ var Flow = class {
997
1118
  advance() {
998
1119
  if (this.closed) return { type: "end" };
999
1120
  if (!this.started) throw new Error("flow has not been started");
1121
+ this.touch();
1000
1122
  if (this.pendingPromptBeat) {
1001
1123
  const b = this.pendingPromptBeat;
1002
1124
  this.pendingPromptBeat = null;
@@ -1116,6 +1238,7 @@ var Flow = class {
1116
1238
  const option = choice.options.find((o) => o.id === id);
1117
1239
  if (!option) throw new Error(`unknown choice option: ${id}`);
1118
1240
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1241
+ this.touch();
1119
1242
  const node = choice.byId.get(id);
1120
1243
  this.emit({ type: "chose", group: choice.groupId, option: id });
1121
1244
  this.pendingChoice = null;
@@ -1143,13 +1266,27 @@ var Flow = class {
1143
1266
  * about: the story is refused, the game is not. */
1144
1267
  writeProperty(ref, value, host) {
1145
1268
  const { scope, name } = this.splitRef(ref);
1269
+ const journal = this.host.journal;
1146
1270
  if (scope === "patter") {
1271
+ if (journal) {
1272
+ const shared = this.host.patterSharedNames.has(name);
1273
+ const prev = shared ? this.host.patterBag.get(name) : this.local.get(name);
1274
+ const bag = this.local;
1275
+ if (prev !== void 0) journal.undo.push(shared ? () => this.host.registry.set("patter", name, prev) : () => bag.set(name, prev));
1276
+ }
1147
1277
  this.patterResolver.set(name, value);
1148
1278
  } else if (scope === "scene") {
1149
1279
  if (this.currentSceneId === null) throw new Error(`'${ref}': the flow has not entered a scene yet`);
1280
+ if (journal) {
1281
+ const bag = this.sceneBagFor(this.currentSceneId, name);
1282
+ const prev = bag?.get(name);
1283
+ if (bag && prev !== void 0) journal.undo.push(() => bag.set(name, prev));
1284
+ }
1150
1285
  this.sceneResolver.set(name, value);
1151
1286
  } else {
1287
+ const prev = journal ? this.host.registry.get(scope, name) : void 0;
1152
1288
  this.host.registry.set(scope, name, value, host ? { host: true } : void 0);
1289
+ if (journal && prev !== void 0) journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
1153
1290
  }
1154
1291
  }
1155
1292
  // -- Save / restore (engine-driven) --------------------------------------
@@ -1176,8 +1313,19 @@ var Flow = class {
1176
1313
  }
1177
1314
  };
1178
1315
  }
1179
- /** @internal Restore this flow from a snapshot. */
1316
+ /** @internal Restore this flow from a snapshot: its cursor, PRNG, visits, and selector cursors. Its
1317
+ * property values are the registry's and stay as they are (a load puts the saved ones there first). */
1180
1318
  restore(snap) {
1319
+ if (this.host.journal) throw new Error("a flow can't be restored while a checkpoint is open");
1320
+ this.restoreCursor(snap);
1321
+ this.releaseBags(true);
1322
+ this.mountLocal();
1323
+ for (const s of /* @__PURE__ */ new Set([this.currentSceneId, ...this.stack.map((f) => f.sceneId)])) {
1324
+ if (s !== null && this.host.bundle.scenes[s]) this.ensureSceneBags(s);
1325
+ }
1326
+ }
1327
+ /** The cursor half of `restore`, without touching the bags (a rollback has already put those back). */
1328
+ restoreCursor(snap) {
1181
1329
  this.rngState = (0, import_expr.toUint32)(snap.rngState);
1182
1330
  this.visitCounts = new Map(Object.entries(snap.visits ?? {}));
1183
1331
  const c = snap.cursor;
@@ -1193,11 +1341,6 @@ var Flow = class {
1193
1341
  }
1194
1342
  return { ...frame };
1195
1343
  });
1196
- this.releaseBags(false);
1197
- this.mountLocal();
1198
- for (const s of /* @__PURE__ */ new Set([c.currentSceneId, ...this.stack.map((f) => f.sceneId)])) {
1199
- if (s !== null && this.host.bundle.scenes[s]) this.ensureSceneBags(s);
1200
- }
1201
1344
  this.activeSnippet = null;
1202
1345
  if (c.activeSnippetId !== null) {
1203
1346
  const node = this.host.nodeIndex.get(c.activeSnippetId);
@@ -1474,6 +1617,24 @@ var Flow = class {
1474
1617
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
1475
1618
  selectorState(group) {
1476
1619
  const map = group.shared ? this.host.sharedSelectors : this.selectors;
1620
+ const journal = this.host.journal;
1621
+ let seen;
1622
+ if (journal) {
1623
+ seen = group.shared ? journal.selectors : journal.flowSelectors.get(this);
1624
+ if (!seen) {
1625
+ seen = /* @__PURE__ */ new Set();
1626
+ journal.flowSelectors.set(this, seen);
1627
+ }
1628
+ }
1629
+ if (journal && seen && !seen.has(group.id)) {
1630
+ seen.add(group.id);
1631
+ const was = map.get(group.id);
1632
+ const copy = was ? { ...was, ...was.bag ? { bag: [...was.bag] } : {} } : void 0;
1633
+ journal.undo.push(() => {
1634
+ if (copy) map.set(group.id, copy);
1635
+ else map.delete(group.id);
1636
+ });
1637
+ }
1477
1638
  let st = map.get(group.id);
1478
1639
  if (!st) {
1479
1640
  st = {};
@@ -1509,8 +1670,18 @@ var Flow = class {
1509
1670
  }
1510
1671
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
1511
1672
  enter(id) {
1512
- this.visitCounts.set(id, (this.visitCounts.get(id) ?? 0) + 1);
1513
- this.host.sharedVisits.set(id, (this.host.sharedVisits.get(id) ?? 0) + 1);
1673
+ const own = this.visitCounts;
1674
+ const ownBefore = own.get(id);
1675
+ own.set(id, (ownBefore ?? 0) + 1);
1676
+ const shared = this.host.sharedVisits;
1677
+ const before = shared.get(id);
1678
+ shared.set(id, (before ?? 0) + 1);
1679
+ if (this.host.journal) this.host.journal.undo.push(() => {
1680
+ if (ownBefore === void 0) own.delete(id);
1681
+ else own.set(id, ownBefore);
1682
+ if (before === void 0) shared.delete(id);
1683
+ else shared.set(id, before);
1684
+ });
1514
1685
  }
1515
1686
  /** Next float in [0, 1): the shared custom PRNG, or this flow's serialisable mulberry32.
1516
1687
  *
@@ -1653,17 +1824,29 @@ var Flow = class {
1653
1824
  ensureSceneBags(s) {
1654
1825
  const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
1655
1826
  const props = this.host.bundle.scenes[s]?.sceneProps ?? [];
1827
+ const journal = this.host.journal;
1656
1828
  if (!this.sceneBags.has(s)) {
1657
1829
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1658
1830
  const key = keys.flowScene(this.id, s);
1659
1831
  this.host.registry.mountOwned(key, bag, { owner: OWNER });
1660
1832
  this.registered.add(key);
1661
1833
  this.sceneBags.set(s, bag);
1834
+ if (journal) journal.undo.push(() => {
1835
+ if (this.sceneBags.get(s) !== bag) return;
1836
+ this.host.registry.remove(key);
1837
+ this.registered.delete(key);
1838
+ this.sceneBags.delete(s);
1839
+ });
1662
1840
  }
1663
1841
  if (!this.host.stageBags.has(s)) {
1664
1842
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1665
1843
  this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1666
1844
  this.host.stageBags.set(s, bag);
1845
+ if (journal) journal.undo.push(() => {
1846
+ if (this.host.stageBags.get(s) !== bag) return;
1847
+ this.host.registry.remove(keys.stage(s));
1848
+ this.host.stageBags.delete(s);
1849
+ });
1667
1850
  }
1668
1851
  }
1669
1852
  /**
@@ -1680,7 +1863,10 @@ var Flow = class {
1680
1863
  if (!decl.temporary) continue;
1681
1864
  const name = decl.name.toLowerCase();
1682
1865
  const bag = shared.has(name) ? this.host.stageBags.get(scene.id) : this.sceneBags.get(scene.id);
1683
- if (bag) bag.set(name, (0, import_scoperegistry2.defaultFor)(decl));
1866
+ if (!bag) continue;
1867
+ const prev = this.host.journal ? bag.get(name) : void 0;
1868
+ if (prev !== void 0) this.host.journal.undo.push(() => bag.set(name, prev));
1869
+ bag.set(name, (0, import_scoperegistry2.defaultFor)(decl));
1684
1870
  }
1685
1871
  }
1686
1872
  };
@@ -1880,6 +2066,7 @@ function effectiveGameData(fields, node) {
1880
2066
  }
1881
2067
  // Annotate the CommonJS export names for ESM import in node:
1882
2068
  0 && (module.exports = {
2069
+ Checkpoint,
1883
2070
  Engine,
1884
2071
  Flow,
1885
2072
  buildTagIndex,