@storylet-studio/runtime 0.7.0 → 0.8.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
@@ -257,6 +257,12 @@ function walk(node, want, evalTruthy, countingCalls) {
257
257
  return evalTruthy(node) === want ? 1 : 0;
258
258
  }
259
259
 
260
+ // ../dialect/src/engine-scopes.ts
261
+ var ENGINE_SCOPES = [
262
+ { token: "patter", engine: "Patterplay", means: "Patter's shared globals" },
263
+ { token: "story", engine: "Storylet Engine", means: "the Storylet Engine's shared @story properties" }
264
+ ];
265
+
260
266
  // ../dialect/src/index.ts
261
267
  var NEVER_PLAYED = 9999;
262
268
  var host = (h) => h.ctx.host ?? {};
@@ -276,16 +282,17 @@ var flagsArg = (fn, args, h) => {
276
282
  if (v === false) return [];
277
283
  throw new EvalError(`${fn}() first argument must be a flags property`);
278
284
  };
285
+ var OWN_SCOPES = ["story", "world", "box", "deck", "hand"];
286
+ var EXTERNAL_SCOPES = ENGINE_SCOPES.map((s) => s.token).filter((t) => !OWN_SCOPES.includes(t));
279
287
  var storyletsDialect = {
280
288
  // A missing property in a PRESENT scope is always an error: every property
281
289
  // is declared with a default, so absence means a publish bug, a drifted
282
- // save, or a foreign scope the host never fed (schema 6.2).
290
+ // save, or a foreign scope the host never fed (schema 6.2). The same holds
291
+ // for another engine's scope: `@patter.glod` is a typo, and it says so the
292
+ // first time the card is evaluated rather than quietly reading false.
283
293
  scopes: [
284
- { token: "story", missing: "throw" },
285
- { token: "world", missing: "throw" },
286
- { token: "box", missing: "throw" },
287
- { token: "deck", missing: "throw" },
288
- { token: "hand", missing: "throw" }
294
+ ...OWN_SCOPES.map((token) => ({ token, missing: "throw" })),
295
+ ...EXTERNAL_SCOPES.map((token) => ({ token, missing: "throw" }))
289
296
  ],
290
297
  defaultScope: "story",
291
298
  functions: {
@@ -450,6 +457,8 @@ var parseHoleRef = (value) => {
450
457
  const m = HOLE_REF.exec(value);
451
458
  return m === null ? void 0 : { scope: m[1], name: m[2] };
452
459
  };
460
+ var SAVE_SCHEMA = "storylets/save@2";
461
+ var SAVE_SCHEMA_V1 = "storylets/save@1";
453
462
 
454
463
  // ../../../expr/packages/scoperegistry/src/index.ts
455
464
  var PropertyBag = class _PropertyBag {
@@ -482,6 +491,13 @@ var PropertyBag = class _PropertyBag {
482
491
  get(name) {
483
492
  return this.values[this.norm(name)];
484
493
  }
494
+ /** A name as this bag keys it: its normalisation policy applied. The registry
495
+ * uses it to key quality ladders and the validation schema the bag's own way,
496
+ * so a case-significant (identity) bag is not quietly folded to lower case
497
+ * one layer up. */
498
+ normalise(name) {
499
+ return this.norm(name);
500
+ }
485
501
  /** Write a property. Engine writes (the default) notify subscribers;
486
502
  * pass `silent: true` for a host write, which reaches only the audit
487
503
  * hook. Throws on a read-only property unless the caller says it is the
@@ -565,6 +581,328 @@ function rowFor(d, value, writable, name, pathPrefix = "") {
565
581
  writable: writable ?? d.writable ?? true
566
582
  };
567
583
  }
584
+ var lowerCase = (name) => name.toLowerCase();
585
+ var SAVE_FRAGMENT_VERSION = 1;
586
+ var ScopeRegistry = class {
587
+ scopes = /* @__PURE__ */ new Map();
588
+ /** Values loaded for keys nobody has registered yet, waiting to be claimed. */
589
+ parked = /* @__PURE__ */ new Map();
590
+ rev = 0;
591
+ /**
592
+ * A counter that moves whenever a scope is registered or removed, and at no
593
+ * other time: it starts at 0 and each registration or removal adds 1. Values
594
+ * changing does not move it. A caller that caches a context built by
595
+ * `toEvalContext()` rebuilds it when this moves, because the context's set of
596
+ * scopes is fixed when it is built while the values it reads stay live.
597
+ */
598
+ get revision() {
599
+ return this.rev;
600
+ }
601
+ /**
602
+ * Register a scope this registry **owns and stores**. Its bag is seeded from
603
+ * each declaration's `default` (or a type default). Owned scopes are
604
+ * type-checked (declarations) and serialized by `save`/`load`.
605
+ *
606
+ * The third argument may be the path prefix alone (the pre-0.7 form) or an
607
+ * options object.
608
+ */
609
+ defineOwned(token, declarations, opts) {
610
+ const o = typeof opts === "string" ? { pathPrefix: opts } : opts ?? {};
611
+ const bag = new PropertyBag(declarations, {
612
+ pathPrefix: o.pathPrefix ?? `${token}.`,
613
+ ...o.normalise ? { normalise: o.normalise } : {}
614
+ });
615
+ return this.mountOwned(token, bag, o.owner !== void 0 ? { owner: o.owner } : void 0);
616
+ }
617
+ /**
618
+ * Attach an EXISTING bag as an owned scope: an engine (or a host) holds the
619
+ * bag and this registry reads, writes, lists and saves it like its own.
620
+ *
621
+ * If values were loaded for this key before anyone registered it, the bag
622
+ * claims them now: laid over its seeded defaults by the bag's own `load` rule.
623
+ */
624
+ mountOwned(token, bag, opts) {
625
+ this.assertFree(token, opts?.owner);
626
+ this.scopes.set(token, { kind: "owned", bag, ...opts?.owner !== void 0 ? { owner: opts.owner } : {} });
627
+ this.rev++;
628
+ const waiting = this.parked.get(token);
629
+ if (waiting) {
630
+ bag.load(waiting);
631
+ this.parked.delete(token);
632
+ }
633
+ return this;
634
+ }
635
+ /**
636
+ * Unregister a scope. With `{ keep: true }` an owned scope's values are parked
637
+ * and handed back when the same key is next registered, which is how a live
638
+ * reload hands an engine's state to its replacement. Throws on an unknown key.
639
+ */
640
+ remove(token, opts) {
641
+ const e = this.scopes.get(token);
642
+ if (!e) throw new Error(`unknown scope '@${token}'`);
643
+ if (opts?.keep && e.kind === "owned") this.parked.set(token, e.bag.save());
644
+ this.scopes.delete(token);
645
+ this.rev++;
646
+ return this;
647
+ }
648
+ /**
649
+ * Drop parked values nobody claimed. Parked values are kept in the next save by
650
+ * default, so nothing loaded is lost to a flow or deck that simply has not
651
+ * reopened yet; a game that knows they are dead drops them here.
652
+ *
653
+ * With a `prefix`, only keys starting with it are dropped: an engine resetting
654
+ * itself drops its own instance keys (`my-engine/`) and leaves every other
655
+ * engine's alone.
656
+ */
657
+ discardParked(prefix) {
658
+ if (prefix === void 0) this.parked.clear();
659
+ else for (const key of [...this.parked.keys()]) if (key.startsWith(prefix)) this.parked.delete(key);
660
+ return this;
661
+ }
662
+ /** An owned scope's bag (subscribe, audit, rows live there). */
663
+ ownedBag(token) {
664
+ const e = this.scopes.get(token);
665
+ if (!e || e.kind !== "owned") throw new Error(`'@${token}' is not an owned scope`);
666
+ return e.bag;
667
+ }
668
+ /**
669
+ * Re-initialise an existing **owned** scope's bag from new declarations,
670
+ * clearing its current values. For scope-local state that resets on a context
671
+ * change (e.g. entering a new scene / site / deck) without disturbing other
672
+ * scopes. Mutates the bag in place, so an `EvalContext` already built from this
673
+ * registry stays valid.
674
+ */
675
+ reseedOwned(token, declarations) {
676
+ this.ownedBag(token).reseed(declarations);
677
+ return this;
678
+ }
679
+ /**
680
+ * Register a **foreign** scope backed by a host `{ get, set? }` resolver. The
681
+ * values live in the host/other engine and are never stored or saved here.
682
+ * `declarations` (optional, e.g. imported from a `scopeRegistrySpec`) are used
683
+ * only for validation; omit them for an opaque scope.
684
+ */
685
+ defineForeign(token, resolver, declarations = [], opts = true) {
686
+ const o = typeof opts === "boolean" ? { writable: opts } : opts;
687
+ this.assertFree(token, o.owner);
688
+ const norm = o.normalise ?? lowerCase;
689
+ const decls = /* @__PURE__ */ new Map();
690
+ for (const d of declarations) decls.set(norm(d.name), d);
691
+ this.scopes.set(token, {
692
+ kind: "foreign",
693
+ resolver,
694
+ decls,
695
+ scopeWritable: o.writable ?? true,
696
+ norm,
697
+ ...o.owner !== void 0 ? { owner: o.owner } : {}
698
+ });
699
+ this.rev++;
700
+ return this;
701
+ }
702
+ has(token) {
703
+ return this.scopes.has(token);
704
+ }
705
+ /** Read a property; undefined if the scope or property is not present. */
706
+ get(scope, name) {
707
+ const e = this.scopes.get(scope);
708
+ if (!e) return void 0;
709
+ return e.kind === "owned" ? e.bag.get(name) : e.resolver.get(e.norm(name));
710
+ }
711
+ /** Write a property (an ENGINE write: the bag's subscribers fire; use
712
+ * the bag directly for silent host writes). Throws on an unknown scope.
713
+ *
714
+ * `writable: false` is the STORY's promise, so a story write is refused and
715
+ * a HOST write is not: pass `{ host: true }` from a host's own surface (its
716
+ * `setProperty`, its tooling, a coverage driver) and never from the path an
717
+ * outcome or effect takes. A foreign scope whose resolver has no `set` is
718
+ * refused for everyone, host included - that is not a rule to bypass, it is
719
+ * a game that gave no way to write. */
720
+ set(scope, name, value, opts) {
721
+ const e = this.scopes.get(scope);
722
+ if (!e) throw new Error(`unknown scope '@${scope}'`);
723
+ if (e.kind === "owned") {
724
+ try {
725
+ e.bag.set(name, value, opts?.host ? { host: true } : void 0);
726
+ } catch {
727
+ throw new Error(`'@${scope}.${name}' is read-only`);
728
+ }
729
+ return;
730
+ }
731
+ const n = e.norm(name);
732
+ if (!e.resolver.set) throw new Error(`'@${scope}.${name}' is read-only`);
733
+ if (!opts?.host && !this.foreignWritable(e, n)) throw new Error(`'@${scope}.${name}' is read-only`);
734
+ e.resolver.set(n, value);
735
+ }
736
+ foreignWritable(e, name) {
737
+ if (!e.resolver.set) return false;
738
+ return e.decls.get(name)?.writable ?? e.scopeWritable;
739
+ }
740
+ /** Examiner rows across every scope with a declared surface: owned bags
741
+ * first, then declared foreign scopes (values read through, writability
742
+ * reflecting the resolver). Opaque foreign scopes are not listed. */
743
+ listProperties() {
744
+ const out = [];
745
+ for (const [token, e] of this.scopes) {
746
+ const owner = e.owner !== void 0 ? { owner: e.owner } : {};
747
+ if (e.kind === "owned") {
748
+ for (const row of e.bag.rows()) out.push({ scope: token, ...owner, ...row });
749
+ } else {
750
+ for (const [n, d] of e.decls) {
751
+ out.push({
752
+ scope: token,
753
+ ...owner,
754
+ ...rowFor(d, e.resolver.get(n), this.foreignWritable(e, n), n, `${token}.`)
755
+ });
756
+ }
757
+ }
758
+ }
759
+ return out;
760
+ }
761
+ /**
762
+ * Build the `EvalContext` expr's `evaluate` consumes: owned scopes as static
763
+ * bags, foreign scopes as their resolvers. `host` carries dialect-function
764
+ * callbacks (PRNG, tag lookups) and is passed through untouched.
765
+ */
766
+ toEvalContext(host2, opts) {
767
+ const view = this.view(opts?.aliases);
768
+ const scopes = {};
769
+ for (const [token, e] of view) scopes[token] = e.kind === "owned" ? e.bag.values : e.resolver;
770
+ const qualities = this.qualityLadders(view);
771
+ return qualities.size === 0 ? { scopes, host: host2 } : {
772
+ scopes,
773
+ host: host2,
774
+ qualities: (scope, name) => {
775
+ const e = view.get(scope);
776
+ return e ? qualities.get(scope)?.get(normOf(e)(name)) : void 0;
777
+ }
778
+ };
779
+ }
780
+ /**
781
+ * The scopes an expression sees: every registered key under its own token,
782
+ * then each alias token pointing at its key's entry (an alias shadows a key of
783
+ * the same name). Keys an engine uses for instance bags (`engine/flow-2/...`)
784
+ * are not valid expression tokens, so they are present but unreachable.
785
+ */
786
+ view(aliases) {
787
+ const out = new Map(this.scopes);
788
+ for (const [token, key] of Object.entries(aliases ?? {})) {
789
+ const e = this.scopes.get(key);
790
+ if (!e) throw new Error(`alias '@${token}' names '${key}', which is not registered`);
791
+ out.set(token, e);
792
+ }
793
+ return out;
794
+ }
795
+ /** Every quality declaration's ladder, keyed scope token then name (the
796
+ * scope's own normalisation). */
797
+ qualityLadders(view) {
798
+ const out = /* @__PURE__ */ new Map();
799
+ for (const [token, e] of view) {
800
+ for (const [n, d] of declsOf(e)) {
801
+ if (d.type !== "quality" || d.stages === void 0) continue;
802
+ let m = out.get(token);
803
+ if (!m) {
804
+ m = /* @__PURE__ */ new Map();
805
+ out.set(token, m);
806
+ }
807
+ m.set(n, d.stages);
808
+ }
809
+ }
810
+ return out;
811
+ }
812
+ /**
813
+ * Build the `ExpressionSchema` expr's validator consumes. Scopes with no
814
+ * declarations are **omitted** (opaque - references into them are not flagged);
815
+ * declared scopes contribute their property types for validation. Aliases
816
+ * apply as they do to `toEvalContext`, so a condition written against `@scene`
817
+ * validates against the instance bag the engine names.
818
+ */
819
+ toSchema(opts) {
820
+ const properties = /* @__PURE__ */ new Map();
821
+ for (const [token, e] of this.view(opts?.aliases)) {
822
+ const decls = declsOf(e);
823
+ if (decls.length === 0) continue;
824
+ const m = /* @__PURE__ */ new Map();
825
+ for (const [n, d] of decls) m.set(n, {
826
+ type: d.type,
827
+ enumValues: d.values,
828
+ ...d.stages !== void 0 ? { stages: d.stages } : {}
829
+ });
830
+ properties.set(token, m);
831
+ }
832
+ return { properties };
833
+ }
834
+ /** Serialize **owned** scopes (foreign scopes are the game's, and the game
835
+ * saves them), as bare bags keyed by token, plus any values still parked, so
836
+ * a save taken before every engine has re-registered loses nothing. The
837
+ * registry knows nothing about game saves: a game embeds this in its own. */
838
+ save() {
839
+ const out = {};
840
+ for (const [token, e] of this.scopes) if (e.kind === "owned") out[token] = e.bag.save();
841
+ for (const [token, vals] of this.parked) out[token] = structuredClone(vals);
842
+ return out;
843
+ }
844
+ /**
845
+ * Restore from a `save` blob. An owned scope lays its section over its current
846
+ * values (the bag's `load` rule). A section for a key nobody has registered
847
+ * yet is PARKED and handed over when that key registers, so a game can load
848
+ * its registry before its engines have reopened their flows or decks. A
849
+ * section for a foreign scope is ignored: those values are the game's.
850
+ *
851
+ * A load replaces whatever was parked before it: it is a whole restore, and
852
+ * residue from an earlier load must not leak into this one.
853
+ *
854
+ * Changed in 0.7.0: sections for unregistered keys used to be dropped.
855
+ */
856
+ load(blob, opts) {
857
+ if (!opts?.keepParked) this.parked.clear();
858
+ for (const [token, vals] of Object.entries(blob)) {
859
+ const e = this.scopes.get(token);
860
+ if (e?.kind === "owned") e.bag.load(vals);
861
+ else if (!e) this.parked.set(token, structuredClone(vals));
862
+ }
863
+ }
864
+ /**
865
+ * `save()` wrapped with a version stamp.
866
+ *
867
+ * @deprecated Versioning belongs to the save that embeds the values; no
868
+ * engine ever called this. Embed `save()` in your own versioned save.
869
+ * Removed at the next breaking release.
870
+ */
871
+ saveFragment() {
872
+ return { version: SAVE_FRAGMENT_VERSION, scopes: this.save() };
873
+ }
874
+ /**
875
+ * Restore from a versioned fragment; an unsupported version throws.
876
+ *
877
+ * @deprecated See `saveFragment`. Removed at the next breaking release.
878
+ */
879
+ loadFragment(fragment) {
880
+ if (fragment.version !== SAVE_FRAGMENT_VERSION) {
881
+ throw new Error(`unsupported owned-state fragment version ${fragment.version} (supported: ${SAVE_FRAGMENT_VERSION})`);
882
+ }
883
+ this.load(fragment.scopes);
884
+ }
885
+ /**
886
+ * A token is taken once. There is no reserved-token list: a clash surfaces
887
+ * here, the moment a game combines its engines, which is the only moment
888
+ * anyone knows which engines are present. With owners recorded the error says
889
+ * whose token it already is.
890
+ */
891
+ assertFree(token, owner) {
892
+ const e = this.scopes.get(token);
893
+ if (!e) return;
894
+ const by = e.owner !== void 0 ? ` by ${e.owner}` : "";
895
+ const wants = owner !== void 0 ? ` (wanted by ${owner})` : "";
896
+ throw new Error(`scope '@${token}' is already registered${by}${wants}`);
897
+ }
898
+ };
899
+ function declsOf(e) {
900
+ if (e.kind === "foreign") return [...e.decls.entries()];
901
+ return e.bag.declarations().map((d) => [e.bag.normalise(d.name), d]);
902
+ }
903
+ function normOf(e) {
904
+ return e.kind === "foreign" ? e.norm : (n) => e.bag.normalise(n);
905
+ }
568
906
  function defaultFor(d) {
569
907
  if (d.default !== void 0) return d.default;
570
908
  switch (d.type) {
@@ -595,6 +933,48 @@ function defaultFor(d) {
595
933
  var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
596
934
  var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
597
935
  var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
936
+ var OWNER = "Storylet Engine";
937
+ var identity = (n) => n;
938
+ var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
939
+ var unesc = (id) => id.replace(/%2F/g, "/").replace(/%25/g, "%");
940
+ var sharedKey = (kind, id) => `storylets/${kind}/${esc(id)}`;
941
+ var flowPrefix = (flowId) => `storylets/flow/${esc(flowId)}/`;
942
+ var flowKey = (flowId, kind, id) => kind === "story" ? `${flowPrefix(flowId)}story` : `${flowPrefix(flowId)}${kind}/${esc(id)}`;
943
+ var emptyPartitionValues = () => ({ story: {}, box: {}, deck: {}, hand: {}, value: {} });
944
+ function partitionsFromSections(sections, flowIds) {
945
+ const shared = emptyPartitionValues();
946
+ const flows = /* @__PURE__ */ new Map();
947
+ const rest = {};
948
+ const flowOf = (escaped) => {
949
+ const id = unesc(escaped);
950
+ if (!flowIds.has(id)) return void 0;
951
+ let p = flows.get(id);
952
+ if (!p) {
953
+ p = emptyPartitionValues();
954
+ flows.set(id, p);
955
+ }
956
+ return p;
957
+ };
958
+ for (const [key, values] of Object.entries(sections)) {
959
+ let m;
960
+ if (key === "story") shared.story = values;
961
+ else if (m = /^storylets\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) shared[m[1]][unesc(m[2])] = values;
962
+ else if (m = /^storylets\/flow\/([^/]+)\/story$/.exec(key)) {
963
+ const p = flowOf(m[1]);
964
+ if (p) p.story = values;
965
+ } else if (m = /^storylets\/flow\/([^/]+)\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) {
966
+ const p = flowOf(m[1]);
967
+ if (p) p[m[2]][unesc(m[3])] = values;
968
+ } else if (!key.startsWith("storylets/")) rest[key] = values;
969
+ }
970
+ return { shared, flows, rest };
971
+ }
972
+ function sectionsOf(p, keyOf, out) {
973
+ if (Object.keys(p.story).length > 0) out[keyOf("story")] = p.story;
974
+ for (const kind of ["box", "deck", "hand", "value"]) {
975
+ for (const [id, values] of Object.entries(p[kind])) if (Object.keys(values).length > 0) out[keyOf(kind, id)] = values;
976
+ }
977
+ }
598
978
  var bagFromDecls = (decls, pathPrefix) => new PropertyBag(decls, { normalise: (n) => n, pathPrefix });
599
979
  function conditionPasses(v) {
600
980
  if (typeof v === "boolean") return v;
@@ -779,7 +1159,7 @@ function finishReport(bundle, saved, flows, draft) {
779
1159
  retypedProperties
780
1160
  };
781
1161
  }
782
- var Engine = class {
1162
+ var Engine = class _Engine {
783
1163
  internals;
784
1164
  seed;
785
1165
  onReplacedFlow;
@@ -789,7 +1169,13 @@ var Engine = class {
789
1169
  * outlives reset/loadGame (the host's container is the host's). The
790
1170
  * self-backed resolver is rebuilt instead. */
791
1171
  hostWorld;
1172
+ /** The options this engine was built with: hotSwap builds its replacement from them. */
1173
+ creationOptions;
1174
+ /** How to register each shared scope again, in registration order: a failed
1175
+ * hotSwap puts this engine back exactly as it was. */
1176
+ sharedMounts = [];
792
1177
  constructor(bundle, opts = {}) {
1178
+ this.creationOptions = opts;
793
1179
  this.seed = opts.seed ?? 0;
794
1180
  this.onReplacedFlow = opts.onReplacedFlow;
795
1181
  if (opts.world !== void 0) this.hostWorld = opts.world;
@@ -813,6 +1199,10 @@ var Engine = class {
813
1199
  flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
814
1200
  sharedDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
815
1201
  shared: void 0,
1202
+ registry: opts.registry ?? new ScopeRegistry(),
1203
+ ownsRegistry: opts.registry === void 0,
1204
+ selfWorld: false,
1205
+ registryView: () => view(),
816
1206
  worldResolver: void 0,
817
1207
  worldReadOnly: /* @__PURE__ */ new Set(),
818
1208
  emitEngine: (flow, event, turn) => {
@@ -827,6 +1217,19 @@ var Engine = class {
827
1217
  engineTracing: () => this.engineTraceHandlers.size > 0
828
1218
  };
829
1219
  this.internals = internals;
1220
+ let viewRevision = -1;
1221
+ let viewCache = { scopes: {}, qualities: void 0 };
1222
+ const view = () => {
1223
+ const reg = internals.registry;
1224
+ if (reg.revision !== viewRevision) {
1225
+ const ctx = reg.toEvalContext();
1226
+ const scopes = {};
1227
+ for (const [k, v] of Object.entries(ctx.scopes)) if (!k.includes("/")) scopes[k] = v;
1228
+ viewCache = { scopes, qualities: ctx.qualities };
1229
+ viewRevision = reg.revision;
1230
+ }
1231
+ return viewCache;
1232
+ };
830
1233
  indexValueOwners(internals.owners.value, bundle);
831
1234
  for (const box of bundle.boxes) {
832
1235
  internals.boxesById.set(box.id, box);
@@ -873,34 +1276,79 @@ var Engine = class {
873
1276
  internals.sharedDecls = declSet(sharedHalf);
874
1277
  this.initShared(this.hostWorld);
875
1278
  }
876
- /** Build the shared stores and the @world seam. `hostWorld` sticks for the
877
- * engine's lifetime; reset/loadGame rebuild the shared bags around it. */
1279
+ /** Build the shared stores, register them and @world, and set up the @world
1280
+ * seam. Once, for the engine's life: reset and loads reseed the bags in
1281
+ * place, so the registry never sees them come and go. */
878
1282
  initShared(hostWorld) {
879
1283
  const internals = this.internals;
1284
+ const reg = internals.registry;
1285
+ const worldDecls = internals.bundle.world.properties;
880
1286
  internals.shared = buildPartition(internals, sharedHalf);
881
1287
  internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
1288
+ const registered = [];
1289
+ const mount = (key, register) => {
1290
+ register();
1291
+ registered.push(key);
1292
+ this.sharedMounts.push({ key, mount: register });
1293
+ };
1294
+ try {
1295
+ const story = internals.shared.story;
1296
+ mount("story", () => {
1297
+ reg.mountOwned("story", story, { owner: OWNER });
1298
+ });
1299
+ for (const kind of OWNED_SCOPES) {
1300
+ for (const [id, bag] of internals.shared[kind]) {
1301
+ if (bag.declarations().length === 0) continue;
1302
+ mount(sharedKey(kind, id), () => {
1303
+ reg.mountOwned(sharedKey(kind, id), bag, { owner: OWNER });
1304
+ });
1305
+ }
1306
+ }
1307
+ if (hostWorld !== void 0) {
1308
+ mount("world", () => {
1309
+ reg.defineForeign("world", hostWorld, worldDecls, { normalise: identity, owner: OWNER });
1310
+ });
1311
+ } else if (internals.ownsRegistry && !reg.has("world")) {
1312
+ reg.defineOwned("world", worldDecls, { normalise: identity, pathPrefix: "world.", owner: OWNER });
1313
+ const worldBag = reg.ownedBag("world");
1314
+ registered.push("world");
1315
+ this.sharedMounts.push({ key: "world", mount: () => {
1316
+ reg.mountOwned("world", worldBag, { owner: OWNER });
1317
+ } });
1318
+ internals.selfWorld = true;
1319
+ }
1320
+ } catch (e) {
1321
+ for (const k of registered) reg.remove(k, { keep: true });
1322
+ throw e;
1323
+ }
1324
+ internals.worldResolver = {
1325
+ get: (n) => reg.get("world", n),
1326
+ set: (n, v) => {
1327
+ reg.set("world", n, v);
1328
+ }
1329
+ };
882
1330
  if (hostWorld !== void 0) {
883
- internals.worldResolver = hostWorld;
884
- const set = hostWorld.set;
885
- internals.worldSet = set !== void 0 ? (n, v) => {
886
- set(n, v);
1331
+ internals.worldSet = hostWorld.set !== void 0 ? (n, v) => {
1332
+ reg.set("world", n, v, { host: true });
887
1333
  } : void 0;
888
1334
  } else {
889
- const bag = bagFromDecls(internals.bundle.world.properties, "world.");
890
- internals.worldResolver = {
891
- // The engine writes through worldSet below, not through this; the `set`
892
- // is the resolver's SHAPE, so @world still reads as writable to anything
893
- // inspecting the seam, and it is the story's door: no host flag on it.
894
- get: (n) => bag.get(n),
895
- set: (n, v) => {
896
- bag.set(n, v);
897
- }
898
- };
899
1335
  internals.worldSet = (n, v, host2) => {
900
- bag.set(n, v, host2 === true ? { host: true } : void 0);
1336
+ reg.set("world", n, v, host2 === true ? { host: true } : void 0);
901
1337
  };
902
1338
  }
903
1339
  }
1340
+ /** Every shared bag back to its declared defaults, in place (the registry
1341
+ * keeps them registered), the self-backed @world included. */
1342
+ reseedShared() {
1343
+ const { shared, sharedDecls, registry } = this.internals;
1344
+ shared.story.reseed(sharedDecls.story);
1345
+ for (const kind of OWNED_SCOPES) {
1346
+ for (const [id, bag] of shared[kind]) bag.reseed(sharedDecls[kind].get(id) ?? []);
1347
+ }
1348
+ if (this.internals.selfWorld) {
1349
+ registry.reseedOwned("world", this.internals.bundle.world.properties);
1350
+ }
1351
+ }
904
1352
  /** Quality ladders by scope for the eval channel (design/quality.md):
905
1353
  * world/story keyed by name; box/deck/value keyed by owner id then name.
906
1354
  * Built once - a bundle's declarations never change. Ladders are
@@ -932,6 +1380,13 @@ var Engine = class {
932
1380
  * state; shared state is untouched. There is no default flow: "main" is
933
1381
  * a caller convention, not an engine rule. */
934
1382
  openFlow(id, opts = {}) {
1383
+ return this.open(id, opts, false);
1384
+ }
1385
+ /** openFlow, and loadGame's rebuild. `claim` says the new flow's bags take
1386
+ * the values the registry holds for them (a load); a fresh open is a reset
1387
+ * of that name, so anything waiting for it is discarded first. */
1388
+ open(id, opts, claim) {
1389
+ this.assertExternalScopes();
935
1390
  const otherClaims = opts.restore !== void 0 ? this.sharedClaimsExcept(id) : void 0;
936
1391
  const existing = this.flowsById.get(id);
937
1392
  if (existing) {
@@ -939,6 +1394,7 @@ var Engine = class {
939
1394
  if (dealt > 0) this.onReplacedFlow?.(id, dealt);
940
1395
  existing.markClosed();
941
1396
  }
1397
+ if (!claim) this.internals.registry.discardParked(flowPrefix(id));
942
1398
  const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
943
1399
  this.flowsById.set(id, flow);
944
1400
  if (opts.restore !== void 0) {
@@ -975,11 +1431,22 @@ var Engine = class {
975
1431
  * self-backed @world included; a host-bound @world is the host's and is
976
1432
  * not touched). */
977
1433
  reset() {
1434
+ this.dropRun(false);
1435
+ this.reseedShared();
1436
+ this.internals.registry.discardParked("storylets/");
1437
+ }
1438
+ /** End the run: clear the log, close every flow, forget spent cards. Each
1439
+ * flow's bags leave the registry; `keepFlows` names the flows whose values
1440
+ * are kept there for the flow that replaces them (a load into the game's
1441
+ * registry). */
1442
+ dropRun(keepFlows) {
978
1443
  this.engineLog = [];
979
- for (const flow of this.flowsById.values()) flow.markClosed();
1444
+ for (const [id, flow] of this.flowsById) {
1445
+ flow.releaseBags(keepFlows !== false && keepFlows.has(id));
1446
+ flow.markClosed();
1447
+ }
980
1448
  this.flowsById.clear();
981
1449
  this.spent.clear();
982
- this.initShared(this.hostWorld);
983
1450
  }
984
1451
  // --- shared scarcity (design/shared-scarcity.md) -----------------------------
985
1452
  /** Cards a shared `redraw: "never"` has taken out of the world, by card id.
@@ -1041,7 +1508,7 @@ var Engine = class {
1041
1508
  */
1042
1509
  getProperty(path) {
1043
1510
  const found = this.resolveShared(path);
1044
- const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.bag.get(found.name);
1511
+ const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.kind === "scope" ? this.internals.registry.get(found.token, found.name) : found.bag.get(found.name);
1045
1512
  if (value === void 0) throw new Error(`no property at "${path}"`);
1046
1513
  return value;
1047
1514
  }
@@ -1054,6 +1521,10 @@ var Engine = class {
1054
1521
  this.internals.worldSet(found.name, value, true);
1055
1522
  return;
1056
1523
  }
1524
+ if (found.kind === "scope") {
1525
+ this.internals.registry.set(found.token, found.name, value, { host: true });
1526
+ return;
1527
+ }
1057
1528
  found.bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
1058
1529
  }
1059
1530
  resolveShared(path) {
@@ -1062,6 +1533,9 @@ var Engine = class {
1062
1533
  throw new Error(`"${path}" is per-flow state - read it on a Flow, not the Engine`);
1063
1534
  };
1064
1535
  if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
1536
+ if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
1537
+ return { kind: "scope", token: parts[0], name: parts[1] };
1538
+ }
1065
1539
  if (parts.length === 2 && parts[0] === "story") {
1066
1540
  const name = parts[1];
1067
1541
  if (this.internals.shared.story.get(name) !== void 0) return { kind: "bag", bag: this.internals.shared.story, name };
@@ -1089,6 +1563,18 @@ var Engine = class {
1089
1563
  diagnose(message) {
1090
1564
  this.internals.emitEngine("", { type: "diagnostic", where: "property address", message });
1091
1565
  }
1566
+ /** Content that names another engine's scope (`@patter.visits`) runs only
1567
+ * where that engine is on this registry: without it every read would answer
1568
+ * false and every write fail, so the flow is refused as it opens, before
1569
+ * anything changes. By then a game has built all of its engines, whatever
1570
+ * order it built them in. The same message on every runtime. */
1571
+ assertExternalScopes() {
1572
+ for (const token of this.internals.bundle.externalScopes ?? []) {
1573
+ if (!this.internals.registry.has(token)) {
1574
+ throw new Error(`this content names @${token}, which no engine on this registry registered: give every engine the game's one registry`);
1575
+ }
1576
+ }
1577
+ }
1092
1578
  /** The shared surface as examiner rows: @world (read through the
1093
1579
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
1094
1580
  listProperties() {
@@ -1140,15 +1626,77 @@ var Engine = class {
1140
1626
  return () => this.engineTraceHandlers.delete(handler);
1141
1627
  }
1142
1628
  // --- persistence (schema 4) -------------------------------------------------
1143
- /** The whole engine, one envelope: the shared partitions once, then
1144
- * every live flow keyed by its id. @world is NEVER here - the host
1145
- * saves its container, each engine saves its own envelope. */
1629
+ /**
1630
+ * Live bundle refresh: rebuild on an edited bundle with the whole run carried
1631
+ * over, and return the replacement with the report its load produced.
1632
+ *
1633
+ * Standalone, that is a save and a load into a new engine, and this one is left
1634
+ * untouched (discard it). With the game's registry the two cannot both hold the
1635
+ * same keys, so this engine is spent afterwards (its flows closed, the
1636
+ * replacement holding everything on the same registry): it carries its
1637
+ * own values into the snapshot, steps out of the registry, and the replacement
1638
+ * loads them the way a standalone save loads: so the report covers the
1639
+ * properties the edit dropped, defaulted, or retyped, and a dropped property
1640
+ * is dropped rather than kept. Values the game loaded that were still waiting
1641
+ * for a flow of this engine carry across as they were, and nothing belonging
1642
+ * to any other engine is touched. A save for another project is refused before
1643
+ * anything moves; if the rebuild fails for any other reason, this engine takes
1644
+ * its registrations back and is left exactly as it was.
1645
+ */
1646
+ hotSwap(bundle, opts = {}) {
1647
+ if (bundle.content.project !== this.internals.bundle.content.project) {
1648
+ throw new Error(`save is for project "${this.internals.bundle.content.project}", bundle is "${bundle.content.project}"`);
1649
+ }
1650
+ const options = { ...this.creationOptions, ...opts };
1651
+ const snapshot = this.saveGame();
1652
+ const reg = this.internals.registry;
1653
+ if (this.internals.ownsRegistry) {
1654
+ const next2 = new _Engine(bundle, { ...options, registry: void 0 });
1655
+ return { engine: next2, report: next2.loadGame(snapshot) };
1656
+ }
1657
+ const mine = {};
1658
+ for (const [key, values] of Object.entries(reg.save())) {
1659
+ if (key === "story" || key.startsWith("storylets/")) mine[key] = values;
1660
+ }
1661
+ const registeredKeys = /* @__PURE__ */ new Set([
1662
+ ...this.sharedMounts.map((m) => m.key),
1663
+ ...[...this.flowsById.values()].flatMap((f) => f.registeredKeys())
1664
+ ]);
1665
+ const waiting = Object.fromEntries(Object.entries(mine).filter(([key]) => !registeredKeys.has(key)));
1666
+ for (const { key } of this.sharedMounts) if (reg.has(key)) reg.remove(key);
1667
+ for (const flow of this.flowsById.values()) flow.releaseBags(false);
1668
+ let next;
1669
+ try {
1670
+ next = new _Engine(bundle, { ...options, registry: reg });
1671
+ const report = next.loadGame({ ...snapshot, registry: mine });
1672
+ const stillWaiting = Object.fromEntries(Object.entries(waiting).filter(([key]) => !reg.has(key)));
1673
+ if (Object.keys(stillWaiting).length > 0) reg.load(stillWaiting, { keepParked: true });
1674
+ this.dropRun(false);
1675
+ return { engine: next, report };
1676
+ } catch (e) {
1677
+ next?.dropRun(false);
1678
+ for (const { key } of next?.sharedMounts ?? []) if (reg.has(key)) reg.remove(key);
1679
+ reg.discardParked("storylets/");
1680
+ for (const { mount } of this.sharedMounts) mount();
1681
+ for (const flow of this.flowsById.values()) flow.mountBags();
1682
+ this.reseedShared();
1683
+ reg.load(mine, { keepParked: true });
1684
+ throw e;
1685
+ }
1686
+ }
1687
+ /** The whole engine's NON-property state, one envelope: the spent cards
1688
+ * once, then every live flow (board, clocks, cooldowns, PRNG, play log)
1689
+ * keyed by its id. The property values are the registry's: a standalone
1690
+ * engine (one that made its own registry) carries them here under
1691
+ * `registry`, self-backed @world included; a game that passed a registry
1692
+ * saves it once itself, beside each engine's envelope. */
1146
1693
  saveGame() {
1147
1694
  return structuredClone({
1148
- schema: "storylets/save@1",
1695
+ schema: SAVE_SCHEMA,
1149
1696
  content: this.internals.bundle.content,
1150
- shared: { props: partitionValues(this.internals.shared), spent: [...this.spent].sort() },
1151
- flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot()]))
1697
+ ...this.internals.ownsRegistry ? { registry: this.internals.registry.save() } : {},
1698
+ shared: { spent: [...this.spent].sort() },
1699
+ flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
1152
1700
  });
1153
1701
  }
1154
1702
  /** ONE flow's blob, to park a visit that is walking away: the same shape
@@ -1160,7 +1708,7 @@ var Engine = class {
1160
1708
  saveFlow(id) {
1161
1709
  const flow = this.flowsById.get(id);
1162
1710
  if (!flow) throw new Error(`unknown flow "${id}"`);
1163
- return structuredClone(flow.snapshot());
1711
+ return structuredClone(flow.snapshot(true));
1164
1712
  }
1165
1713
  /** What `loadGame(envelope)` would do that is not a plain restore, without
1166
1714
  * doing any of it (design/engine-server.md 4.9). Pure: nothing on this
@@ -1183,16 +1731,31 @@ var Engine = class {
1183
1731
  * Handles held from before the load are closed and inert (Patter's
1184
1732
  * rule); take fresh ones from getFlow()/flows().
1185
1733
  *
1734
+ * Property values come from the registry. An envelope that carries them
1735
+ * (a standalone engine's, or a version 1 envelope) has them walked, cleaned,
1736
+ * and moved into the registry here, over fresh defaults. Otherwise the game
1737
+ * loads its registry itself, before or after this call: each flow's bags
1738
+ * are handed back to the registry with their values, and the restored
1739
+ * flows claim them. The report then covers only what this envelope holds;
1740
+ * the registry's own load rule applies to the values.
1741
+ *
1186
1742
  * Returns the report `previewLoad` would have given for this envelope: the
1187
1743
  * drift tolerance that makes a load forgiving is what hides its cost, so
1188
1744
  * the cost comes back with the load whether or not anybody looked first. */
1189
1745
  loadGame(envelope) {
1190
1746
  this.assertSameProject(envelope);
1747
+ this.assertExternalScopes();
1191
1748
  const plan = this.planLoad(structuredClone(envelope));
1192
- this.reset();
1193
- loadPartition(this.internals.shared, plan.shared);
1749
+ const reg = this.internals.registry;
1750
+ if (plan.sections !== void 0) {
1751
+ this.reset();
1752
+ if (this.internals.ownsRegistry) reg.load(plan.sections);
1753
+ else reg.load(plan.sections, { keepParked: true });
1754
+ } else {
1755
+ this.dropRun(new Set(plan.flows.map(([id]) => id)));
1756
+ }
1194
1757
  for (const id of plan.spent) this.spent.add(id);
1195
- for (const [id, clean] of plan.flows) this.openFlow(id).restore(clean);
1758
+ for (const [id, clean] of plan.flows) this.open(id, {}, true).restore(clean);
1196
1759
  return plan.report;
1197
1760
  }
1198
1761
  assertSameProject(envelope) {
@@ -1204,26 +1767,43 @@ var Engine = class {
1204
1767
  * half writes. Nothing here touches the engine, which is what lets
1205
1768
  * previewLoad and loadGame share it. */
1206
1769
  planLoad(envelope) {
1770
+ const schema = envelope.schema;
1771
+ if (schema !== SAVE_SCHEMA && schema !== SAVE_SCHEMA_V1) throw new Error(`unsupported save schema: ${String(schema)}`);
1207
1772
  const draft = emptyDraft();
1208
- const shared = walkPartition(
1209
- this.internals,
1210
- this.internals.sharedDecls,
1211
- envelope.shared?.props,
1212
- void 0,
1213
- draft
1214
- );
1773
+ const flowIds = new Set(Object.keys(envelope.flows ?? {}));
1774
+ let moved;
1775
+ if (envelope.schema === SAVE_SCHEMA_V1) {
1776
+ moved = {
1777
+ shared: envelope.shared?.props ?? emptyPartitionValues(),
1778
+ flows: new Map(Object.entries(envelope.flows ?? {}).map(([id, f]) => [id, f.props ?? emptyPartitionValues()])),
1779
+ rest: {}
1780
+ };
1781
+ } else if (envelope.registry !== void 0) {
1782
+ moved = partitionsFromSections(envelope.registry, flowIds);
1783
+ }
1784
+ const shared = moved !== void 0 ? walkPartition(this.internals, this.internals.sharedDecls, moved.shared, void 0, draft) : void 0;
1215
1785
  const spent = [];
1216
1786
  for (const cardId of envelope.shared?.spent ?? []) {
1217
1787
  if (this.internals.cardsById.has(cardId)) spent.push(cardId);
1218
1788
  else draft.droppedSpent.push(cardId);
1219
1789
  }
1220
1790
  const flows = [];
1791
+ const sections = moved !== void 0 ? { ...moved.rest } : void 0;
1792
+ if (sections !== void 0 && shared !== void 0) {
1793
+ sectionsOf(shared, (kind, id) => kind === "story" ? "story" : sharedKey(kind, id), sections);
1794
+ }
1221
1795
  for (const [id, saved] of Object.entries(envelope.flows ?? {})) {
1222
- flows.push([id, this.planFlowRestore(id, saved, void 0, draft)]);
1796
+ const withProps = moved !== void 0 ? { ...saved, props: moved.flows.get(id) ?? emptyPartitionValues() } : { ...saved };
1797
+ const clean = this.planFlowRestore(id, withProps, void 0, draft);
1798
+ if (sections !== void 0 && clean.props !== void 0) {
1799
+ sectionsOf(clean.props, (kind, owner) => flowKey(id, kind, owner), sections);
1800
+ delete clean.props;
1801
+ }
1802
+ flows.push([id, clean]);
1223
1803
  }
1224
1804
  return {
1225
1805
  report: finishReport(this.internals.bundle.content, envelope.content, flows.map(([id]) => id), draft),
1226
- shared,
1806
+ ...sections !== void 0 ? { sections } : {},
1227
1807
  spent,
1228
1808
  flows
1229
1809
  };
@@ -1234,7 +1814,7 @@ var Engine = class {
1234
1814
  * there is nobody else to compete with. */
1235
1815
  planFlowRestore(id, saved, otherClaims, draft) {
1236
1816
  const internals = this.internals;
1237
- const props = walkPartition(internals, internals.flowDecls, saved.props, id, draft);
1817
+ const props = saved.props !== void 0 ? walkPartition(internals, internals.flowDecls, saved.props, id, draft) : void 0;
1238
1818
  const cooldowns = {};
1239
1819
  for (const [cardId, turn] of Object.entries(saved.cooldowns ?? {})) {
1240
1820
  if (internals.cardsById.has(cardId)) cooldowns[cardId] = turn;
@@ -1275,7 +1855,7 @@ var Engine = class {
1275
1855
  board[handId] = kept;
1276
1856
  }
1277
1857
  return {
1278
- props,
1858
+ ...props !== void 0 ? { props } : {},
1279
1859
  turns: saved.turns ?? {},
1280
1860
  prng: saved.prng,
1281
1861
  cooldowns,
@@ -1318,8 +1898,12 @@ var Flow = class {
1318
1898
  lastPlayOf = /* @__PURE__ */ new Map();
1319
1899
  tagPlayCount = /* @__PURE__ */ new Map();
1320
1900
  lastPlayInTag = /* @__PURE__ */ new Map();
1321
- /** The per-flow property partitions (the not-shared halves). */
1901
+ /** The per-flow property partitions (the not-shared halves), each bag
1902
+ * that declares something registered under this flow's keys. */
1322
1903
  stores;
1904
+ registered = [];
1905
+ /** This flow's bags that declare something, under their registry keys. */
1906
+ bagKeys = [];
1323
1907
  traceHandlers = /* @__PURE__ */ new Set();
1324
1908
  logEntries = [];
1325
1909
  logSeq = 0;
@@ -1337,6 +1921,13 @@ var Flow = class {
1337
1921
  this.id = id;
1338
1922
  this.prng = makePrng(seed);
1339
1923
  this.stores = buildPartition(internals, flowHalf);
1924
+ const put = (key, bag) => {
1925
+ if (bag.declarations().length === 0) return;
1926
+ this.bagKeys.push([key, bag]);
1927
+ };
1928
+ put(flowKey(id, "story"), this.stores.story);
1929
+ for (const kind of OWNED_SCOPES) for (const [owner, bag] of this.stores[kind]) put(flowKey(id, kind, owner), bag);
1930
+ this.mountBags();
1340
1931
  for (const box of internals.bundle.boxes) {
1341
1932
  this.turnCounts.set(box.id, 0);
1342
1933
  for (const hand of box.hands) this.boardContents.set(hand.id, []);
@@ -1364,8 +1955,28 @@ var Flow = class {
1364
1955
  }
1365
1956
  /** @internal */
1366
1957
  markClosed() {
1958
+ this.releaseBags(false);
1367
1959
  this.closed = true;
1368
1960
  }
1961
+ /** @internal - take this flow's bags out of the registry; with `keep`, their
1962
+ * values wait there for the flow that replaces this one (a load into the
1963
+ * game's registry). Idempotent. */
1964
+ releaseBags(keep) {
1965
+ for (const key of this.registered) this.internals.registry.remove(key, { keep });
1966
+ this.registered.length = 0;
1967
+ }
1968
+ /** @internal - the registry keys this flow holds right now. */
1969
+ registeredKeys() {
1970
+ return [...this.registered];
1971
+ }
1972
+ /** @internal - register this flow's bags (again): at construction, and when a
1973
+ * failed hotSwap hands them back. Each claims what the registry holds for it. */
1974
+ mountBags() {
1975
+ for (const [key, bag] of this.bagKeys) {
1976
+ this.internals.registry.mountOwned(key, bag, { owner: OWNER });
1977
+ this.registered.push(key);
1978
+ }
1979
+ }
1369
1980
  assertOpen() {
1370
1981
  if (this.closed) throw new Error(`flow "${this.id}" is closed`);
1371
1982
  }
@@ -1502,8 +2113,12 @@ var Flow = class {
1502
2113
  * is the flow's MERGED view - its own copies over the shared values,
1503
2114
  * names disjoint - and @world reads through the engine's resolver. */
1504
2115
  evalCtx(box, deck, handEnv) {
2116
+ const others = this.internals.registryView();
1505
2117
  return {
1506
2118
  scopes: {
2119
+ // Every other engine's game-wide scope first (every engine reads every
2120
+ // scope); this engine's own tokens are its merged views, over the top.
2121
+ ...others.scopes,
1507
2122
  world: this.internals.worldResolver,
1508
2123
  story: this.storyReader,
1509
2124
  box: this.boxReaders.get(box.id) ?? {},
@@ -1514,8 +2129,8 @@ var Flow = class {
1514
2129
  // The quality channel, answering for THIS ask's box and deck. Only wired
1515
2130
  // when a quality exists, so a bundle without one evaluates byte-
1516
2131
  // identically to before the feature.
1517
- ...this.internals.hasQualities ? {
1518
- qualities: (scope, name) => scope === "world" ? this.internals.ladders.world.get(name) : scope === "story" ? this.internals.ladders.story.get(name) : scope === "box" ? this.internals.ladders.box.get(box.id)?.get(name) : scope === "deck" && deck ? this.internals.ladders.deck.get(deck.id)?.get(name) : scope === "hand" ? this.handLadder(handEnv, name) : void 0
2132
+ ...this.internals.hasQualities || others.qualities !== void 0 ? {
2133
+ qualities: (scope, name) => scope === "world" ? this.internals.ladders.world.get(name) ?? others.qualities?.(scope, name) : scope === "story" ? this.internals.ladders.story.get(name) : scope === "box" ? this.internals.ladders.box.get(box.id)?.get(name) : scope === "deck" && deck ? this.internals.ladders.deck.get(deck.id)?.get(name) : scope === "hand" ? this.handLadder(handEnv, name) : others.qualities?.(scope, name)
1519
2134
  } : {}
1520
2135
  };
1521
2136
  }
@@ -2154,8 +2769,17 @@ var Flow = class {
2154
2769
  if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
2155
2770
  return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
2156
2771
  }
2157
- default:
2772
+ default: {
2773
+ if (this.internals.registry.has(scope)) {
2774
+ const prev = this.internals.registry.get(scope, name);
2775
+ this.internals.registry.set(scope, name, value);
2776
+ return { path: `${scope}.${name}`, ...prev !== void 0 ? { prev } : {} };
2777
+ }
2778
+ if (this.internals.bundle.externalScopes?.includes(scope)) {
2779
+ throw new Error(`@${scope}.${name} cannot be written: no engine on this registry registered @${scope}`);
2780
+ }
2158
2781
  throw new Error(`bad change target scope "@${scope}"`);
2782
+ }
2159
2783
  }
2160
2784
  }
2161
2785
  /** Advance one box's clock (schema 3.4): a turn is one draw-from-stock
@@ -2248,7 +2872,7 @@ var Flow = class {
2248
2872
  getProperty(path) {
2249
2873
  this.assertOpen();
2250
2874
  const found = this.resolvePath(path);
2251
- const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
2875
+ const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.kind === "scope" ? this.internals.registry.get(found.token, found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
2252
2876
  if (value === void 0) throw new Error(`no property at "${path}"`);
2253
2877
  return value;
2254
2878
  }
@@ -2260,6 +2884,10 @@ var Flow = class {
2260
2884
  this.internals.worldSet(found.name, value, true);
2261
2885
  return;
2262
2886
  }
2887
+ if (found.kind === "scope") {
2888
+ this.internals.registry.set(found.token, found.name, value, { host: true });
2889
+ return;
2890
+ }
2263
2891
  const bag = found.own !== void 0 && found.own.get(found.name) !== void 0 ? found.own : found.shared !== void 0 && found.shared.get(found.name) !== void 0 ? found.shared : void 0;
2264
2892
  if (bag === void 0) throw new Error(`no property at "${path}"`);
2265
2893
  bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
@@ -2267,6 +2895,9 @@ var Flow = class {
2267
2895
  resolvePath(path) {
2268
2896
  const parts = path.split(".");
2269
2897
  if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
2898
+ if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
2899
+ return { kind: "scope", token: parts[0], name: parts[1] };
2900
+ }
2270
2901
  if (parts.length === 2 && parts[0] === "story") {
2271
2902
  return { kind: "bag", own: this.stores.story, shared: this.internals.shared.story, name: parts[1] };
2272
2903
  }
@@ -2285,10 +2916,11 @@ var Flow = class {
2285
2916
  throw new Error(`bad property path "${path}"`);
2286
2917
  }
2287
2918
  // --- persistence (schema 4) -------------------------------------------------
2288
- /** @internal - this flow's blob inside the engine's envelope. */
2289
- snapshot() {
2919
+ /** @internal - this flow's blob: inside the engine's envelope without its
2920
+ * properties (the registry has them), or parked whole by saveFlow. */
2921
+ snapshot(withProps) {
2290
2922
  return {
2291
- props: partitionValues(this.stores),
2923
+ ...withProps ? { props: partitionValues(this.stores) } : {},
2292
2924
  turns: Object.fromEntries(this.turnCounts),
2293
2925
  prng: this.prng.state(),
2294
2926
  cooldowns: this.cooldowns,
@@ -2299,7 +2931,7 @@ var Flow = class {
2299
2931
  /** @internal - restore a freshly opened flow from its blob (loadGame).
2300
2932
  * Orphaned keys (deleted entities) drop; new declarations keep defaults. */
2301
2933
  restore(saved) {
2302
- loadPartition(this.stores, saved.props);
2934
+ if (saved.props !== void 0) loadPartition(this.stores, saved.props);
2303
2935
  this.turnCounts = new Map(this.internals.bundle.boxes.map((b) => [b.id, 0]));
2304
2936
  for (const [boxId, turn] of Object.entries(saved.turns ?? {})) {
2305
2937
  if (this.turnCounts.has(boxId)) this.turnCounts.set(boxId, turn);