@storylet-studio/runtime 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,12 +3,16 @@ import { deserialiseAst, evaluate } from "@wildwinter/expr";
3
3
  import { matchedSpecificity } from "@wildwinter/expr-specificity";
4
4
  import { storyletsDialect, NEVER_PLAYED } from "@storylet-studio/dialect";
5
5
  import {
6
+ BUNDLE_SCHEMAS,
6
7
  PLACE_GROUP,
8
+ allTagGroups,
7
9
  ambiguousValueAddressMessage,
8
10
  effectiveGameId,
11
+ groupsOfBox,
9
12
  isHoleRef,
10
13
  parseHoleRef,
11
- valueAddresses
14
+ valueAddresses,
15
+ zoneQualifiedValueAddressMessage
12
16
  } from "@storylet-studio/model";
13
17
  import { SAVE_SCHEMA, SAVE_SCHEMA_V1 } from "@storylet-studio/model";
14
18
  import { PropertyBag as StateBag, ScopeRegistry } from "@wildwinter/scoperegistry";
@@ -17,7 +21,7 @@ import { PropertyBag as StateBag, ScopeRegistry } from "@wildwinter/scoperegistr
17
21
  import { makePrng, shuffleInPlace } from "@wildwinter/expr";
18
22
 
19
23
  // src/engine.ts
20
- var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
24
+ var tagKey = (boxId, groupId, tagId) => `${boxId}${groupId}${tagId}`;
21
25
  var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
22
26
  var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
23
27
  var OWNER = "Storylet Engine";
@@ -74,11 +78,12 @@ var isShared = (scope, d) => d.shared ?? SCOPE_DEFAULT_SHARED[scope];
74
78
  var sharedHalf = (scope, decls) => decls.filter((d) => isShared(scope, d));
75
79
  var flowHalf = (scope, decls) => decls.filter((d) => !isShared(scope, d));
76
80
  var OWNED_SCOPES = ["box", "deck", "hand", "value"];
81
+ var emptyOwnerIndex = () => ({ gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map(), zoneQualified: /* @__PURE__ */ new Map() });
77
82
  var emptyOwnerIndexes = () => ({
78
- box: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
79
- deck: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
80
- hand: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
81
- value: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() }
83
+ box: emptyOwnerIndex(),
84
+ deck: emptyOwnerIndex(),
85
+ hand: emptyOwnerIndex(),
86
+ value: emptyOwnerIndex()
82
87
  });
83
88
  var indexOwner = (index, entity) => {
84
89
  const gameId = effectiveGameId(entity);
@@ -90,6 +95,7 @@ var indexValueOwners = (index, bundle) => {
90
95
  for (const [id, segment] of addresses.print) index.gameId.set(id, segment);
91
96
  for (const [segment, id] of addresses.accept) index.id.set(segment, id);
92
97
  for (const [gameId, candidates] of addresses.repeated) index.repeated.set(gameId, candidates);
98
+ for (const [segment, zone] of addresses.zoneQualified) index.zoneQualified.set(segment, zone);
93
99
  };
94
100
  var handDeclsOf = (internals, hand) => {
95
101
  if (hand.template !== void 0) {
@@ -101,6 +107,8 @@ var addressOf = (internals, kind, id) => `${kind}.${internals.owners[kind].gameI
101
107
  var resolveOwner = (internals, kind, segment) => {
102
108
  const candidates = internals.owners[kind].repeated.get(segment);
103
109
  if (candidates !== void 0) return { ambiguous: candidates };
110
+ const zone = internals.owners[kind].zoneQualified.get(segment);
111
+ if (zone !== void 0) return { zone };
104
112
  const byGameId = internals.owners[kind].id.get(segment);
105
113
  if (byGameId !== void 0) return { id: byGameId, legacy: false };
106
114
  if (internals.owners[kind].gameId.has(segment)) return { id: segment, legacy: true };
@@ -110,6 +118,7 @@ var ownerOrThrow = (internals, kind, segment, name) => {
110
118
  const owner = resolveOwner(internals, kind, segment);
111
119
  if (owner === void 0) throw new Error(`no ${kind} store "${segment}"`);
112
120
  if ("ambiguous" in owner) throw new Error(ambiguousValueAddressMessage(segment, name, owner.ambiguous));
121
+ if ("zone" in owner) throw new Error(zoneQualifiedValueAddressMessage(segment, owner.zone, name));
113
122
  return owner;
114
123
  };
115
124
  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.`;
@@ -127,9 +136,12 @@ var buildPartition = (internals, half) => {
127
136
  hand: new Map(b.boxes.flatMap((box) => box.hands.map(
128
137
  (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), at("hand", hand.id))]
129
138
  ))),
130
- value: new Map(b.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
139
+ // Every box's tags, then the project map's zones ONCE (design/project-
140
+ // map-contract.md 3.3): a zone is one bag per partition, whichever boxes'
141
+ // hands are dealt to it.
142
+ value: new Map(allTagGroups(b).flatMap((group) => group.tags.map(
131
143
  (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), at("value", tag.id))]
132
- ))))
144
+ )))
133
145
  };
134
146
  };
135
147
  var partitionValues = (p) => ({
@@ -246,6 +258,68 @@ function finishReport(bundle, saved, flows, draft) {
246
258
  retypedProperties
247
259
  };
248
260
  }
261
+ var refuseUnreadableBundle = (bundle) => {
262
+ const schema = bundle.schema;
263
+ if (typeof schema !== "string" || !BUNDLE_SCHEMAS.includes(schema)) {
264
+ throw new Error(`unsupported bundle schema: ${String(schema)} (this runtime reads ${BUNDLE_SCHEMAS.join(" and ")})`);
265
+ }
266
+ const problems = [];
267
+ const map = bundle.map?.group;
268
+ const mapName = map !== void 0 ? effectiveGameId(map) : void 0;
269
+ if (map !== void 0 && mapName === PLACE_GROUP) {
270
+ problems.push(`the project map's tag group is called "${PLACE_GROUP}", which is reserved for a box's own hands`);
271
+ }
272
+ const boxTags = /* @__PURE__ */ new Map();
273
+ for (const box of bundle.boxes) {
274
+ for (const group of box.tagGroups) {
275
+ for (const tag of group.tags) {
276
+ const gameId = effectiveGameId(tag);
277
+ if (!boxTags.has(gameId)) boxTags.set(gameId, { box: effectiveGameId(box), group: effectiveGameId(group) });
278
+ }
279
+ }
280
+ }
281
+ for (const tag of map?.tags ?? []) {
282
+ const zone = effectiveGameId(tag);
283
+ const clash = boxTags.get(zone);
284
+ if (clash !== void 0) {
285
+ problems.push(`the project map's zone "${zone}" has the name of tag "${zone}" in box "${clash.box}", group "${clash.group}", so "value.${zone}.<name>" would name two things`);
286
+ }
287
+ }
288
+ for (const box of bundle.boxes) {
289
+ const boxName = effectiveGameId(box);
290
+ if (box.usesMap === true) {
291
+ if (map === void 0) {
292
+ problems.push(`box "${boxName}" uses the project map, but the bundle has no map`);
293
+ continue;
294
+ }
295
+ const twin = box.tagGroups.find((g) => effectiveGameId(g) === mapName);
296
+ if (twin !== void 0) {
297
+ problems.push(`box "${boxName}" uses the project map and declares its own tag group "${mapName}", the map's name`);
298
+ }
299
+ continue;
300
+ }
301
+ if (map === void 0) continue;
302
+ const names = (where) => {
303
+ problems.push(`box "${boxName}" is not on the project map, but ${where} names the map's tag group "${mapName}"`);
304
+ };
305
+ for (const deck of box.decks) {
306
+ for (const card of deck.cards) {
307
+ if (card.tags?.[map.id] !== void 0) names(`card "${effectiveGameId(card)}"`);
308
+ }
309
+ }
310
+ for (const template of box.handTemplates) {
311
+ if (template.bindings?.[map.id] !== void 0 || template.chooses?.includes(map.id) === true) {
312
+ names(`hand template "${effectiveGameId(template)}"`);
313
+ }
314
+ }
315
+ for (const hand of box.hands) {
316
+ if (hand.chosen?.[map.id] !== void 0 || hand.rule?.bindings?.[map.id] !== void 0) {
317
+ names(`hand "${effectiveGameId(hand)}"`);
318
+ }
319
+ }
320
+ }
321
+ if (problems.length > 0) throw new Error(`bundle refused: ${problems.join("; ")}`);
322
+ };
249
323
  var Engine = class _Engine {
250
324
  internals;
251
325
  seed;
@@ -262,6 +336,7 @@ var Engine = class _Engine {
262
336
  * hotSwap puts this engine back exactly as it was. */
263
337
  sharedMounts = [];
264
338
  constructor(bundle, opts = {}) {
339
+ refuseUnreadableBundle(bundle);
265
340
  this.creationOptions = opts;
266
341
  this.seed = opts.seed ?? 0;
267
342
  this.onReplacedFlow = opts.onReplacedFlow;
@@ -345,6 +420,10 @@ var Engine = class _Engine {
345
420
  indexOwner(internals.owners.hand, hand);
346
421
  }
347
422
  }
423
+ if (bundle.map !== void 0) {
424
+ internals.groupsById.set(bundle.map.group.id, { group: bundle.map.group });
425
+ if (bundle.map.group.required === true) internals.requiredGroups.add(bundle.map.group.id);
426
+ }
348
427
  this.initLadders();
349
428
  const declSet = (half) => ({
350
429
  story: half("story", bundle.story.properties),
@@ -355,9 +434,9 @@ var Engine = class _Engine {
355
434
  hand: new Map(bundle.boxes.flatMap((box) => box.hands.map(
356
435
  (hand) => [hand.id, half("hand", handDeclsOf(internals, hand))]
357
436
  ))),
358
- value: new Map(bundle.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
437
+ value: new Map(allTagGroups(bundle).flatMap((group) => group.tags.map(
359
438
  (tag) => [tag.id, half("value", tag.properties ?? [])]
360
- ))))
439
+ )))
361
440
  });
362
441
  internals.flowDecls = declSet(flowHalf);
363
442
  internals.sharedDecls = declSet(sharedHalf);
@@ -458,6 +537,7 @@ var Engine = class _Engine {
458
537
  }
459
538
  for (const hand of box.hands) internals.ladders.hand.set(hand.id, grab(handDeclsOf(internals, hand)));
460
539
  }
540
+ for (const tag of b.map?.group.tags ?? []) internals.ladders.value.set(tag.id, grab(tag.properties));
461
541
  const any = (m) => [...m.values()].some((x) => x.size > 0);
462
542
  internals.hasQualities = internals.ladders.world.size > 0 || internals.ladders.story.size > 0 || any(internals.ladders.box) || any(internals.ladders.deck) || any(internals.ladders.value) || any(internals.ladders.hand);
463
543
  }
@@ -781,11 +861,35 @@ var Engine = class _Engine {
781
861
  return structuredClone({
782
862
  schema: SAVE_SCHEMA,
783
863
  content: this.internals.bundle.content,
784
- ...this.internals.ownsRegistry ? { registry: this.internals.registry.save() } : {},
864
+ ...this.internals.ownsRegistry ? { registry: this.registrySection() } : {},
785
865
  shared: { spent: [...this.spent].sort() },
786
866
  flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
787
867
  });
788
868
  }
869
+ /** The registry's values in CANONICAL order, the order a load rebuilds them
870
+ * in: the engine-wide keys as the constructor registered them, then each
871
+ * flow's keys in `flows()` order (each flow's own registration order), then
872
+ * anything else the registry holds (values still waiting for a key), as the
873
+ * registry lists it. The registry itself lists keys in registration order,
874
+ * and a flow replaced in place (`open()` above keeps its slot in
875
+ * `flowsById`) re-registers its keys at the END, so `openFlow("a");
876
+ * openFlow("b"); openFlow("a")` saved b's keys before a's while a load
877
+ * rebuilt a's first: the same run, different `.storyletsave` bytes, and a
878
+ * save loaded and saved again no longer equal to itself. It is the
879
+ * 2026-08-29 rule carried into the section save@2 moved the per-flow values
880
+ * to (2026-10-01). Order does not matter on READ (`partitionsFromSections`
881
+ * sorts by key shape), so a save written in the old order loads as before. */
882
+ registrySection() {
883
+ const all = this.internals.registry.save();
884
+ const out = {};
885
+ const take = (key) => {
886
+ if (Object.prototype.hasOwnProperty.call(all, key) && !Object.prototype.hasOwnProperty.call(out, key)) out[key] = all[key];
887
+ };
888
+ for (const { key } of this.sharedMounts) take(key);
889
+ for (const flow of this.flowsById.values()) for (const key of flow.registeredKeys()) take(key);
890
+ for (const key of Object.keys(all)) take(key);
891
+ return out;
892
+ }
789
893
  /** ONE flow's blob, to park a visit that is walking away: the same shape
790
894
  * the envelope carries per flow, and the same shape `openFlow`'s `restore`
791
895
  * option takes back (design/engine-server.md 4.1). Saving the whole
@@ -1115,9 +1219,16 @@ var Flow = class {
1115
1219
  /** Tag group names are box-scoped: two boxes may name a group the same way
1116
1220
  * (schema 1 - boxes namespace their groups), so a name is only ever
1117
1221
  * resolved inside the box being asked, never bundle-wide. Ids are
1118
- * project-unique and accepted here too, still confined to the box. */
1222
+ * project-unique and accepted here too, still confined to the box.
1223
+ *
1224
+ * A box on the project map sees ONE namespace: its own groups, then the
1225
+ * map's group (design/project-map-contract.md 3.1). A box that has not
1226
+ * opted in does not see the map's name at all, so a peek naming it there
1227
+ * is the ordinary unknown-group refusal. Own groups first is stated for
1228
+ * determinism only: a bundle that loads never has the two share a name. */
1119
1229
  groupInBox(box, ref) {
1120
- return box.tagGroups.find((g) => effectiveGameId(g) === ref) ?? box.tagGroups.find((g) => g.id === ref);
1230
+ const groups = groupsOfBox(this.internals.bundle, box);
1231
+ return groups.find((g) => effectiveGameId(g) === ref) ?? groups.find((g) => g.id === ref);
1121
1232
  }
1122
1233
  /** Fold one play into the indexes. O(the card's tags), not O(the log). */
1123
1234
  indexPlay(record) {
@@ -1127,7 +1238,7 @@ var Flow = class {
1127
1238
  if (!entry) return;
1128
1239
  for (const [groupId, tagIds] of Object.entries(entry.card.tags ?? {})) {
1129
1240
  for (const tagId of tagIds) {
1130
- const key = tagKey(groupId, tagId);
1241
+ const key = tagKey(entry.box.id, groupId, tagId);
1131
1242
  this.tagPlayCount.set(key, (this.tagPlayCount.get(key) ?? 0) + 1);
1132
1243
  this.lastPlayInTag.set(key, record);
1133
1244
  }
@@ -1143,8 +1254,10 @@ var Flow = class {
1143
1254
  for (const record of this.playLog) this.indexPlay(record);
1144
1255
  }
1145
1256
  /** `box` is the box whose ask is being evaluated: the play-history
1146
- * functions take a bare group name, so it resolves there (a card's tags
1147
- * reference its own box's group, which keeps the counts box-local).
1257
+ * functions take a bare group name, so it resolves there, and they count
1258
+ * only that box's own plays (the box is in the index key). That was
1259
+ * automatic while every group was a box's; a project-map zone is shared,
1260
+ * and its history is still not (design/project-map-contract.md 3.7, D7).
1148
1261
  * History is THIS flow's: countPlayed answers "have I done this". */
1149
1262
  /** One host per box, built once.
1150
1263
  *
@@ -1169,7 +1282,7 @@ var Flow = class {
1169
1282
  const keyOf = (group, tag) => {
1170
1283
  const found = this.groupInBox(box, group);
1171
1284
  const t = found?.tags.find((v) => v.gameId === tag);
1172
- return found && t ? tagKey(found.id, t.id) : void 0;
1285
+ return found && t ? tagKey(box.id, found.id, t.id) : void 0;
1173
1286
  };
1174
1287
  const since = (record) => {
1175
1288
  const entry = this.internals.cardsByGameId.get(record.card);
@@ -1376,7 +1489,7 @@ var Flow = class {
1376
1489
  * is what says otherwise.
1377
1490
  */
1378
1491
  bindStateGroups(box, boundTags, askNames) {
1379
- for (const group of box.tagGroups) {
1492
+ for (const group of groupsOfBox(this.internals.bundle, box)) {
1380
1493
  if (group.boundBy === void 0 || boundTags.has(group.id)) continue;
1381
1494
  const ref = /^@(world|story)\.([a-z][a-z0-9_-]*)$/.exec(group.boundBy);
1382
1495
  if (!ref) {
@@ -2035,7 +2148,7 @@ var Flow = class {
2035
2148
  };
2036
2149
 
2037
2150
  // src/describe.ts
2038
- import { effectiveGameId as effectiveGameId2, isHoleRef as isHoleRef2 } from "@storylet-studio/model";
2151
+ import { effectiveGameId as effectiveGameId2, groupsOfBox as groupsOfBox2, isHoleRef as isHoleRef2 } from "@storylet-studio/model";
2039
2152
  var summarise = (decls) => decls.map((d) => ({
2040
2153
  name: d.name,
2041
2154
  type: d.type,
@@ -2051,12 +2164,12 @@ var handDecls = (hand, box) => {
2051
2164
  }
2052
2165
  return hand.properties ?? [];
2053
2166
  };
2054
- var movableHoles = (hand, box) => {
2167
+ var movableHoles = (bundle, hand, box) => {
2055
2168
  const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
2056
2169
  const out = [];
2057
2170
  for (const [groupId, value] of Object.entries(filled ?? {})) {
2058
2171
  if (!isHoleRef2(value)) continue;
2059
- const group = box.tagGroups.find((g) => g.id === groupId);
2172
+ const group = groupsOfBox2(bundle, box).find((g) => g.id === groupId);
2060
2173
  if (group === void 0) continue;
2061
2174
  out.push({ group: effectiveGameId2(group), from: value });
2062
2175
  }
@@ -2081,6 +2194,7 @@ function describeBundle(bundle) {
2081
2194
  boxes.push({
2082
2195
  gameId: boxGameId,
2083
2196
  ...box.title !== void 0 ? { title: box.title } : {},
2197
+ ...box.usesMap === true ? { usesMap: true } : {},
2084
2198
  ranking: { specificity: box.ranking.specificity },
2085
2199
  ...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
2086
2200
  ...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
@@ -2104,7 +2218,7 @@ function describeBundle(bundle) {
2104
2218
  totals.tagGroups += box.tagGroups.length;
2105
2219
  for (const hand of box.hands) {
2106
2220
  const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
2107
- const movable = movableHoles(hand, box);
2221
+ const movable = movableHoles(bundle, hand, box);
2108
2222
  hands.push({
2109
2223
  gameId: effectiveGameId2(hand),
2110
2224
  ...hand.title !== void 0 ? { title: hand.title } : {},
@@ -2133,6 +2247,15 @@ function describeBundle(bundle) {
2133
2247
  }
2134
2248
  }
2135
2249
  }
2250
+ const map = bundle.map;
2251
+ if (map !== void 0) {
2252
+ const group = effectiveGameId2(map.group);
2253
+ for (const tag of map.group.tags) {
2254
+ const decls = tag.properties ?? [];
2255
+ if (decls.length > 0) properties.push({ scope: "tag", owner: effectiveGameId2(tag), group, properties: summarise(decls) });
2256
+ }
2257
+ totals.tagGroups += 1;
2258
+ }
2136
2259
  return {
2137
2260
  identity: {
2138
2261
  schema: bundle.schema,
@@ -2145,13 +2268,16 @@ function describeBundle(bundle) {
2145
2268
  boxes,
2146
2269
  hands,
2147
2270
  properties,
2148
- maps: (bundle.maps ?? []).map((map) => ({
2149
- box: map.box,
2150
- group: map.group,
2151
- zones: map.zones.length,
2152
- backgrounds: map.backgrounds?.length ?? 0,
2153
- sites: map.sites?.length ?? 0
2154
- }))
2271
+ ...map !== void 0 ? {
2272
+ map: {
2273
+ group: effectiveGameId2(map.group),
2274
+ tags: map.group.tags.map((tag) => effectiveGameId2(tag)),
2275
+ boxes: bundle.boxes.filter((box) => box.usesMap === true).map((box) => effectiveGameId2(box)),
2276
+ zones: map.geometry?.zones.length ?? 0,
2277
+ backgrounds: map.geometry?.backgrounds?.length ?? 0,
2278
+ sites: Object.fromEntries(Object.entries(map.geometry?.sites ?? {}).map(([box, sites]) => [box, sites.length]))
2279
+ }
2280
+ } : {}
2155
2281
  };
2156
2282
  }
2157
2283
  export {