@patterkit/runtime 0.13.0 → 0.14.1

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
@@ -40,7 +40,21 @@ function buildTagIndex(bundle) {
40
40
  }
41
41
 
42
42
  // src/engine.ts
43
+ import { SAVE_VERSION } from "@patterkit/model";
43
44
  var astCache = /* @__PURE__ */ new WeakMap();
45
+ var OWNER = "Patter";
46
+ var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
47
+ var OWNS_REGISTRY = /* @__PURE__ */ Symbol("ownsRegistry");
48
+ var keys = {
49
+ /** A scene's SHARED `@scene` props (one bag per scene, every flow's). */
50
+ stage: (sceneId) => `patter/scene/${esc(sceneId)}`,
51
+ /** Everything one flow registers starts with this. */
52
+ flow: (flowId) => `patter/flow/${esc(flowId)}/`,
53
+ /** A flow's NOT-shared `@patter` globals. */
54
+ flowGlobals: (flowId) => `patter/flow/${esc(flowId)}/patter`,
55
+ /** A flow's NOT-shared `@scene` props for one scene. */
56
+ flowScene: (flowId, sceneId) => `patter/flow/${esc(flowId)}/scene/${esc(sceneId)}`
57
+ };
44
58
  var Engine = class _Engine {
45
59
  host;
46
60
  defaultSeed;
@@ -100,18 +114,32 @@ var Engine = class _Engine {
100
114
  const patterSharedDecls = props.filter((p) => p.shared ?? true).map(toDecl);
101
115
  const patterLocalDecls = props.filter((p) => !(p.shared ?? true)).map(toDecl);
102
116
  const patterSharedNames = new Set(patterSharedDecls.map((d) => d.name.toLowerCase()));
103
- const shared = new ScopeRegistry().defineOwned("patter", patterSharedDecls, "@patter.");
104
- const hostBound = /* @__PURE__ */ new Set();
105
- if (options.world) {
106
- const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
107
- const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
108
- shared.defineForeign("world", options.world, decls, worldSpec?.writable ?? true);
109
- hostBound.add("world");
110
- }
111
- for (const spec of bundle.scopeRegistry?.scopes ?? []) {
112
- if (hostBound.has(spec.token)) continue;
113
- const decls = (spec.declarations ?? []).map(toForeignDecl);
114
- shared.defineForeign(spec.token, selfBackedResolver(spec.declarations ?? []), decls, spec.writable ?? true);
117
+ const registry = options.registry ?? new ScopeRegistry();
118
+ const ownsRegistry = !options.registry || options[OWNS_REGISTRY] === true;
119
+ const patterBag = new PropertyBag(patterSharedDecls, { pathPrefix: "@patter." });
120
+ const hostScopes = [];
121
+ const registered = [];
122
+ try {
123
+ registry.mountOwned("patter", patterBag, { owner: OWNER });
124
+ registered.push("patter");
125
+ const hostBound = /* @__PURE__ */ new Set();
126
+ if (options.world) {
127
+ const worldSpec = bundle.scopeRegistry?.scopes.find((s) => s.token === "world");
128
+ const decls = (worldSpec?.declarations ?? []).map(toForeignDecl);
129
+ registry.defineForeign("world", options.world, decls, { writable: worldSpec?.writable ?? true, owner: OWNER });
130
+ registered.push("world");
131
+ hostBound.add("world");
132
+ }
133
+ for (const spec of ownsRegistry ? bundle.scopeRegistry?.scopes ?? [] : []) {
134
+ if (hostBound.has(spec.token) || registry.has(spec.token)) continue;
135
+ const decls = (spec.declarations ?? []).map((d) => selfBackedDecl(d, spec.writable));
136
+ registry.defineOwned(spec.token, decls, { owner: OWNER });
137
+ registered.push(spec.token);
138
+ hostScopes.push(spec.token);
139
+ }
140
+ } catch (e) {
141
+ for (const k of registered) registry.remove(k, { keep: true });
142
+ throw e;
115
143
  }
116
144
  const sceneSharedNames = /* @__PURE__ */ new Map();
117
145
  for (const [sceneId, scene] of Object.entries(bundle.scenes)) {
@@ -133,7 +161,10 @@ var Engine = class _Engine {
133
161
  blockGameIdToId: this.blockGameIdToId,
134
162
  // same instances the engine resolves with
135
163
  tagIndex: buildTagIndex(bundle),
136
- shared,
164
+ registry,
165
+ ownsRegistry,
166
+ patterBag,
167
+ hostScopes,
137
168
  patterSharedDecls,
138
169
  patterLocalDecls,
139
170
  patterSharedNames,
@@ -150,7 +181,8 @@ var Engine = class _Engine {
150
181
  captionClose: (bundle.closedCaptions ?? DEFAULT_CAPTION_DELIMITERS).close,
151
182
  captionCharacter: bundle.closedCaptions?.character || DEFAULT_CAPTION_CHARACTER,
152
183
  // absent/empty -> SFX
153
- refSplitCache: /* @__PURE__ */ new Map()
184
+ refSplitCache: /* @__PURE__ */ new Map(),
185
+ refSplitRevision: registry.revision
154
186
  };
155
187
  }
156
188
  /** The active locale (string + character-name lookups resolve in it). */
@@ -195,10 +227,12 @@ var Engine = class _Engine {
195
227
  * content-drift policy (§9.8) resolves edits under the cursor: stack frames re-find their next
196
228
  * child by id, drifted options drop, a vanished snippet is skipped.
197
229
  *
198
- * Returns the REPLACEMENT engine; this one is left untouched and should be discarded. Hosts
199
- * re-bind their flow handles via `next.getFlow(id)`. If the restore throws (defensive - §9.8
200
- * makes this unreachable for ordinary edits), the swap falls back to a cold engine with each
201
- * saved flow restarted from the top of the scene it was in.
230
+ * Returns the REPLACEMENT engine, on the same registry. This one hands its bags over (each is
231
+ * removed from the registry with its values kept, and the replacement claims them as it
232
+ * registers), its flows are closed, and it should be discarded. Hosts re-bind their flow handles
233
+ * via `next.getFlow(id)`. If the restore throws (defensive - §9.8 makes this unreachable for
234
+ * ordinary edits), the swap falls back to a fresh engine with each saved flow restarted from the
235
+ * top of the scene it was in; the shared properties carry over.
202
236
  */
203
237
  hotSwap(bundle) {
204
238
  const snapshot = this.saveGame();
@@ -207,12 +241,19 @@ var Engine = class _Engine {
207
241
  next2.setClosedCaptions(this.host.captionsOn);
208
242
  return next2;
209
243
  };
210
- const next = new _Engine(bundle, this.creationOptions);
244
+ const make = () => new _Engine(bundle, {
245
+ ...this.creationOptions,
246
+ registry: this.host.registry,
247
+ [OWNS_REGISTRY]: this.host.ownsRegistry
248
+ });
249
+ this.release(true);
250
+ const next = make();
211
251
  try {
212
252
  next.loadGame(snapshot);
213
253
  return carryOver(next);
214
254
  } catch {
215
- const fresh = new _Engine(bundle, this.creationOptions);
255
+ next.release(true);
256
+ const fresh = make();
216
257
  for (const [id, f] of Object.entries(snapshot.flows)) {
217
258
  const sceneId = f.cursor.currentSceneId;
218
259
  try {
@@ -251,6 +292,7 @@ var Engine = class _Engine {
251
292
  * call to reach for when you want a speaker's variation state to carry on.
252
293
  */
253
294
  openFlow(id, opts = {}) {
295
+ this.assertExternalScopes();
254
296
  const sceneId = this.resolveSceneRef(opts.scene);
255
297
  const blockId = this.resolveBlockRef(sceneId, opts.block);
256
298
  this.flowsById.get(id)?.close();
@@ -290,6 +332,17 @@ var Engine = class _Engine {
290
332
  }
291
333
  return existing.advanceToStop().played;
292
334
  }
335
+ /** Content that names another engine's scope (`@story.act`) runs only where that engine is on this
336
+ * registry: without it every read would answer false and every write fail, so the flow is refused as
337
+ * it opens (or the save as it loads), before anything changes. By then a game has built all of its
338
+ * engines, whatever order it built them in. The same message on every runtime. */
339
+ assertExternalScopes() {
340
+ for (const token of this.host.bundle.externalScopes ?? []) {
341
+ if (!this.host.registry.has(token)) {
342
+ throw new Error(`this content names @${token}, which no engine on this registry registered: give every engine the game's one registry`);
343
+ }
344
+ }
345
+ }
293
346
  /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
294
347
  resolveSceneRef(ref) {
295
348
  if (ref == null) return void 0;
@@ -475,7 +528,7 @@ var Engine = class _Engine {
475
528
  * loadGame() replaces every bag, so re-enumerate after a load.
476
529
  */
477
530
  listBags() {
478
- const mounts = [{ bag: this.host.shared.ownedBag("patter") }];
531
+ const mounts = [{ bag: this.host.patterBag }];
479
532
  for (const [sceneId, bag] of this.host.stageBags) mounts.push({ bag, pathPrefix: `@scene:${sceneId}.` });
480
533
  return mounts;
481
534
  }
@@ -494,15 +547,31 @@ var Engine = class _Engine {
494
547
  reset() {
495
548
  for (const flow of this.flowsById.values()) flow.close();
496
549
  this.flowsById.clear();
497
- this.host.shared.reseedOwned("patter", this.host.patterSharedDecls);
550
+ this.host.patterBag.reseed(this.host.patterSharedDecls);
498
551
  this.host.sharedVisits.clear();
499
552
  this.host.sharedSelectors.clear();
553
+ for (const s of this.host.stageBags.keys()) this.host.registry.remove(keys.stage(s));
554
+ this.host.stageBags.clear();
555
+ this.host.registry.discardParked("patter/");
556
+ }
557
+ /** Remove every bag this engine registered, keeping the values parked when `keep` (a live reload
558
+ * handing its state to a replacement), and close its flows. The engine is inert afterwards. */
559
+ release(keep) {
560
+ for (const flow of this.flowsById.values()) {
561
+ flow.releaseBags(keep);
562
+ flow.close();
563
+ }
564
+ this.flowsById.clear();
565
+ const reg = this.host.registry;
566
+ for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep });
500
567
  this.host.stageBags.clear();
568
+ for (const t of ["patter", ...this.host.hostScopes]) if (reg.has(t)) reg.remove(t, { keep });
569
+ if (this.creationOptions.world && reg.has("world")) reg.remove("world");
501
570
  }
502
571
  /** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
503
572
  getProperty(ref) {
504
573
  const { scope, name } = this.splitShared(ref);
505
- return this.host.shared.get(scope, name);
574
+ return this.host.registry.get(scope, name);
506
575
  }
507
576
  /** Write a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level).
508
577
  *
@@ -513,7 +582,7 @@ var Engine = class _Engine {
513
582
  * were each refused the one property they existed to move.) */
514
583
  setProperty(ref, value) {
515
584
  const { scope, name } = this.splitShared(ref);
516
- this.host.shared.set(scope, name, value, { host: true });
585
+ this.host.registry.set(scope, name, value, { host: true });
517
586
  }
518
587
  /** The shared `@patter` properties, for a live state inspector: each with its ref, type, current
519
588
  * value, declared default (for reset), and enum options. Mirrors the Unity / Godot ports. */
@@ -560,54 +629,66 @@ var Engine = class _Engine {
560
629
  // @scene is scene-namespaced and needs a flow's current scene - silently
561
630
  // routing it into the shared bag (as a junk "scene.x" key) was a trap.
562
631
  splitShared(ref) {
563
- let split = this.host.refSplitCache.get(ref);
564
- if (!split) {
565
- split = splitRef(ref, (t) => t === "scene" || this.host.shared.has(t));
566
- this.host.refSplitCache.set(ref, split);
567
- }
632
+ const split = splitHostRef(this.host, ref);
568
633
  if (split.scope === "scene") {
569
634
  throw new Error(`'${ref}': @scene properties are scene-scoped - read/write them on a Flow, not the Engine`);
570
635
  }
571
636
  return split;
572
637
  }
573
- /** Snapshot shared `@patter` state only (for a unified cross-engine save blob, Phase D). */
638
+ /** The shared `@patter` values alone, as `{ patter: {...} }`.
639
+ * @deprecated The registry holds every property now: a game saves `registry.save()` once, and a
640
+ * standalone engine's `saveGame()` carries its registry's values. Removed at the next breaking release. */
574
641
  save() {
575
- return this.host.shared.save();
642
+ return { patter: this.host.patterBag.save() };
576
643
  }
577
- /** Restore shared `@patter` values (world properties untouched). */
644
+ /** Lay a `{ patter: {...} }` section over the shared `@patter` values; anything else is ignored.
645
+ * @deprecated See `save`. Removed at the next breaking release. */
578
646
  load(blob) {
579
- this.host.shared.load(blob);
647
+ if (blob.patter) this.host.patterBag.load(blob.patter);
580
648
  }
581
- /** Snapshot the whole game: shared `@patter` + visit counts + every live flow. */
649
+ /** Snapshot the whole game's NON-property state: visit counts, shared selector cursors, and every
650
+ * live flow's cursor and PRNG. The property values are the registry's: a standalone engine (one that
651
+ * made its own registry) carries them here under `registry`; a game that passed a registry saves it
652
+ * once itself, beside each engine's `saveGame()`. */
582
653
  saveGame() {
583
654
  const flows = {};
584
655
  for (const [id, flow] of this.flowsById) flows[id] = flow.snapshot();
585
656
  return {
586
- version: 2,
587
- shared: this.host.shared.save(),
657
+ version: SAVE_VERSION,
658
+ ...this.host.ownsRegistry ? { registry: this.host.registry.save() } : {},
588
659
  sharedVisits: Object.fromEntries(this.host.sharedVisits),
589
660
  sharedSelectors: serialiseSelectors(this.host.sharedSelectors),
590
- stageBags: Object.fromEntries([...this.host.stageBags].map(([s, bag]) => [s, bag.save()])),
591
661
  flows
592
662
  };
593
663
  }
594
- /** Restore a `saveGame()`: shared globals + visit counts + shared scene bags + reconstruct every flow. */
664
+ /**
665
+ * Restore a `saveGame()`: visit counts, shared selector cursors, and every flow. Property values come
666
+ * from the registry. A save that carries them (a standalone engine's, or a version 2 save from before
667
+ * the registry held them) has them moved into the registry here; otherwise the game loads its
668
+ * registry itself, before or after this call. Either order works: this engine's bags are handed back
669
+ * to the registry (values kept) and the restored flows claim them as they register.
670
+ */
595
671
  loadGame(save) {
596
- if (save.version !== 2) throw new Error(`unsupported save version: ${save.version}`);
597
- this.host.shared.load(save.shared);
672
+ const version = save.version;
673
+ if (version !== 2 && version !== SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
674
+ this.assertExternalScopes();
675
+ const reg = this.host.registry;
676
+ for (const [id, flow] of this.flowsById) {
677
+ flow.releaseBags(id in save.flows);
678
+ flow.close();
679
+ }
680
+ this.flowsById.clear();
681
+ for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep: true });
682
+ this.host.stageBags.clear();
683
+ const values = save.version === 2 ? sectionsFromV2(save) : save.registry;
684
+ if (values) {
685
+ if (this.host.ownsRegistry) reg.load(values);
686
+ else reg.load(values, { keepParked: true });
687
+ }
598
688
  this.host.sharedVisits.clear();
599
689
  for (const [id, n] of Object.entries(save.sharedVisits ?? {})) this.host.sharedVisits.set(id, n);
600
690
  this.host.sharedSelectors.clear();
601
691
  for (const [id, st] of deserialiseSelectors(save.sharedSelectors)) this.host.sharedSelectors.set(id, st);
602
- this.host.stageBags.clear();
603
- for (const [s, values] of Object.entries(save.stageBags ?? {})) {
604
- const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
605
- const decls = (this.host.bundle.scenes[s]?.sceneProps ?? []).filter((d) => shared.has(d.name.toLowerCase()));
606
- const bag = new PropertyBag(decls);
607
- bag.load(values);
608
- this.host.stageBags.set(s, bag);
609
- }
610
- this.flowsById.clear();
611
692
  for (const [id, snap] of Object.entries(save.flows)) {
612
693
  const flow = new Flow(id, this.host, this.defaultSeed);
613
694
  flow.restore(snap);
@@ -619,7 +700,9 @@ var Flow = class {
619
700
  id;
620
701
  host;
621
702
  local;
622
- // owns "patter" = the NOT-shared globals (this flow's copy)
703
+ // the NOT-shared `@patter` globals (this flow's copy), keys.flowGlobals
704
+ /** The registry keys this flow has registered (its globals and each scene bag). */
705
+ registered = /* @__PURE__ */ new Set();
623
706
  rngState;
624
707
  // Execution cursor. The `stack` is the continuation stack: each frame is a
625
708
  // position within a block's children (the top frame is the active block run;
@@ -644,40 +727,44 @@ var Flow = class {
644
727
  /** Per-node entry counts for this flow (node id -> times entered). */
645
728
  visitCounts = /* @__PURE__ */ new Map();
646
729
  // Per-flow halves of the two scopes. The NOT-shared `@patter` globals live in
647
- // `local` (owned scope "patter"); the NOT-shared `@scene` props live in
648
- // `sceneBags` (namespaced per scene; they PERSIST across re-entries, spec §7).
649
- // The SHARED halves live on the host (`host.shared` / `host.stageBags`). Each
650
- // resolver presents one merged scope, routing each property to its half by the
651
- // declared `shared` flag.
730
+ // `local`; the NOT-shared `@scene` props live in `sceneBags` (namespaced per
731
+ // scene; they PERSIST across re-entries, spec §7). The SHARED halves live on the
732
+ // host (`host.patterBag` / `host.stageBags`). All of them are registered in the
733
+ // game's registry. Each resolver presents one merged scope, routing each property
734
+ // to its half by the declared `shared` flag: that split is why `@patter` and
735
+ // `@scene` are composed here rather than aliased to a single registry key.
652
736
  /** This flow's per-scene LOCAL scene props; see FlowHost.stageBags. */
653
737
  sceneBags = /* @__PURE__ */ new Map();
654
738
  patterResolver = {
655
- get: (n) => this.host.patterSharedNames.has(n) ? this.host.shared.get("patter", n) : this.local.get("patter", n),
739
+ get: (n) => this.host.patterSharedNames.has(n) ? this.host.patterBag.get(n) : this.local.get(n),
656
740
  set: (n, v) => {
657
- if (this.host.patterSharedNames.has(n)) this.host.shared.set("patter", n, v);
658
- else this.local.set("patter", n, v);
741
+ if (this.host.patterSharedNames.has(n)) this.host.registry.set("patter", n, v);
742
+ else this.local.set(n, v);
659
743
  }
660
744
  };
661
745
  sceneResolver = {
662
746
  get: (n) => {
663
747
  const s = this.currentSceneId;
664
748
  if (s === null) return void 0;
665
- const bag = this.host.sceneSharedNames.get(s)?.has(n) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
666
- return bag?.get(n);
749
+ return this.sceneBagFor(s, n)?.get(n);
667
750
  },
668
751
  set: (n, v) => {
669
752
  const s = this.currentSceneId;
670
753
  if (s === null) return;
671
- const bag = this.host.sceneSharedNames.get(s)?.has(n) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
754
+ const bag = this.sceneBagFor(s, n);
672
755
  if (bag) bag.set(n, v);
673
756
  }
674
757
  };
675
- // The eval context is built ONCE: every constituent resolves live state at
676
- // call time (shared bags mutate in place per scoperegistry's contract;
677
- // patter/scene route through this flow's resolvers, which read the current
678
- // `local`/`sceneBags`/`currentSceneId`; the host callbacks read current flow
679
- // fields). Rebuilding it per evaluation was the engine's hottest allocation.
758
+ // The eval context is built once and REFRESHED only when the registry's set of
759
+ // scopes moves (its `revision`): every constituent resolves live state at call
760
+ // time (bags mutate in place per scoperegistry's contract; patter/scene route
761
+ // through this flow's resolvers, which read the current `local`/`sceneBags`/
762
+ // `currentSceneId`; the host callbacks read current flow fields), but another
763
+ // engine registering `@story` after this flow opened must still be readable.
764
+ // Rebuilding it per evaluation was the engine's hottest allocation.
680
765
  evalCtx;
766
+ ctxRevision = -1;
767
+ registryQualities;
681
768
  flowLog = [];
682
769
  /** Monotonic across the flow's life; survives clearLog so two reads agree on order. */
683
770
  flowSeq = 0;
@@ -685,12 +772,9 @@ var Flow = class {
685
772
  this.id = id;
686
773
  this.host = host;
687
774
  this.rngState = toUint32(seed);
688
- this.local = this.freshLocal();
689
- const scopes = { ...host.shared.toEvalContext().scopes };
690
- scopes["patter"] = this.patterResolver;
691
- scopes["scene"] = this.sceneResolver;
775
+ this.local = this.newLocal();
692
776
  this.evalCtx = {
693
- scopes,
777
+ scopes: {},
694
778
  host: {
695
779
  nextRandom: this.rng,
696
780
  visits: (id2) => this.visitCounts.get(id2) ?? 0,
@@ -703,6 +787,21 @@ var Flow = class {
703
787
  qualities: (scope, name) => this.stagesFor(scope, name)
704
788
  };
705
789
  }
790
+ /** The eval context, its scopes refreshed if the registry's set of scopes has moved since. */
791
+ context() {
792
+ const reg = this.host.registry;
793
+ if (reg.revision !== this.ctxRevision) {
794
+ const base = reg.toEvalContext();
795
+ const scopes = this.evalCtx.scopes;
796
+ for (const k of Object.keys(scopes)) delete scopes[k];
797
+ Object.assign(scopes, base.scopes);
798
+ scopes["patter"] = this.patterResolver;
799
+ scopes["scene"] = this.sceneResolver;
800
+ this.registryQualities = base.qualities;
801
+ this.ctxRevision = reg.revision;
802
+ }
803
+ return this.evalCtx;
804
+ }
706
805
  /** The stage ladder of `@scope.name` when it is a declared quality, else undefined. Names compare
707
806
  * lowercase, as the compiler emits references (the selfBackedResolver lesson). */
708
807
  stagesFor(scope, name) {
@@ -715,13 +814,14 @@ var Flow = class {
715
814
  const scene = this.currentSceneId != null ? this.host.bundle.scenes[this.currentSceneId] : void 0;
716
815
  return fromDecls(scene?.sceneProps);
717
816
  }
718
- return fromDecls(this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations);
817
+ return this.registryQualities?.(scope, name) ?? fromDecls(this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations);
719
818
  }
720
819
  // -- Host API -------------------------------------------------------------
721
820
  /** Begin this flow at a scene (and optionally a specific block within it). */
722
821
  start(sceneId, blockId) {
723
- this.sceneBags.clear();
724
- this.local = this.freshLocal();
822
+ this.releaseBags(false);
823
+ this.host.registry.discardParked(keys.flow(this.id));
824
+ this.mountLocal();
725
825
  this.selectors.clear();
726
826
  this.visitCounts.clear();
727
827
  this.stack = [];
@@ -821,6 +921,7 @@ var Flow = class {
821
921
  * cannot quietly mutate the world. Terminal: unlike ending, a close is never revived.
822
922
  */
823
923
  close() {
924
+ this.releaseBags(false);
824
925
  this.closed = true;
825
926
  this.flowEnded = true;
826
927
  this.stack = [];
@@ -930,7 +1031,7 @@ var Flow = class {
930
1031
  * halves are the Engine's listBags.
931
1032
  */
932
1033
  listBags() {
933
- const mounts = [{ bag: this.local.ownedBag("patter"), pathPrefix: `${this.id}/@patter.` }];
1034
+ const mounts = [{ bag: this.local, pathPrefix: `${this.id}/@patter.` }];
934
1035
  for (const [sceneId, bag] of this.sceneBags) mounts.push({ bag, pathPrefix: `${this.id}/@scene:${sceneId}.` });
935
1036
  return mounts;
936
1037
  }
@@ -978,7 +1079,7 @@ var Flow = class {
978
1079
  const { scope, name } = this.splitRef(ref);
979
1080
  if (scope === "patter") return this.patterResolver.get(name);
980
1081
  if (scope === "scene") return this.sceneResolver.get(name);
981
- return this.host.shared.get(scope, name);
1082
+ return this.host.registry.get(scope, name);
982
1083
  }
983
1084
  /** Write a property by ref (routed by scope, then by the property's `shared` flag). The GAME's
984
1085
  * surface: it writes with host authority, so a `writable: false` host declaration does not refuse
@@ -996,16 +1097,13 @@ var Flow = class {
996
1097
  if (this.currentSceneId === null) throw new Error(`'${ref}': the flow has not entered a scene yet`);
997
1098
  this.sceneResolver.set(name, value);
998
1099
  } else {
999
- this.host.shared.set(scope, name, value, host ? { host: true } : void 0);
1100
+ this.host.registry.set(scope, name, value, host ? { host: true } : void 0);
1000
1101
  }
1001
1102
  }
1002
1103
  // -- Save / restore (engine-driven) --------------------------------------
1003
- /** @internal Snapshot this flow's cursor + per-flow scopes (not-shared `@patter`/`@scene`) + PRNG. */
1104
+ /** @internal Snapshot this flow's cursor + PRNG + visits. Its properties are the registry's. */
1004
1105
  snapshot() {
1005
1106
  return {
1006
- scopes: this.local.save(),
1007
- // owned scope "patter" = the NOT-shared globals (@scene saved separately)
1008
- sceneBags: Object.fromEntries([...this.sceneBags].map(([s, bag]) => [s, bag.save()])),
1009
1107
  rngState: this.rngState,
1010
1108
  visits: Object.fromEntries(this.visitCounts),
1011
1109
  cursor: {
@@ -1043,15 +1141,11 @@ var Flow = class {
1043
1141
  }
1044
1142
  return { ...frame };
1045
1143
  });
1046
- this.sceneBags = new Map(Object.entries(snap.sceneBags ?? {}).map(([s, values]) => {
1047
- const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
1048
- const decls = (this.host.bundle.scenes[s]?.sceneProps ?? []).filter((d) => !shared.has(d.name.toLowerCase()));
1049
- const bag = new PropertyBag(decls);
1050
- bag.load(values);
1051
- return [s, bag];
1052
- }));
1053
- this.local = this.freshLocal();
1054
- this.local.load(snap.scopes);
1144
+ this.releaseBags(false);
1145
+ this.mountLocal();
1146
+ for (const s of /* @__PURE__ */ new Set([c.currentSceneId, ...this.stack.map((f) => f.sceneId)])) {
1147
+ if (s !== null && this.host.bundle.scenes[s]) this.ensureSceneBags(s);
1148
+ }
1055
1149
  this.activeSnippet = null;
1056
1150
  if (c.activeSnippetId !== null) {
1057
1151
  const node = this.host.nodeIndex.get(c.activeSnippetId);
@@ -1322,7 +1416,7 @@ var Flow = class {
1322
1416
  * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1323
1417
  */
1324
1418
  matchedSpec(node, want) {
1325
- const evalTruthy = (n) => truthy(evaluate(n, this.evalCtx, patterDialect));
1419
+ const evalTruthy = (n) => truthy(evaluate(n, this.context(), patterDialect));
1326
1420
  return scoreSpecificity(node, evalTruthy, { want });
1327
1421
  }
1328
1422
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
@@ -1349,7 +1443,7 @@ var Flow = class {
1349
1443
  return truthy(this.evalExpr(node.condition));
1350
1444
  }
1351
1445
  evalExpr(expr) {
1352
- return evaluate(this.conditionAst(expr), this.evalCtx, patterDialect);
1446
+ return evaluate(this.conditionAst(expr), this.context(), patterDialect);
1353
1447
  }
1354
1448
  /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
1355
1449
  * evaluator and the Best-match specificity walker so both work off one parse. */
@@ -1474,18 +1568,51 @@ var Flow = class {
1474
1568
  const key = castStringKey(character);
1475
1569
  return this.host.strings[key] ?? this.host.defaultStrings[key] ?? this.host.castDisplay.get(character);
1476
1570
  }
1477
- /** Split a ref into scope + name. Tokens: `@scene`, foreign tokens, else `@patter` (incl. bare `@name`). */
1571
+ /** Split a ref into scope + name. Tokens: `@scene`, registered tokens, else `@patter` (incl. bare `@name`). */
1478
1572
  splitRef(ref) {
1479
- let hit = this.host.refSplitCache.get(ref);
1480
- if (!hit) {
1481
- hit = splitRef(ref, (t) => t === "scene" || this.host.shared.has(t));
1482
- this.host.refSplitCache.set(ref, hit);
1483
- }
1484
- return hit;
1573
+ return splitHostRef(this.host, ref);
1574
+ }
1575
+ /** A fresh, unregistered bag for the NOT-shared `@patter` globals (the shared ones live on the host). */
1576
+ newLocal() {
1577
+ return new PropertyBag(this.host.patterLocalDecls, { pathPrefix: "@patter." });
1578
+ }
1579
+ /** Register a fresh globals bag under this flow's key; it claims any values waiting there. */
1580
+ mountLocal() {
1581
+ this.local = this.newLocal();
1582
+ const key = keys.flowGlobals(this.id);
1583
+ this.host.registry.mountOwned(key, this.local, { owner: OWNER });
1584
+ this.registered.add(key);
1585
+ }
1586
+ /** @internal Remove every bag this flow registered; with `keep`, their values wait in the registry for
1587
+ * the flow that replaces this one. Engine-driven (close, loadGame, hotSwap). */
1588
+ releaseBags(keep) {
1589
+ for (const key of this.registered) this.host.registry.remove(key, { keep });
1590
+ this.registered.clear();
1591
+ this.sceneBags.clear();
1485
1592
  }
1486
- /** The per-flow registry: the NOT-shared `@patter` globals (the shared ones live on the host). */
1487
- freshLocal() {
1488
- return new ScopeRegistry().defineOwned("patter", this.host.patterLocalDecls, "@patter.");
1593
+ /** The bag a `@scene` property of scene `s` lives in (stage or this flow's), made if missing. */
1594
+ sceneBagFor(s, name) {
1595
+ if (!this.host.bundle.scenes[s]) return void 0;
1596
+ this.ensureSceneBags(s);
1597
+ return this.host.sceneSharedNames.get(s)?.has(name.toLowerCase()) ? this.host.stageBags.get(s) : this.sceneBags.get(s);
1598
+ }
1599
+ /** Make (and register) scene `s`'s stage bag and this flow's bag for it, if not made yet. A bag made
1600
+ * here claims whatever values the registry holds for its key: that is how a loaded save reaches it. */
1601
+ ensureSceneBags(s) {
1602
+ const shared = this.host.sceneSharedNames.get(s) ?? /* @__PURE__ */ new Set();
1603
+ const props = this.host.bundle.scenes[s]?.sceneProps ?? [];
1604
+ if (!this.sceneBags.has(s)) {
1605
+ const bag = new PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1606
+ const key = keys.flowScene(this.id, s);
1607
+ this.host.registry.mountOwned(key, bag, { owner: OWNER });
1608
+ this.registered.add(key);
1609
+ this.sceneBags.set(s, bag);
1610
+ }
1611
+ if (!this.host.stageBags.has(s)) {
1612
+ const bag = new PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
1613
+ this.host.registry.mountOwned(keys.stage(s), bag, { owner: OWNER });
1614
+ this.host.stageBags.set(s, bag);
1615
+ }
1489
1616
  }
1490
1617
  /**
1491
1618
  * Seed a scene's `@scene` props (spec §7). The not-shared props seed THIS flow's
@@ -1496,13 +1623,7 @@ var Flow = class {
1496
1623
  */
1497
1624
  seedScene(scene) {
1498
1625
  const shared = this.host.sceneSharedNames.get(scene.id) ?? /* @__PURE__ */ new Set();
1499
- const props = scene.sceneProps ?? [];
1500
- if (!this.sceneBags.has(scene.id)) {
1501
- this.sceneBags.set(scene.id, new PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase()))));
1502
- }
1503
- if (!this.host.stageBags.has(scene.id)) {
1504
- this.host.stageBags.set(scene.id, new PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase()))));
1505
- }
1626
+ this.ensureSceneBags(scene.id);
1506
1627
  for (const decl of scene.sceneProps ?? []) {
1507
1628
  if (!decl.temporary) continue;
1508
1629
  const name = decl.name.toLowerCase();
@@ -1548,16 +1669,32 @@ function toDecl(decl) {
1548
1669
  function toForeignDecl(decl) {
1549
1670
  return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
1550
1671
  }
1551
- function selfBackedResolver(decls) {
1552
- const key = (name) => name.toLowerCase();
1553
- const bag = /* @__PURE__ */ new Map();
1554
- for (const d of decls) bag.set(key(d.name), defaultFor(d));
1555
- return {
1556
- get: (name) => bag.get(key(name)),
1557
- set: (name, value) => {
1558
- bag.set(key(name), value);
1559
- }
1560
- };
1672
+ function selfBackedDecl(decl, scopeWritable) {
1673
+ const d = toForeignDecl(decl);
1674
+ const writable = decl.writable ?? scopeWritable;
1675
+ return writable === void 0 ? d : { ...d, writable };
1676
+ }
1677
+ function splitHostRef(host, ref) {
1678
+ if (host.refSplitRevision !== host.registry.revision) {
1679
+ host.refSplitCache.clear();
1680
+ host.refSplitRevision = host.registry.revision;
1681
+ }
1682
+ let hit = host.refSplitCache.get(ref);
1683
+ if (!hit) {
1684
+ hit = splitRef(ref, (t) => t === "scene" || host.registry.has(t) || (host.bundle.externalScopes?.includes(t) ?? false));
1685
+ host.refSplitCache.set(ref, hit);
1686
+ }
1687
+ return hit;
1688
+ }
1689
+ function sectionsFromV2(save) {
1690
+ const out = {};
1691
+ if (save.shared?.patter) out["patter"] = save.shared.patter;
1692
+ for (const [s, v] of Object.entries(save.stageBags ?? {})) out[keys.stage(s)] = v;
1693
+ for (const [id, f] of Object.entries(save.flows ?? {})) {
1694
+ if (f.scopes?.patter) out[keys.flowGlobals(id)] = f.scopes.patter;
1695
+ for (const [s, v] of Object.entries(f.sceneBags ?? {})) out[keys.flowScene(id, s)] = v;
1696
+ }
1697
+ return out;
1561
1698
  }
1562
1699
  function truthy(v) {
1563
1700
  if (typeof v === "boolean") return v;