@storylet-studio/runtime 0.4.1 → 0.6.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
  }
@@ -1643,6 +2054,7 @@ var Flow = class {
1643
2054
  gameId: effectiveGameId(o),
1644
2055
  ...o.title !== void 0 ? { title: o.title } : {},
1645
2056
  ...o.purpose !== void 0 ? { purpose: o.purpose } : {},
2057
+ ...o.fields !== void 0 ? { fields: o.fields } : {},
1646
2058
  available: this.passes(o.condition, ctx)
1647
2059
  }));
1648
2060
  }
@@ -1659,7 +2071,8 @@ var Flow = class {
1659
2071
  if (!this.passes(outcome.condition, ctx)) {
1660
2072
  throw new Error(`outcome "${outcomeGameId}" on "${effectiveGameId(entry.card)}" is gated shut`);
1661
2073
  }
1662
- const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? this.internals.bundle.settings.playAdvancesTurns);
2074
+ const perPlay = entry.box.turn !== void 0 ? 0 : this.internals.bundle.settings.playAdvancesTurns;
2075
+ const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? perPlay);
1663
2076
  const writes = [];
1664
2077
  for (const [target, expr] of Object.entries(outcome.changes)) {
1665
2078
  writes.push({ target, value: this.eval(expr, ctx) });
@@ -1683,7 +2096,11 @@ var Flow = class {
1683
2096
  (this.boardContents.get(handId) ?? []).filter((id) => id !== entry.card.id)
1684
2097
  );
1685
2098
  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);
2099
+ if (this.tracing) this.emit({ type: "play", card: effectiveGameId(entry.card), outcome: effectiveGameId(outcome), turn: newTurn }, newTurn);
2100
+ }
2101
+ /** One owned property's address, owner segment and all (4.4). */
2102
+ address(kind, id) {
2103
+ return addressOf(this.internals, kind, id);
1687
2104
  }
1688
2105
  /** Land one change in whichever partition declares the name: the flow's
1689
2106
  * bag when the property is per-flow, the shared bag when it is shared -
@@ -1704,24 +2121,24 @@ var Flow = class {
1704
2121
  const [, scope, name] = match;
1705
2122
  switch (scope) {
1706
2123
  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`);
2124
+ const worldSet = this.internals.worldSet;
2125
+ if (!worldSet) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
1709
2126
  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);
2127
+ const prev = this.internals.worldResolver.get(name);
2128
+ worldSet(name, value);
1712
2129
  return { path: `world.${name}`, ...prev !== void 0 ? { prev } : {} };
1713
2130
  }
1714
2131
  case "story":
1715
2132
  return this.landIn("story", void 0, name, value, `story.${name}`);
1716
2133
  case "box":
1717
- return this.landIn("box", entry.box.id, name, value, `box.${entry.box.id}.${name}`);
2134
+ return this.landIn("box", entry.box.id, name, value, `${this.address("box", entry.box.id)}.${name}`);
1718
2135
  case "deck":
1719
- return this.landIn("deck", entry.deck.id, name, value, `deck.${entry.deck.id}.${name}`);
2136
+ return this.landIn("deck", entry.deck.id, name, value, `${this.address("deck", entry.deck.id)}.${name}`);
1720
2137
  case "hand": {
1721
2138
  const source = handEnv.sources.get(name);
1722
2139
  if (!source) throw new Error(`@hand.${name} is not composed in this ask`);
1723
2140
  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}`);
2141
+ return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
1725
2142
  }
1726
2143
  default:
1727
2144
  throw new Error(`bad change target scope "@${scope}"`);
@@ -1757,7 +2174,7 @@ var Flow = class {
1757
2174
  this.assertOpen();
1758
2175
  const mounts = [{ prefix: "story", bag: this.stores.story }];
1759
2176
  for (const kind of ["box", "deck", "hand", "value"]) {
1760
- for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
2177
+ for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: addressOf(this.internals, kind, id), bag });
1761
2178
  }
1762
2179
  return mounts;
1763
2180
  }
@@ -1780,10 +2197,15 @@ var Flow = class {
1780
2197
  ...d.values !== void 0 ? { values: d.values } : {},
1781
2198
  ...d.stages !== void 0 ? { stages: d.stages } : {},
1782
2199
  // @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
2200
+ // resolver can be written at all AND what the declaration says, which is the
2201
+ // shared registry's own rule for a foreign scope (its foreignWritable). The
2202
+ // `as PropertyView` cast this replaced was hiding the field's absence: the row
2203
+ // type has always required it, and these rows shipped without one.
2204
+ //
2205
+ // A row is where `writable: false` is meant to SHOW (Reboot.md 10): it tells a
2206
+ // state panel this is the game's value, not the story's. It does not stop the
2207
+ // panel editing it - the host's setProperty passes `{ host: true }`.
2208
+ writable: this.internals.worldSet !== void 0 && !this.internals.worldReadOnly.has(d.name)
1787
2209
  });
1788
2210
  }
1789
2211
  const add = (_prefix, shared, own) => {
@@ -1793,15 +2215,22 @@ var Flow = class {
1793
2215
  }
1794
2216
  };
1795
2217
  add("story", this.internals.shared.story, this.stores.story);
1796
- for (const kind of ["box", "deck", "hand", "value"]) {
2218
+ for (const kind of OWNED_SCOPES) {
1797
2219
  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));
2220
+ for (const id of ids) {
2221
+ add(addressOf(this.internals, kind, id), this.internals.shared[kind].get(id), this.stores[kind].get(id));
2222
+ }
1799
2223
  }
1800
2224
  return out;
1801
2225
  }
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. */
2226
+ /** Read by path: "world.x", "story.gold", "value.docks.danger",
2227
+ * "box.village.heat", "deck.wares.n", "hand.the-elder.zone" - the flow's
2228
+ * merged view, routed by the declaration's sharing.
2229
+ *
2230
+ * The owner segment is the entity's GAMEID, the name it is called by
2231
+ * everywhere else (4.4). Its internal id is accepted for this release and
2232
+ * earns a `diagnostic` naming the address to move to; the next lockstep
2233
+ * release refuses it. */
1805
2234
  getProperty(path) {
1806
2235
  this.assertOpen();
1807
2236
  const found = this.resolvePath(path);
@@ -1813,13 +2242,13 @@ var Flow = class {
1813
2242
  this.assertOpen();
1814
2243
  const found = this.resolvePath(path);
1815
2244
  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);
2245
+ if (!this.internals.worldSet) throw new Error(`@world is read-only here: the host bound no write`);
2246
+ this.internals.worldSet(found.name, value, true);
1818
2247
  return;
1819
2248
  }
1820
2249
  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
2250
  if (bag === void 0) throw new Error(`no property at "${path}"`);
1822
- bag.set(found.name, value, { silent: true, reason: "host setProperty" });
2251
+ bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
1823
2252
  }
1824
2253
  resolvePath(path) {
1825
2254
  const parts = path.split(".");
@@ -1829,10 +2258,15 @@ var Flow = class {
1829
2258
  }
1830
2259
  if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
1831
2260
  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] };
2261
+ const [, segment, name] = parts;
2262
+ const owner = ownerOrThrow(this.internals, kind, segment, name);
2263
+ if (owner.legacy && this.tracing) {
2264
+ this.emit({ type: "diagnostic", where: "property address", message: legacyAddressMessage(this.internals, kind, segment, name) });
2265
+ }
2266
+ const own = this.stores[kind].get(owner.id);
2267
+ const shared = this.internals.shared[kind].get(owner.id);
2268
+ if (own === void 0 && shared === void 0) throw new Error(`no ${kind} store "${segment}"`);
2269
+ return { kind: "bag", ...own !== void 0 ? { own } : {}, ...shared !== void 0 ? { shared } : {}, name };
1836
2270
  }
1837
2271
  throw new Error(`bad property path "${path}"`);
1838
2272
  }
@@ -1873,14 +2307,27 @@ var summarise = (decls) => decls.map((d) => ({
1873
2307
  type: d.type,
1874
2308
  default: d.default,
1875
2309
  ...d.values !== void 0 ? { values: d.values } : {},
2310
+ ...d.durable === true ? { durable: true } : {},
1876
2311
  ...d.purpose !== void 0 ? { purpose: d.purpose } : {}
1877
2312
  }));
2313
+ var durableCardCount = (box) => box.decks.reduce((n, deck) => n + deck.cards.filter((card) => (card.durable ?? deck.durable) === true).length, 0);
1878
2314
  var handDecls = (hand, box) => {
1879
2315
  if (hand.template !== void 0) {
1880
2316
  return box.handTemplates.find((t) => t.id === hand.template)?.properties ?? [];
1881
2317
  }
1882
2318
  return hand.properties ?? [];
1883
2319
  };
2320
+ var movableHoles = (hand, box) => {
2321
+ const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
2322
+ const out = [];
2323
+ for (const [groupId, value] of Object.entries(filled ?? {})) {
2324
+ if (!isHoleRef(value)) continue;
2325
+ const group = box.tagGroups.find((g) => g.id === groupId);
2326
+ if (group === void 0) continue;
2327
+ out.push({ group: effectiveGameId(group), from: value });
2328
+ }
2329
+ return out;
2330
+ };
1884
2331
  var handSlots = (hand, box) => {
1885
2332
  if (hand.slots !== void 0) return hand.slots;
1886
2333
  const declared = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template)?.slots : hand.rule?.slots;
@@ -1901,6 +2348,8 @@ function describeBundle(bundle) {
1901
2348
  gameId: boxGameId,
1902
2349
  ...box.title !== void 0 ? { title: box.title } : {},
1903
2350
  ranking: { specificity: box.ranking.specificity },
2351
+ ...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
2352
+ ...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
1904
2353
  tagGroups: box.tagGroups.map((group) => ({
1905
2354
  gameId: effectiveGameId(group),
1906
2355
  tags: group.tags.map((tag) => effectiveGameId(tag))
@@ -1921,12 +2370,14 @@ function describeBundle(bundle) {
1921
2370
  totals.tagGroups += box.tagGroups.length;
1922
2371
  for (const hand of box.hands) {
1923
2372
  const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
2373
+ const movable = movableHoles(hand, box);
1924
2374
  hands.push({
1925
2375
  gameId: effectiveGameId(hand),
1926
2376
  ...hand.title !== void 0 ? { title: hand.title } : {},
1927
2377
  box: boxGameId,
1928
2378
  slots: handSlots(hand, box),
1929
- ...template !== void 0 ? { template: effectiveGameId(template) } : {}
2379
+ ...template !== void 0 ? { template: effectiveGameId(template) } : {},
2380
+ ...movable.length > 0 ? { movable } : {}
1930
2381
  });
1931
2382
  }
1932
2383
  const push = (scope, owner, decls, group) => {
@@ -1964,7 +2415,8 @@ function describeBundle(bundle) {
1964
2415
  box: map.box,
1965
2416
  group: map.group,
1966
2417
  zones: map.zones.length,
1967
- backgrounds: map.backgrounds?.length ?? 0
2418
+ backgrounds: map.backgrounds?.length ?? 0,
2419
+ sites: map.sites?.length ?? 0
1968
2420
  }))
1969
2421
  };
1970
2422
  }