@storylet-studio/runtime 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -408,7 +408,48 @@ function effectiveGameId(entity) {
408
408
  const fromTitle = entity.title ? gameIdify(entity.title) : "";
409
409
  return fromTitle || entity.id;
410
410
  }
411
+ function valueAddresses(bundle) {
412
+ const tags = [];
413
+ for (const box of bundle.boxes) {
414
+ const boxGameId = effectiveGameId(box);
415
+ for (const group of box.tagGroups) {
416
+ for (const tag of group.tags) {
417
+ const gameId = effectiveGameId(tag);
418
+ tags.push({ id: tag.id, gameId, qualified: `${boxGameId}/${gameId}` });
419
+ }
420
+ }
421
+ }
422
+ const forms = /* @__PURE__ */ new Map();
423
+ for (const tag of tags) {
424
+ const list = forms.get(tag.gameId) ?? [];
425
+ if (!list.includes(tag.qualified)) list.push(tag.qualified);
426
+ forms.set(tag.gameId, list);
427
+ }
428
+ const print = /* @__PURE__ */ new Map();
429
+ const accept = /* @__PURE__ */ new Map();
430
+ const repeated = /* @__PURE__ */ new Map();
431
+ for (const tag of tags) {
432
+ const candidates = forms.get(tag.gameId) ?? [tag.qualified];
433
+ const ambiguous = candidates.length > 1;
434
+ print.set(tag.id, ambiguous ? tag.qualified : tag.gameId);
435
+ if (!accept.has(tag.qualified)) accept.set(tag.qualified, tag.id);
436
+ if (!ambiguous && !accept.has(tag.gameId)) accept.set(tag.gameId, tag.id);
437
+ if (ambiguous) repeated.set(tag.gameId, candidates);
438
+ }
439
+ return { print, accept, repeated };
440
+ }
441
+ function ambiguousValueAddressMessage(segment, name, candidates) {
442
+ const forms = candidates.map((q) => `"value.${q}.${name}"`);
443
+ const list = forms.length <= 1 ? forms[0] ?? "" : `${forms.slice(0, -1).join(", ")} or ${forms[forms.length - 1]}`;
444
+ return `"value.${segment}.${name}" names a tag in ${candidates.length} boxes; write ${list}`;
445
+ }
411
446
  var PLACE_GROUP = "place";
447
+ var HOLE_REF = /^@(hand|world|story)\.([a-z][a-z0-9_-]*)$/;
448
+ var isHoleRef = (value) => value.startsWith("@");
449
+ var parseHoleRef = (value) => {
450
+ const m = HOLE_REF.exec(value);
451
+ return m === null ? void 0 : { scope: m[1], name: m[2] };
452
+ };
412
453
 
413
454
  // ../../../expr/packages/scoperegistry/src/index.ts
414
455
  var PropertyBag = class _PropertyBag {
@@ -443,10 +484,14 @@ var PropertyBag = class _PropertyBag {
443
484
  }
444
485
  /** Write a property. Engine writes (the default) notify subscribers;
445
486
  * pass `silent: true` for a host write, which reaches only the audit
446
- * hook. Throws on a read-only property. Returns the change. */
487
+ * hook. Throws on a read-only property unless the caller says it is the
488
+ * HOST (`host: true`), for whom `writable: false` was never a rule - it is
489
+ * the story's promise, not the game's. `silent` and `host` are separate on
490
+ * purpose: one is about who hears the write, the other about who may make
491
+ * it. Returns the change. */
447
492
  set(name, value, opts) {
448
493
  const n = this.norm(name);
449
- if (this.decls.get(n)?.writable === false) throw new Error(`'${name}' is read-only`);
494
+ if (!opts?.host && this.decls.get(n)?.writable === false) throw new Error(`'${name}' is read-only`);
450
495
  const change = {
451
496
  name: n,
452
497
  prev: this.values[n],
@@ -561,27 +606,62 @@ var SCOPE_DEFAULT_SHARED = { story: true, box: false, deck: false, hand: false,
561
606
  var isShared = (scope, d) => d.shared ?? SCOPE_DEFAULT_SHARED[scope];
562
607
  var sharedHalf = (scope, decls) => decls.filter((d) => isShared(scope, d));
563
608
  var flowHalf = (scope, decls) => decls.filter((d) => !isShared(scope, d));
609
+ var OWNED_SCOPES = ["box", "deck", "hand", "value"];
610
+ var emptyOwnerIndexes = () => ({
611
+ box: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
612
+ deck: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
613
+ hand: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
614
+ value: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() }
615
+ });
616
+ var indexOwner = (index, entity) => {
617
+ const gameId = effectiveGameId(entity);
618
+ index.gameId.set(entity.id, gameId);
619
+ if (!index.id.has(gameId)) index.id.set(gameId, entity.id);
620
+ };
621
+ var indexValueOwners = (index, bundle) => {
622
+ const addresses = valueAddresses(bundle);
623
+ for (const [id, segment] of addresses.print) index.gameId.set(id, segment);
624
+ for (const [segment, id] of addresses.accept) index.id.set(segment, id);
625
+ for (const [gameId, candidates] of addresses.repeated) index.repeated.set(gameId, candidates);
626
+ };
564
627
  var handDeclsOf = (internals, hand) => {
565
628
  if (hand.template !== void 0) {
566
629
  return internals.templatesById.get(hand.template)?.properties ?? internals.bundle.boxes.flatMap((b) => b.handTemplates).find((t) => t.id === hand.template)?.properties ?? [];
567
630
  }
568
631
  return hand.properties ?? [];
569
632
  };
633
+ var addressOf = (internals, kind, id) => `${kind}.${internals.owners[kind].gameId.get(id) ?? id}`;
634
+ var resolveOwner = (internals, kind, segment) => {
635
+ const candidates = internals.owners[kind].repeated.get(segment);
636
+ if (candidates !== void 0) return { ambiguous: candidates };
637
+ const byGameId = internals.owners[kind].id.get(segment);
638
+ if (byGameId !== void 0) return { id: byGameId, legacy: false };
639
+ if (internals.owners[kind].gameId.has(segment)) return { id: segment, legacy: true };
640
+ return void 0;
641
+ };
642
+ var ownerOrThrow = (internals, kind, segment, name) => {
643
+ const owner = resolveOwner(internals, kind, segment);
644
+ if (owner === void 0) throw new Error(`no ${kind} store "${segment}"`);
645
+ if ("ambiguous" in owner) throw new Error(ambiguousValueAddressMessage(segment, name, owner.ambiguous));
646
+ return owner;
647
+ };
648
+ var legacyAddressMessage = (internals, kind, segment, name) => `"${kind}.${segment}.${name}" names the ${kind} by its internal id; write "${addressOf(internals, kind, segment)}.${name}". The internal-id form is refused after the next release.`;
570
649
  var buildPartition = (internals, half) => {
571
650
  const b = internals.bundle;
651
+ const at = (kind, id) => `${addressOf(internals, kind, id)}.`;
572
652
  return {
573
653
  story: bagFromDecls(half("story", b.story.properties), "story."),
574
- box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties), `box.${box.id}.`)])),
654
+ box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties), at("box", box.id))])),
575
655
  deck: new Map(b.boxes.flatMap((box) => box.decks.map(
576
- (deck) => [deck.id, bagFromDecls(half("deck", deck.properties), `deck.${deck.id}.`)]
656
+ (deck) => [deck.id, bagFromDecls(half("deck", deck.properties), at("deck", deck.id))]
577
657
  ))),
578
658
  // A template instance inherits the template's property declarations;
579
659
  // a standalone hand declares its own (schema 2.6).
580
660
  hand: new Map(b.boxes.flatMap((box) => box.hands.map(
581
- (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), `hand.${hand.id}.`)]
661
+ (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), at("hand", hand.id))]
582
662
  ))),
583
663
  value: new Map(b.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
584
- (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), `value.${tag.id}.`)]
664
+ (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), at("value", tag.id))]
585
665
  ))))
586
666
  };
587
667
  };
@@ -600,6 +680,105 @@ var loadPartition = (p, values) => {
600
680
  }
601
681
  }
602
682
  };
683
+ var emptyDraft = () => ({
684
+ evicted: [],
685
+ droppedCooldowns: [],
686
+ droppedSpent: [],
687
+ droppedProperties: [],
688
+ defaultedProperties: [],
689
+ retypedProperties: []
690
+ });
691
+ var SORT_SEP = "";
692
+ var byKey = (items, key) => [...items].map((item) => ({ item, k: key(item) })).sort((a, b) => a.k < b.k ? -1 : a.k > b.k ? 1 : 0).map((e) => e.item);
693
+ var propKey = (p) => `${p.flow ?? ""}${SORT_SEP}${p.path}`;
694
+ function valueFits(decl, value) {
695
+ switch (decl.type) {
696
+ case "boolean":
697
+ return typeof value === "boolean";
698
+ case "number":
699
+ return typeof value === "number";
700
+ case "string":
701
+ return typeof value === "string";
702
+ case "enum":
703
+ return typeof value === "string" && (decl.values === void 0 || decl.values.includes(value));
704
+ case "quality":
705
+ return typeof value === "string" && (decl.stages === void 0 || decl.stages.includes(value));
706
+ case "flags":
707
+ return Array.isArray(value) && (decl.values === void 0 || value.every((f) => decl.values.includes(f)));
708
+ default:
709
+ return true;
710
+ }
711
+ }
712
+ function walkScope(decls, saved, path, flow, draft) {
713
+ const at = (name) => ({ ...flow !== void 0 ? { flow } : {}, path: path(name) });
714
+ const byName = new Map((decls ?? []).map((d) => [d.name, d]));
715
+ const values = saved ?? {};
716
+ const clean = {};
717
+ for (const [name, value] of Object.entries(values)) {
718
+ const decl = byName.get(name);
719
+ if (decl === void 0) {
720
+ draft.droppedProperties.push(at(name));
721
+ continue;
722
+ }
723
+ if (!valueFits(decl, value)) {
724
+ draft.retypedProperties.push(at(name));
725
+ continue;
726
+ }
727
+ clean[name] = value;
728
+ }
729
+ for (const decl of decls ?? []) {
730
+ if (!(decl.name in values)) draft.defaultedProperties.push(at(decl.name));
731
+ }
732
+ return clean;
733
+ }
734
+ function walkPartition(internals, decls, values, flow, draft) {
735
+ const out = {
736
+ story: walkScope(decls.story, values?.story, (n) => `story.${n}`, flow, draft),
737
+ box: {},
738
+ deck: {},
739
+ hand: {},
740
+ value: {}
741
+ };
742
+ for (const kind of ["box", "deck", "hand", "value"]) {
743
+ const savedKind = values?.[kind] ?? {};
744
+ const ids = [.../* @__PURE__ */ new Set([...decls[kind].keys(), ...Object.keys(savedKind)])].sort();
745
+ for (const id of ids) {
746
+ const owner = addressOf(internals, kind, id);
747
+ out[kind][id] = walkScope(
748
+ decls[kind].get(id),
749
+ savedKind[id],
750
+ (n) => `${owner}.${n}`,
751
+ flow,
752
+ draft
753
+ );
754
+ }
755
+ }
756
+ return out;
757
+ }
758
+ function finishReport(bundle, saved, flows, draft) {
759
+ const drift = saved.version !== bundle.version || saved.hash !== bundle.hash;
760
+ const evicted = byKey(draft.evicted, (e) => [e.flow, e.hand, e.card, e.reason].join(SORT_SEP));
761
+ const droppedCooldowns = byKey(draft.droppedCooldowns, (c) => `${c.flow}${SORT_SEP}${c.card}`);
762
+ const droppedSpent = [...draft.droppedSpent].sort();
763
+ const droppedProperties = byKey(draft.droppedProperties, propKey);
764
+ const defaultedProperties = byKey(draft.defaultedProperties, propKey);
765
+ const retypedProperties = byKey(draft.retypedProperties, propKey);
766
+ return {
767
+ // `flows` is what the load restores, not something it had to change, so
768
+ // it never makes a report inexact.
769
+ exact: !drift && evicted.length === 0 && droppedCooldowns.length === 0 && droppedSpent.length === 0 && droppedProperties.length === 0 && defaultedProperties.length === 0 && retypedProperties.length === 0,
770
+ project: bundle.project,
771
+ version: { saved: saved.version, bundle: bundle.version },
772
+ hash: { saved: saved.hash, bundle: bundle.hash },
773
+ flows,
774
+ evicted,
775
+ droppedCooldowns,
776
+ droppedSpent,
777
+ droppedProperties,
778
+ defaultedProperties,
779
+ retypedProperties
780
+ };
781
+ }
603
782
  var Engine = class {
604
783
  internals;
605
784
  seed;
@@ -623,6 +802,7 @@ var Engine = class {
623
802
  boxesById: /* @__PURE__ */ new Map(),
624
803
  handsById: /* @__PURE__ */ new Map(),
625
804
  handsByGameId: /* @__PURE__ */ new Map(),
805
+ owners: emptyOwnerIndexes(),
626
806
  templatesById: /* @__PURE__ */ new Map(),
627
807
  groupsById: /* @__PURE__ */ new Map(),
628
808
  requiredGroups: /* @__PURE__ */ new Set(),
@@ -631,6 +811,7 @@ var Engine = class {
631
811
  hasQualities: false,
632
812
  hasShared: false,
633
813
  flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
814
+ sharedDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
634
815
  shared: void 0,
635
816
  worldResolver: void 0,
636
817
  worldReadOnly: /* @__PURE__ */ new Set(),
@@ -646,14 +827,17 @@ var Engine = class {
646
827
  engineTracing: () => this.engineTraceHandlers.size > 0
647
828
  };
648
829
  this.internals = internals;
830
+ indexValueOwners(internals.owners.value, bundle);
649
831
  for (const box of bundle.boxes) {
650
832
  internals.boxesById.set(box.id, box);
651
833
  internals.boxesByGameId.set(effectiveGameId(box), box);
834
+ indexOwner(internals.owners.box, box);
652
835
  for (const group of box.tagGroups) {
653
836
  internals.groupsById.set(group.id, { group, box });
654
837
  if (group.required === true) internals.requiredGroups.add(group.id);
655
838
  }
656
839
  for (const deck of box.decks) {
840
+ indexOwner(internals.owners.deck, deck);
657
841
  if (deck.shared === true) internals.hasShared = true;
658
842
  for (const card of deck.cards) {
659
843
  const entry = { card, deck, box };
@@ -668,22 +852,25 @@ var Engine = class {
668
852
  for (const hand of box.hands) {
669
853
  internals.handsById.set(hand.id, { hand, box });
670
854
  internals.handsByGameId.set(effectiveGameId(hand), { hand, box });
855
+ indexOwner(internals.owners.hand, hand);
671
856
  }
672
857
  }
673
858
  this.initLadders();
674
- internals.flowDecls = {
675
- story: flowHalf("story", bundle.story.properties),
676
- box: new Map(bundle.boxes.map((box) => [box.id, flowHalf("box", box.properties)])),
859
+ const declSet = (half) => ({
860
+ story: half("story", bundle.story.properties),
861
+ box: new Map(bundle.boxes.map((box) => [box.id, half("box", box.properties)])),
677
862
  deck: new Map(bundle.boxes.flatMap((box) => box.decks.map(
678
- (deck) => [deck.id, flowHalf("deck", deck.properties)]
863
+ (deck) => [deck.id, half("deck", deck.properties)]
679
864
  ))),
680
865
  hand: new Map(bundle.boxes.flatMap((box) => box.hands.map(
681
- (hand) => [hand.id, flowHalf("hand", handDeclsOf(internals, hand))]
866
+ (hand) => [hand.id, half("hand", handDeclsOf(internals, hand))]
682
867
  ))),
683
868
  value: new Map(bundle.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
684
- (tag) => [tag.id, flowHalf("value", tag.properties ?? [])]
869
+ (tag) => [tag.id, half("value", tag.properties ?? [])]
685
870
  ))))
686
- };
871
+ });
872
+ internals.flowDecls = declSet(flowHalf);
873
+ internals.sharedDecls = declSet(sharedHalf);
687
874
  this.initShared(this.hostWorld);
688
875
  }
689
876
  /** Build the shared stores and the @world seam. `hostWorld` sticks for the
@@ -694,14 +881,24 @@ var Engine = class {
694
881
  internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
695
882
  if (hostWorld !== void 0) {
696
883
  internals.worldResolver = hostWorld;
884
+ const set = hostWorld.set;
885
+ internals.worldSet = set !== void 0 ? (n, v) => {
886
+ set(n, v);
887
+ } : void 0;
697
888
  } else {
698
889
  const bag = bagFromDecls(internals.bundle.world.properties, "world.");
699
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.
700
894
  get: (n) => bag.get(n),
701
895
  set: (n, v) => {
702
896
  bag.set(n, v);
703
897
  }
704
898
  };
899
+ internals.worldSet = (n, v, host2) => {
900
+ bag.set(n, v, host2 === true ? { host: true } : void 0);
901
+ };
705
902
  }
706
903
  }
707
904
  /** Quality ladders by scope for the eval channel (design/quality.md):
@@ -735,6 +932,7 @@ var Engine = class {
735
932
  * state; shared state is untouched. There is no default flow: "main" is
736
933
  * a caller convention, not an engine rule. */
737
934
  openFlow(id, opts = {}) {
935
+ const otherClaims = opts.restore !== void 0 ? this.sharedClaimsExcept(id) : void 0;
738
936
  const existing = this.flowsById.get(id);
739
937
  if (existing) {
740
938
  const dealt = existing.heldCardIds().length;
@@ -743,6 +941,13 @@ var Engine = class {
743
941
  }
744
942
  const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
745
943
  this.flowsById.set(id, flow);
944
+ if (opts.restore !== void 0) {
945
+ const draft = emptyDraft();
946
+ const clean = this.planFlowRestore(id, structuredClone(opts.restore), otherClaims, draft);
947
+ flow.restore(clean);
948
+ const content = this.internals.bundle.content;
949
+ opts.onRestoreReport?.(finishReport(content, content, [id], draft));
950
+ }
746
951
  return flow;
747
952
  }
748
953
  getFlow(id) {
@@ -816,10 +1021,21 @@ var Engine = class {
816
1021
  }
817
1022
  return counts;
818
1023
  }
1024
+ /** The same ledger with one name left out: what the REST of the world
1025
+ * holds, which is the question a resume under that name has to ask. */
1026
+ sharedClaimsExcept(id) {
1027
+ const counts = /* @__PURE__ */ new Map();
1028
+ for (const [flowId, flow] of this.flowsById) {
1029
+ if (flowId === id) continue;
1030
+ for (const cardId of flow.heldCardIds()) counts.set(cardId, (counts.get(cardId) ?? 0) + 1);
1031
+ }
1032
+ return counts;
1033
+ }
819
1034
  // --- engine-level state access ----------------------------------------------
820
1035
  /**
821
1036
  * Read shared state by path: "world.x", "story.gold" (when shared),
822
- * "box.b_x.heat" (when shared). A ref that resolves PER-FLOW throws,
1037
+ * "box.village.heat" (when shared) - the owner segment is its GAMEID
1038
+ * (design/engine-server.md 4.4). A ref that resolves PER-FLOW throws,
823
1039
  * naming the fix - silently answering with some flow's copy (or a junk
824
1040
  * default) was the bug Patter's engine.getProperty guard exists to stop.
825
1041
  */
@@ -832,13 +1048,13 @@ var Engine = class {
832
1048
  setProperty(path, value) {
833
1049
  const found = this.resolveShared(path);
834
1050
  if (found.kind === "world") {
835
- if (!this.internals.worldResolver.set) {
1051
+ if (!this.internals.worldSet) {
836
1052
  throw new Error(`@world is read-only here: the host bound no write`);
837
1053
  }
838
- this.internals.worldResolver.set(found.name, value);
1054
+ this.internals.worldSet(found.name, value, true);
839
1055
  return;
840
1056
  }
841
- found.bag.set(found.name, value, { silent: true, reason: "host setProperty" });
1057
+ found.bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
842
1058
  }
843
1059
  resolveShared(path) {
844
1060
  const parts = path.split(".");
@@ -854,15 +1070,25 @@ var Engine = class {
854
1070
  }
855
1071
  if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
856
1072
  const kind = parts[0];
857
- const [, id, name] = parts;
1073
+ const [, segment, name] = parts;
1074
+ const owner = ownerOrThrow(this.internals, kind, segment, name);
1075
+ if (owner.legacy) this.diagnose(legacyAddressMessage(this.internals, kind, segment, name));
1076
+ const id = owner.id;
858
1077
  const bag = this.internals.shared[kind].get(id);
859
1078
  if (bag !== void 0 && bag.get(name) !== void 0) return { kind: "bag", bag, name };
860
1079
  if (this.internals.flowDecls[kind].get(id)?.some((d) => d.name === name)) perFlow();
861
- if (bag === void 0 && !this.internals.flowDecls[kind].has(id)) throw new Error(`no ${kind} store "${id}"`);
1080
+ if (bag === void 0 && !this.internals.flowDecls[kind].has(id)) throw new Error(`no ${kind} store "${segment}"`);
862
1081
  throw new Error(`no property at "${path}"`);
863
1082
  }
864
1083
  throw new Error(`bad property path "${path}"`);
865
1084
  }
1085
+ /** The engine's own surface has no flow, so an engine-level diagnostic
1086
+ * carries the empty flow id - the same way a LoadReport's shared half
1087
+ * carries no flow. It reaches the run log and the engine tap; there is
1088
+ * nowhere else for it to go, and it fires only on a legacy address. */
1089
+ diagnose(message) {
1090
+ this.internals.emitEngine("", { type: "diagnostic", where: "property address", message });
1091
+ }
866
1092
  /** The shared surface as examiner rows: @world (read through the
867
1093
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
868
1094
  listProperties() {
@@ -878,20 +1104,24 @@ var Engine = class {
878
1104
  ...d.values !== void 0 ? { values: d.values } : {},
879
1105
  ...d.stages !== void 0 ? { stages: d.stages } : {},
880
1106
  // @world is FOREIGN - a host resolver backs it - so writability is whether that
881
- // resolver can be written at all, which is the shared registry's own rule for a
882
- // foreign scope. The `as PropertyView` cast this replaced was hiding the field's
883
- // absence: the row type has always required it, and these rows shipped without one.
884
- writable: this.internals.worldResolver.set !== void 0
1107
+ // resolver can be written at all AND what the declaration says, which is the
1108
+ // shared registry's own rule for a foreign scope (its foreignWritable). The
1109
+ // `as PropertyView` cast this replaced was hiding the field's absence: the row
1110
+ // type has always required it, and these rows shipped without one.
1111
+ //
1112
+ // A row is where `writable: false` is meant to SHOW (Reboot.md 10): it tells a
1113
+ // state panel this is the game's value, not the story's. It does not stop the
1114
+ // panel editing it - the host's setProperty passes `{ host: true }`.
1115
+ writable: this.internals.worldSet !== void 0 && !this.internals.worldReadOnly.has(d.name)
885
1116
  });
886
1117
  }
887
1118
  const add = (_prefix, bag) => {
888
1119
  for (const row of bag.rows()) out.push(row);
889
1120
  };
890
1121
  add("story", this.internals.shared.story);
891
- for (const [id, bag] of this.internals.shared.box) add(`box.${id}`, bag);
892
- for (const [id, bag] of this.internals.shared.deck) add(`deck.${id}`, bag);
893
- for (const [id, bag] of this.internals.shared.hand) add(`hand.${id}`, bag);
894
- for (const [id, bag] of this.internals.shared.value) add(`value.${id}`, bag);
1122
+ for (const kind of OWNED_SCOPES) {
1123
+ for (const [id, bag] of this.internals.shared[kind]) add(addressOf(this.internals, kind, id), bag);
1124
+ }
895
1125
  return out;
896
1126
  }
897
1127
  /** The SHARED kernel bags with their store path prefixes (the state
@@ -900,7 +1130,7 @@ var Engine = class {
900
1130
  listBags() {
901
1131
  const mounts = [{ prefix: "story", bag: this.internals.shared.story }];
902
1132
  for (const kind of ["box", "deck", "hand", "value"]) {
903
- for (const [id, bag] of this.internals.shared[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
1133
+ for (const [id, bag] of this.internals.shared[kind]) mounts.push({ prefix: addressOf(this.internals, kind, id), bag });
904
1134
  }
905
1135
  return mounts;
906
1136
  }
@@ -921,20 +1151,137 @@ var Engine = class {
921
1151
  flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot()]))
922
1152
  });
923
1153
  }
1154
+ /** ONE flow's blob, to park a visit that is walking away: the same shape
1155
+ * the envelope carries per flow, and the same shape `openFlow`'s `restore`
1156
+ * option takes back (design/engine-server.md 4.1). Saving the whole
1157
+ * envelope to park one of four hundred players is wrong in cost and in
1158
+ * meaning. Throws for a name that is not open - a closed flow has nothing
1159
+ * left to save. */
1160
+ saveFlow(id) {
1161
+ const flow = this.flowsById.get(id);
1162
+ if (!flow) throw new Error(`unknown flow "${id}"`);
1163
+ return structuredClone(flow.snapshot());
1164
+ }
1165
+ /** What `loadGame(envelope)` would do that is not a plain restore, without
1166
+ * doing any of it (design/engine-server.md 4.9). Pure: nothing on this
1167
+ * engine moves. A project mismatch is refused here exactly as `loadGame`
1168
+ * refuses it - it is the one thing neither call will tolerate. */
1169
+ previewLoad(envelope) {
1170
+ this.assertSameProject(envelope);
1171
+ return this.planLoad(envelope).report;
1172
+ }
1173
+ /** What `openFlow(id, { restore: saved })` would do to a flow of that name,
1174
+ * without doing it: the same report shape, since a visit parked under one
1175
+ * build and resumed under the next raises the same questions. Pure. */
1176
+ previewFlowRestore(id, saved) {
1177
+ const draft = emptyDraft();
1178
+ this.planFlowRestore(id, saved, this.sharedClaimsExcept(id), draft);
1179
+ const content = this.internals.bundle.content;
1180
+ return finishReport(content, content, [id], draft);
1181
+ }
924
1182
  /** Restore: shared state once, then every flow REBUILT from its blob.
925
1183
  * Handles held from before the load are closed and inert (Patter's
926
- * rule); take fresh ones from getFlow()/flows(). */
1184
+ * rule); take fresh ones from getFlow()/flows().
1185
+ *
1186
+ * Returns the report `previewLoad` would have given for this envelope: the
1187
+ * drift tolerance that makes a load forgiving is what hides its cost, so
1188
+ * the cost comes back with the load whether or not anybody looked first. */
927
1189
  loadGame(envelope) {
1190
+ this.assertSameProject(envelope);
1191
+ const plan = this.planLoad(structuredClone(envelope));
1192
+ this.reset();
1193
+ loadPartition(this.internals.shared, plan.shared);
1194
+ for (const id of plan.spent) this.spent.add(id);
1195
+ for (const [id, clean] of plan.flows) this.openFlow(id).restore(clean);
1196
+ return plan.report;
1197
+ }
1198
+ assertSameProject(envelope) {
928
1199
  if (envelope.content.project !== this.internals.bundle.content.project) {
929
1200
  throw new Error(`save is for project "${envelope.content.project}", bundle is "${this.internals.bundle.content.project}"`);
930
1201
  }
931
- const env = structuredClone(envelope);
932
- this.reset();
933
- loadPartition(this.internals.shared, env.shared.props);
934
- for (const id of env.shared.spent ?? []) this.spent.add(id);
935
- for (const [id, saved] of Object.entries(env.flows ?? {})) {
936
- this.openFlow(id).restore(saved);
1202
+ }
1203
+ /** The whole-envelope walk: the report, and the cleaned state the apply
1204
+ * half writes. Nothing here touches the engine, which is what lets
1205
+ * previewLoad and loadGame share it. */
1206
+ planLoad(envelope) {
1207
+ const draft = emptyDraft();
1208
+ const shared = walkPartition(
1209
+ this.internals,
1210
+ this.internals.sharedDecls,
1211
+ envelope.shared?.props,
1212
+ void 0,
1213
+ draft
1214
+ );
1215
+ const spent = [];
1216
+ for (const cardId of envelope.shared?.spent ?? []) {
1217
+ if (this.internals.cardsById.has(cardId)) spent.push(cardId);
1218
+ else draft.droppedSpent.push(cardId);
1219
+ }
1220
+ const flows = [];
1221
+ for (const [id, saved] of Object.entries(envelope.flows ?? {})) {
1222
+ flows.push([id, this.planFlowRestore(id, saved, void 0, draft)]);
1223
+ }
1224
+ return {
1225
+ report: finishReport(this.internals.bundle.content, envelope.content, flows.map(([id]) => id), draft),
1226
+ shared,
1227
+ spent,
1228
+ flows
1229
+ };
1230
+ }
1231
+ /** One flow's walk. `otherClaims` is the rest of the world's shared ledger
1232
+ * and is present only for a SINGLE-flow restore into a live engine: a
1233
+ * whole-envelope load rebuilds every flow from one consistent moment, so
1234
+ * there is nobody else to compete with. */
1235
+ planFlowRestore(id, saved, otherClaims, draft) {
1236
+ const internals = this.internals;
1237
+ const props = walkPartition(internals, internals.flowDecls, saved.props, id, draft);
1238
+ const cooldowns = {};
1239
+ for (const [cardId, turn] of Object.entries(saved.cooldowns ?? {})) {
1240
+ if (internals.cardsById.has(cardId)) cooldowns[cardId] = turn;
1241
+ else draft.droppedCooldowns.push({ flow: id, card: cardId });
1242
+ }
1243
+ const cardName = (cardId) => {
1244
+ const entry = internals.cardsById.get(cardId);
1245
+ return entry ? effectiveGameId(entry.card) : cardId;
1246
+ };
1247
+ const board = {};
1248
+ const restored = /* @__PURE__ */ new Map();
1249
+ for (const [handId, ids] of Object.entries(saved.board ?? {})) {
1250
+ const known = internals.handsById.get(handId);
1251
+ if (known === void 0) {
1252
+ for (const cardId of ids) {
1253
+ draft.evicted.push({ flow: id, hand: handId, card: cardName(cardId), reason: "hand-vanished" });
1254
+ }
1255
+ continue;
1256
+ }
1257
+ const hand = effectiveGameId(known.hand);
1258
+ const kept = [];
1259
+ for (const cardId of ids) {
1260
+ const entry = internals.cardsById.get(cardId);
1261
+ if (entry === void 0) {
1262
+ draft.evicted.push({ flow: id, hand, card: cardId, reason: "vanished" });
1263
+ continue;
1264
+ }
1265
+ if (otherClaims !== void 0 && cardIsShared(entry.card, entry.deck.shared ?? false)) {
1266
+ const held = (otherClaims.get(cardId) ?? 0) + (restored.get(cardId) ?? 0);
1267
+ if (held >= sharedCap(entry.card)) {
1268
+ draft.evicted.push({ flow: id, hand, card: effectiveGameId(entry.card), reason: "claimed-elsewhere" });
1269
+ continue;
1270
+ }
1271
+ restored.set(cardId, (restored.get(cardId) ?? 0) + 1);
1272
+ }
1273
+ kept.push(cardId);
1274
+ }
1275
+ board[handId] = kept;
937
1276
  }
1277
+ return {
1278
+ props,
1279
+ turns: saved.turns ?? {},
1280
+ prng: saved.prng,
1281
+ cooldowns,
1282
+ board,
1283
+ playLog: saved.playLog ?? []
1284
+ };
938
1285
  }
939
1286
  };
940
1287
  var Flow = class {
@@ -1210,6 +1557,10 @@ var Flow = class {
1210
1557
  boundTags.set(groupId, tagId);
1211
1558
  }
1212
1559
  for (const [groupId, tagId] of Object.entries(hand.chosen ?? {})) {
1560
+ if (isHoleRef(tagId)) {
1561
+ this.fillHoleFromProperty(hand, groupId, tagId, boundTags, askNames);
1562
+ continue;
1563
+ }
1213
1564
  boundTags.set(groupId, tagId);
1214
1565
  const found = this.internals.groupsById.get(groupId);
1215
1566
  const tag = found?.group.tags.find((t) => t.id === tagId);
@@ -1218,6 +1569,10 @@ var Flow = class {
1218
1569
  condition = template.condition;
1219
1570
  } else {
1220
1571
  for (const [groupId, tagId] of Object.entries(hand.rule?.bindings ?? {})) {
1572
+ if (isHoleRef(tagId)) {
1573
+ this.fillHoleFromProperty(hand, groupId, tagId, boundTags, askNames);
1574
+ continue;
1575
+ }
1221
1576
  boundTags.set(groupId, tagId);
1222
1577
  const found = this.internals.groupsById.get(groupId);
1223
1578
  const tag = found?.group.tags.find((t) => t.id === tagId);
@@ -1253,6 +1608,58 @@ var Flow = class {
1253
1608
  this.bindStateGroups(box, boundTags, askNames);
1254
1609
  return { box, boundTags, askNames };
1255
1610
  }
1611
+ /**
1612
+ * Fill one hole from the property its value names: the hand that moves
1613
+ * (design/engine-server.md 4.6).
1614
+ *
1615
+ * The semantics are `bindStateGroups`' below, word for word, applied per
1616
+ * HOLE instead of per group: resolved at ask time, and a value naming no tag
1617
+ * leaves the hole UNBOUND (a wildcard) with a diagnostic rather than dealing
1618
+ * a silently empty hand. What is added is the `@hand` scope - the asking
1619
+ * hand's OWN declared state, read here from the flow's merged view (the
1620
+ * shared half under the flow's own, so a `shared: true` declaration moves
1621
+ * the hole for every flow and a per-flow one moves it for this flow alone).
1622
+ *
1623
+ * Read BEFORE tag composition, which is the whole reason it is safe: the
1624
+ * @hand bag a card sees is built from the bound tags, so resolving a hole
1625
+ * from it would be circular. A hand's own declarations are not, so they are.
1626
+ */
1627
+ fillHoleFromProperty(hand, groupId, ref, boundTags, askNames) {
1628
+ const found = this.internals.groupsById.get(groupId);
1629
+ const groupName = found ? effectiveGameId(found.group) : groupId;
1630
+ const where = `hand ${effectiveGameId(hand)}, tag group ${groupName}`;
1631
+ const parsed = parseHoleRef(ref);
1632
+ if (!parsed) {
1633
+ this.emit({ type: "diagnostic", where, message: `"${ref}" is not a @hand, @world or @story property reference` });
1634
+ return;
1635
+ }
1636
+ if (!found) {
1637
+ this.emit({ type: "diagnostic", where, message: `"${ref}" fills a tag group that is not in this box` });
1638
+ return;
1639
+ }
1640
+ let value;
1641
+ if (parsed.scope === "hand") {
1642
+ value = this.valuesOf("hand", hand.id)[parsed.name];
1643
+ } else {
1644
+ try {
1645
+ value = this.getProperty(`${parsed.scope}.${parsed.name}`);
1646
+ } catch {
1647
+ value = void 0;
1648
+ }
1649
+ }
1650
+ if (value === void 0) {
1651
+ this.emit({ type: "diagnostic", where, message: `"${ref}" names a property that is not declared` });
1652
+ return;
1653
+ }
1654
+ const wanted = typeof value === "string" ? value : String(value);
1655
+ const tag = found.group.tags.find((t) => effectiveGameId(t) === wanted);
1656
+ if (!tag) {
1657
+ this.emit({ type: "diagnostic", where, message: `${ref} is "${wanted}", which is not one of the tags of "${groupName}"` });
1658
+ return;
1659
+ }
1660
+ boundTags.set(groupId, tag.id);
1661
+ askNames[groupName] = effectiveGameId(tag);
1662
+ }
1256
1663
  /**
1257
1664
  * Bind every `boundBy` group in the box from the property it names.
1258
1665
  *
@@ -1383,8 +1790,8 @@ var Flow = class {
1383
1790
  runAsk(ask, claimed, trace) {
1384
1791
  const { box } = ask;
1385
1792
  const handEnv = this.buildHandEnv(ask);
1386
- const verdict = (id, v) => {
1387
- trace?.push({ id, verdict: v });
1793
+ const verdict = (card, v) => {
1794
+ trace?.push({ id: effectiveGameId(card), verdict: v });
1388
1795
  };
1389
1796
  if (!this.passes(ask.condition, this.evalCtx(box, void 0, handEnv), `hand ${ask.hand ? effectiveGameId(ask.hand) : ""} condition`)) {
1390
1797
  return { ordered: [], handEnv };
@@ -1401,19 +1808,19 @@ var Flow = class {
1401
1808
  for (const card of deck.cards) {
1402
1809
  const shared = cardIsShared(card, deckShared);
1403
1810
  if (!gateOk.get(deck.id)) {
1404
- verdict(card.id, "deck-gate");
1811
+ verdict(card, "deck-gate");
1405
1812
  continue;
1406
1813
  }
1407
1814
  if (shared && this.engine.isTaken(card.id)) {
1408
- verdict(card.id, "taken");
1815
+ verdict(card, "taken");
1409
1816
  continue;
1410
1817
  }
1411
1818
  if ((this.cooldowns[card.id] ?? 0) > turn) {
1412
- verdict(card.id, "cooldown");
1819
+ verdict(card, "cooldown");
1413
1820
  continue;
1414
1821
  }
1415
1822
  if (!this.tagsMatch(card, handEnv.boundTags)) {
1416
- verdict(card.id, "tags");
1823
+ verdict(card, "tags");
1417
1824
  continue;
1418
1825
  }
1419
1826
  if (card.condition && !this.passes(
@@ -1421,12 +1828,12 @@ var Flow = class {
1421
1828
  deckCtx,
1422
1829
  this.tracing ? `card ${card.gameId} condition` : void 0
1423
1830
  )) {
1424
- verdict(card.id, "condition");
1831
+ verdict(card, "condition");
1425
1832
  continue;
1426
1833
  }
1427
1834
  const refused = claimed(card, shared);
1428
1835
  if (refused) {
1429
- verdict(card.id, refused);
1836
+ verdict(card, refused);
1430
1837
  continue;
1431
1838
  }
1432
1839
  let priority;
@@ -1436,7 +1843,7 @@ var Flow = class {
1436
1843
  try {
1437
1844
  const v = this.eval(card.priority, deckCtx);
1438
1845
  if (typeof v !== "number") {
1439
- verdict(card.id, "priority");
1846
+ verdict(card, "priority");
1440
1847
  continue;
1441
1848
  }
1442
1849
  priority = v;
@@ -1444,7 +1851,7 @@ var Flow = class {
1444
1851
  if (this.tracing) {
1445
1852
  this.emit({ type: "diagnostic", where: `card ${card.gameId} priority`, message: e instanceof Error ? e.message : String(e) });
1446
1853
  }
1447
- verdict(card.id, "priority");
1854
+ verdict(card, "priority");
1448
1855
  continue;
1449
1856
  }
1450
1857
  }
@@ -1475,11 +1882,13 @@ var Flow = class {
1475
1882
  i = j;
1476
1883
  }
1477
1884
  for (const s of scored) {
1478
- trace?.push({ id: s.entry.card.id, verdict: "dealt", priority: s.priority, specificity: s.spec });
1885
+ trace?.push({ id: effectiveGameId(s.entry.card), verdict: "dealt", priority: s.priority, specificity: s.spec });
1479
1886
  }
1480
1887
  return { ordered: scored.map((s) => s.entry), handEnv };
1481
1888
  }
1482
- /** Flip eligible-but-not-taken trace entries to "capped". */
1889
+ /** Flip eligible-but-not-taken trace entries to "capped". `taken` is keyed
1890
+ * by GAMEID, as the trace rows are (4.4): the two must move together or
1891
+ * every dealt card silently reads as capped. */
1483
1892
  capTrace(trace, taken) {
1484
1893
  for (const entry of trace) {
1485
1894
  if (entry.verdict === "dealt" && !taken.has(entry.id)) entry.verdict = "capped";
@@ -1520,7 +1929,7 @@ var Flow = class {
1520
1929
  const { ordered } = this.runAsk(ask, (card, shared) => this.claimVerdict(card, shared, claimCounts, worldClaims), trace);
1521
1930
  const listed = n === void 0 ? ordered : ordered.slice(0, Math.max(n, 0));
1522
1931
  if (trace) {
1523
- this.capTrace(trace, new Set(listed.map((e) => e.card.id)));
1932
+ this.capTrace(trace, new Set(listed.map((e) => effectiveGameId(e.card))));
1524
1933
  this.emit({ type: "peek", box: effectiveGameId(box), criteria, cards: trace }, this.turnCounts.get(box.id) ?? 0);
1525
1934
  }
1526
1935
  return { box: effectiveGameId(box), cards: listed.map((e) => this.view(e)) };
@@ -1550,7 +1959,8 @@ var Flow = class {
1550
1959
  const turn = this.turnCounts.get(box.id) ?? 0;
1551
1960
  const evicted = [];
1552
1961
  const evict = (cardId, reason) => {
1553
- evicted.push({ card: cardId, reason });
1962
+ const known = this.internals.cardsById.get(cardId);
1963
+ evicted.push({ card: known ? effectiveGameId(known.card) : cardId, reason });
1554
1964
  return false;
1555
1965
  };
1556
1966
  const survivors = (this.boardContents.get(hand.id) ?? []).filter((cardId) => {
@@ -1567,7 +1977,7 @@ var Flow = class {
1567
1977
  });
1568
1978
  this.boardContents.set(hand.id, survivors);
1569
1979
  if (this.tracing) {
1570
- for (const e of evicted) this.emit({ type: "evict", hand: hand.id, card: e.card, reason: e.reason }, turn);
1980
+ for (const e of evicted) this.emit({ type: "evict", hand: effectiveGameId(hand), card: e.card, reason: e.reason }, turn);
1571
1981
  }
1572
1982
  }
1573
1983
  const claimCounts = this.claims();
@@ -1584,14 +1994,15 @@ var Flow = class {
1584
1994
  (card, shared) => own.has(card.id) ? "claimed" : this.claimVerdict(card, shared, claimCounts, worldClaims),
1585
1995
  trace
1586
1996
  );
1587
- const added = ordered.slice(0, free).map((e) => e.card.id);
1997
+ const taking = ordered.slice(0, free);
1998
+ const added = taking.map((e) => e.card.id);
1588
1999
  this.boardContents.set(hand.id, [...contents, ...added]);
1589
2000
  for (const id of added) {
1590
2001
  claimCounts.set(id, (claimCounts.get(id) ?? 0) + 1);
1591
2002
  worldClaims.set(id, (worldClaims.get(id) ?? 0) + 1);
1592
2003
  }
1593
2004
  if (trace) {
1594
- this.capTrace(trace, new Set(added));
2005
+ this.capTrace(trace, new Set(taking.map((e) => effectiveGameId(e.card))));
1595
2006
  this.emit({ type: "deal", hand: effectiveGameId(hand), cards: trace }, this.turnCounts.get(box.id) ?? 0);
1596
2007
  }
1597
2008
  }
@@ -1659,7 +2070,8 @@ var Flow = class {
1659
2070
  if (!this.passes(outcome.condition, ctx)) {
1660
2071
  throw new Error(`outcome "${outcomeGameId}" on "${effectiveGameId(entry.card)}" is gated shut`);
1661
2072
  }
1662
- const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? this.internals.bundle.settings.playAdvancesTurns);
2073
+ const perPlay = entry.box.turn !== void 0 ? 0 : this.internals.bundle.settings.playAdvancesTurns;
2074
+ const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? perPlay);
1663
2075
  const writes = [];
1664
2076
  for (const [target, expr] of Object.entries(outcome.changes)) {
1665
2077
  writes.push({ target, value: this.eval(expr, ctx) });
@@ -1683,7 +2095,11 @@ var Flow = class {
1683
2095
  (this.boardContents.get(handId) ?? []).filter((id) => id !== entry.card.id)
1684
2096
  );
1685
2097
  this.turnCounts.set(entry.box.id, newTurn);
1686
- if (this.tracing) this.emit({ type: "play", card: entry.card.id, outcome: effectiveGameId(outcome), turn: newTurn }, newTurn);
2098
+ if (this.tracing) this.emit({ type: "play", card: effectiveGameId(entry.card), outcome: effectiveGameId(outcome), turn: newTurn }, newTurn);
2099
+ }
2100
+ /** One owned property's address, owner segment and all (4.4). */
2101
+ address(kind, id) {
2102
+ return addressOf(this.internals, kind, id);
1687
2103
  }
1688
2104
  /** Land one change in whichever partition declares the name: the flow's
1689
2105
  * bag when the property is per-flow, the shared bag when it is shared -
@@ -1704,24 +2120,24 @@ var Flow = class {
1704
2120
  const [, scope, name] = match;
1705
2121
  switch (scope) {
1706
2122
  case "world": {
1707
- const resolver = this.internals.worldResolver;
1708
- if (!resolver.set) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
2123
+ const worldSet = this.internals.worldSet;
2124
+ if (!worldSet) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
1709
2125
  if (this.internals.worldReadOnly.has(name)) throw new Error(`'@world.${name}' is read-only (writable: false)`);
1710
- const prev = resolver.get(name);
1711
- resolver.set(name, value);
2126
+ const prev = this.internals.worldResolver.get(name);
2127
+ worldSet(name, value);
1712
2128
  return { path: `world.${name}`, ...prev !== void 0 ? { prev } : {} };
1713
2129
  }
1714
2130
  case "story":
1715
2131
  return this.landIn("story", void 0, name, value, `story.${name}`);
1716
2132
  case "box":
1717
- return this.landIn("box", entry.box.id, name, value, `box.${entry.box.id}.${name}`);
2133
+ return this.landIn("box", entry.box.id, name, value, `${this.address("box", entry.box.id)}.${name}`);
1718
2134
  case "deck":
1719
- return this.landIn("deck", entry.deck.id, name, value, `deck.${entry.deck.id}.${name}`);
2135
+ return this.landIn("deck", entry.deck.id, name, value, `${this.address("deck", entry.deck.id)}.${name}`);
1720
2136
  case "hand": {
1721
2137
  const source = handEnv.sources.get(name);
1722
2138
  if (!source) throw new Error(`@hand.${name} is not composed in this ask`);
1723
2139
  if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
1724
- return this.landIn(source.kind, source.id, name, value, `${source.kind}.${source.id}.${name}`);
2140
+ return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
1725
2141
  }
1726
2142
  default:
1727
2143
  throw new Error(`bad change target scope "@${scope}"`);
@@ -1757,7 +2173,7 @@ var Flow = class {
1757
2173
  this.assertOpen();
1758
2174
  const mounts = [{ prefix: "story", bag: this.stores.story }];
1759
2175
  for (const kind of ["box", "deck", "hand", "value"]) {
1760
- for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
2176
+ for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: addressOf(this.internals, kind, id), bag });
1761
2177
  }
1762
2178
  return mounts;
1763
2179
  }
@@ -1780,10 +2196,15 @@ var Flow = class {
1780
2196
  ...d.values !== void 0 ? { values: d.values } : {},
1781
2197
  ...d.stages !== void 0 ? { stages: d.stages } : {},
1782
2198
  // @world is FOREIGN - a host resolver backs it - so writability is whether that
1783
- // resolver can be written at all, which is the shared registry's own rule for a
1784
- // foreign scope. The `as PropertyView` cast this replaced was hiding the field's
1785
- // absence: the row type has always required it, and these rows shipped without one.
1786
- writable: this.internals.worldResolver.set !== void 0
2199
+ // resolver can be written at all AND what the declaration says, which is the
2200
+ // shared registry's own rule for a foreign scope (its foreignWritable). The
2201
+ // `as PropertyView` cast this replaced was hiding the field's absence: the row
2202
+ // type has always required it, and these rows shipped without one.
2203
+ //
2204
+ // A row is where `writable: false` is meant to SHOW (Reboot.md 10): it tells a
2205
+ // state panel this is the game's value, not the story's. It does not stop the
2206
+ // panel editing it - the host's setProperty passes `{ host: true }`.
2207
+ writable: this.internals.worldSet !== void 0 && !this.internals.worldReadOnly.has(d.name)
1787
2208
  });
1788
2209
  }
1789
2210
  const add = (_prefix, shared, own) => {
@@ -1793,15 +2214,22 @@ var Flow = class {
1793
2214
  }
1794
2215
  };
1795
2216
  add("story", this.internals.shared.story, this.stores.story);
1796
- for (const kind of ["box", "deck", "hand", "value"]) {
2217
+ for (const kind of OWNED_SCOPES) {
1797
2218
  const ids = /* @__PURE__ */ new Set([...this.internals.shared[kind].keys(), ...this.stores[kind].keys()]);
1798
- for (const id of ids) add(`${kind}.${id}`, this.internals.shared[kind].get(id), this.stores[kind].get(id));
2219
+ for (const id of ids) {
2220
+ add(addressOf(this.internals, kind, id), this.internals.shared[kind].get(id), this.stores[kind].get(id));
2221
+ }
1799
2222
  }
1800
2223
  return out;
1801
2224
  }
1802
- /** Read by path: "world.x", "story.gold", "value.v_docks.danger",
1803
- * "box.b_x.heat", "deck.k_main.n", "hand.h_board.owner" - the flow's
1804
- * merged view, routed by the declaration's sharing. */
2225
+ /** Read by path: "world.x", "story.gold", "value.docks.danger",
2226
+ * "box.village.heat", "deck.wares.n", "hand.the-elder.zone" - the flow's
2227
+ * merged view, routed by the declaration's sharing.
2228
+ *
2229
+ * The owner segment is the entity's GAMEID, the name it is called by
2230
+ * everywhere else (4.4). Its internal id is accepted for this release and
2231
+ * earns a `diagnostic` naming the address to move to; the next lockstep
2232
+ * release refuses it. */
1805
2233
  getProperty(path) {
1806
2234
  this.assertOpen();
1807
2235
  const found = this.resolvePath(path);
@@ -1813,13 +2241,13 @@ var Flow = class {
1813
2241
  this.assertOpen();
1814
2242
  const found = this.resolvePath(path);
1815
2243
  if (found.kind === "world") {
1816
- if (!this.internals.worldResolver.set) throw new Error(`@world is read-only here: the host bound no write`);
1817
- this.internals.worldResolver.set(found.name, value);
2244
+ if (!this.internals.worldSet) throw new Error(`@world is read-only here: the host bound no write`);
2245
+ this.internals.worldSet(found.name, value, true);
1818
2246
  return;
1819
2247
  }
1820
2248
  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;
1821
2249
  if (bag === void 0) throw new Error(`no property at "${path}"`);
1822
- bag.set(found.name, value, { silent: true, reason: "host setProperty" });
2250
+ bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
1823
2251
  }
1824
2252
  resolvePath(path) {
1825
2253
  const parts = path.split(".");
@@ -1829,10 +2257,15 @@ var Flow = class {
1829
2257
  }
1830
2258
  if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
1831
2259
  const kind = parts[0];
1832
- const own = this.stores[kind].get(parts[1]);
1833
- const shared = this.internals.shared[kind].get(parts[1]);
1834
- if (own === void 0 && shared === void 0) throw new Error(`no ${parts[0]} store "${parts[1]}"`);
1835
- return { kind: "bag", ...own !== void 0 ? { own } : {}, ...shared !== void 0 ? { shared } : {}, name: parts[2] };
2260
+ const [, segment, name] = parts;
2261
+ const owner = ownerOrThrow(this.internals, kind, segment, name);
2262
+ if (owner.legacy && this.tracing) {
2263
+ this.emit({ type: "diagnostic", where: "property address", message: legacyAddressMessage(this.internals, kind, segment, name) });
2264
+ }
2265
+ const own = this.stores[kind].get(owner.id);
2266
+ const shared = this.internals.shared[kind].get(owner.id);
2267
+ if (own === void 0 && shared === void 0) throw new Error(`no ${kind} store "${segment}"`);
2268
+ return { kind: "bag", ...own !== void 0 ? { own } : {}, ...shared !== void 0 ? { shared } : {}, name };
1836
2269
  }
1837
2270
  throw new Error(`bad property path "${path}"`);
1838
2271
  }
@@ -1873,14 +2306,27 @@ var summarise = (decls) => decls.map((d) => ({
1873
2306
  type: d.type,
1874
2307
  default: d.default,
1875
2308
  ...d.values !== void 0 ? { values: d.values } : {},
2309
+ ...d.durable === true ? { durable: true } : {},
1876
2310
  ...d.purpose !== void 0 ? { purpose: d.purpose } : {}
1877
2311
  }));
2312
+ var durableCardCount = (box) => box.decks.reduce((n, deck) => n + deck.cards.filter((card) => (card.durable ?? deck.durable) === true).length, 0);
1878
2313
  var handDecls = (hand, box) => {
1879
2314
  if (hand.template !== void 0) {
1880
2315
  return box.handTemplates.find((t) => t.id === hand.template)?.properties ?? [];
1881
2316
  }
1882
2317
  return hand.properties ?? [];
1883
2318
  };
2319
+ var movableHoles = (hand, box) => {
2320
+ const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
2321
+ const out = [];
2322
+ for (const [groupId, value] of Object.entries(filled ?? {})) {
2323
+ if (!isHoleRef(value)) continue;
2324
+ const group = box.tagGroups.find((g) => g.id === groupId);
2325
+ if (group === void 0) continue;
2326
+ out.push({ group: effectiveGameId(group), from: value });
2327
+ }
2328
+ return out;
2329
+ };
1884
2330
  var handSlots = (hand, box) => {
1885
2331
  if (hand.slots !== void 0) return hand.slots;
1886
2332
  const declared = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template)?.slots : hand.rule?.slots;
@@ -1901,6 +2347,8 @@ function describeBundle(bundle) {
1901
2347
  gameId: boxGameId,
1902
2348
  ...box.title !== void 0 ? { title: box.title } : {},
1903
2349
  ranking: { specificity: box.ranking.specificity },
2350
+ ...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
2351
+ ...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
1904
2352
  tagGroups: box.tagGroups.map((group) => ({
1905
2353
  gameId: effectiveGameId(group),
1906
2354
  tags: group.tags.map((tag) => effectiveGameId(tag))
@@ -1921,12 +2369,14 @@ function describeBundle(bundle) {
1921
2369
  totals.tagGroups += box.tagGroups.length;
1922
2370
  for (const hand of box.hands) {
1923
2371
  const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
2372
+ const movable = movableHoles(hand, box);
1924
2373
  hands.push({
1925
2374
  gameId: effectiveGameId(hand),
1926
2375
  ...hand.title !== void 0 ? { title: hand.title } : {},
1927
2376
  box: boxGameId,
1928
2377
  slots: handSlots(hand, box),
1929
- ...template !== void 0 ? { template: effectiveGameId(template) } : {}
2378
+ ...template !== void 0 ? { template: effectiveGameId(template) } : {},
2379
+ ...movable.length > 0 ? { movable } : {}
1930
2380
  });
1931
2381
  }
1932
2382
  const push = (scope, owner, decls, group) => {
@@ -1964,7 +2414,8 @@ function describeBundle(bundle) {
1964
2414
  box: map.box,
1965
2415
  group: map.group,
1966
2416
  zones: map.zones.length,
1967
- backgrounds: map.backgrounds?.length ?? 0
2417
+ backgrounds: map.backgrounds?.length ?? 0,
2418
+ sites: map.sites?.length ?? 0
1968
2419
  }))
1969
2420
  };
1970
2421
  }