@patterkit/runtime 0.15.0 → 0.16.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,28 @@ version number always means the same runtime behaviour. This package is versione
33
33
 
34
34
  ## [Unreleased]
35
35
 
36
+ ## [0.16.0] - 2026-10-04
37
+
38
+ ### Added
39
+
40
+ - **Checkpoints: try something, then undo it.** `engine.checkpoint()` opens a checkpoint; `engine.rollback(checkpoint)` puts the game back exactly
41
+ as it was then, and `engine.commit(checkpoint)` keeps everything. A rollback undoes every change the story made in between:
42
+ property values in every scope (a game's own `@world` store is written back through the game), visit
43
+ counts, shuffle and sequence positions, `@scene` bags made since, and every flow's position and random
44
+ state; a flow opened since is closed, and its name is free again. `engine.inCheckpoint` says whether one is open.
45
+ The use is asking "would this say anything?" without consequences: move a flow to an address, step
46
+ it, and roll back if it had nothing to give, so the scene's on-entry effects, the visits, and the
47
+ shuffle draws never happened. Each change records how to undo itself as it happens, so a checkpoint
48
+ costs what the steps inside it do, not what the game has built up, and the record is let go when it
49
+ closes. One is open at a time; while it is, the calls a rollback couldn't undo are refused
50
+ (`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.
51
+
52
+ ### Fixed
53
+
54
+ - **`Flow.restore()` on a running flow keeps that flow's own property values.** It used to drop them back to
55
+ their defaults, since a snapshot holds a flow's position and memory, not its property values. Loading
56
+ a save is unchanged.
57
+
36
58
  ## [0.15.0] - 2026-10-03
37
59
 
38
60
  ### 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);
@@ -327,8 +337,20 @@ var Engine = class _Engine {
327
337
  this.assertExternalScopes();
328
338
  const sceneId = this.resolveSceneRef(opts.scene);
329
339
  const blockId = this.resolveBlockRef(sceneId, opts.block);
330
- this.flowsById.get(id)?.close();
340
+ const prior = this.flowsById.get(id);
341
+ const journal = this.host.journal;
342
+ 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`);
343
+ prior?.close();
331
344
  const flow = new Flow(id, this.host, opts.seed ?? this.defaultSeed);
345
+ if (journal) {
346
+ journal.flows.add(flow);
347
+ journal.opened.add(flow);
348
+ journal.undo.push(() => {
349
+ flow.close();
350
+ if (prior) this.flowsById.set(id, prior);
351
+ else this.flowsById.delete(id);
352
+ });
353
+ }
332
354
  this.flowsById.set(id, flow);
333
355
  flow.start(sceneId, blockId);
334
356
  return flow;
@@ -584,9 +606,64 @@ var Engine = class _Engine {
584
606
  for (const [sceneId, bag] of this.host.stageBags) mounts.push({ bag, pathPrefix: `@scene:${sceneId}.` });
585
607
  return mounts;
586
608
  }
609
+ /**
610
+ * Open a checkpoint: from here until `rollback` or `commit`, the engine keeps what it needs to undo
611
+ * every change the story makes. `rollback` puts the game back exactly as it was at the checkpoint:
612
+ * property values (every scope, including a host's `@world`, which is written back through the
613
+ * game), visit counts, shuffle and sequence positions, `@scene` bags made since, every flow's cursor
614
+ * and PRNG, and any flow opened since (closed and forgotten). `commit` keeps everything and stops
615
+ * recording.
616
+ *
617
+ * The use is asking "would this say anything?" without consequences: move a flow to an address,
618
+ * advance it, and roll back if it had nothing to give, so its scene's `onEntry` effects, its visits,
619
+ * and its shuffle draws never happened.
620
+ *
621
+ * One checkpoint at a time. Inside one, the calls that replace or drop flows or their bags wholesale
622
+ * (`reset`, `loadGame`, `hotSwap`, `closeFlow`, `openFlow` on the name of an open flow, and a flow's own
623
+ * `start`, `reset`, and `restore`) throw. Not undone:
624
+ * trace and log events already emitted, and draws from a custom `rng` the game supplied.
625
+ */
626
+ checkpoint() {
627
+ if (this.host.journal) throw new Error("a checkpoint is already open: roll it back or commit it first");
628
+ const journal = { undo: [], flows: /* @__PURE__ */ new Set(), opened: /* @__PURE__ */ new Set(), selectors: /* @__PURE__ */ new Set(), flowSelectors: /* @__PURE__ */ new Map() };
629
+ this.host.journal = journal;
630
+ return new Checkpoint(journal);
631
+ }
632
+ /** Undo everything since `checkpoint` (see there), and close it. */
633
+ rollback(checkpoint) {
634
+ this.endCheckpoint(checkpoint);
635
+ const undo = checkpoint.journal.undo;
636
+ for (let i = undo.length - 1; i >= 0; i--) undo[i]();
637
+ _Engine.forget(checkpoint.journal);
638
+ }
639
+ /** Keep everything since `checkpoint`, and close it. */
640
+ commit(checkpoint) {
641
+ this.endCheckpoint(checkpoint);
642
+ _Engine.forget(checkpoint.journal);
643
+ }
644
+ /** True while a checkpoint is open. */
645
+ get inCheckpoint() {
646
+ return this.host.journal !== null;
647
+ }
648
+ endCheckpoint(checkpoint) {
649
+ if (this.host.journal !== checkpoint.journal) throw new Error("that checkpoint is not the open one");
650
+ this.host.journal = null;
651
+ }
652
+ /** Let go of what a closed checkpoint recorded, so a handle the game keeps holds nothing. */
653
+ static forget(journal) {
654
+ journal.undo = [];
655
+ journal.flows.clear();
656
+ journal.opened.clear();
657
+ journal.selectors.clear();
658
+ journal.flowSelectors.clear();
659
+ }
660
+ refuseInCheckpoint(what) {
661
+ if (this.host.journal) throw new Error(`${what} can't be undone, so it isn't allowed while a checkpoint is open`);
662
+ }
587
663
  /** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
588
664
  * holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
589
665
  closeFlow(id) {
666
+ this.refuseInCheckpoint("closeFlow");
590
667
  this.flowsById.get(id)?.close();
591
668
  this.flowsById.delete(id);
592
669
  }
@@ -597,6 +674,7 @@ var Engine = class _Engine {
597
674
  * After reset, open fresh flows with `openFlow`.
598
675
  */
599
676
  reset() {
677
+ this.refuseInCheckpoint("reset");
600
678
  for (const flow of this.flowsById.values()) flow.close();
601
679
  this.flowsById.clear();
602
680
  this.host.patterBag.reseed(this.host.patterSharedDecls);
@@ -634,7 +712,9 @@ var Engine = class _Engine {
634
712
  * were each refused the one property they existed to move.) */
635
713
  setProperty(ref, value) {
636
714
  const { scope, name } = this.splitShared(ref);
715
+ const prev = this.host.journal ? this.host.registry.get(scope, name) : void 0;
637
716
  this.host.registry.set(scope, name, value, { host: true });
717
+ if (this.host.journal && prev !== void 0) this.host.journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
638
718
  }
639
719
  /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
640
720
  * value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
@@ -721,6 +801,7 @@ var Engine = class _Engine {
721
801
  * to the registry (values kept) and the restored flows claim them as they register.
722
802
  */
723
803
  loadGame(save) {
804
+ this.refuseInCheckpoint("loadGame");
724
805
  const version = save.version;
725
806
  if (version !== 2 && version !== import_model2.SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
726
807
  this.assertExternalScopes();
@@ -871,6 +952,7 @@ var Flow = class {
871
952
  // -- Host API -------------------------------------------------------------
872
953
  /** Begin this flow at a scene (and optionally a specific block within it). */
873
954
  start(sceneId, blockId) {
955
+ 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
956
  this.releaseBags(false);
875
957
  this.host.registry.discardParked(keys.flow(this.id));
876
958
  this.mountLocal();
@@ -930,6 +1012,7 @@ var Flow = class {
930
1012
  */
931
1013
  goto(scene, block) {
932
1014
  if (this.closed) return false;
1015
+ this.touch();
933
1016
  if (scene === "END") {
934
1017
  this.started = true;
935
1018
  this.pendingChoice = null;
@@ -983,6 +1066,19 @@ var Flow = class {
983
1066
  this.pendingPromptBeat = null;
984
1067
  this.pendingPromptOwnerId = null;
985
1068
  }
1069
+ /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
1070
+ * put them back: a handful of fields and a copy of the call stack (a few frames), never its history.
1071
+ * Its visits, selector cursors, and property values are recorded change by change as they happen. */
1072
+ touch() {
1073
+ const journal = this.host.journal;
1074
+ if (!journal || journal.flows.has(this)) return;
1075
+ journal.flows.add(this);
1076
+ const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId } = this;
1077
+ const stack = this.stack.map((f) => ({ ...f }));
1078
+ journal.undo.push(() => {
1079
+ Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId, stack });
1080
+ });
1081
+ }
986
1082
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
987
1083
  get isClosed() {
988
1084
  return this.closed;
@@ -997,6 +1093,7 @@ var Flow = class {
997
1093
  advance() {
998
1094
  if (this.closed) return { type: "end" };
999
1095
  if (!this.started) throw new Error("flow has not been started");
1096
+ this.touch();
1000
1097
  if (this.pendingPromptBeat) {
1001
1098
  const b = this.pendingPromptBeat;
1002
1099
  this.pendingPromptBeat = null;
@@ -1116,6 +1213,7 @@ var Flow = class {
1116
1213
  const option = choice.options.find((o) => o.id === id);
1117
1214
  if (!option) throw new Error(`unknown choice option: ${id}`);
1118
1215
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1216
+ this.touch();
1119
1217
  const node = choice.byId.get(id);
1120
1218
  this.emit({ type: "chose", group: choice.groupId, option: id });
1121
1219
  this.pendingChoice = null;
@@ -1143,13 +1241,27 @@ var Flow = class {
1143
1241
  * about: the story is refused, the game is not. */
1144
1242
  writeProperty(ref, value, host) {
1145
1243
  const { scope, name } = this.splitRef(ref);
1244
+ const journal = this.host.journal;
1146
1245
  if (scope === "patter") {
1246
+ if (journal) {
1247
+ const shared = this.host.patterSharedNames.has(name);
1248
+ const prev = shared ? this.host.patterBag.get(name) : this.local.get(name);
1249
+ const bag = this.local;
1250
+ if (prev !== void 0) journal.undo.push(shared ? () => this.host.registry.set("patter", name, prev) : () => bag.set(name, prev));
1251
+ }
1147
1252
  this.patterResolver.set(name, value);
1148
1253
  } else if (scope === "scene") {
1149
1254
  if (this.currentSceneId === null) throw new Error(`'${ref}': the flow has not entered a scene yet`);
1255
+ if (journal) {
1256
+ const bag = this.sceneBagFor(this.currentSceneId, name);
1257
+ const prev = bag?.get(name);
1258
+ if (bag && prev !== void 0) journal.undo.push(() => bag.set(name, prev));
1259
+ }
1150
1260
  this.sceneResolver.set(name, value);
1151
1261
  } else {
1262
+ const prev = journal ? this.host.registry.get(scope, name) : void 0;
1152
1263
  this.host.registry.set(scope, name, value, host ? { host: true } : void 0);
1264
+ if (journal && prev !== void 0) journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
1153
1265
  }
1154
1266
  }
1155
1267
  // -- Save / restore (engine-driven) --------------------------------------
@@ -1176,8 +1288,19 @@ var Flow = class {
1176
1288
  }
1177
1289
  };
1178
1290
  }
1179
- /** @internal Restore this flow from a snapshot. */
1291
+ /** @internal Restore this flow from a snapshot: its cursor, PRNG, visits, and selector cursors. Its
1292
+ * property values are the registry's and stay as they are (a load puts the saved ones there first). */
1180
1293
  restore(snap) {
1294
+ if (this.host.journal) throw new Error("a flow can't be restored while a checkpoint is open");
1295
+ this.restoreCursor(snap);
1296
+ this.releaseBags(true);
1297
+ this.mountLocal();
1298
+ for (const s of /* @__PURE__ */ new Set([this.currentSceneId, ...this.stack.map((f) => f.sceneId)])) {
1299
+ if (s !== null && this.host.bundle.scenes[s]) this.ensureSceneBags(s);
1300
+ }
1301
+ }
1302
+ /** The cursor half of `restore`, without touching the bags (a rollback has already put those back). */
1303
+ restoreCursor(snap) {
1181
1304
  this.rngState = (0, import_expr.toUint32)(snap.rngState);
1182
1305
  this.visitCounts = new Map(Object.entries(snap.visits ?? {}));
1183
1306
  const c = snap.cursor;
@@ -1193,11 +1316,6 @@ var Flow = class {
1193
1316
  }
1194
1317
  return { ...frame };
1195
1318
  });
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
1319
  this.activeSnippet = null;
1202
1320
  if (c.activeSnippetId !== null) {
1203
1321
  const node = this.host.nodeIndex.get(c.activeSnippetId);
@@ -1474,6 +1592,24 @@ var Flow = class {
1474
1592
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
1475
1593
  selectorState(group) {
1476
1594
  const map = group.shared ? this.host.sharedSelectors : this.selectors;
1595
+ const journal = this.host.journal;
1596
+ let seen;
1597
+ if (journal) {
1598
+ seen = group.shared ? journal.selectors : journal.flowSelectors.get(this);
1599
+ if (!seen) {
1600
+ seen = /* @__PURE__ */ new Set();
1601
+ journal.flowSelectors.set(this, seen);
1602
+ }
1603
+ }
1604
+ if (journal && seen && !seen.has(group.id)) {
1605
+ seen.add(group.id);
1606
+ const was = map.get(group.id);
1607
+ const copy = was ? { ...was, ...was.bag ? { bag: [...was.bag] } : {} } : void 0;
1608
+ journal.undo.push(() => {
1609
+ if (copy) map.set(group.id, copy);
1610
+ else map.delete(group.id);
1611
+ });
1612
+ }
1477
1613
  let st = map.get(group.id);
1478
1614
  if (!st) {
1479
1615
  st = {};
@@ -1509,8 +1645,18 @@ var Flow = class {
1509
1645
  }
1510
1646
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
1511
1647
  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);
1648
+ const own = this.visitCounts;
1649
+ const ownBefore = own.get(id);
1650
+ own.set(id, (ownBefore ?? 0) + 1);
1651
+ const shared = this.host.sharedVisits;
1652
+ const before = shared.get(id);
1653
+ shared.set(id, (before ?? 0) + 1);
1654
+ if (this.host.journal) this.host.journal.undo.push(() => {
1655
+ if (ownBefore === void 0) own.delete(id);
1656
+ else own.set(id, ownBefore);
1657
+ if (before === void 0) shared.delete(id);
1658
+ else shared.set(id, before);
1659
+ });
1514
1660
  }
1515
1661
  /** Next float in [0, 1): the shared custom PRNG, or this flow's serialisable mulberry32.
1516
1662
  *
@@ -1653,17 +1799,29 @@ var Flow = class {
1653
1799
  ensureSceneBags(s) {
1654
1800
  const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
1655
1801
  const props = this.host.bundle.scenes[s]?.sceneProps ?? [];
1802
+ const journal = this.host.journal;
1656
1803
  if (!this.sceneBags.has(s)) {
1657
1804
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1658
1805
  const key = keys.flowScene(this.id, s);
1659
1806
  this.host.registry.mountOwned(key, bag, { owner: OWNER });
1660
1807
  this.registered.add(key);
1661
1808
  this.sceneBags.set(s, bag);
1809
+ if (journal) journal.undo.push(() => {
1810
+ if (this.sceneBags.get(s) !== bag) return;
1811
+ this.host.registry.remove(key);
1812
+ this.registered.delete(key);
1813
+ this.sceneBags.delete(s);
1814
+ });
1662
1815
  }
1663
1816
  if (!this.host.stageBags.has(s)) {
1664
1817
  const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1665
1818
  this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1666
1819
  this.host.stageBags.set(s, bag);
1820
+ if (journal) journal.undo.push(() => {
1821
+ if (this.host.stageBags.get(s) !== bag) return;
1822
+ this.host.registry.remove(keys.stage(s));
1823
+ this.host.stageBags.delete(s);
1824
+ });
1667
1825
  }
1668
1826
  }
1669
1827
  /**
@@ -1680,7 +1838,10 @@ var Flow = class {
1680
1838
  if (!decl.temporary) continue;
1681
1839
  const name = decl.name.toLowerCase();
1682
1840
  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));
1841
+ if (!bag) continue;
1842
+ const prev = this.host.journal ? bag.get(name) : void 0;
1843
+ if (prev !== void 0) this.host.journal.undo.push(() => bag.set(name, prev));
1844
+ bag.set(name, (0, import_scoperegistry2.defaultFor)(decl));
1684
1845
  }
1685
1846
  }
1686
1847
  };
@@ -1880,6 +2041,7 @@ function effectiveGameData(fields, node) {
1880
2041
  }
1881
2042
  // Annotate the CommonJS export names for ESM import in node:
1882
2043
  0 && (module.exports = {
2044
+ Checkpoint,
1883
2045
  Engine,
1884
2046
  Flow,
1885
2047
  buildTagIndex,