@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.cjs CHANGED
@@ -287,6 +287,12 @@ function walk(node, want, evalTruthy, countingCalls) {
287
287
  return evalTruthy(node) === want ? 1 : 0;
288
288
  }
289
289
 
290
+ // ../dialect/src/engine-scopes.ts
291
+ var ENGINE_SCOPES = [
292
+ { token: "patter", engine: "Patterplay", means: "Patter's shared globals" },
293
+ { token: "story", engine: "Storylet Engine", means: "the Storylet Engine's shared @story properties" }
294
+ ];
295
+
290
296
  // ../dialect/src/index.ts
291
297
  var NEVER_PLAYED = 9999;
292
298
  var host = (h) => h.ctx.host ?? {};
@@ -306,16 +312,17 @@ var flagsArg = (fn, args, h) => {
306
312
  if (v === false) return [];
307
313
  throw new EvalError(`${fn}() first argument must be a flags property`);
308
314
  };
315
+ var OWN_SCOPES = ["story", "world", "box", "deck", "hand"];
316
+ var EXTERNAL_SCOPES = ENGINE_SCOPES.map((s) => s.token).filter((t) => !OWN_SCOPES.includes(t));
309
317
  var storyletsDialect = {
310
318
  // A missing property in a PRESENT scope is always an error: every property
311
319
  // is declared with a default, so absence means a publish bug, a drifted
312
- // save, or a foreign scope the host never fed (schema 6.2).
320
+ // save, or a foreign scope the host never fed (schema 6.2). The same holds
321
+ // for another engine's scope: `@patter.glod` is a typo, and it says so the
322
+ // first time the card is evaluated rather than quietly reading false.
313
323
  scopes: [
314
- { token: "story", missing: "throw" },
315
- { token: "world", missing: "throw" },
316
- { token: "box", missing: "throw" },
317
- { token: "deck", missing: "throw" },
318
- { token: "hand", missing: "throw" }
324
+ ...OWN_SCOPES.map((token) => ({ token, missing: "throw" })),
325
+ ...EXTERNAL_SCOPES.map((token) => ({ token, missing: "throw" }))
319
326
  ],
320
327
  defaultScope: "story",
321
328
  functions: {
@@ -480,6 +487,8 @@ var parseHoleRef = (value) => {
480
487
  const m = HOLE_REF.exec(value);
481
488
  return m === null ? void 0 : { scope: m[1], name: m[2] };
482
489
  };
490
+ var SAVE_SCHEMA = "storylets/save@2";
491
+ var SAVE_SCHEMA_V1 = "storylets/save@1";
483
492
 
484
493
  // ../../../expr/packages/scoperegistry/src/index.ts
485
494
  var PropertyBag = class _PropertyBag {
@@ -512,6 +521,13 @@ var PropertyBag = class _PropertyBag {
512
521
  get(name) {
513
522
  return this.values[this.norm(name)];
514
523
  }
524
+ /** A name as this bag keys it: its normalisation policy applied. The registry
525
+ * uses it to key quality ladders and the validation schema the bag's own way,
526
+ * so a case-significant (identity) bag is not quietly folded to lower case
527
+ * one layer up. */
528
+ normalise(name) {
529
+ return this.norm(name);
530
+ }
515
531
  /** Write a property. Engine writes (the default) notify subscribers;
516
532
  * pass `silent: true` for a host write, which reaches only the audit
517
533
  * hook. Throws on a read-only property unless the caller says it is the
@@ -595,6 +611,328 @@ function rowFor(d, value, writable, name, pathPrefix = "") {
595
611
  writable: writable ?? d.writable ?? true
596
612
  };
597
613
  }
614
+ var lowerCase = (name) => name.toLowerCase();
615
+ var SAVE_FRAGMENT_VERSION = 1;
616
+ var ScopeRegistry = class {
617
+ scopes = /* @__PURE__ */ new Map();
618
+ /** Values loaded for keys nobody has registered yet, waiting to be claimed. */
619
+ parked = /* @__PURE__ */ new Map();
620
+ rev = 0;
621
+ /**
622
+ * A counter that moves whenever a scope is registered or removed, and at no
623
+ * other time: it starts at 0 and each registration or removal adds 1. Values
624
+ * changing does not move it. A caller that caches a context built by
625
+ * `toEvalContext()` rebuilds it when this moves, because the context's set of
626
+ * scopes is fixed when it is built while the values it reads stay live.
627
+ */
628
+ get revision() {
629
+ return this.rev;
630
+ }
631
+ /**
632
+ * Register a scope this registry **owns and stores**. Its bag is seeded from
633
+ * each declaration's `default` (or a type default). Owned scopes are
634
+ * type-checked (declarations) and serialized by `save`/`load`.
635
+ *
636
+ * The third argument may be the path prefix alone (the pre-0.7 form) or an
637
+ * options object.
638
+ */
639
+ defineOwned(token, declarations, opts) {
640
+ const o = typeof opts === "string" ? { pathPrefix: opts } : opts ?? {};
641
+ const bag = new PropertyBag(declarations, {
642
+ pathPrefix: o.pathPrefix ?? `${token}.`,
643
+ ...o.normalise ? { normalise: o.normalise } : {}
644
+ });
645
+ return this.mountOwned(token, bag, o.owner !== void 0 ? { owner: o.owner } : void 0);
646
+ }
647
+ /**
648
+ * Attach an EXISTING bag as an owned scope: an engine (or a host) holds the
649
+ * bag and this registry reads, writes, lists and saves it like its own.
650
+ *
651
+ * If values were loaded for this key before anyone registered it, the bag
652
+ * claims them now: laid over its seeded defaults by the bag's own `load` rule.
653
+ */
654
+ mountOwned(token, bag, opts) {
655
+ this.assertFree(token, opts?.owner);
656
+ this.scopes.set(token, { kind: "owned", bag, ...opts?.owner !== void 0 ? { owner: opts.owner } : {} });
657
+ this.rev++;
658
+ const waiting = this.parked.get(token);
659
+ if (waiting) {
660
+ bag.load(waiting);
661
+ this.parked.delete(token);
662
+ }
663
+ return this;
664
+ }
665
+ /**
666
+ * Unregister a scope. With `{ keep: true }` an owned scope's values are parked
667
+ * and handed back when the same key is next registered, which is how a live
668
+ * reload hands an engine's state to its replacement. Throws on an unknown key.
669
+ */
670
+ remove(token, opts) {
671
+ const e = this.scopes.get(token);
672
+ if (!e) throw new Error(`unknown scope '@${token}'`);
673
+ if (opts?.keep && e.kind === "owned") this.parked.set(token, e.bag.save());
674
+ this.scopes.delete(token);
675
+ this.rev++;
676
+ return this;
677
+ }
678
+ /**
679
+ * Drop parked values nobody claimed. Parked values are kept in the next save by
680
+ * default, so nothing loaded is lost to a flow or deck that simply has not
681
+ * reopened yet; a game that knows they are dead drops them here.
682
+ *
683
+ * With a `prefix`, only keys starting with it are dropped: an engine resetting
684
+ * itself drops its own instance keys (`my-engine/`) and leaves every other
685
+ * engine's alone.
686
+ */
687
+ discardParked(prefix) {
688
+ if (prefix === void 0) this.parked.clear();
689
+ else for (const key of [...this.parked.keys()]) if (key.startsWith(prefix)) this.parked.delete(key);
690
+ return this;
691
+ }
692
+ /** An owned scope's bag (subscribe, audit, rows live there). */
693
+ ownedBag(token) {
694
+ const e = this.scopes.get(token);
695
+ if (!e || e.kind !== "owned") throw new Error(`'@${token}' is not an owned scope`);
696
+ return e.bag;
697
+ }
698
+ /**
699
+ * Re-initialise an existing **owned** scope's bag from new declarations,
700
+ * clearing its current values. For scope-local state that resets on a context
701
+ * change (e.g. entering a new scene / site / deck) without disturbing other
702
+ * scopes. Mutates the bag in place, so an `EvalContext` already built from this
703
+ * registry stays valid.
704
+ */
705
+ reseedOwned(token, declarations) {
706
+ this.ownedBag(token).reseed(declarations);
707
+ return this;
708
+ }
709
+ /**
710
+ * Register a **foreign** scope backed by a host `{ get, set? }` resolver. The
711
+ * values live in the host/other engine and are never stored or saved here.
712
+ * `declarations` (optional, e.g. imported from a `scopeRegistrySpec`) are used
713
+ * only for validation; omit them for an opaque scope.
714
+ */
715
+ defineForeign(token, resolver, declarations = [], opts = true) {
716
+ const o = typeof opts === "boolean" ? { writable: opts } : opts;
717
+ this.assertFree(token, o.owner);
718
+ const norm = o.normalise ?? lowerCase;
719
+ const decls = /* @__PURE__ */ new Map();
720
+ for (const d of declarations) decls.set(norm(d.name), d);
721
+ this.scopes.set(token, {
722
+ kind: "foreign",
723
+ resolver,
724
+ decls,
725
+ scopeWritable: o.writable ?? true,
726
+ norm,
727
+ ...o.owner !== void 0 ? { owner: o.owner } : {}
728
+ });
729
+ this.rev++;
730
+ return this;
731
+ }
732
+ has(token) {
733
+ return this.scopes.has(token);
734
+ }
735
+ /** Read a property; undefined if the scope or property is not present. */
736
+ get(scope, name) {
737
+ const e = this.scopes.get(scope);
738
+ if (!e) return void 0;
739
+ return e.kind === "owned" ? e.bag.get(name) : e.resolver.get(e.norm(name));
740
+ }
741
+ /** Write a property (an ENGINE write: the bag's subscribers fire; use
742
+ * the bag directly for silent host writes). Throws on an unknown scope.
743
+ *
744
+ * `writable: false` is the STORY's promise, so a story write is refused and
745
+ * a HOST write is not: pass `{ host: true }` from a host's own surface (its
746
+ * `setProperty`, its tooling, a coverage driver) and never from the path an
747
+ * outcome or effect takes. A foreign scope whose resolver has no `set` is
748
+ * refused for everyone, host included - that is not a rule to bypass, it is
749
+ * a game that gave no way to write. */
750
+ set(scope, name, value, opts) {
751
+ const e = this.scopes.get(scope);
752
+ if (!e) throw new Error(`unknown scope '@${scope}'`);
753
+ if (e.kind === "owned") {
754
+ try {
755
+ e.bag.set(name, value, opts?.host ? { host: true } : void 0);
756
+ } catch {
757
+ throw new Error(`'@${scope}.${name}' is read-only`);
758
+ }
759
+ return;
760
+ }
761
+ const n = e.norm(name);
762
+ if (!e.resolver.set) throw new Error(`'@${scope}.${name}' is read-only`);
763
+ if (!opts?.host && !this.foreignWritable(e, n)) throw new Error(`'@${scope}.${name}' is read-only`);
764
+ e.resolver.set(n, value);
765
+ }
766
+ foreignWritable(e, name) {
767
+ if (!e.resolver.set) return false;
768
+ return e.decls.get(name)?.writable ?? e.scopeWritable;
769
+ }
770
+ /** Examiner rows across every scope with a declared surface: owned bags
771
+ * first, then declared foreign scopes (values read through, writability
772
+ * reflecting the resolver). Opaque foreign scopes are not listed. */
773
+ listProperties() {
774
+ const out = [];
775
+ for (const [token, e] of this.scopes) {
776
+ const owner = e.owner !== void 0 ? { owner: e.owner } : {};
777
+ if (e.kind === "owned") {
778
+ for (const row of e.bag.rows()) out.push({ scope: token, ...owner, ...row });
779
+ } else {
780
+ for (const [n, d] of e.decls) {
781
+ out.push({
782
+ scope: token,
783
+ ...owner,
784
+ ...rowFor(d, e.resolver.get(n), this.foreignWritable(e, n), n, `${token}.`)
785
+ });
786
+ }
787
+ }
788
+ }
789
+ return out;
790
+ }
791
+ /**
792
+ * Build the `EvalContext` expr's `evaluate` consumes: owned scopes as static
793
+ * bags, foreign scopes as their resolvers. `host` carries dialect-function
794
+ * callbacks (PRNG, tag lookups) and is passed through untouched.
795
+ */
796
+ toEvalContext(host2, opts) {
797
+ const view = this.view(opts?.aliases);
798
+ const scopes = {};
799
+ for (const [token, e] of view) scopes[token] = e.kind === "owned" ? e.bag.values : e.resolver;
800
+ const qualities = this.qualityLadders(view);
801
+ return qualities.size === 0 ? { scopes, host: host2 } : {
802
+ scopes,
803
+ host: host2,
804
+ qualities: (scope, name) => {
805
+ const e = view.get(scope);
806
+ return e ? qualities.get(scope)?.get(normOf(e)(name)) : void 0;
807
+ }
808
+ };
809
+ }
810
+ /**
811
+ * The scopes an expression sees: every registered key under its own token,
812
+ * then each alias token pointing at its key's entry (an alias shadows a key of
813
+ * the same name). Keys an engine uses for instance bags (`engine/flow-2/...`)
814
+ * are not valid expression tokens, so they are present but unreachable.
815
+ */
816
+ view(aliases) {
817
+ const out = new Map(this.scopes);
818
+ for (const [token, key] of Object.entries(aliases ?? {})) {
819
+ const e = this.scopes.get(key);
820
+ if (!e) throw new Error(`alias '@${token}' names '${key}', which is not registered`);
821
+ out.set(token, e);
822
+ }
823
+ return out;
824
+ }
825
+ /** Every quality declaration's ladder, keyed scope token then name (the
826
+ * scope's own normalisation). */
827
+ qualityLadders(view) {
828
+ const out = /* @__PURE__ */ new Map();
829
+ for (const [token, e] of view) {
830
+ for (const [n, d] of declsOf(e)) {
831
+ if (d.type !== "quality" || d.stages === void 0) continue;
832
+ let m = out.get(token);
833
+ if (!m) {
834
+ m = /* @__PURE__ */ new Map();
835
+ out.set(token, m);
836
+ }
837
+ m.set(n, d.stages);
838
+ }
839
+ }
840
+ return out;
841
+ }
842
+ /**
843
+ * Build the `ExpressionSchema` expr's validator consumes. Scopes with no
844
+ * declarations are **omitted** (opaque - references into them are not flagged);
845
+ * declared scopes contribute their property types for validation. Aliases
846
+ * apply as they do to `toEvalContext`, so a condition written against `@scene`
847
+ * validates against the instance bag the engine names.
848
+ */
849
+ toSchema(opts) {
850
+ const properties = /* @__PURE__ */ new Map();
851
+ for (const [token, e] of this.view(opts?.aliases)) {
852
+ const decls = declsOf(e);
853
+ if (decls.length === 0) continue;
854
+ const m = /* @__PURE__ */ new Map();
855
+ for (const [n, d] of decls) m.set(n, {
856
+ type: d.type,
857
+ enumValues: d.values,
858
+ ...d.stages !== void 0 ? { stages: d.stages } : {}
859
+ });
860
+ properties.set(token, m);
861
+ }
862
+ return { properties };
863
+ }
864
+ /** Serialize **owned** scopes (foreign scopes are the game's, and the game
865
+ * saves them), as bare bags keyed by token, plus any values still parked, so
866
+ * a save taken before every engine has re-registered loses nothing. The
867
+ * registry knows nothing about game saves: a game embeds this in its own. */
868
+ save() {
869
+ const out = {};
870
+ for (const [token, e] of this.scopes) if (e.kind === "owned") out[token] = e.bag.save();
871
+ for (const [token, vals] of this.parked) out[token] = structuredClone(vals);
872
+ return out;
873
+ }
874
+ /**
875
+ * Restore from a `save` blob. An owned scope lays its section over its current
876
+ * values (the bag's `load` rule). A section for a key nobody has registered
877
+ * yet is PARKED and handed over when that key registers, so a game can load
878
+ * its registry before its engines have reopened their flows or decks. A
879
+ * section for a foreign scope is ignored: those values are the game's.
880
+ *
881
+ * A load replaces whatever was parked before it: it is a whole restore, and
882
+ * residue from an earlier load must not leak into this one.
883
+ *
884
+ * Changed in 0.7.0: sections for unregistered keys used to be dropped.
885
+ */
886
+ load(blob, opts) {
887
+ if (!opts?.keepParked) this.parked.clear();
888
+ for (const [token, vals] of Object.entries(blob)) {
889
+ const e = this.scopes.get(token);
890
+ if (e?.kind === "owned") e.bag.load(vals);
891
+ else if (!e) this.parked.set(token, structuredClone(vals));
892
+ }
893
+ }
894
+ /**
895
+ * `save()` wrapped with a version stamp.
896
+ *
897
+ * @deprecated Versioning belongs to the save that embeds the values; no
898
+ * engine ever called this. Embed `save()` in your own versioned save.
899
+ * Removed at the next breaking release.
900
+ */
901
+ saveFragment() {
902
+ return { version: SAVE_FRAGMENT_VERSION, scopes: this.save() };
903
+ }
904
+ /**
905
+ * Restore from a versioned fragment; an unsupported version throws.
906
+ *
907
+ * @deprecated See `saveFragment`. Removed at the next breaking release.
908
+ */
909
+ loadFragment(fragment) {
910
+ if (fragment.version !== SAVE_FRAGMENT_VERSION) {
911
+ throw new Error(`unsupported owned-state fragment version ${fragment.version} (supported: ${SAVE_FRAGMENT_VERSION})`);
912
+ }
913
+ this.load(fragment.scopes);
914
+ }
915
+ /**
916
+ * A token is taken once. There is no reserved-token list: a clash surfaces
917
+ * here, the moment a game combines its engines, which is the only moment
918
+ * anyone knows which engines are present. With owners recorded the error says
919
+ * whose token it already is.
920
+ */
921
+ assertFree(token, owner) {
922
+ const e = this.scopes.get(token);
923
+ if (!e) return;
924
+ const by = e.owner !== void 0 ? ` by ${e.owner}` : "";
925
+ const wants = owner !== void 0 ? ` (wanted by ${owner})` : "";
926
+ throw new Error(`scope '@${token}' is already registered${by}${wants}`);
927
+ }
928
+ };
929
+ function declsOf(e) {
930
+ if (e.kind === "foreign") return [...e.decls.entries()];
931
+ return e.bag.declarations().map((d) => [e.bag.normalise(d.name), d]);
932
+ }
933
+ function normOf(e) {
934
+ return e.kind === "foreign" ? e.norm : (n) => e.bag.normalise(n);
935
+ }
598
936
  function defaultFor(d) {
599
937
  if (d.default !== void 0) return d.default;
600
938
  switch (d.type) {
@@ -625,6 +963,48 @@ function defaultFor(d) {
625
963
  var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
626
964
  var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
627
965
  var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
966
+ var OWNER = "Storylet Engine";
967
+ var identity = (n) => n;
968
+ var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
969
+ var unesc = (id) => id.replace(/%2F/g, "/").replace(/%25/g, "%");
970
+ var sharedKey = (kind, id) => `storylets/${kind}/${esc(id)}`;
971
+ var flowPrefix = (flowId) => `storylets/flow/${esc(flowId)}/`;
972
+ var flowKey = (flowId, kind, id) => kind === "story" ? `${flowPrefix(flowId)}story` : `${flowPrefix(flowId)}${kind}/${esc(id)}`;
973
+ var emptyPartitionValues = () => ({ story: {}, box: {}, deck: {}, hand: {}, value: {} });
974
+ function partitionsFromSections(sections, flowIds) {
975
+ const shared = emptyPartitionValues();
976
+ const flows = /* @__PURE__ */ new Map();
977
+ const rest = {};
978
+ const flowOf = (escaped) => {
979
+ const id = unesc(escaped);
980
+ if (!flowIds.has(id)) return void 0;
981
+ let p = flows.get(id);
982
+ if (!p) {
983
+ p = emptyPartitionValues();
984
+ flows.set(id, p);
985
+ }
986
+ return p;
987
+ };
988
+ for (const [key, values] of Object.entries(sections)) {
989
+ let m;
990
+ if (key === "story") shared.story = values;
991
+ else if (m = /^storylets\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) shared[m[1]][unesc(m[2])] = values;
992
+ else if (m = /^storylets\/flow\/([^/]+)\/story$/.exec(key)) {
993
+ const p = flowOf(m[1]);
994
+ if (p) p.story = values;
995
+ } else if (m = /^storylets\/flow\/([^/]+)\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) {
996
+ const p = flowOf(m[1]);
997
+ if (p) p[m[2]][unesc(m[3])] = values;
998
+ } else if (!key.startsWith("storylets/")) rest[key] = values;
999
+ }
1000
+ return { shared, flows, rest };
1001
+ }
1002
+ function sectionsOf(p, keyOf, out) {
1003
+ if (Object.keys(p.story).length > 0) out[keyOf("story")] = p.story;
1004
+ for (const kind of ["box", "deck", "hand", "value"]) {
1005
+ for (const [id, values] of Object.entries(p[kind])) if (Object.keys(values).length > 0) out[keyOf(kind, id)] = values;
1006
+ }
1007
+ }
628
1008
  var bagFromDecls = (decls, pathPrefix) => new PropertyBag(decls, { normalise: (n) => n, pathPrefix });
629
1009
  function conditionPasses(v) {
630
1010
  if (typeof v === "boolean") return v;
@@ -809,7 +1189,7 @@ function finishReport(bundle, saved, flows, draft) {
809
1189
  retypedProperties
810
1190
  };
811
1191
  }
812
- var Engine = class {
1192
+ var Engine = class _Engine {
813
1193
  internals;
814
1194
  seed;
815
1195
  onReplacedFlow;
@@ -819,7 +1199,13 @@ var Engine = class {
819
1199
  * outlives reset/loadGame (the host's container is the host's). The
820
1200
  * self-backed resolver is rebuilt instead. */
821
1201
  hostWorld;
1202
+ /** The options this engine was built with: hotSwap builds its replacement from them. */
1203
+ creationOptions;
1204
+ /** How to register each shared scope again, in registration order: a failed
1205
+ * hotSwap puts this engine back exactly as it was. */
1206
+ sharedMounts = [];
822
1207
  constructor(bundle, opts = {}) {
1208
+ this.creationOptions = opts;
823
1209
  this.seed = opts.seed ?? 0;
824
1210
  this.onReplacedFlow = opts.onReplacedFlow;
825
1211
  if (opts.world !== void 0) this.hostWorld = opts.world;
@@ -843,6 +1229,10 @@ var Engine = class {
843
1229
  flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
844
1230
  sharedDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
845
1231
  shared: void 0,
1232
+ registry: opts.registry ?? new ScopeRegistry(),
1233
+ ownsRegistry: opts.registry === void 0,
1234
+ selfWorld: false,
1235
+ registryView: () => view(),
846
1236
  worldResolver: void 0,
847
1237
  worldReadOnly: /* @__PURE__ */ new Set(),
848
1238
  emitEngine: (flow, event, turn) => {
@@ -857,6 +1247,19 @@ var Engine = class {
857
1247
  engineTracing: () => this.engineTraceHandlers.size > 0
858
1248
  };
859
1249
  this.internals = internals;
1250
+ let viewRevision = -1;
1251
+ let viewCache = { scopes: {}, qualities: void 0 };
1252
+ const view = () => {
1253
+ const reg = internals.registry;
1254
+ if (reg.revision !== viewRevision) {
1255
+ const ctx = reg.toEvalContext();
1256
+ const scopes = {};
1257
+ for (const [k, v] of Object.entries(ctx.scopes)) if (!k.includes("/")) scopes[k] = v;
1258
+ viewCache = { scopes, qualities: ctx.qualities };
1259
+ viewRevision = reg.revision;
1260
+ }
1261
+ return viewCache;
1262
+ };
860
1263
  indexValueOwners(internals.owners.value, bundle);
861
1264
  for (const box of bundle.boxes) {
862
1265
  internals.boxesById.set(box.id, box);
@@ -903,34 +1306,79 @@ var Engine = class {
903
1306
  internals.sharedDecls = declSet(sharedHalf);
904
1307
  this.initShared(this.hostWorld);
905
1308
  }
906
- /** Build the shared stores and the @world seam. `hostWorld` sticks for the
907
- * engine's lifetime; reset/loadGame rebuild the shared bags around it. */
1309
+ /** Build the shared stores, register them and @world, and set up the @world
1310
+ * seam. Once, for the engine's life: reset and loads reseed the bags in
1311
+ * place, so the registry never sees them come and go. */
908
1312
  initShared(hostWorld) {
909
1313
  const internals = this.internals;
1314
+ const reg = internals.registry;
1315
+ const worldDecls = internals.bundle.world.properties;
910
1316
  internals.shared = buildPartition(internals, sharedHalf);
911
1317
  internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
1318
+ const registered = [];
1319
+ const mount = (key, register) => {
1320
+ register();
1321
+ registered.push(key);
1322
+ this.sharedMounts.push({ key, mount: register });
1323
+ };
1324
+ try {
1325
+ const story = internals.shared.story;
1326
+ mount("story", () => {
1327
+ reg.mountOwned("story", story, { owner: OWNER });
1328
+ });
1329
+ for (const kind of OWNED_SCOPES) {
1330
+ for (const [id, bag] of internals.shared[kind]) {
1331
+ if (bag.declarations().length === 0) continue;
1332
+ mount(sharedKey(kind, id), () => {
1333
+ reg.mountOwned(sharedKey(kind, id), bag, { owner: OWNER });
1334
+ });
1335
+ }
1336
+ }
1337
+ if (hostWorld !== void 0) {
1338
+ mount("world", () => {
1339
+ reg.defineForeign("world", hostWorld, worldDecls, { normalise: identity, owner: OWNER });
1340
+ });
1341
+ } else if (internals.ownsRegistry && !reg.has("world")) {
1342
+ reg.defineOwned("world", worldDecls, { normalise: identity, pathPrefix: "world.", owner: OWNER });
1343
+ const worldBag = reg.ownedBag("world");
1344
+ registered.push("world");
1345
+ this.sharedMounts.push({ key: "world", mount: () => {
1346
+ reg.mountOwned("world", worldBag, { owner: OWNER });
1347
+ } });
1348
+ internals.selfWorld = true;
1349
+ }
1350
+ } catch (e) {
1351
+ for (const k of registered) reg.remove(k, { keep: true });
1352
+ throw e;
1353
+ }
1354
+ internals.worldResolver = {
1355
+ get: (n) => reg.get("world", n),
1356
+ set: (n, v) => {
1357
+ reg.set("world", n, v);
1358
+ }
1359
+ };
912
1360
  if (hostWorld !== void 0) {
913
- internals.worldResolver = hostWorld;
914
- const set = hostWorld.set;
915
- internals.worldSet = set !== void 0 ? (n, v) => {
916
- set(n, v);
1361
+ internals.worldSet = hostWorld.set !== void 0 ? (n, v) => {
1362
+ reg.set("world", n, v, { host: true });
917
1363
  } : void 0;
918
1364
  } else {
919
- const bag = bagFromDecls(internals.bundle.world.properties, "world.");
920
- internals.worldResolver = {
921
- // The engine writes through worldSet below, not through this; the `set`
922
- // is the resolver's SHAPE, so @world still reads as writable to anything
923
- // inspecting the seam, and it is the story's door: no host flag on it.
924
- get: (n) => bag.get(n),
925
- set: (n, v) => {
926
- bag.set(n, v);
927
- }
928
- };
929
1365
  internals.worldSet = (n, v, host2) => {
930
- bag.set(n, v, host2 === true ? { host: true } : void 0);
1366
+ reg.set("world", n, v, host2 === true ? { host: true } : void 0);
931
1367
  };
932
1368
  }
933
1369
  }
1370
+ /** Every shared bag back to its declared defaults, in place (the registry
1371
+ * keeps them registered), the self-backed @world included. */
1372
+ reseedShared() {
1373
+ const { shared, sharedDecls, registry } = this.internals;
1374
+ shared.story.reseed(sharedDecls.story);
1375
+ for (const kind of OWNED_SCOPES) {
1376
+ for (const [id, bag] of shared[kind]) bag.reseed(sharedDecls[kind].get(id) ?? []);
1377
+ }
1378
+ if (this.internals.selfWorld) {
1379
+ registry.reseedOwned("world", this.internals.bundle.world.properties);
1380
+ }
1381
+ }
934
1382
  /** Quality ladders by scope for the eval channel (design/quality.md):
935
1383
  * world/story keyed by name; box/deck/value keyed by owner id then name.
936
1384
  * Built once - a bundle's declarations never change. Ladders are
@@ -962,6 +1410,13 @@ var Engine = class {
962
1410
  * state; shared state is untouched. There is no default flow: "main" is
963
1411
  * a caller convention, not an engine rule. */
964
1412
  openFlow(id, opts = {}) {
1413
+ return this.open(id, opts, false);
1414
+ }
1415
+ /** openFlow, and loadGame's rebuild. `claim` says the new flow's bags take
1416
+ * the values the registry holds for them (a load); a fresh open is a reset
1417
+ * of that name, so anything waiting for it is discarded first. */
1418
+ open(id, opts, claim) {
1419
+ this.assertExternalScopes();
965
1420
  const otherClaims = opts.restore !== void 0 ? this.sharedClaimsExcept(id) : void 0;
966
1421
  const existing = this.flowsById.get(id);
967
1422
  if (existing) {
@@ -969,6 +1424,7 @@ var Engine = class {
969
1424
  if (dealt > 0) this.onReplacedFlow?.(id, dealt);
970
1425
  existing.markClosed();
971
1426
  }
1427
+ if (!claim) this.internals.registry.discardParked(flowPrefix(id));
972
1428
  const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
973
1429
  this.flowsById.set(id, flow);
974
1430
  if (opts.restore !== void 0) {
@@ -1005,11 +1461,22 @@ var Engine = class {
1005
1461
  * self-backed @world included; a host-bound @world is the host's and is
1006
1462
  * not touched). */
1007
1463
  reset() {
1464
+ this.dropRun(false);
1465
+ this.reseedShared();
1466
+ this.internals.registry.discardParked("storylets/");
1467
+ }
1468
+ /** End the run: clear the log, close every flow, forget spent cards. Each
1469
+ * flow's bags leave the registry; `keepFlows` names the flows whose values
1470
+ * are kept there for the flow that replaces them (a load into the game's
1471
+ * registry). */
1472
+ dropRun(keepFlows) {
1008
1473
  this.engineLog = [];
1009
- for (const flow of this.flowsById.values()) flow.markClosed();
1474
+ for (const [id, flow] of this.flowsById) {
1475
+ flow.releaseBags(keepFlows !== false && keepFlows.has(id));
1476
+ flow.markClosed();
1477
+ }
1010
1478
  this.flowsById.clear();
1011
1479
  this.spent.clear();
1012
- this.initShared(this.hostWorld);
1013
1480
  }
1014
1481
  // --- shared scarcity (design/shared-scarcity.md) -----------------------------
1015
1482
  /** Cards a shared `redraw: "never"` has taken out of the world, by card id.
@@ -1071,7 +1538,7 @@ var Engine = class {
1071
1538
  */
1072
1539
  getProperty(path) {
1073
1540
  const found = this.resolveShared(path);
1074
- const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.bag.get(found.name);
1541
+ 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);
1075
1542
  if (value === void 0) throw new Error(`no property at "${path}"`);
1076
1543
  return value;
1077
1544
  }
@@ -1084,6 +1551,10 @@ var Engine = class {
1084
1551
  this.internals.worldSet(found.name, value, true);
1085
1552
  return;
1086
1553
  }
1554
+ if (found.kind === "scope") {
1555
+ this.internals.registry.set(found.token, found.name, value, { host: true });
1556
+ return;
1557
+ }
1087
1558
  found.bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
1088
1559
  }
1089
1560
  resolveShared(path) {
@@ -1092,6 +1563,9 @@ var Engine = class {
1092
1563
  throw new Error(`"${path}" is per-flow state - read it on a Flow, not the Engine`);
1093
1564
  };
1094
1565
  if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
1566
+ if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
1567
+ return { kind: "scope", token: parts[0], name: parts[1] };
1568
+ }
1095
1569
  if (parts.length === 2 && parts[0] === "story") {
1096
1570
  const name = parts[1];
1097
1571
  if (this.internals.shared.story.get(name) !== void 0) return { kind: "bag", bag: this.internals.shared.story, name };
@@ -1119,6 +1593,18 @@ var Engine = class {
1119
1593
  diagnose(message) {
1120
1594
  this.internals.emitEngine("", { type: "diagnostic", where: "property address", message });
1121
1595
  }
1596
+ /** Content that names another engine's scope (`@patter.visits`) runs only
1597
+ * where that engine is on this registry: without it every read would answer
1598
+ * false and every write fail, so the flow is refused as it opens, before
1599
+ * anything changes. By then a game has built all of its engines, whatever
1600
+ * order it built them in. The same message on every runtime. */
1601
+ assertExternalScopes() {
1602
+ for (const token of this.internals.bundle.externalScopes ?? []) {
1603
+ if (!this.internals.registry.has(token)) {
1604
+ throw new Error(`this content names @${token}, which no engine on this registry registered: give every engine the game's one registry`);
1605
+ }
1606
+ }
1607
+ }
1122
1608
  /** The shared surface as examiner rows: @world (read through the
1123
1609
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
1124
1610
  listProperties() {
@@ -1170,15 +1656,77 @@ var Engine = class {
1170
1656
  return () => this.engineTraceHandlers.delete(handler);
1171
1657
  }
1172
1658
  // --- persistence (schema 4) -------------------------------------------------
1173
- /** The whole engine, one envelope: the shared partitions once, then
1174
- * every live flow keyed by its id. @world is NEVER here - the host
1175
- * saves its container, each engine saves its own envelope. */
1659
+ /**
1660
+ * Live bundle refresh: rebuild on an edited bundle with the whole run carried
1661
+ * over, and return the replacement with the report its load produced.
1662
+ *
1663
+ * Standalone, that is a save and a load into a new engine, and this one is left
1664
+ * untouched (discard it). With the game's registry the two cannot both hold the
1665
+ * same keys, so this engine is spent afterwards (its flows closed, the
1666
+ * replacement holding everything on the same registry): it carries its
1667
+ * own values into the snapshot, steps out of the registry, and the replacement
1668
+ * loads them the way a standalone save loads: so the report covers the
1669
+ * properties the edit dropped, defaulted, or retyped, and a dropped property
1670
+ * is dropped rather than kept. Values the game loaded that were still waiting
1671
+ * for a flow of this engine carry across as they were, and nothing belonging
1672
+ * to any other engine is touched. A save for another project is refused before
1673
+ * anything moves; if the rebuild fails for any other reason, this engine takes
1674
+ * its registrations back and is left exactly as it was.
1675
+ */
1676
+ hotSwap(bundle, opts = {}) {
1677
+ if (bundle.content.project !== this.internals.bundle.content.project) {
1678
+ throw new Error(`save is for project "${this.internals.bundle.content.project}", bundle is "${bundle.content.project}"`);
1679
+ }
1680
+ const options = { ...this.creationOptions, ...opts };
1681
+ const snapshot = this.saveGame();
1682
+ const reg = this.internals.registry;
1683
+ if (this.internals.ownsRegistry) {
1684
+ const next2 = new _Engine(bundle, { ...options, registry: void 0 });
1685
+ return { engine: next2, report: next2.loadGame(snapshot) };
1686
+ }
1687
+ const mine = {};
1688
+ for (const [key, values] of Object.entries(reg.save())) {
1689
+ if (key === "story" || key.startsWith("storylets/")) mine[key] = values;
1690
+ }
1691
+ const registeredKeys = /* @__PURE__ */ new Set([
1692
+ ...this.sharedMounts.map((m) => m.key),
1693
+ ...[...this.flowsById.values()].flatMap((f) => f.registeredKeys())
1694
+ ]);
1695
+ const waiting = Object.fromEntries(Object.entries(mine).filter(([key]) => !registeredKeys.has(key)));
1696
+ for (const { key } of this.sharedMounts) if (reg.has(key)) reg.remove(key);
1697
+ for (const flow of this.flowsById.values()) flow.releaseBags(false);
1698
+ let next;
1699
+ try {
1700
+ next = new _Engine(bundle, { ...options, registry: reg });
1701
+ const report = next.loadGame({ ...snapshot, registry: mine });
1702
+ const stillWaiting = Object.fromEntries(Object.entries(waiting).filter(([key]) => !reg.has(key)));
1703
+ if (Object.keys(stillWaiting).length > 0) reg.load(stillWaiting, { keepParked: true });
1704
+ this.dropRun(false);
1705
+ return { engine: next, report };
1706
+ } catch (e) {
1707
+ next?.dropRun(false);
1708
+ for (const { key } of next?.sharedMounts ?? []) if (reg.has(key)) reg.remove(key);
1709
+ reg.discardParked("storylets/");
1710
+ for (const { mount } of this.sharedMounts) mount();
1711
+ for (const flow of this.flowsById.values()) flow.mountBags();
1712
+ this.reseedShared();
1713
+ reg.load(mine, { keepParked: true });
1714
+ throw e;
1715
+ }
1716
+ }
1717
+ /** The whole engine's NON-property state, one envelope: the spent cards
1718
+ * once, then every live flow (board, clocks, cooldowns, PRNG, play log)
1719
+ * keyed by its id. The property values are the registry's: a standalone
1720
+ * engine (one that made its own registry) carries them here under
1721
+ * `registry`, self-backed @world included; a game that passed a registry
1722
+ * saves it once itself, beside each engine's envelope. */
1176
1723
  saveGame() {
1177
1724
  return structuredClone({
1178
- schema: "storylets/save@1",
1725
+ schema: SAVE_SCHEMA,
1179
1726
  content: this.internals.bundle.content,
1180
- shared: { props: partitionValues(this.internals.shared), spent: [...this.spent].sort() },
1181
- flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot()]))
1727
+ ...this.internals.ownsRegistry ? { registry: this.internals.registry.save() } : {},
1728
+ shared: { spent: [...this.spent].sort() },
1729
+ flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
1182
1730
  });
1183
1731
  }
1184
1732
  /** ONE flow's blob, to park a visit that is walking away: the same shape
@@ -1190,7 +1738,7 @@ var Engine = class {
1190
1738
  saveFlow(id) {
1191
1739
  const flow = this.flowsById.get(id);
1192
1740
  if (!flow) throw new Error(`unknown flow "${id}"`);
1193
- return structuredClone(flow.snapshot());
1741
+ return structuredClone(flow.snapshot(true));
1194
1742
  }
1195
1743
  /** What `loadGame(envelope)` would do that is not a plain restore, without
1196
1744
  * doing any of it (design/engine-server.md 4.9). Pure: nothing on this
@@ -1213,16 +1761,31 @@ var Engine = class {
1213
1761
  * Handles held from before the load are closed and inert (Patter's
1214
1762
  * rule); take fresh ones from getFlow()/flows().
1215
1763
  *
1764
+ * Property values come from the registry. An envelope that carries them
1765
+ * (a standalone engine's, or a version 1 envelope) has them walked, cleaned,
1766
+ * and moved into the registry here, over fresh defaults. Otherwise the game
1767
+ * loads its registry itself, before or after this call: each flow's bags
1768
+ * are handed back to the registry with their values, and the restored
1769
+ * flows claim them. The report then covers only what this envelope holds;
1770
+ * the registry's own load rule applies to the values.
1771
+ *
1216
1772
  * Returns the report `previewLoad` would have given for this envelope: the
1217
1773
  * drift tolerance that makes a load forgiving is what hides its cost, so
1218
1774
  * the cost comes back with the load whether or not anybody looked first. */
1219
1775
  loadGame(envelope) {
1220
1776
  this.assertSameProject(envelope);
1777
+ this.assertExternalScopes();
1221
1778
  const plan = this.planLoad(structuredClone(envelope));
1222
- this.reset();
1223
- loadPartition(this.internals.shared, plan.shared);
1779
+ const reg = this.internals.registry;
1780
+ if (plan.sections !== void 0) {
1781
+ this.reset();
1782
+ if (this.internals.ownsRegistry) reg.load(plan.sections);
1783
+ else reg.load(plan.sections, { keepParked: true });
1784
+ } else {
1785
+ this.dropRun(new Set(plan.flows.map(([id]) => id)));
1786
+ }
1224
1787
  for (const id of plan.spent) this.spent.add(id);
1225
- for (const [id, clean] of plan.flows) this.openFlow(id).restore(clean);
1788
+ for (const [id, clean] of plan.flows) this.open(id, {}, true).restore(clean);
1226
1789
  return plan.report;
1227
1790
  }
1228
1791
  assertSameProject(envelope) {
@@ -1234,26 +1797,43 @@ var Engine = class {
1234
1797
  * half writes. Nothing here touches the engine, which is what lets
1235
1798
  * previewLoad and loadGame share it. */
1236
1799
  planLoad(envelope) {
1800
+ const schema = envelope.schema;
1801
+ if (schema !== SAVE_SCHEMA && schema !== SAVE_SCHEMA_V1) throw new Error(`unsupported save schema: ${String(schema)}`);
1237
1802
  const draft = emptyDraft();
1238
- const shared = walkPartition(
1239
- this.internals,
1240
- this.internals.sharedDecls,
1241
- envelope.shared?.props,
1242
- void 0,
1243
- draft
1244
- );
1803
+ const flowIds = new Set(Object.keys(envelope.flows ?? {}));
1804
+ let moved;
1805
+ if (envelope.schema === SAVE_SCHEMA_V1) {
1806
+ moved = {
1807
+ shared: envelope.shared?.props ?? emptyPartitionValues(),
1808
+ flows: new Map(Object.entries(envelope.flows ?? {}).map(([id, f]) => [id, f.props ?? emptyPartitionValues()])),
1809
+ rest: {}
1810
+ };
1811
+ } else if (envelope.registry !== void 0) {
1812
+ moved = partitionsFromSections(envelope.registry, flowIds);
1813
+ }
1814
+ const shared = moved !== void 0 ? walkPartition(this.internals, this.internals.sharedDecls, moved.shared, void 0, draft) : void 0;
1245
1815
  const spent = [];
1246
1816
  for (const cardId of envelope.shared?.spent ?? []) {
1247
1817
  if (this.internals.cardsById.has(cardId)) spent.push(cardId);
1248
1818
  else draft.droppedSpent.push(cardId);
1249
1819
  }
1250
1820
  const flows = [];
1821
+ const sections = moved !== void 0 ? { ...moved.rest } : void 0;
1822
+ if (sections !== void 0 && shared !== void 0) {
1823
+ sectionsOf(shared, (kind, id) => kind === "story" ? "story" : sharedKey(kind, id), sections);
1824
+ }
1251
1825
  for (const [id, saved] of Object.entries(envelope.flows ?? {})) {
1252
- flows.push([id, this.planFlowRestore(id, saved, void 0, draft)]);
1826
+ const withProps = moved !== void 0 ? { ...saved, props: moved.flows.get(id) ?? emptyPartitionValues() } : { ...saved };
1827
+ const clean = this.planFlowRestore(id, withProps, void 0, draft);
1828
+ if (sections !== void 0 && clean.props !== void 0) {
1829
+ sectionsOf(clean.props, (kind, owner) => flowKey(id, kind, owner), sections);
1830
+ delete clean.props;
1831
+ }
1832
+ flows.push([id, clean]);
1253
1833
  }
1254
1834
  return {
1255
1835
  report: finishReport(this.internals.bundle.content, envelope.content, flows.map(([id]) => id), draft),
1256
- shared,
1836
+ ...sections !== void 0 ? { sections } : {},
1257
1837
  spent,
1258
1838
  flows
1259
1839
  };
@@ -1264,7 +1844,7 @@ var Engine = class {
1264
1844
  * there is nobody else to compete with. */
1265
1845
  planFlowRestore(id, saved, otherClaims, draft) {
1266
1846
  const internals = this.internals;
1267
- const props = walkPartition(internals, internals.flowDecls, saved.props, id, draft);
1847
+ const props = saved.props !== void 0 ? walkPartition(internals, internals.flowDecls, saved.props, id, draft) : void 0;
1268
1848
  const cooldowns = {};
1269
1849
  for (const [cardId, turn] of Object.entries(saved.cooldowns ?? {})) {
1270
1850
  if (internals.cardsById.has(cardId)) cooldowns[cardId] = turn;
@@ -1305,7 +1885,7 @@ var Engine = class {
1305
1885
  board[handId] = kept;
1306
1886
  }
1307
1887
  return {
1308
- props,
1888
+ ...props !== void 0 ? { props } : {},
1309
1889
  turns: saved.turns ?? {},
1310
1890
  prng: saved.prng,
1311
1891
  cooldowns,
@@ -1348,8 +1928,12 @@ var Flow = class {
1348
1928
  lastPlayOf = /* @__PURE__ */ new Map();
1349
1929
  tagPlayCount = /* @__PURE__ */ new Map();
1350
1930
  lastPlayInTag = /* @__PURE__ */ new Map();
1351
- /** The per-flow property partitions (the not-shared halves). */
1931
+ /** The per-flow property partitions (the not-shared halves), each bag
1932
+ * that declares something registered under this flow's keys. */
1352
1933
  stores;
1934
+ registered = [];
1935
+ /** This flow's bags that declare something, under their registry keys. */
1936
+ bagKeys = [];
1353
1937
  traceHandlers = /* @__PURE__ */ new Set();
1354
1938
  logEntries = [];
1355
1939
  logSeq = 0;
@@ -1367,6 +1951,13 @@ var Flow = class {
1367
1951
  this.id = id;
1368
1952
  this.prng = makePrng(seed);
1369
1953
  this.stores = buildPartition(internals, flowHalf);
1954
+ const put = (key, bag) => {
1955
+ if (bag.declarations().length === 0) return;
1956
+ this.bagKeys.push([key, bag]);
1957
+ };
1958
+ put(flowKey(id, "story"), this.stores.story);
1959
+ for (const kind of OWNED_SCOPES) for (const [owner, bag] of this.stores[kind]) put(flowKey(id, kind, owner), bag);
1960
+ this.mountBags();
1370
1961
  for (const box of internals.bundle.boxes) {
1371
1962
  this.turnCounts.set(box.id, 0);
1372
1963
  for (const hand of box.hands) this.boardContents.set(hand.id, []);
@@ -1394,8 +1985,28 @@ var Flow = class {
1394
1985
  }
1395
1986
  /** @internal */
1396
1987
  markClosed() {
1988
+ this.releaseBags(false);
1397
1989
  this.closed = true;
1398
1990
  }
1991
+ /** @internal - take this flow's bags out of the registry; with `keep`, their
1992
+ * values wait there for the flow that replaces this one (a load into the
1993
+ * game's registry). Idempotent. */
1994
+ releaseBags(keep) {
1995
+ for (const key of this.registered) this.internals.registry.remove(key, { keep });
1996
+ this.registered.length = 0;
1997
+ }
1998
+ /** @internal - the registry keys this flow holds right now. */
1999
+ registeredKeys() {
2000
+ return [...this.registered];
2001
+ }
2002
+ /** @internal - register this flow's bags (again): at construction, and when a
2003
+ * failed hotSwap hands them back. Each claims what the registry holds for it. */
2004
+ mountBags() {
2005
+ for (const [key, bag] of this.bagKeys) {
2006
+ this.internals.registry.mountOwned(key, bag, { owner: OWNER });
2007
+ this.registered.push(key);
2008
+ }
2009
+ }
1399
2010
  assertOpen() {
1400
2011
  if (this.closed) throw new Error(`flow "${this.id}" is closed`);
1401
2012
  }
@@ -1532,8 +2143,12 @@ var Flow = class {
1532
2143
  * is the flow's MERGED view - its own copies over the shared values,
1533
2144
  * names disjoint - and @world reads through the engine's resolver. */
1534
2145
  evalCtx(box, deck, handEnv) {
2146
+ const others = this.internals.registryView();
1535
2147
  return {
1536
2148
  scopes: {
2149
+ // Every other engine's game-wide scope first (every engine reads every
2150
+ // scope); this engine's own tokens are its merged views, over the top.
2151
+ ...others.scopes,
1537
2152
  world: this.internals.worldResolver,
1538
2153
  story: this.storyReader,
1539
2154
  box: this.boxReaders.get(box.id) ?? {},
@@ -1544,8 +2159,8 @@ var Flow = class {
1544
2159
  // The quality channel, answering for THIS ask's box and deck. Only wired
1545
2160
  // when a quality exists, so a bundle without one evaluates byte-
1546
2161
  // identically to before the feature.
1547
- ...this.internals.hasQualities ? {
1548
- 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
2162
+ ...this.internals.hasQualities || others.qualities !== void 0 ? {
2163
+ 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)
1549
2164
  } : {}
1550
2165
  };
1551
2166
  }
@@ -2184,8 +2799,17 @@ var Flow = class {
2184
2799
  if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
2185
2800
  return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
2186
2801
  }
2187
- default:
2802
+ default: {
2803
+ if (this.internals.registry.has(scope)) {
2804
+ const prev = this.internals.registry.get(scope, name);
2805
+ this.internals.registry.set(scope, name, value);
2806
+ return { path: `${scope}.${name}`, ...prev !== void 0 ? { prev } : {} };
2807
+ }
2808
+ if (this.internals.bundle.externalScopes?.includes(scope)) {
2809
+ throw new Error(`@${scope}.${name} cannot be written: no engine on this registry registered @${scope}`);
2810
+ }
2188
2811
  throw new Error(`bad change target scope "@${scope}"`);
2812
+ }
2189
2813
  }
2190
2814
  }
2191
2815
  /** Advance one box's clock (schema 3.4): a turn is one draw-from-stock
@@ -2278,7 +2902,7 @@ var Flow = class {
2278
2902
  getProperty(path) {
2279
2903
  this.assertOpen();
2280
2904
  const found = this.resolvePath(path);
2281
- const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
2905
+ 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);
2282
2906
  if (value === void 0) throw new Error(`no property at "${path}"`);
2283
2907
  return value;
2284
2908
  }
@@ -2290,6 +2914,10 @@ var Flow = class {
2290
2914
  this.internals.worldSet(found.name, value, true);
2291
2915
  return;
2292
2916
  }
2917
+ if (found.kind === "scope") {
2918
+ this.internals.registry.set(found.token, found.name, value, { host: true });
2919
+ return;
2920
+ }
2293
2921
  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;
2294
2922
  if (bag === void 0) throw new Error(`no property at "${path}"`);
2295
2923
  bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
@@ -2297,6 +2925,9 @@ var Flow = class {
2297
2925
  resolvePath(path) {
2298
2926
  const parts = path.split(".");
2299
2927
  if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
2928
+ if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
2929
+ return { kind: "scope", token: parts[0], name: parts[1] };
2930
+ }
2300
2931
  if (parts.length === 2 && parts[0] === "story") {
2301
2932
  return { kind: "bag", own: this.stores.story, shared: this.internals.shared.story, name: parts[1] };
2302
2933
  }
@@ -2315,10 +2946,11 @@ var Flow = class {
2315
2946
  throw new Error(`bad property path "${path}"`);
2316
2947
  }
2317
2948
  // --- persistence (schema 4) -------------------------------------------------
2318
- /** @internal - this flow's blob inside the engine's envelope. */
2319
- snapshot() {
2949
+ /** @internal - this flow's blob: inside the engine's envelope without its
2950
+ * properties (the registry has them), or parked whole by saveFlow. */
2951
+ snapshot(withProps) {
2320
2952
  return {
2321
- props: partitionValues(this.stores),
2953
+ ...withProps ? { props: partitionValues(this.stores) } : {},
2322
2954
  turns: Object.fromEntries(this.turnCounts),
2323
2955
  prng: this.prng.state(),
2324
2956
  cooldowns: this.cooldowns,
@@ -2329,7 +2961,7 @@ var Flow = class {
2329
2961
  /** @internal - restore a freshly opened flow from its blob (loadGame).
2330
2962
  * Orphaned keys (deleted entities) drop; new declarations keep defaults. */
2331
2963
  restore(saved) {
2332
- loadPartition(this.stores, saved.props);
2964
+ if (saved.props !== void 0) loadPartition(this.stores, saved.props);
2333
2965
  this.turnCounts = new Map(this.internals.bundle.boxes.map((b) => [b.id, 0]));
2334
2966
  for (const [boxId, turn] of Object.entries(saved.turns ?? {})) {
2335
2967
  if (this.turnCounts.has(boxId)) this.turnCounts.set(boxId, turn);