@storylet-studio/runtime 0.4.1 → 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.cjs CHANGED
@@ -438,7 +438,48 @@ function effectiveGameId(entity) {
438
438
  const fromTitle = entity.title ? gameIdify(entity.title) : "";
439
439
  return fromTitle || entity.id;
440
440
  }
441
+ function valueAddresses(bundle) {
442
+ const tags = [];
443
+ for (const box of bundle.boxes) {
444
+ const boxGameId = effectiveGameId(box);
445
+ for (const group of box.tagGroups) {
446
+ for (const tag of group.tags) {
447
+ const gameId = effectiveGameId(tag);
448
+ tags.push({ id: tag.id, gameId, qualified: `${boxGameId}/${gameId}` });
449
+ }
450
+ }
451
+ }
452
+ const forms = /* @__PURE__ */ new Map();
453
+ for (const tag of tags) {
454
+ const list = forms.get(tag.gameId) ?? [];
455
+ if (!list.includes(tag.qualified)) list.push(tag.qualified);
456
+ forms.set(tag.gameId, list);
457
+ }
458
+ const print = /* @__PURE__ */ new Map();
459
+ const accept = /* @__PURE__ */ new Map();
460
+ const repeated = /* @__PURE__ */ new Map();
461
+ for (const tag of tags) {
462
+ const candidates = forms.get(tag.gameId) ?? [tag.qualified];
463
+ const ambiguous = candidates.length > 1;
464
+ print.set(tag.id, ambiguous ? tag.qualified : tag.gameId);
465
+ if (!accept.has(tag.qualified)) accept.set(tag.qualified, tag.id);
466
+ if (!ambiguous && !accept.has(tag.gameId)) accept.set(tag.gameId, tag.id);
467
+ if (ambiguous) repeated.set(tag.gameId, candidates);
468
+ }
469
+ return { print, accept, repeated };
470
+ }
471
+ function ambiguousValueAddressMessage(segment, name, candidates) {
472
+ const forms = candidates.map((q) => `"value.${q}.${name}"`);
473
+ const list = forms.length <= 1 ? forms[0] ?? "" : `${forms.slice(0, -1).join(", ")} or ${forms[forms.length - 1]}`;
474
+ return `"value.${segment}.${name}" names a tag in ${candidates.length} boxes; write ${list}`;
475
+ }
441
476
  var PLACE_GROUP = "place";
477
+ var HOLE_REF = /^@(hand|world|story)\.([a-z][a-z0-9_-]*)$/;
478
+ var isHoleRef = (value) => value.startsWith("@");
479
+ var parseHoleRef = (value) => {
480
+ const m = HOLE_REF.exec(value);
481
+ return m === null ? void 0 : { scope: m[1], name: m[2] };
482
+ };
442
483
 
443
484
  // ../../../expr/packages/scoperegistry/src/index.ts
444
485
  var PropertyBag = class _PropertyBag {
@@ -473,10 +514,14 @@ var PropertyBag = class _PropertyBag {
473
514
  }
474
515
  /** Write a property. Engine writes (the default) notify subscribers;
475
516
  * pass `silent: true` for a host write, which reaches only the audit
476
- * hook. Throws on a read-only property. Returns the change. */
517
+ * hook. Throws on a read-only property unless the caller says it is the
518
+ * HOST (`host: true`), for whom `writable: false` was never a rule - it is
519
+ * the story's promise, not the game's. `silent` and `host` are separate on
520
+ * purpose: one is about who hears the write, the other about who may make
521
+ * it. Returns the change. */
477
522
  set(name, value, opts) {
478
523
  const n = this.norm(name);
479
- if (this.decls.get(n)?.writable === false) throw new Error(`'${name}' is read-only`);
524
+ if (!opts?.host && this.decls.get(n)?.writable === false) throw new Error(`'${name}' is read-only`);
480
525
  const change = {
481
526
  name: n,
482
527
  prev: this.values[n],
@@ -591,27 +636,62 @@ var SCOPE_DEFAULT_SHARED = { story: true, box: false, deck: false, hand: false,
591
636
  var isShared = (scope, d) => d.shared ?? SCOPE_DEFAULT_SHARED[scope];
592
637
  var sharedHalf = (scope, decls) => decls.filter((d) => isShared(scope, d));
593
638
  var flowHalf = (scope, decls) => decls.filter((d) => !isShared(scope, d));
639
+ var OWNED_SCOPES = ["box", "deck", "hand", "value"];
640
+ var emptyOwnerIndexes = () => ({
641
+ box: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
642
+ deck: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
643
+ hand: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
644
+ value: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() }
645
+ });
646
+ var indexOwner = (index, entity) => {
647
+ const gameId = effectiveGameId(entity);
648
+ index.gameId.set(entity.id, gameId);
649
+ if (!index.id.has(gameId)) index.id.set(gameId, entity.id);
650
+ };
651
+ var indexValueOwners = (index, bundle) => {
652
+ const addresses = valueAddresses(bundle);
653
+ for (const [id, segment] of addresses.print) index.gameId.set(id, segment);
654
+ for (const [segment, id] of addresses.accept) index.id.set(segment, id);
655
+ for (const [gameId, candidates] of addresses.repeated) index.repeated.set(gameId, candidates);
656
+ };
594
657
  var handDeclsOf = (internals, hand) => {
595
658
  if (hand.template !== void 0) {
596
659
  return internals.templatesById.get(hand.template)?.properties ?? internals.bundle.boxes.flatMap((b) => b.handTemplates).find((t) => t.id === hand.template)?.properties ?? [];
597
660
  }
598
661
  return hand.properties ?? [];
599
662
  };
663
+ var addressOf = (internals, kind, id) => `${kind}.${internals.owners[kind].gameId.get(id) ?? id}`;
664
+ var resolveOwner = (internals, kind, segment) => {
665
+ const candidates = internals.owners[kind].repeated.get(segment);
666
+ if (candidates !== void 0) return { ambiguous: candidates };
667
+ const byGameId = internals.owners[kind].id.get(segment);
668
+ if (byGameId !== void 0) return { id: byGameId, legacy: false };
669
+ if (internals.owners[kind].gameId.has(segment)) return { id: segment, legacy: true };
670
+ return void 0;
671
+ };
672
+ var ownerOrThrow = (internals, kind, segment, name) => {
673
+ const owner = resolveOwner(internals, kind, segment);
674
+ if (owner === void 0) throw new Error(`no ${kind} store "${segment}"`);
675
+ if ("ambiguous" in owner) throw new Error(ambiguousValueAddressMessage(segment, name, owner.ambiguous));
676
+ return owner;
677
+ };
678
+ 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.`;
600
679
  var buildPartition = (internals, half) => {
601
680
  const b = internals.bundle;
681
+ const at = (kind, id) => `${addressOf(internals, kind, id)}.`;
602
682
  return {
603
683
  story: bagFromDecls(half("story", b.story.properties), "story."),
604
- box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties), `box.${box.id}.`)])),
684
+ box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties), at("box", box.id))])),
605
685
  deck: new Map(b.boxes.flatMap((box) => box.decks.map(
606
- (deck) => [deck.id, bagFromDecls(half("deck", deck.properties), `deck.${deck.id}.`)]
686
+ (deck) => [deck.id, bagFromDecls(half("deck", deck.properties), at("deck", deck.id))]
607
687
  ))),
608
688
  // A template instance inherits the template's property declarations;
609
689
  // a standalone hand declares its own (schema 2.6).
610
690
  hand: new Map(b.boxes.flatMap((box) => box.hands.map(
611
- (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), `hand.${hand.id}.`)]
691
+ (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), at("hand", hand.id))]
612
692
  ))),
613
693
  value: new Map(b.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
614
- (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), `value.${tag.id}.`)]
694
+ (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), at("value", tag.id))]
615
695
  ))))
616
696
  };
617
697
  };
@@ -630,6 +710,105 @@ var loadPartition = (p, values) => {
630
710
  }
631
711
  }
632
712
  };
713
+ var emptyDraft = () => ({
714
+ evicted: [],
715
+ droppedCooldowns: [],
716
+ droppedSpent: [],
717
+ droppedProperties: [],
718
+ defaultedProperties: [],
719
+ retypedProperties: []
720
+ });
721
+ var SORT_SEP = "";
722
+ 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);
723
+ var propKey = (p) => `${p.flow ?? ""}${SORT_SEP}${p.path}`;
724
+ function valueFits(decl, value) {
725
+ switch (decl.type) {
726
+ case "boolean":
727
+ return typeof value === "boolean";
728
+ case "number":
729
+ return typeof value === "number";
730
+ case "string":
731
+ return typeof value === "string";
732
+ case "enum":
733
+ return typeof value === "string" && (decl.values === void 0 || decl.values.includes(value));
734
+ case "quality":
735
+ return typeof value === "string" && (decl.stages === void 0 || decl.stages.includes(value));
736
+ case "flags":
737
+ return Array.isArray(value) && (decl.values === void 0 || value.every((f) => decl.values.includes(f)));
738
+ default:
739
+ return true;
740
+ }
741
+ }
742
+ function walkScope(decls, saved, path, flow, draft) {
743
+ const at = (name) => ({ ...flow !== void 0 ? { flow } : {}, path: path(name) });
744
+ const byName = new Map((decls ?? []).map((d) => [d.name, d]));
745
+ const values = saved ?? {};
746
+ const clean = {};
747
+ for (const [name, value] of Object.entries(values)) {
748
+ const decl = byName.get(name);
749
+ if (decl === void 0) {
750
+ draft.droppedProperties.push(at(name));
751
+ continue;
752
+ }
753
+ if (!valueFits(decl, value)) {
754
+ draft.retypedProperties.push(at(name));
755
+ continue;
756
+ }
757
+ clean[name] = value;
758
+ }
759
+ for (const decl of decls ?? []) {
760
+ if (!(decl.name in values)) draft.defaultedProperties.push(at(decl.name));
761
+ }
762
+ return clean;
763
+ }
764
+ function walkPartition(internals, decls, values, flow, draft) {
765
+ const out = {
766
+ story: walkScope(decls.story, values?.story, (n) => `story.${n}`, flow, draft),
767
+ box: {},
768
+ deck: {},
769
+ hand: {},
770
+ value: {}
771
+ };
772
+ for (const kind of ["box", "deck", "hand", "value"]) {
773
+ const savedKind = values?.[kind] ?? {};
774
+ const ids = [.../* @__PURE__ */ new Set([...decls[kind].keys(), ...Object.keys(savedKind)])].sort();
775
+ for (const id of ids) {
776
+ const owner = addressOf(internals, kind, id);
777
+ out[kind][id] = walkScope(
778
+ decls[kind].get(id),
779
+ savedKind[id],
780
+ (n) => `${owner}.${n}`,
781
+ flow,
782
+ draft
783
+ );
784
+ }
785
+ }
786
+ return out;
787
+ }
788
+ function finishReport(bundle, saved, flows, draft) {
789
+ const drift = saved.version !== bundle.version || saved.hash !== bundle.hash;
790
+ const evicted = byKey(draft.evicted, (e) => [e.flow, e.hand, e.card, e.reason].join(SORT_SEP));
791
+ const droppedCooldowns = byKey(draft.droppedCooldowns, (c) => `${c.flow}${SORT_SEP}${c.card}`);
792
+ const droppedSpent = [...draft.droppedSpent].sort();
793
+ const droppedProperties = byKey(draft.droppedProperties, propKey);
794
+ const defaultedProperties = byKey(draft.defaultedProperties, propKey);
795
+ const retypedProperties = byKey(draft.retypedProperties, propKey);
796
+ return {
797
+ // `flows` is what the load restores, not something it had to change, so
798
+ // it never makes a report inexact.
799
+ exact: !drift && evicted.length === 0 && droppedCooldowns.length === 0 && droppedSpent.length === 0 && droppedProperties.length === 0 && defaultedProperties.length === 0 && retypedProperties.length === 0,
800
+ project: bundle.project,
801
+ version: { saved: saved.version, bundle: bundle.version },
802
+ hash: { saved: saved.hash, bundle: bundle.hash },
803
+ flows,
804
+ evicted,
805
+ droppedCooldowns,
806
+ droppedSpent,
807
+ droppedProperties,
808
+ defaultedProperties,
809
+ retypedProperties
810
+ };
811
+ }
633
812
  var Engine = class {
634
813
  internals;
635
814
  seed;
@@ -653,6 +832,7 @@ var Engine = class {
653
832
  boxesById: /* @__PURE__ */ new Map(),
654
833
  handsById: /* @__PURE__ */ new Map(),
655
834
  handsByGameId: /* @__PURE__ */ new Map(),
835
+ owners: emptyOwnerIndexes(),
656
836
  templatesById: /* @__PURE__ */ new Map(),
657
837
  groupsById: /* @__PURE__ */ new Map(),
658
838
  requiredGroups: /* @__PURE__ */ new Set(),
@@ -661,6 +841,7 @@ var Engine = class {
661
841
  hasQualities: false,
662
842
  hasShared: false,
663
843
  flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
844
+ sharedDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
664
845
  shared: void 0,
665
846
  worldResolver: void 0,
666
847
  worldReadOnly: /* @__PURE__ */ new Set(),
@@ -676,14 +857,17 @@ var Engine = class {
676
857
  engineTracing: () => this.engineTraceHandlers.size > 0
677
858
  };
678
859
  this.internals = internals;
860
+ indexValueOwners(internals.owners.value, bundle);
679
861
  for (const box of bundle.boxes) {
680
862
  internals.boxesById.set(box.id, box);
681
863
  internals.boxesByGameId.set(effectiveGameId(box), box);
864
+ indexOwner(internals.owners.box, box);
682
865
  for (const group of box.tagGroups) {
683
866
  internals.groupsById.set(group.id, { group, box });
684
867
  if (group.required === true) internals.requiredGroups.add(group.id);
685
868
  }
686
869
  for (const deck of box.decks) {
870
+ indexOwner(internals.owners.deck, deck);
687
871
  if (deck.shared === true) internals.hasShared = true;
688
872
  for (const card of deck.cards) {
689
873
  const entry = { card, deck, box };
@@ -698,22 +882,25 @@ var Engine = class {
698
882
  for (const hand of box.hands) {
699
883
  internals.handsById.set(hand.id, { hand, box });
700
884
  internals.handsByGameId.set(effectiveGameId(hand), { hand, box });
885
+ indexOwner(internals.owners.hand, hand);
701
886
  }
702
887
  }
703
888
  this.initLadders();
704
- internals.flowDecls = {
705
- story: flowHalf("story", bundle.story.properties),
706
- box: new Map(bundle.boxes.map((box) => [box.id, flowHalf("box", box.properties)])),
889
+ const declSet = (half) => ({
890
+ story: half("story", bundle.story.properties),
891
+ box: new Map(bundle.boxes.map((box) => [box.id, half("box", box.properties)])),
707
892
  deck: new Map(bundle.boxes.flatMap((box) => box.decks.map(
708
- (deck) => [deck.id, flowHalf("deck", deck.properties)]
893
+ (deck) => [deck.id, half("deck", deck.properties)]
709
894
  ))),
710
895
  hand: new Map(bundle.boxes.flatMap((box) => box.hands.map(
711
- (hand) => [hand.id, flowHalf("hand", handDeclsOf(internals, hand))]
896
+ (hand) => [hand.id, half("hand", handDeclsOf(internals, hand))]
712
897
  ))),
713
898
  value: new Map(bundle.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
714
- (tag) => [tag.id, flowHalf("value", tag.properties ?? [])]
899
+ (tag) => [tag.id, half("value", tag.properties ?? [])]
715
900
  ))))
716
- };
901
+ });
902
+ internals.flowDecls = declSet(flowHalf);
903
+ internals.sharedDecls = declSet(sharedHalf);
717
904
  this.initShared(this.hostWorld);
718
905
  }
719
906
  /** Build the shared stores and the @world seam. `hostWorld` sticks for the
@@ -724,14 +911,24 @@ var Engine = class {
724
911
  internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
725
912
  if (hostWorld !== void 0) {
726
913
  internals.worldResolver = hostWorld;
914
+ const set = hostWorld.set;
915
+ internals.worldSet = set !== void 0 ? (n, v) => {
916
+ set(n, v);
917
+ } : void 0;
727
918
  } else {
728
919
  const bag = bagFromDecls(internals.bundle.world.properties, "world.");
729
920
  internals.worldResolver = {
921
+ // The engine writes through worldSet below, not through this; the `set`
922
+ // is the resolver's SHAPE, so @world still reads as writable to anything
923
+ // inspecting the seam, and it is the story's door: no host flag on it.
730
924
  get: (n) => bag.get(n),
731
925
  set: (n, v) => {
732
926
  bag.set(n, v);
733
927
  }
734
928
  };
929
+ internals.worldSet = (n, v, host2) => {
930
+ bag.set(n, v, host2 === true ? { host: true } : void 0);
931
+ };
735
932
  }
736
933
  }
737
934
  /** Quality ladders by scope for the eval channel (design/quality.md):
@@ -765,6 +962,7 @@ var Engine = class {
765
962
  * state; shared state is untouched. There is no default flow: "main" is
766
963
  * a caller convention, not an engine rule. */
767
964
  openFlow(id, opts = {}) {
965
+ const otherClaims = opts.restore !== void 0 ? this.sharedClaimsExcept(id) : void 0;
768
966
  const existing = this.flowsById.get(id);
769
967
  if (existing) {
770
968
  const dealt = existing.heldCardIds().length;
@@ -773,6 +971,13 @@ var Engine = class {
773
971
  }
774
972
  const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
775
973
  this.flowsById.set(id, flow);
974
+ if (opts.restore !== void 0) {
975
+ const draft = emptyDraft();
976
+ const clean = this.planFlowRestore(id, structuredClone(opts.restore), otherClaims, draft);
977
+ flow.restore(clean);
978
+ const content = this.internals.bundle.content;
979
+ opts.onRestoreReport?.(finishReport(content, content, [id], draft));
980
+ }
776
981
  return flow;
777
982
  }
778
983
  getFlow(id) {
@@ -846,10 +1051,21 @@ var Engine = class {
846
1051
  }
847
1052
  return counts;
848
1053
  }
1054
+ /** The same ledger with one name left out: what the REST of the world
1055
+ * holds, which is the question a resume under that name has to ask. */
1056
+ sharedClaimsExcept(id) {
1057
+ const counts = /* @__PURE__ */ new Map();
1058
+ for (const [flowId, flow] of this.flowsById) {
1059
+ if (flowId === id) continue;
1060
+ for (const cardId of flow.heldCardIds()) counts.set(cardId, (counts.get(cardId) ?? 0) + 1);
1061
+ }
1062
+ return counts;
1063
+ }
849
1064
  // --- engine-level state access ----------------------------------------------
850
1065
  /**
851
1066
  * Read shared state by path: "world.x", "story.gold" (when shared),
852
- * "box.b_x.heat" (when shared). A ref that resolves PER-FLOW throws,
1067
+ * "box.village.heat" (when shared) - the owner segment is its GAMEID
1068
+ * (design/engine-server.md 4.4). A ref that resolves PER-FLOW throws,
853
1069
  * naming the fix - silently answering with some flow's copy (or a junk
854
1070
  * default) was the bug Patter's engine.getProperty guard exists to stop.
855
1071
  */
@@ -862,13 +1078,13 @@ var Engine = class {
862
1078
  setProperty(path, value) {
863
1079
  const found = this.resolveShared(path);
864
1080
  if (found.kind === "world") {
865
- if (!this.internals.worldResolver.set) {
1081
+ if (!this.internals.worldSet) {
866
1082
  throw new Error(`@world is read-only here: the host bound no write`);
867
1083
  }
868
- this.internals.worldResolver.set(found.name, value);
1084
+ this.internals.worldSet(found.name, value, true);
869
1085
  return;
870
1086
  }
871
- found.bag.set(found.name, value, { silent: true, reason: "host setProperty" });
1087
+ found.bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
872
1088
  }
873
1089
  resolveShared(path) {
874
1090
  const parts = path.split(".");
@@ -884,15 +1100,25 @@ var Engine = class {
884
1100
  }
885
1101
  if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
886
1102
  const kind = parts[0];
887
- const [, id, name] = parts;
1103
+ const [, segment, name] = parts;
1104
+ const owner = ownerOrThrow(this.internals, kind, segment, name);
1105
+ if (owner.legacy) this.diagnose(legacyAddressMessage(this.internals, kind, segment, name));
1106
+ const id = owner.id;
888
1107
  const bag = this.internals.shared[kind].get(id);
889
1108
  if (bag !== void 0 && bag.get(name) !== void 0) return { kind: "bag", bag, name };
890
1109
  if (this.internals.flowDecls[kind].get(id)?.some((d) => d.name === name)) perFlow();
891
- if (bag === void 0 && !this.internals.flowDecls[kind].has(id)) throw new Error(`no ${kind} store "${id}"`);
1110
+ if (bag === void 0 && !this.internals.flowDecls[kind].has(id)) throw new Error(`no ${kind} store "${segment}"`);
892
1111
  throw new Error(`no property at "${path}"`);
893
1112
  }
894
1113
  throw new Error(`bad property path "${path}"`);
895
1114
  }
1115
+ /** The engine's own surface has no flow, so an engine-level diagnostic
1116
+ * carries the empty flow id - the same way a LoadReport's shared half
1117
+ * carries no flow. It reaches the run log and the engine tap; there is
1118
+ * nowhere else for it to go, and it fires only on a legacy address. */
1119
+ diagnose(message) {
1120
+ this.internals.emitEngine("", { type: "diagnostic", where: "property address", message });
1121
+ }
896
1122
  /** The shared surface as examiner rows: @world (read through the
897
1123
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
898
1124
  listProperties() {
@@ -908,20 +1134,24 @@ var Engine = class {
908
1134
  ...d.values !== void 0 ? { values: d.values } : {},
909
1135
  ...d.stages !== void 0 ? { stages: d.stages } : {},
910
1136
  // @world is FOREIGN - a host resolver backs it - so writability is whether that
911
- // resolver can be written at all, which is the shared registry's own rule for a
912
- // foreign scope. The `as PropertyView` cast this replaced was hiding the field's
913
- // absence: the row type has always required it, and these rows shipped without one.
914
- writable: this.internals.worldResolver.set !== void 0
1137
+ // resolver can be written at all AND what the declaration says, which is the
1138
+ // shared registry's own rule for a foreign scope (its foreignWritable). The
1139
+ // `as PropertyView` cast this replaced was hiding the field's absence: the row
1140
+ // type has always required it, and these rows shipped without one.
1141
+ //
1142
+ // A row is where `writable: false` is meant to SHOW (Reboot.md 10): it tells a
1143
+ // state panel this is the game's value, not the story's. It does not stop the
1144
+ // panel editing it - the host's setProperty passes `{ host: true }`.
1145
+ writable: this.internals.worldSet !== void 0 && !this.internals.worldReadOnly.has(d.name)
915
1146
  });
916
1147
  }
917
1148
  const add = (_prefix, bag) => {
918
1149
  for (const row of bag.rows()) out.push(row);
919
1150
  };
920
1151
  add("story", this.internals.shared.story);
921
- for (const [id, bag] of this.internals.shared.box) add(`box.${id}`, bag);
922
- for (const [id, bag] of this.internals.shared.deck) add(`deck.${id}`, bag);
923
- for (const [id, bag] of this.internals.shared.hand) add(`hand.${id}`, bag);
924
- for (const [id, bag] of this.internals.shared.value) add(`value.${id}`, bag);
1152
+ for (const kind of OWNED_SCOPES) {
1153
+ for (const [id, bag] of this.internals.shared[kind]) add(addressOf(this.internals, kind, id), bag);
1154
+ }
925
1155
  return out;
926
1156
  }
927
1157
  /** The SHARED kernel bags with their store path prefixes (the state
@@ -930,7 +1160,7 @@ var Engine = class {
930
1160
  listBags() {
931
1161
  const mounts = [{ prefix: "story", bag: this.internals.shared.story }];
932
1162
  for (const kind of ["box", "deck", "hand", "value"]) {
933
- for (const [id, bag] of this.internals.shared[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
1163
+ for (const [id, bag] of this.internals.shared[kind]) mounts.push({ prefix: addressOf(this.internals, kind, id), bag });
934
1164
  }
935
1165
  return mounts;
936
1166
  }
@@ -951,20 +1181,137 @@ var Engine = class {
951
1181
  flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot()]))
952
1182
  });
953
1183
  }
1184
+ /** ONE flow's blob, to park a visit that is walking away: the same shape
1185
+ * the envelope carries per flow, and the same shape `openFlow`'s `restore`
1186
+ * option takes back (design/engine-server.md 4.1). Saving the whole
1187
+ * envelope to park one of four hundred players is wrong in cost and in
1188
+ * meaning. Throws for a name that is not open - a closed flow has nothing
1189
+ * left to save. */
1190
+ saveFlow(id) {
1191
+ const flow = this.flowsById.get(id);
1192
+ if (!flow) throw new Error(`unknown flow "${id}"`);
1193
+ return structuredClone(flow.snapshot());
1194
+ }
1195
+ /** What `loadGame(envelope)` would do that is not a plain restore, without
1196
+ * doing any of it (design/engine-server.md 4.9). Pure: nothing on this
1197
+ * engine moves. A project mismatch is refused here exactly as `loadGame`
1198
+ * refuses it - it is the one thing neither call will tolerate. */
1199
+ previewLoad(envelope) {
1200
+ this.assertSameProject(envelope);
1201
+ return this.planLoad(envelope).report;
1202
+ }
1203
+ /** What `openFlow(id, { restore: saved })` would do to a flow of that name,
1204
+ * without doing it: the same report shape, since a visit parked under one
1205
+ * build and resumed under the next raises the same questions. Pure. */
1206
+ previewFlowRestore(id, saved) {
1207
+ const draft = emptyDraft();
1208
+ this.planFlowRestore(id, saved, this.sharedClaimsExcept(id), draft);
1209
+ const content = this.internals.bundle.content;
1210
+ return finishReport(content, content, [id], draft);
1211
+ }
954
1212
  /** Restore: shared state once, then every flow REBUILT from its blob.
955
1213
  * Handles held from before the load are closed and inert (Patter's
956
- * rule); take fresh ones from getFlow()/flows(). */
1214
+ * rule); take fresh ones from getFlow()/flows().
1215
+ *
1216
+ * Returns the report `previewLoad` would have given for this envelope: the
1217
+ * drift tolerance that makes a load forgiving is what hides its cost, so
1218
+ * the cost comes back with the load whether or not anybody looked first. */
957
1219
  loadGame(envelope) {
1220
+ this.assertSameProject(envelope);
1221
+ const plan = this.planLoad(structuredClone(envelope));
1222
+ this.reset();
1223
+ loadPartition(this.internals.shared, plan.shared);
1224
+ for (const id of plan.spent) this.spent.add(id);
1225
+ for (const [id, clean] of plan.flows) this.openFlow(id).restore(clean);
1226
+ return plan.report;
1227
+ }
1228
+ assertSameProject(envelope) {
958
1229
  if (envelope.content.project !== this.internals.bundle.content.project) {
959
1230
  throw new Error(`save is for project "${envelope.content.project}", bundle is "${this.internals.bundle.content.project}"`);
960
1231
  }
961
- const env = structuredClone(envelope);
962
- this.reset();
963
- loadPartition(this.internals.shared, env.shared.props);
964
- for (const id of env.shared.spent ?? []) this.spent.add(id);
965
- for (const [id, saved] of Object.entries(env.flows ?? {})) {
966
- this.openFlow(id).restore(saved);
1232
+ }
1233
+ /** The whole-envelope walk: the report, and the cleaned state the apply
1234
+ * half writes. Nothing here touches the engine, which is what lets
1235
+ * previewLoad and loadGame share it. */
1236
+ planLoad(envelope) {
1237
+ const draft = emptyDraft();
1238
+ const shared = walkPartition(
1239
+ this.internals,
1240
+ this.internals.sharedDecls,
1241
+ envelope.shared?.props,
1242
+ void 0,
1243
+ draft
1244
+ );
1245
+ const spent = [];
1246
+ for (const cardId of envelope.shared?.spent ?? []) {
1247
+ if (this.internals.cardsById.has(cardId)) spent.push(cardId);
1248
+ else draft.droppedSpent.push(cardId);
1249
+ }
1250
+ const flows = [];
1251
+ for (const [id, saved] of Object.entries(envelope.flows ?? {})) {
1252
+ flows.push([id, this.planFlowRestore(id, saved, void 0, draft)]);
1253
+ }
1254
+ return {
1255
+ report: finishReport(this.internals.bundle.content, envelope.content, flows.map(([id]) => id), draft),
1256
+ shared,
1257
+ spent,
1258
+ flows
1259
+ };
1260
+ }
1261
+ /** One flow's walk. `otherClaims` is the rest of the world's shared ledger
1262
+ * and is present only for a SINGLE-flow restore into a live engine: a
1263
+ * whole-envelope load rebuilds every flow from one consistent moment, so
1264
+ * there is nobody else to compete with. */
1265
+ planFlowRestore(id, saved, otherClaims, draft) {
1266
+ const internals = this.internals;
1267
+ const props = walkPartition(internals, internals.flowDecls, saved.props, id, draft);
1268
+ const cooldowns = {};
1269
+ for (const [cardId, turn] of Object.entries(saved.cooldowns ?? {})) {
1270
+ if (internals.cardsById.has(cardId)) cooldowns[cardId] = turn;
1271
+ else draft.droppedCooldowns.push({ flow: id, card: cardId });
1272
+ }
1273
+ const cardName = (cardId) => {
1274
+ const entry = internals.cardsById.get(cardId);
1275
+ return entry ? effectiveGameId(entry.card) : cardId;
1276
+ };
1277
+ const board = {};
1278
+ const restored = /* @__PURE__ */ new Map();
1279
+ for (const [handId, ids] of Object.entries(saved.board ?? {})) {
1280
+ const known = internals.handsById.get(handId);
1281
+ if (known === void 0) {
1282
+ for (const cardId of ids) {
1283
+ draft.evicted.push({ flow: id, hand: handId, card: cardName(cardId), reason: "hand-vanished" });
1284
+ }
1285
+ continue;
1286
+ }
1287
+ const hand = effectiveGameId(known.hand);
1288
+ const kept = [];
1289
+ for (const cardId of ids) {
1290
+ const entry = internals.cardsById.get(cardId);
1291
+ if (entry === void 0) {
1292
+ draft.evicted.push({ flow: id, hand, card: cardId, reason: "vanished" });
1293
+ continue;
1294
+ }
1295
+ if (otherClaims !== void 0 && cardIsShared(entry.card, entry.deck.shared ?? false)) {
1296
+ const held = (otherClaims.get(cardId) ?? 0) + (restored.get(cardId) ?? 0);
1297
+ if (held >= sharedCap(entry.card)) {
1298
+ draft.evicted.push({ flow: id, hand, card: effectiveGameId(entry.card), reason: "claimed-elsewhere" });
1299
+ continue;
1300
+ }
1301
+ restored.set(cardId, (restored.get(cardId) ?? 0) + 1);
1302
+ }
1303
+ kept.push(cardId);
1304
+ }
1305
+ board[handId] = kept;
967
1306
  }
1307
+ return {
1308
+ props,
1309
+ turns: saved.turns ?? {},
1310
+ prng: saved.prng,
1311
+ cooldowns,
1312
+ board,
1313
+ playLog: saved.playLog ?? []
1314
+ };
968
1315
  }
969
1316
  };
970
1317
  var Flow = class {
@@ -1240,6 +1587,10 @@ var Flow = class {
1240
1587
  boundTags.set(groupId, tagId);
1241
1588
  }
1242
1589
  for (const [groupId, tagId] of Object.entries(hand.chosen ?? {})) {
1590
+ if (isHoleRef(tagId)) {
1591
+ this.fillHoleFromProperty(hand, groupId, tagId, boundTags, askNames);
1592
+ continue;
1593
+ }
1243
1594
  boundTags.set(groupId, tagId);
1244
1595
  const found = this.internals.groupsById.get(groupId);
1245
1596
  const tag = found?.group.tags.find((t) => t.id === tagId);
@@ -1248,6 +1599,10 @@ var Flow = class {
1248
1599
  condition = template.condition;
1249
1600
  } else {
1250
1601
  for (const [groupId, tagId] of Object.entries(hand.rule?.bindings ?? {})) {
1602
+ if (isHoleRef(tagId)) {
1603
+ this.fillHoleFromProperty(hand, groupId, tagId, boundTags, askNames);
1604
+ continue;
1605
+ }
1251
1606
  boundTags.set(groupId, tagId);
1252
1607
  const found = this.internals.groupsById.get(groupId);
1253
1608
  const tag = found?.group.tags.find((t) => t.id === tagId);
@@ -1283,6 +1638,58 @@ var Flow = class {
1283
1638
  this.bindStateGroups(box, boundTags, askNames);
1284
1639
  return { box, boundTags, askNames };
1285
1640
  }
1641
+ /**
1642
+ * Fill one hole from the property its value names: the hand that moves
1643
+ * (design/engine-server.md 4.6).
1644
+ *
1645
+ * The semantics are `bindStateGroups`' below, word for word, applied per
1646
+ * HOLE instead of per group: resolved at ask time, and a value naming no tag
1647
+ * leaves the hole UNBOUND (a wildcard) with a diagnostic rather than dealing
1648
+ * a silently empty hand. What is added is the `@hand` scope - the asking
1649
+ * hand's OWN declared state, read here from the flow's merged view (the
1650
+ * shared half under the flow's own, so a `shared: true` declaration moves
1651
+ * the hole for every flow and a per-flow one moves it for this flow alone).
1652
+ *
1653
+ * Read BEFORE tag composition, which is the whole reason it is safe: the
1654
+ * @hand bag a card sees is built from the bound tags, so resolving a hole
1655
+ * from it would be circular. A hand's own declarations are not, so they are.
1656
+ */
1657
+ fillHoleFromProperty(hand, groupId, ref, boundTags, askNames) {
1658
+ const found = this.internals.groupsById.get(groupId);
1659
+ const groupName = found ? effectiveGameId(found.group) : groupId;
1660
+ const where = `hand ${effectiveGameId(hand)}, tag group ${groupName}`;
1661
+ const parsed = parseHoleRef(ref);
1662
+ if (!parsed) {
1663
+ this.emit({ type: "diagnostic", where, message: `"${ref}" is not a @hand, @world or @story property reference` });
1664
+ return;
1665
+ }
1666
+ if (!found) {
1667
+ this.emit({ type: "diagnostic", where, message: `"${ref}" fills a tag group that is not in this box` });
1668
+ return;
1669
+ }
1670
+ let value;
1671
+ if (parsed.scope === "hand") {
1672
+ value = this.valuesOf("hand", hand.id)[parsed.name];
1673
+ } else {
1674
+ try {
1675
+ value = this.getProperty(`${parsed.scope}.${parsed.name}`);
1676
+ } catch {
1677
+ value = void 0;
1678
+ }
1679
+ }
1680
+ if (value === void 0) {
1681
+ this.emit({ type: "diagnostic", where, message: `"${ref}" names a property that is not declared` });
1682
+ return;
1683
+ }
1684
+ const wanted = typeof value === "string" ? value : String(value);
1685
+ const tag = found.group.tags.find((t) => effectiveGameId(t) === wanted);
1686
+ if (!tag) {
1687
+ this.emit({ type: "diagnostic", where, message: `${ref} is "${wanted}", which is not one of the tags of "${groupName}"` });
1688
+ return;
1689
+ }
1690
+ boundTags.set(groupId, tag.id);
1691
+ askNames[groupName] = effectiveGameId(tag);
1692
+ }
1286
1693
  /**
1287
1694
  * Bind every `boundBy` group in the box from the property it names.
1288
1695
  *
@@ -1413,8 +1820,8 @@ var Flow = class {
1413
1820
  runAsk(ask, claimed, trace) {
1414
1821
  const { box } = ask;
1415
1822
  const handEnv = this.buildHandEnv(ask);
1416
- const verdict = (id, v) => {
1417
- trace?.push({ id, verdict: v });
1823
+ const verdict = (card, v) => {
1824
+ trace?.push({ id: effectiveGameId(card), verdict: v });
1418
1825
  };
1419
1826
  if (!this.passes(ask.condition, this.evalCtx(box, void 0, handEnv), `hand ${ask.hand ? effectiveGameId(ask.hand) : ""} condition`)) {
1420
1827
  return { ordered: [], handEnv };
@@ -1431,19 +1838,19 @@ var Flow = class {
1431
1838
  for (const card of deck.cards) {
1432
1839
  const shared = cardIsShared(card, deckShared);
1433
1840
  if (!gateOk.get(deck.id)) {
1434
- verdict(card.id, "deck-gate");
1841
+ verdict(card, "deck-gate");
1435
1842
  continue;
1436
1843
  }
1437
1844
  if (shared && this.engine.isTaken(card.id)) {
1438
- verdict(card.id, "taken");
1845
+ verdict(card, "taken");
1439
1846
  continue;
1440
1847
  }
1441
1848
  if ((this.cooldowns[card.id] ?? 0) > turn) {
1442
- verdict(card.id, "cooldown");
1849
+ verdict(card, "cooldown");
1443
1850
  continue;
1444
1851
  }
1445
1852
  if (!this.tagsMatch(card, handEnv.boundTags)) {
1446
- verdict(card.id, "tags");
1853
+ verdict(card, "tags");
1447
1854
  continue;
1448
1855
  }
1449
1856
  if (card.condition && !this.passes(
@@ -1451,12 +1858,12 @@ var Flow = class {
1451
1858
  deckCtx,
1452
1859
  this.tracing ? `card ${card.gameId} condition` : void 0
1453
1860
  )) {
1454
- verdict(card.id, "condition");
1861
+ verdict(card, "condition");
1455
1862
  continue;
1456
1863
  }
1457
1864
  const refused = claimed(card, shared);
1458
1865
  if (refused) {
1459
- verdict(card.id, refused);
1866
+ verdict(card, refused);
1460
1867
  continue;
1461
1868
  }
1462
1869
  let priority;
@@ -1466,7 +1873,7 @@ var Flow = class {
1466
1873
  try {
1467
1874
  const v = this.eval(card.priority, deckCtx);
1468
1875
  if (typeof v !== "number") {
1469
- verdict(card.id, "priority");
1876
+ verdict(card, "priority");
1470
1877
  continue;
1471
1878
  }
1472
1879
  priority = v;
@@ -1474,7 +1881,7 @@ var Flow = class {
1474
1881
  if (this.tracing) {
1475
1882
  this.emit({ type: "diagnostic", where: `card ${card.gameId} priority`, message: e instanceof Error ? e.message : String(e) });
1476
1883
  }
1477
- verdict(card.id, "priority");
1884
+ verdict(card, "priority");
1478
1885
  continue;
1479
1886
  }
1480
1887
  }
@@ -1505,11 +1912,13 @@ var Flow = class {
1505
1912
  i = j;
1506
1913
  }
1507
1914
  for (const s of scored) {
1508
- trace?.push({ id: s.entry.card.id, verdict: "dealt", priority: s.priority, specificity: s.spec });
1915
+ trace?.push({ id: effectiveGameId(s.entry.card), verdict: "dealt", priority: s.priority, specificity: s.spec });
1509
1916
  }
1510
1917
  return { ordered: scored.map((s) => s.entry), handEnv };
1511
1918
  }
1512
- /** Flip eligible-but-not-taken trace entries to "capped". */
1919
+ /** Flip eligible-but-not-taken trace entries to "capped". `taken` is keyed
1920
+ * by GAMEID, as the trace rows are (4.4): the two must move together or
1921
+ * every dealt card silently reads as capped. */
1513
1922
  capTrace(trace, taken) {
1514
1923
  for (const entry of trace) {
1515
1924
  if (entry.verdict === "dealt" && !taken.has(entry.id)) entry.verdict = "capped";
@@ -1550,7 +1959,7 @@ var Flow = class {
1550
1959
  const { ordered } = this.runAsk(ask, (card, shared) => this.claimVerdict(card, shared, claimCounts, worldClaims), trace);
1551
1960
  const listed = n === void 0 ? ordered : ordered.slice(0, Math.max(n, 0));
1552
1961
  if (trace) {
1553
- this.capTrace(trace, new Set(listed.map((e) => e.card.id)));
1962
+ this.capTrace(trace, new Set(listed.map((e) => effectiveGameId(e.card))));
1554
1963
  this.emit({ type: "peek", box: effectiveGameId(box), criteria, cards: trace }, this.turnCounts.get(box.id) ?? 0);
1555
1964
  }
1556
1965
  return { box: effectiveGameId(box), cards: listed.map((e) => this.view(e)) };
@@ -1580,7 +1989,8 @@ var Flow = class {
1580
1989
  const turn = this.turnCounts.get(box.id) ?? 0;
1581
1990
  const evicted = [];
1582
1991
  const evict = (cardId, reason) => {
1583
- evicted.push({ card: cardId, reason });
1992
+ const known = this.internals.cardsById.get(cardId);
1993
+ evicted.push({ card: known ? effectiveGameId(known.card) : cardId, reason });
1584
1994
  return false;
1585
1995
  };
1586
1996
  const survivors = (this.boardContents.get(hand.id) ?? []).filter((cardId) => {
@@ -1597,7 +2007,7 @@ var Flow = class {
1597
2007
  });
1598
2008
  this.boardContents.set(hand.id, survivors);
1599
2009
  if (this.tracing) {
1600
- for (const e of evicted) this.emit({ type: "evict", hand: hand.id, card: e.card, reason: e.reason }, turn);
2010
+ for (const e of evicted) this.emit({ type: "evict", hand: effectiveGameId(hand), card: e.card, reason: e.reason }, turn);
1601
2011
  }
1602
2012
  }
1603
2013
  const claimCounts = this.claims();
@@ -1614,14 +2024,15 @@ var Flow = class {
1614
2024
  (card, shared) => own.has(card.id) ? "claimed" : this.claimVerdict(card, shared, claimCounts, worldClaims),
1615
2025
  trace
1616
2026
  );
1617
- const added = ordered.slice(0, free).map((e) => e.card.id);
2027
+ const taking = ordered.slice(0, free);
2028
+ const added = taking.map((e) => e.card.id);
1618
2029
  this.boardContents.set(hand.id, [...contents, ...added]);
1619
2030
  for (const id of added) {
1620
2031
  claimCounts.set(id, (claimCounts.get(id) ?? 0) + 1);
1621
2032
  worldClaims.set(id, (worldClaims.get(id) ?? 0) + 1);
1622
2033
  }
1623
2034
  if (trace) {
1624
- this.capTrace(trace, new Set(added));
2035
+ this.capTrace(trace, new Set(taking.map((e) => effectiveGameId(e.card))));
1625
2036
  this.emit({ type: "deal", hand: effectiveGameId(hand), cards: trace }, this.turnCounts.get(box.id) ?? 0);
1626
2037
  }
1627
2038
  }
@@ -1689,7 +2100,8 @@ var Flow = class {
1689
2100
  if (!this.passes(outcome.condition, ctx)) {
1690
2101
  throw new Error(`outcome "${outcomeGameId}" on "${effectiveGameId(entry.card)}" is gated shut`);
1691
2102
  }
1692
- const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? this.internals.bundle.settings.playAdvancesTurns);
2103
+ const perPlay = entry.box.turn !== void 0 ? 0 : this.internals.bundle.settings.playAdvancesTurns;
2104
+ const newTurn = (this.turnCounts.get(entry.box.id) ?? 0) + (opts.advanceTurns ?? perPlay);
1693
2105
  const writes = [];
1694
2106
  for (const [target, expr] of Object.entries(outcome.changes)) {
1695
2107
  writes.push({ target, value: this.eval(expr, ctx) });
@@ -1713,7 +2125,11 @@ var Flow = class {
1713
2125
  (this.boardContents.get(handId) ?? []).filter((id) => id !== entry.card.id)
1714
2126
  );
1715
2127
  this.turnCounts.set(entry.box.id, newTurn);
1716
- if (this.tracing) this.emit({ type: "play", card: entry.card.id, outcome: effectiveGameId(outcome), turn: newTurn }, newTurn);
2128
+ if (this.tracing) this.emit({ type: "play", card: effectiveGameId(entry.card), outcome: effectiveGameId(outcome), turn: newTurn }, newTurn);
2129
+ }
2130
+ /** One owned property's address, owner segment and all (4.4). */
2131
+ address(kind, id) {
2132
+ return addressOf(this.internals, kind, id);
1717
2133
  }
1718
2134
  /** Land one change in whichever partition declares the name: the flow's
1719
2135
  * bag when the property is per-flow, the shared bag when it is shared -
@@ -1734,24 +2150,24 @@ var Flow = class {
1734
2150
  const [, scope, name] = match;
1735
2151
  switch (scope) {
1736
2152
  case "world": {
1737
- const resolver = this.internals.worldResolver;
1738
- if (!resolver.set) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
2153
+ const worldSet = this.internals.worldSet;
2154
+ if (!worldSet) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
1739
2155
  if (this.internals.worldReadOnly.has(name)) throw new Error(`'@world.${name}' is read-only (writable: false)`);
1740
- const prev = resolver.get(name);
1741
- resolver.set(name, value);
2156
+ const prev = this.internals.worldResolver.get(name);
2157
+ worldSet(name, value);
1742
2158
  return { path: `world.${name}`, ...prev !== void 0 ? { prev } : {} };
1743
2159
  }
1744
2160
  case "story":
1745
2161
  return this.landIn("story", void 0, name, value, `story.${name}`);
1746
2162
  case "box":
1747
- return this.landIn("box", entry.box.id, name, value, `box.${entry.box.id}.${name}`);
2163
+ return this.landIn("box", entry.box.id, name, value, `${this.address("box", entry.box.id)}.${name}`);
1748
2164
  case "deck":
1749
- return this.landIn("deck", entry.deck.id, name, value, `deck.${entry.deck.id}.${name}`);
2165
+ return this.landIn("deck", entry.deck.id, name, value, `${this.address("deck", entry.deck.id)}.${name}`);
1750
2166
  case "hand": {
1751
2167
  const source = handEnv.sources.get(name);
1752
2168
  if (!source) throw new Error(`@hand.${name} is not composed in this ask`);
1753
2169
  if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
1754
- return this.landIn(source.kind, source.id, name, value, `${source.kind}.${source.id}.${name}`);
2170
+ return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
1755
2171
  }
1756
2172
  default:
1757
2173
  throw new Error(`bad change target scope "@${scope}"`);
@@ -1787,7 +2203,7 @@ var Flow = class {
1787
2203
  this.assertOpen();
1788
2204
  const mounts = [{ prefix: "story", bag: this.stores.story }];
1789
2205
  for (const kind of ["box", "deck", "hand", "value"]) {
1790
- for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: `${kind}.${id}`, bag });
2206
+ for (const [id, bag] of this.stores[kind]) mounts.push({ prefix: addressOf(this.internals, kind, id), bag });
1791
2207
  }
1792
2208
  return mounts;
1793
2209
  }
@@ -1810,10 +2226,15 @@ var Flow = class {
1810
2226
  ...d.values !== void 0 ? { values: d.values } : {},
1811
2227
  ...d.stages !== void 0 ? { stages: d.stages } : {},
1812
2228
  // @world is FOREIGN - a host resolver backs it - so writability is whether that
1813
- // resolver can be written at all, which is the shared registry's own rule for a
1814
- // foreign scope. The `as PropertyView` cast this replaced was hiding the field's
1815
- // absence: the row type has always required it, and these rows shipped without one.
1816
- writable: this.internals.worldResolver.set !== void 0
2229
+ // resolver can be written at all AND what the declaration says, which is the
2230
+ // shared registry's own rule for a foreign scope (its foreignWritable). The
2231
+ // `as PropertyView` cast this replaced was hiding the field's absence: the row
2232
+ // type has always required it, and these rows shipped without one.
2233
+ //
2234
+ // A row is where `writable: false` is meant to SHOW (Reboot.md 10): it tells a
2235
+ // state panel this is the game's value, not the story's. It does not stop the
2236
+ // panel editing it - the host's setProperty passes `{ host: true }`.
2237
+ writable: this.internals.worldSet !== void 0 && !this.internals.worldReadOnly.has(d.name)
1817
2238
  });
1818
2239
  }
1819
2240
  const add = (_prefix, shared, own) => {
@@ -1823,15 +2244,22 @@ var Flow = class {
1823
2244
  }
1824
2245
  };
1825
2246
  add("story", this.internals.shared.story, this.stores.story);
1826
- for (const kind of ["box", "deck", "hand", "value"]) {
2247
+ for (const kind of OWNED_SCOPES) {
1827
2248
  const ids = /* @__PURE__ */ new Set([...this.internals.shared[kind].keys(), ...this.stores[kind].keys()]);
1828
- for (const id of ids) add(`${kind}.${id}`, this.internals.shared[kind].get(id), this.stores[kind].get(id));
2249
+ for (const id of ids) {
2250
+ add(addressOf(this.internals, kind, id), this.internals.shared[kind].get(id), this.stores[kind].get(id));
2251
+ }
1829
2252
  }
1830
2253
  return out;
1831
2254
  }
1832
- /** Read by path: "world.x", "story.gold", "value.v_docks.danger",
1833
- * "box.b_x.heat", "deck.k_main.n", "hand.h_board.owner" - the flow's
1834
- * merged view, routed by the declaration's sharing. */
2255
+ /** Read by path: "world.x", "story.gold", "value.docks.danger",
2256
+ * "box.village.heat", "deck.wares.n", "hand.the-elder.zone" - the flow's
2257
+ * merged view, routed by the declaration's sharing.
2258
+ *
2259
+ * The owner segment is the entity's GAMEID, the name it is called by
2260
+ * everywhere else (4.4). Its internal id is accepted for this release and
2261
+ * earns a `diagnostic` naming the address to move to; the next lockstep
2262
+ * release refuses it. */
1835
2263
  getProperty(path) {
1836
2264
  this.assertOpen();
1837
2265
  const found = this.resolvePath(path);
@@ -1843,13 +2271,13 @@ var Flow = class {
1843
2271
  this.assertOpen();
1844
2272
  const found = this.resolvePath(path);
1845
2273
  if (found.kind === "world") {
1846
- if (!this.internals.worldResolver.set) throw new Error(`@world is read-only here: the host bound no write`);
1847
- this.internals.worldResolver.set(found.name, value);
2274
+ if (!this.internals.worldSet) throw new Error(`@world is read-only here: the host bound no write`);
2275
+ this.internals.worldSet(found.name, value, true);
1848
2276
  return;
1849
2277
  }
1850
2278
  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;
1851
2279
  if (bag === void 0) throw new Error(`no property at "${path}"`);
1852
- bag.set(found.name, value, { silent: true, reason: "host setProperty" });
2280
+ bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
1853
2281
  }
1854
2282
  resolvePath(path) {
1855
2283
  const parts = path.split(".");
@@ -1859,10 +2287,15 @@ var Flow = class {
1859
2287
  }
1860
2288
  if (parts.length === 3 && (parts[0] === "box" || parts[0] === "deck" || parts[0] === "hand" || parts[0] === "value")) {
1861
2289
  const kind = parts[0];
1862
- const own = this.stores[kind].get(parts[1]);
1863
- const shared = this.internals.shared[kind].get(parts[1]);
1864
- if (own === void 0 && shared === void 0) throw new Error(`no ${parts[0]} store "${parts[1]}"`);
1865
- return { kind: "bag", ...own !== void 0 ? { own } : {}, ...shared !== void 0 ? { shared } : {}, name: parts[2] };
2290
+ const [, segment, name] = parts;
2291
+ const owner = ownerOrThrow(this.internals, kind, segment, name);
2292
+ if (owner.legacy && this.tracing) {
2293
+ this.emit({ type: "diagnostic", where: "property address", message: legacyAddressMessage(this.internals, kind, segment, name) });
2294
+ }
2295
+ const own = this.stores[kind].get(owner.id);
2296
+ const shared = this.internals.shared[kind].get(owner.id);
2297
+ if (own === void 0 && shared === void 0) throw new Error(`no ${kind} store "${segment}"`);
2298
+ return { kind: "bag", ...own !== void 0 ? { own } : {}, ...shared !== void 0 ? { shared } : {}, name };
1866
2299
  }
1867
2300
  throw new Error(`bad property path "${path}"`);
1868
2301
  }
@@ -1903,14 +2336,27 @@ var summarise = (decls) => decls.map((d) => ({
1903
2336
  type: d.type,
1904
2337
  default: d.default,
1905
2338
  ...d.values !== void 0 ? { values: d.values } : {},
2339
+ ...d.durable === true ? { durable: true } : {},
1906
2340
  ...d.purpose !== void 0 ? { purpose: d.purpose } : {}
1907
2341
  }));
2342
+ var durableCardCount = (box) => box.decks.reduce((n, deck) => n + deck.cards.filter((card) => (card.durable ?? deck.durable) === true).length, 0);
1908
2343
  var handDecls = (hand, box) => {
1909
2344
  if (hand.template !== void 0) {
1910
2345
  return box.handTemplates.find((t) => t.id === hand.template)?.properties ?? [];
1911
2346
  }
1912
2347
  return hand.properties ?? [];
1913
2348
  };
2349
+ var movableHoles = (hand, box) => {
2350
+ const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
2351
+ const out = [];
2352
+ for (const [groupId, value] of Object.entries(filled ?? {})) {
2353
+ if (!isHoleRef(value)) continue;
2354
+ const group = box.tagGroups.find((g) => g.id === groupId);
2355
+ if (group === void 0) continue;
2356
+ out.push({ group: effectiveGameId(group), from: value });
2357
+ }
2358
+ return out;
2359
+ };
1914
2360
  var handSlots = (hand, box) => {
1915
2361
  if (hand.slots !== void 0) return hand.slots;
1916
2362
  const declared = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template)?.slots : hand.rule?.slots;
@@ -1931,6 +2377,8 @@ function describeBundle(bundle) {
1931
2377
  gameId: boxGameId,
1932
2378
  ...box.title !== void 0 ? { title: box.title } : {},
1933
2379
  ranking: { specificity: box.ranking.specificity },
2380
+ ...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
2381
+ ...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
1934
2382
  tagGroups: box.tagGroups.map((group) => ({
1935
2383
  gameId: effectiveGameId(group),
1936
2384
  tags: group.tags.map((tag) => effectiveGameId(tag))
@@ -1951,12 +2399,14 @@ function describeBundle(bundle) {
1951
2399
  totals.tagGroups += box.tagGroups.length;
1952
2400
  for (const hand of box.hands) {
1953
2401
  const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
2402
+ const movable = movableHoles(hand, box);
1954
2403
  hands.push({
1955
2404
  gameId: effectiveGameId(hand),
1956
2405
  ...hand.title !== void 0 ? { title: hand.title } : {},
1957
2406
  box: boxGameId,
1958
2407
  slots: handSlots(hand, box),
1959
- ...template !== void 0 ? { template: effectiveGameId(template) } : {}
2408
+ ...template !== void 0 ? { template: effectiveGameId(template) } : {},
2409
+ ...movable.length > 0 ? { movable } : {}
1960
2410
  });
1961
2411
  }
1962
2412
  const push = (scope, owner, decls, group) => {
@@ -1994,7 +2444,8 @@ function describeBundle(bundle) {
1994
2444
  box: map.box,
1995
2445
  group: map.group,
1996
2446
  zones: map.zones.length,
1997
- backgrounds: map.backgrounds?.length ?? 0
2447
+ backgrounds: map.backgrounds?.length ?? 0,
2448
+ sites: map.sites?.length ?? 0
1998
2449
  }))
1999
2450
  };
2000
2451
  }