@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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ScalarValue } from '@wildwinter/expr';
2
- import { ScopeResolver, ScopeRegistry, PropertyBag, ScopeDeclaration, LogMount, PropertyRow } from '@wildwinter/scoperegistry';
2
+ import { ScopeRegistry, PropertyBag, ScopeDeclaration, LogMount, ScopeResolver, PropertyRow } from '@wildwinter/scoperegistry';
3
3
  export { PropertyRow } from '@wildwinter/scoperegistry';
4
4
  import { GameData, Bundle, CompiledGroup, CompiledSnippet, CompiledBlock, FlowSnapshot, EngineSave, SaveGame, SaveGameV2, PropertyType, ScalarValue as ScalarValue$1, GameDataNodeKind, GameDataField } from '@patterkit/model';
5
5
  export { Bundle, EngineSave, FlowCursor, FlowSnapshot, SaveEnvelope, SaveGame, SavedChoice, SavedChoiceOption, SavedChoicePrompt, SelectorSnapshot, StackFrame } from '@patterkit/model';
@@ -277,6 +277,25 @@ interface SelectorState {
277
277
  bag?: string[];
278
278
  last?: string;
279
279
  }
280
+ /** What an open checkpoint records: how to undo each change, newest last. Each piece of state records
281
+ * itself when it changes (a value, a count, a cursor), never a copy of everything, so a checkpoint costs
282
+ * what the steps inside it do, however much history the game has built up. */
283
+ interface Journal {
284
+ undo: Array<() => void>;
285
+ /** Flows already snapshotted (or opened inside the checkpoint, so closed on rollback). */
286
+ flows: Set<Flow>;
287
+ /** Flows opened inside the checkpoint. */
288
+ opened: Set<Flow>;
289
+ /** Selector cursors already copied: shared ones by group id, a flow's own in `flowSelectors`. */
290
+ selectors: Set<string>;
291
+ flowSelectors: Map<Flow, Set<string>>;
292
+ }
293
+ /** An open checkpoint, from {@link Engine.checkpoint}. Opaque: hand it back to `rollback` or `commit`. */
294
+ declare class Checkpoint {
295
+ readonly journal: Journal;
296
+ /** @internal */
297
+ constructor(journal: Journal);
298
+ }
280
299
  /** Shared, read-mostly context the engine hands to every flow it owns. */
281
300
  interface FlowHost {
282
301
  /** True when the run asked for a log; flows skip building entries otherwise. */
@@ -328,6 +347,8 @@ interface FlowHost {
328
347
  /** Per-scene SHARED scene props, each registered under `keys.stage(sceneId)`. Made the first time any
329
348
  * flow needs the scene, so a bag loaded before then waits in the registry and is claimed here. */
330
349
  stageBags: Map<string, PropertyBag>;
350
+ /** The open checkpoint's undo journal, or null when none is open (see {@link Engine.checkpoint}). */
351
+ journal: Journal | null;
331
352
  customRng?: () => number;
332
353
  /** Play a chosen option's prompt as its first beat (spec §5); default false. */
333
354
  replayPromptOnChoose?: boolean;
@@ -548,6 +569,34 @@ declare class Engine {
548
569
  * loadGame() replaces every bag, so re-enumerate after a load.
549
570
  */
550
571
  listBags(): LogMount[];
572
+ /**
573
+ * Open a checkpoint: from here until `rollback` or `commit`, the engine keeps what it needs to undo
574
+ * every change the story makes. `rollback` puts the game back exactly as it was at the checkpoint:
575
+ * property values (every scope, including a host's `@world`, which is written back through the
576
+ * game), visit counts, shuffle and sequence positions, `@scene` bags made since, every flow's cursor
577
+ * and PRNG, and any flow opened since (closed and forgotten). `commit` keeps everything and stops
578
+ * recording.
579
+ *
580
+ * The use is asking "would this say anything?" without consequences: move a flow to an address,
581
+ * advance it, and roll back if it had nothing to give, so its scene's `onEntry` effects, its visits,
582
+ * and its shuffle draws never happened.
583
+ *
584
+ * One checkpoint at a time. Inside one, the calls that replace or drop flows or their bags wholesale
585
+ * (`reset`, `loadGame`, `hotSwap`, `closeFlow`, `openFlow` on the name of an open flow, and a flow's own
586
+ * `start`, `reset`, and `restore`) throw. Not undone:
587
+ * trace and log events already emitted, and draws from a custom `rng` the game supplied.
588
+ */
589
+ checkpoint(): Checkpoint;
590
+ /** Undo everything since `checkpoint` (see there), and close it. */
591
+ rollback(checkpoint: Checkpoint): void;
592
+ /** Keep everything since `checkpoint`, and close it. */
593
+ commit(checkpoint: Checkpoint): void;
594
+ /** True while a checkpoint is open. */
595
+ get inCheckpoint(): boolean;
596
+ private endCheckpoint;
597
+ /** Let go of what a closed checkpoint recorded, so a handle the game keeps holds nothing. */
598
+ private static forget;
599
+ private refuseInCheckpoint;
551
600
  /** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
552
601
  * holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
553
602
  closeFlow(id: string): void;
@@ -686,6 +735,10 @@ declare class Flow {
686
735
  * cannot quietly mutate the world. Terminal: unlike ending, a close is never revived.
687
736
  */
688
737
  close(): void;
738
+ /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
739
+ * put them back: a handful of fields and a copy of the call stack (a few frames), never its history.
740
+ * Its visits, selector cursors, and property values are recorded change by change as they happen. */
741
+ private touch;
689
742
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
690
743
  get isClosed(): boolean;
691
744
  /** The scene the cursor is currently in - set on entry and whenever a jump crosses scenes. Read
@@ -739,8 +792,11 @@ declare class Flow {
739
792
  private writeProperty;
740
793
  /** @internal Snapshot this flow's cursor + PRNG + visits. Its properties are the registry's. */
741
794
  snapshot(): FlowSnapshot;
742
- /** @internal Restore this flow from a snapshot. */
795
+ /** @internal Restore this flow from a snapshot: its cursor, PRNG, visits, and selector cursors. Its
796
+ * property values are the registry's and stay as they are (a load puts the saved ones there first). */
743
797
  restore(snap: FlowSnapshot): void;
798
+ /** The cursor half of `restore`, without touching the bags (a rollback has already put those back). */
799
+ private restoreCursor;
744
800
  /** Set the current scene, reset its scene-local props, run onEntry. */
745
801
  private enterSceneSetup;
746
802
  /**
@@ -1027,4 +1083,4 @@ declare function effectiveGameData(fields: GameDataField[], node: GameData | und
1027
1083
  */
1028
1084
  declare function buildTagIndex(bundle: Bundle): Map<string, string[]>;
1029
1085
 
1030
- export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
1086
+ export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, Checkpoint, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ScalarValue } from '@wildwinter/expr';
2
- import { ScopeResolver, ScopeRegistry, PropertyBag, ScopeDeclaration, LogMount, PropertyRow } from '@wildwinter/scoperegistry';
2
+ import { ScopeRegistry, PropertyBag, ScopeDeclaration, LogMount, ScopeResolver, PropertyRow } from '@wildwinter/scoperegistry';
3
3
  export { PropertyRow } from '@wildwinter/scoperegistry';
4
4
  import { GameData, Bundle, CompiledGroup, CompiledSnippet, CompiledBlock, FlowSnapshot, EngineSave, SaveGame, SaveGameV2, PropertyType, ScalarValue as ScalarValue$1, GameDataNodeKind, GameDataField } from '@patterkit/model';
5
5
  export { Bundle, EngineSave, FlowCursor, FlowSnapshot, SaveEnvelope, SaveGame, SavedChoice, SavedChoiceOption, SavedChoicePrompt, SelectorSnapshot, StackFrame } from '@patterkit/model';
@@ -277,6 +277,25 @@ interface SelectorState {
277
277
  bag?: string[];
278
278
  last?: string;
279
279
  }
280
+ /** What an open checkpoint records: how to undo each change, newest last. Each piece of state records
281
+ * itself when it changes (a value, a count, a cursor), never a copy of everything, so a checkpoint costs
282
+ * what the steps inside it do, however much history the game has built up. */
283
+ interface Journal {
284
+ undo: Array<() => void>;
285
+ /** Flows already snapshotted (or opened inside the checkpoint, so closed on rollback). */
286
+ flows: Set<Flow>;
287
+ /** Flows opened inside the checkpoint. */
288
+ opened: Set<Flow>;
289
+ /** Selector cursors already copied: shared ones by group id, a flow's own in `flowSelectors`. */
290
+ selectors: Set<string>;
291
+ flowSelectors: Map<Flow, Set<string>>;
292
+ }
293
+ /** An open checkpoint, from {@link Engine.checkpoint}. Opaque: hand it back to `rollback` or `commit`. */
294
+ declare class Checkpoint {
295
+ readonly journal: Journal;
296
+ /** @internal */
297
+ constructor(journal: Journal);
298
+ }
280
299
  /** Shared, read-mostly context the engine hands to every flow it owns. */
281
300
  interface FlowHost {
282
301
  /** True when the run asked for a log; flows skip building entries otherwise. */
@@ -328,6 +347,8 @@ interface FlowHost {
328
347
  /** Per-scene SHARED scene props, each registered under `keys.stage(sceneId)`. Made the first time any
329
348
  * flow needs the scene, so a bag loaded before then waits in the registry and is claimed here. */
330
349
  stageBags: Map<string, PropertyBag>;
350
+ /** The open checkpoint's undo journal, or null when none is open (see {@link Engine.checkpoint}). */
351
+ journal: Journal | null;
331
352
  customRng?: () => number;
332
353
  /** Play a chosen option's prompt as its first beat (spec §5); default false. */
333
354
  replayPromptOnChoose?: boolean;
@@ -548,6 +569,34 @@ declare class Engine {
548
569
  * loadGame() replaces every bag, so re-enumerate after a load.
549
570
  */
550
571
  listBags(): LogMount[];
572
+ /**
573
+ * Open a checkpoint: from here until `rollback` or `commit`, the engine keeps what it needs to undo
574
+ * every change the story makes. `rollback` puts the game back exactly as it was at the checkpoint:
575
+ * property values (every scope, including a host's `@world`, which is written back through the
576
+ * game), visit counts, shuffle and sequence positions, `@scene` bags made since, every flow's cursor
577
+ * and PRNG, and any flow opened since (closed and forgotten). `commit` keeps everything and stops
578
+ * recording.
579
+ *
580
+ * The use is asking "would this say anything?" without consequences: move a flow to an address,
581
+ * advance it, and roll back if it had nothing to give, so its scene's `onEntry` effects, its visits,
582
+ * and its shuffle draws never happened.
583
+ *
584
+ * One checkpoint at a time. Inside one, the calls that replace or drop flows or their bags wholesale
585
+ * (`reset`, `loadGame`, `hotSwap`, `closeFlow`, `openFlow` on the name of an open flow, and a flow's own
586
+ * `start`, `reset`, and `restore`) throw. Not undone:
587
+ * trace and log events already emitted, and draws from a custom `rng` the game supplied.
588
+ */
589
+ checkpoint(): Checkpoint;
590
+ /** Undo everything since `checkpoint` (see there), and close it. */
591
+ rollback(checkpoint: Checkpoint): void;
592
+ /** Keep everything since `checkpoint`, and close it. */
593
+ commit(checkpoint: Checkpoint): void;
594
+ /** True while a checkpoint is open. */
595
+ get inCheckpoint(): boolean;
596
+ private endCheckpoint;
597
+ /** Let go of what a closed checkpoint recorded, so a handle the game keeps holds nothing. */
598
+ private static forget;
599
+ private refuseInCheckpoint;
551
600
  /** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
552
601
  * holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
553
602
  closeFlow(id: string): void;
@@ -686,6 +735,10 @@ declare class Flow {
686
735
  * cannot quietly mutate the world. Terminal: unlike ending, a close is never revived.
687
736
  */
688
737
  close(): void;
738
+ /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
739
+ * put them back: a handful of fields and a copy of the call stack (a few frames), never its history.
740
+ * Its visits, selector cursors, and property values are recorded change by change as they happen. */
741
+ private touch;
689
742
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
690
743
  get isClosed(): boolean;
691
744
  /** The scene the cursor is currently in - set on entry and whenever a jump crosses scenes. Read
@@ -739,8 +792,11 @@ declare class Flow {
739
792
  private writeProperty;
740
793
  /** @internal Snapshot this flow's cursor + PRNG + visits. Its properties are the registry's. */
741
794
  snapshot(): FlowSnapshot;
742
- /** @internal Restore this flow from a snapshot. */
795
+ /** @internal Restore this flow from a snapshot: its cursor, PRNG, visits, and selector cursors. Its
796
+ * property values are the registry's and stay as they are (a load puts the saved ones there first). */
743
797
  restore(snap: FlowSnapshot): void;
798
+ /** The cursor half of `restore`, without touching the bags (a rollback has already put those back). */
799
+ private restoreCursor;
744
800
  /** Set the current scene, reset its scene-local props, run onEntry. */
745
801
  private enterSceneSetup;
746
802
  /**
@@ -1027,4 +1083,4 @@ declare function effectiveGameData(fields: GameDataField[], node: GameData | und
1027
1083
  */
1028
1084
  declare function buildTagIndex(bundle: Bundle): Map<string, string[]>;
1029
1085
 
1030
- export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
1086
+ export { type AddressSummary, type AdvanceToStopResult, type BeatInfo, type BundleCounts, type BundleDescription, type BundleIdentity, Checkpoint, type ChoiceOption, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, type FlatBeat, Flow, type GameDataFieldSummary, type GameDataSummary, type HostScopeSummary, type LogEntry, type OpenFlowOptions, type OutlineBlock, type OutlineNode, type OutlineScene, type OwnedProperties, type PropertySummary, type StepResult, type TraceEvent, type TraceHandler, type WorldResolver, buildTagIndex, describeBundle, effectiveGameData, gameDataFields, gameDataValue };
package/dist/index.js CHANGED
@@ -55,6 +55,13 @@ var keys = {
55
55
  /** A flow's NOT-shared `@scene` props for one scene. */
56
56
  flowScene: (flowId, sceneId) => `patter/flow/${esc(flowId)}/scene/${esc(sceneId)}`
57
57
  };
58
+ var Checkpoint = class {
59
+ /** @internal */
60
+ constructor(journal) {
61
+ this.journal = journal;
62
+ }
63
+ journal;
64
+ };
58
65
  var Engine = class _Engine {
59
66
  host;
60
67
  defaultSeed;
@@ -172,6 +179,7 @@ var Engine = class _Engine {
172
179
  sharedVisits: /* @__PURE__ */ new Map(),
173
180
  sharedSelectors: /* @__PURE__ */ new Map(),
174
181
  stageBags: /* @__PURE__ */ new Map(),
182
+ journal: null,
175
183
  customRng: options.rng,
176
184
  onDryChoice: options.onDryChoice,
177
185
  replayPromptOnChoose: options.replayPromptOnChoose ?? false,
@@ -235,6 +243,7 @@ var Engine = class _Engine {
235
243
  * top of the scene it was in; the shared properties carry over.
236
244
  */
237
245
  hotSwap(bundle) {
246
+ this.refuseInCheckpoint("hotSwap");
238
247
  const snapshot = this.saveGame();
239
248
  const carryOver = (next2) => {
240
249
  next2.setLocale(this.currentLocale);
@@ -295,8 +304,20 @@ var Engine = class _Engine {
295
304
  this.assertExternalScopes();
296
305
  const sceneId = this.resolveSceneRef(opts.scene);
297
306
  const blockId = this.resolveBlockRef(sceneId, opts.block);
298
- this.flowsById.get(id)?.close();
307
+ const prior = this.flowsById.get(id);
308
+ const journal = this.host.journal;
309
+ 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`);
310
+ prior?.close();
299
311
  const flow = new Flow(id, this.host, opts.seed ?? this.defaultSeed);
312
+ if (journal) {
313
+ journal.flows.add(flow);
314
+ journal.opened.add(flow);
315
+ journal.undo.push(() => {
316
+ flow.close();
317
+ if (prior) this.flowsById.set(id, prior);
318
+ else this.flowsById.delete(id);
319
+ });
320
+ }
300
321
  this.flowsById.set(id, flow);
301
322
  flow.start(sceneId, blockId);
302
323
  return flow;
@@ -552,9 +573,64 @@ var Engine = class _Engine {
552
573
  for (const [sceneId, bag] of this.host.stageBags) mounts.push({ bag, pathPrefix: `@scene:${sceneId}.` });
553
574
  return mounts;
554
575
  }
576
+ /**
577
+ * Open a checkpoint: from here until `rollback` or `commit`, the engine keeps what it needs to undo
578
+ * every change the story makes. `rollback` puts the game back exactly as it was at the checkpoint:
579
+ * property values (every scope, including a host's `@world`, which is written back through the
580
+ * game), visit counts, shuffle and sequence positions, `@scene` bags made since, every flow's cursor
581
+ * and PRNG, and any flow opened since (closed and forgotten). `commit` keeps everything and stops
582
+ * recording.
583
+ *
584
+ * The use is asking "would this say anything?" without consequences: move a flow to an address,
585
+ * advance it, and roll back if it had nothing to give, so its scene's `onEntry` effects, its visits,
586
+ * and its shuffle draws never happened.
587
+ *
588
+ * One checkpoint at a time. Inside one, the calls that replace or drop flows or their bags wholesale
589
+ * (`reset`, `loadGame`, `hotSwap`, `closeFlow`, `openFlow` on the name of an open flow, and a flow's own
590
+ * `start`, `reset`, and `restore`) throw. Not undone:
591
+ * trace and log events already emitted, and draws from a custom `rng` the game supplied.
592
+ */
593
+ checkpoint() {
594
+ if (this.host.journal) throw new Error("a checkpoint is already open: roll it back or commit it first");
595
+ const journal = { undo: [], flows: /* @__PURE__ */ new Set(), opened: /* @__PURE__ */ new Set(), selectors: /* @__PURE__ */ new Set(), flowSelectors: /* @__PURE__ */ new Map() };
596
+ this.host.journal = journal;
597
+ return new Checkpoint(journal);
598
+ }
599
+ /** Undo everything since `checkpoint` (see there), and close it. */
600
+ rollback(checkpoint) {
601
+ this.endCheckpoint(checkpoint);
602
+ const undo = checkpoint.journal.undo;
603
+ for (let i = undo.length - 1; i >= 0; i--) undo[i]();
604
+ _Engine.forget(checkpoint.journal);
605
+ }
606
+ /** Keep everything since `checkpoint`, and close it. */
607
+ commit(checkpoint) {
608
+ this.endCheckpoint(checkpoint);
609
+ _Engine.forget(checkpoint.journal);
610
+ }
611
+ /** True while a checkpoint is open. */
612
+ get inCheckpoint() {
613
+ return this.host.journal !== null;
614
+ }
615
+ endCheckpoint(checkpoint) {
616
+ if (this.host.journal !== checkpoint.journal) throw new Error("that checkpoint is not the open one");
617
+ this.host.journal = null;
618
+ }
619
+ /** Let go of what a closed checkpoint recorded, so a handle the game keeps holds nothing. */
620
+ static forget(journal) {
621
+ journal.undo = [];
622
+ journal.flows.clear();
623
+ journal.opened.clear();
624
+ journal.selectors.clear();
625
+ journal.flowSelectors.clear();
626
+ }
627
+ refuseInCheckpoint(what) {
628
+ if (this.host.journal) throw new Error(`${what} can't be undone, so it isn't allowed while a checkpoint is open`);
629
+ }
555
630
  /** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
556
631
  * holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
557
632
  closeFlow(id) {
633
+ this.refuseInCheckpoint("closeFlow");
558
634
  this.flowsById.get(id)?.close();
559
635
  this.flowsById.delete(id);
560
636
  }
@@ -565,6 +641,7 @@ var Engine = class _Engine {
565
641
  * After reset, open fresh flows with `openFlow`.
566
642
  */
567
643
  reset() {
644
+ this.refuseInCheckpoint("reset");
568
645
  for (const flow of this.flowsById.values()) flow.close();
569
646
  this.flowsById.clear();
570
647
  this.host.patterBag.reseed(this.host.patterSharedDecls);
@@ -602,7 +679,9 @@ var Engine = class _Engine {
602
679
  * were each refused the one property they existed to move.) */
603
680
  setProperty(ref, value) {
604
681
  const { scope, name } = this.splitShared(ref);
682
+ const prev = this.host.journal ? this.host.registry.get(scope, name) : void 0;
605
683
  this.host.registry.set(scope, name, value, { host: true });
684
+ if (this.host.journal && prev !== void 0) this.host.journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
606
685
  }
607
686
  /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
608
687
  * value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
@@ -689,6 +768,7 @@ var Engine = class _Engine {
689
768
  * to the registry (values kept) and the restored flows claim them as they register.
690
769
  */
691
770
  loadGame(save) {
771
+ this.refuseInCheckpoint("loadGame");
692
772
  const version = save.version;
693
773
  if (version !== 2 && version !== SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
694
774
  this.assertExternalScopes();
@@ -839,6 +919,7 @@ var Flow = class {
839
919
  // -- Host API -------------------------------------------------------------
840
920
  /** Begin this flow at a scene (and optionally a specific block within it). */
841
921
  start(sceneId, blockId) {
922
+ 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");
842
923
  this.releaseBags(false);
843
924
  this.host.registry.discardParked(keys.flow(this.id));
844
925
  this.mountLocal();
@@ -898,6 +979,7 @@ var Flow = class {
898
979
  */
899
980
  goto(scene, block) {
900
981
  if (this.closed) return false;
982
+ this.touch();
901
983
  if (scene === "END") {
902
984
  this.started = true;
903
985
  this.pendingChoice = null;
@@ -951,6 +1033,19 @@ var Flow = class {
951
1033
  this.pendingPromptBeat = null;
952
1034
  this.pendingPromptOwnerId = null;
953
1035
  }
1036
+ /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
1037
+ * put them back: a handful of fields and a copy of the call stack (a few frames), never its history.
1038
+ * Its visits, selector cursors, and property values are recorded change by change as they happen. */
1039
+ touch() {
1040
+ const journal = this.host.journal;
1041
+ if (!journal || journal.flows.has(this)) return;
1042
+ journal.flows.add(this);
1043
+ const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId } = this;
1044
+ const stack = this.stack.map((f) => ({ ...f }));
1045
+ journal.undo.push(() => {
1046
+ Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId, stack });
1047
+ });
1048
+ }
954
1049
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
955
1050
  get isClosed() {
956
1051
  return this.closed;
@@ -965,6 +1060,7 @@ var Flow = class {
965
1060
  advance() {
966
1061
  if (this.closed) return { type: "end" };
967
1062
  if (!this.started) throw new Error("flow has not been started");
1063
+ this.touch();
968
1064
  if (this.pendingPromptBeat) {
969
1065
  const b = this.pendingPromptBeat;
970
1066
  this.pendingPromptBeat = null;
@@ -1084,6 +1180,7 @@ var Flow = class {
1084
1180
  const option = choice.options.find((o) => o.id === id);
1085
1181
  if (!option) throw new Error(`unknown choice option: ${id}`);
1086
1182
  if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
1183
+ this.touch();
1087
1184
  const node = choice.byId.get(id);
1088
1185
  this.emit({ type: "chose", group: choice.groupId, option: id });
1089
1186
  this.pendingChoice = null;
@@ -1111,13 +1208,27 @@ var Flow = class {
1111
1208
  * about: the story is refused, the game is not. */
1112
1209
  writeProperty(ref, value, host) {
1113
1210
  const { scope, name } = this.splitRef(ref);
1211
+ const journal = this.host.journal;
1114
1212
  if (scope === "patter") {
1213
+ if (journal) {
1214
+ const shared = this.host.patterSharedNames.has(name);
1215
+ const prev = shared ? this.host.patterBag.get(name) : this.local.get(name);
1216
+ const bag = this.local;
1217
+ if (prev !== void 0) journal.undo.push(shared ? () => this.host.registry.set("patter", name, prev) : () => bag.set(name, prev));
1218
+ }
1115
1219
  this.patterResolver.set(name, value);
1116
1220
  } else if (scope === "scene") {
1117
1221
  if (this.currentSceneId === null) throw new Error(`'${ref}': the flow has not entered a scene yet`);
1222
+ if (journal) {
1223
+ const bag = this.sceneBagFor(this.currentSceneId, name);
1224
+ const prev = bag?.get(name);
1225
+ if (bag && prev !== void 0) journal.undo.push(() => bag.set(name, prev));
1226
+ }
1118
1227
  this.sceneResolver.set(name, value);
1119
1228
  } else {
1229
+ const prev = journal ? this.host.registry.get(scope, name) : void 0;
1120
1230
  this.host.registry.set(scope, name, value, host ? { host: true } : void 0);
1231
+ if (journal && prev !== void 0) journal.undo.push(() => this.host.registry.set(scope, name, prev, { host: true }));
1121
1232
  }
1122
1233
  }
1123
1234
  // -- Save / restore (engine-driven) --------------------------------------
@@ -1144,8 +1255,19 @@ var Flow = class {
1144
1255
  }
1145
1256
  };
1146
1257
  }
1147
- /** @internal Restore this flow from a snapshot. */
1258
+ /** @internal Restore this flow from a snapshot: its cursor, PRNG, visits, and selector cursors. Its
1259
+ * property values are the registry's and stay as they are (a load puts the saved ones there first). */
1148
1260
  restore(snap) {
1261
+ if (this.host.journal) throw new Error("a flow can't be restored while a checkpoint is open");
1262
+ this.restoreCursor(snap);
1263
+ this.releaseBags(true);
1264
+ this.mountLocal();
1265
+ for (const s of /* @__PURE__ */ new Set([this.currentSceneId, ...this.stack.map((f) => f.sceneId)])) {
1266
+ if (s !== null && this.host.bundle.scenes[s]) this.ensureSceneBags(s);
1267
+ }
1268
+ }
1269
+ /** The cursor half of `restore`, without touching the bags (a rollback has already put those back). */
1270
+ restoreCursor(snap) {
1149
1271
  this.rngState = toUint32(snap.rngState);
1150
1272
  this.visitCounts = new Map(Object.entries(snap.visits ?? {}));
1151
1273
  const c = snap.cursor;
@@ -1161,11 +1283,6 @@ var Flow = class {
1161
1283
  }
1162
1284
  return { ...frame };
1163
1285
  });
1164
- this.releaseBags(false);
1165
- this.mountLocal();
1166
- for (const s of /* @__PURE__ */ new Set([c.currentSceneId, ...this.stack.map((f) => f.sceneId)])) {
1167
- if (s !== null && this.host.bundle.scenes[s]) this.ensureSceneBags(s);
1168
- }
1169
1286
  this.activeSnippet = null;
1170
1287
  if (c.activeSnippetId !== null) {
1171
1288
  const node = this.host.nodeIndex.get(c.activeSnippetId);
@@ -1442,6 +1559,24 @@ var Flow = class {
1442
1559
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
1443
1560
  selectorState(group) {
1444
1561
  const map = group.shared ? this.host.sharedSelectors : this.selectors;
1562
+ const journal = this.host.journal;
1563
+ let seen;
1564
+ if (journal) {
1565
+ seen = group.shared ? journal.selectors : journal.flowSelectors.get(this);
1566
+ if (!seen) {
1567
+ seen = /* @__PURE__ */ new Set();
1568
+ journal.flowSelectors.set(this, seen);
1569
+ }
1570
+ }
1571
+ if (journal && seen && !seen.has(group.id)) {
1572
+ seen.add(group.id);
1573
+ const was = map.get(group.id);
1574
+ const copy = was ? { ...was, ...was.bag ? { bag: [...was.bag] } : {} } : void 0;
1575
+ journal.undo.push(() => {
1576
+ if (copy) map.set(group.id, copy);
1577
+ else map.delete(group.id);
1578
+ });
1579
+ }
1445
1580
  let st = map.get(group.id);
1446
1581
  if (!st) {
1447
1582
  st = {};
@@ -1477,8 +1612,18 @@ var Flow = class {
1477
1612
  }
1478
1613
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
1479
1614
  enter(id) {
1480
- this.visitCounts.set(id, (this.visitCounts.get(id) ?? 0) + 1);
1481
- this.host.sharedVisits.set(id, (this.host.sharedVisits.get(id) ?? 0) + 1);
1615
+ const own = this.visitCounts;
1616
+ const ownBefore = own.get(id);
1617
+ own.set(id, (ownBefore ?? 0) + 1);
1618
+ const shared = this.host.sharedVisits;
1619
+ const before = shared.get(id);
1620
+ shared.set(id, (before ?? 0) + 1);
1621
+ if (this.host.journal) this.host.journal.undo.push(() => {
1622
+ if (ownBefore === void 0) own.delete(id);
1623
+ else own.set(id, ownBefore);
1624
+ if (before === void 0) shared.delete(id);
1625
+ else shared.set(id, before);
1626
+ });
1482
1627
  }
1483
1628
  /** Next float in [0, 1): the shared custom PRNG, or this flow's serialisable mulberry32.
1484
1629
  *
@@ -1621,17 +1766,29 @@ var Flow = class {
1621
1766
  ensureSceneBags(s) {
1622
1767
  const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
1623
1768
  const props = this.host.bundle.scenes[s]?.sceneProps ?? [];
1769
+ const journal = this.host.journal;
1624
1770
  if (!this.sceneBags.has(s)) {
1625
1771
  const bag = new PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1626
1772
  const key = keys.flowScene(this.id, s);
1627
1773
  this.host.registry.mountOwned(key, bag, { owner: OWNER });
1628
1774
  this.registered.add(key);
1629
1775
  this.sceneBags.set(s, bag);
1776
+ if (journal) journal.undo.push(() => {
1777
+ if (this.sceneBags.get(s) !== bag) return;
1778
+ this.host.registry.remove(key);
1779
+ this.registered.delete(key);
1780
+ this.sceneBags.delete(s);
1781
+ });
1630
1782
  }
1631
1783
  if (!this.host.stageBags.has(s)) {
1632
1784
  const bag = new PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1633
1785
  this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1634
1786
  this.host.stageBags.set(s, bag);
1787
+ if (journal) journal.undo.push(() => {
1788
+ if (this.host.stageBags.get(s) !== bag) return;
1789
+ this.host.registry.remove(keys.stage(s));
1790
+ this.host.stageBags.delete(s);
1791
+ });
1635
1792
  }
1636
1793
  }
1637
1794
  /**
@@ -1648,7 +1805,10 @@ var Flow = class {
1648
1805
  if (!decl.temporary) continue;
1649
1806
  const name = decl.name.toLowerCase();
1650
1807
  const bag = shared.has(name) ? this.host.stageBags.get(scene.id) : this.sceneBags.get(scene.id);
1651
- if (bag) bag.set(name, defaultFor(decl));
1808
+ if (!bag) continue;
1809
+ const prev = this.host.journal ? bag.get(name) : void 0;
1810
+ if (prev !== void 0) this.host.journal.undo.push(() => bag.set(name, prev));
1811
+ bag.set(name, defaultFor(decl));
1652
1812
  }
1653
1813
  }
1654
1814
  };
@@ -1847,6 +2007,7 @@ function effectiveGameData(fields, node) {
1847
2007
  return out;
1848
2008
  }
1849
2009
  export {
2010
+ Checkpoint,
1850
2011
  Engine,
1851
2012
  Flow,
1852
2013
  buildTagIndex,