@storylet-studio/runtime 0.8.2 → 0.9.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @storylet-studio/runtime
2
2
 
3
3
  The Storylet Engine **JS reference runtime**: the Engine and Flow surface of
4
- [the bundle format](https://storylet.studio/format/bundle/) section 5 -
4
+ [the bundle format](https://storylets.dev/format/bundle/) section 5 -
5
5
  the engine (`openFlow` / `getFlow` / `flows` / `closeFlow` / `reset` /
6
6
  `saveGame` / `loadGame` / `saveFlow` / `previewLoad` / `previewFlowRestore` /
7
7
  `subscribeTrace` / `log` / `clearLog` /
@@ -24,4 +24,4 @@ game. Pass the game's as `new Engine(bundle, { registry })` to share it with any
24
24
  starting `storylets/`, `@world` is the game's to register, and `saveGame()` leaves the values
25
25
  to the game, which saves the registry once. Without one the engine makes its own registry, self-backs
26
26
  `@world`, and `saveGame()` carries every value, so one call is still the whole run. Version 1
27
- envelopes still load. See [Running it with Patter](https://storylet.studio/play/with-patter/#one-registry).
27
+ envelopes still load. See [Running it with Patter](https://storylets.dev/play/with-patter/#one-registry).
package/dist/index.cjs CHANGED
@@ -40,7 +40,7 @@ var import_scoperegistry = require("@wildwinter/scoperegistry");
40
40
  var import_expr = require("@wildwinter/expr");
41
41
 
42
42
  // src/engine.ts
43
- var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
43
+ var tagKey = (boxId, groupId, tagId) => `${boxId}${groupId}${tagId}`;
44
44
  var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
45
45
  var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
46
46
  var OWNER = "Storylet Engine";
@@ -97,11 +97,12 @@ var isShared = (scope, d) => d.shared ?? SCOPE_DEFAULT_SHARED[scope];
97
97
  var sharedHalf = (scope, decls) => decls.filter((d) => isShared(scope, d));
98
98
  var flowHalf = (scope, decls) => decls.filter((d) => !isShared(scope, d));
99
99
  var OWNED_SCOPES = ["box", "deck", "hand", "value"];
100
+ var emptyOwnerIndex = () => ({ gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map(), zoneQualified: /* @__PURE__ */ new Map() });
100
101
  var emptyOwnerIndexes = () => ({
101
- box: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
102
- deck: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
103
- hand: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() },
104
- value: { gameId: /* @__PURE__ */ new Map(), id: /* @__PURE__ */ new Map(), repeated: /* @__PURE__ */ new Map() }
102
+ box: emptyOwnerIndex(),
103
+ deck: emptyOwnerIndex(),
104
+ hand: emptyOwnerIndex(),
105
+ value: emptyOwnerIndex()
105
106
  });
106
107
  var indexOwner = (index, entity) => {
107
108
  const gameId = (0, import_model.effectiveGameId)(entity);
@@ -113,6 +114,7 @@ var indexValueOwners = (index, bundle) => {
113
114
  for (const [id, segment] of addresses.print) index.gameId.set(id, segment);
114
115
  for (const [segment, id] of addresses.accept) index.id.set(segment, id);
115
116
  for (const [gameId, candidates] of addresses.repeated) index.repeated.set(gameId, candidates);
117
+ for (const [segment, zone] of addresses.zoneQualified) index.zoneQualified.set(segment, zone);
116
118
  };
117
119
  var handDeclsOf = (internals, hand) => {
118
120
  if (hand.template !== void 0) {
@@ -124,6 +126,8 @@ var addressOf = (internals, kind, id) => `${kind}.${internals.owners[kind].gameI
124
126
  var resolveOwner = (internals, kind, segment) => {
125
127
  const candidates = internals.owners[kind].repeated.get(segment);
126
128
  if (candidates !== void 0) return { ambiguous: candidates };
129
+ const zone = internals.owners[kind].zoneQualified.get(segment);
130
+ if (zone !== void 0) return { zone };
127
131
  const byGameId = internals.owners[kind].id.get(segment);
128
132
  if (byGameId !== void 0) return { id: byGameId, legacy: false };
129
133
  if (internals.owners[kind].gameId.has(segment)) return { id: segment, legacy: true };
@@ -133,6 +137,7 @@ var ownerOrThrow = (internals, kind, segment, name) => {
133
137
  const owner = resolveOwner(internals, kind, segment);
134
138
  if (owner === void 0) throw new Error(`no ${kind} store "${segment}"`);
135
139
  if ("ambiguous" in owner) throw new Error((0, import_model.ambiguousValueAddressMessage)(segment, name, owner.ambiguous));
140
+ if ("zone" in owner) throw new Error((0, import_model.zoneQualifiedValueAddressMessage)(segment, owner.zone, name));
136
141
  return owner;
137
142
  };
138
143
  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.`;
@@ -150,9 +155,12 @@ var buildPartition = (internals, half) => {
150
155
  hand: new Map(b.boxes.flatMap((box) => box.hands.map(
151
156
  (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), at("hand", hand.id))]
152
157
  ))),
153
- value: new Map(b.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
158
+ // Every box's tags, then the project map's zones ONCE (design/project-
159
+ // map-contract.md 3.3): a zone is one bag per partition, whichever boxes'
160
+ // hands are dealt to it.
161
+ value: new Map((0, import_model.allTagGroups)(b).flatMap((group) => group.tags.map(
154
162
  (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), at("value", tag.id))]
155
- ))))
163
+ )))
156
164
  };
157
165
  };
158
166
  var partitionValues = (p) => ({
@@ -269,6 +277,68 @@ function finishReport(bundle, saved, flows, draft) {
269
277
  retypedProperties
270
278
  };
271
279
  }
280
+ var refuseUnreadableBundle = (bundle) => {
281
+ const schema = bundle.schema;
282
+ if (typeof schema !== "string" || !import_model.BUNDLE_SCHEMAS.includes(schema)) {
283
+ throw new Error(`unsupported bundle schema: ${String(schema)} (this runtime reads ${import_model.BUNDLE_SCHEMAS.join(" and ")})`);
284
+ }
285
+ const problems = [];
286
+ const map = bundle.map?.group;
287
+ const mapName = map !== void 0 ? (0, import_model.effectiveGameId)(map) : void 0;
288
+ if (map !== void 0 && mapName === import_model.PLACE_GROUP) {
289
+ problems.push(`the project map's tag group is called "${import_model.PLACE_GROUP}", which is reserved for a box's own hands`);
290
+ }
291
+ const boxTags = /* @__PURE__ */ new Map();
292
+ for (const box of bundle.boxes) {
293
+ for (const group of box.tagGroups) {
294
+ for (const tag of group.tags) {
295
+ const gameId = (0, import_model.effectiveGameId)(tag);
296
+ if (!boxTags.has(gameId)) boxTags.set(gameId, { box: (0, import_model.effectiveGameId)(box), group: (0, import_model.effectiveGameId)(group) });
297
+ }
298
+ }
299
+ }
300
+ for (const tag of map?.tags ?? []) {
301
+ const zone = (0, import_model.effectiveGameId)(tag);
302
+ const clash = boxTags.get(zone);
303
+ if (clash !== void 0) {
304
+ 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`);
305
+ }
306
+ }
307
+ for (const box of bundle.boxes) {
308
+ const boxName = (0, import_model.effectiveGameId)(box);
309
+ if (box.usesMap === true) {
310
+ if (map === void 0) {
311
+ problems.push(`box "${boxName}" uses the project map, but the bundle has no map`);
312
+ continue;
313
+ }
314
+ const twin = box.tagGroups.find((g) => (0, import_model.effectiveGameId)(g) === mapName);
315
+ if (twin !== void 0) {
316
+ problems.push(`box "${boxName}" uses the project map and declares its own tag group "${mapName}", the map's name`);
317
+ }
318
+ continue;
319
+ }
320
+ if (map === void 0) continue;
321
+ const names = (where) => {
322
+ problems.push(`box "${boxName}" is not on the project map, but ${where} names the map's tag group "${mapName}"`);
323
+ };
324
+ for (const deck of box.decks) {
325
+ for (const card of deck.cards) {
326
+ if (card.tags?.[map.id] !== void 0) names(`card "${(0, import_model.effectiveGameId)(card)}"`);
327
+ }
328
+ }
329
+ for (const template of box.handTemplates) {
330
+ if (template.bindings?.[map.id] !== void 0 || template.chooses?.includes(map.id) === true) {
331
+ names(`hand template "${(0, import_model.effectiveGameId)(template)}"`);
332
+ }
333
+ }
334
+ for (const hand of box.hands) {
335
+ if (hand.chosen?.[map.id] !== void 0 || hand.rule?.bindings?.[map.id] !== void 0) {
336
+ names(`hand "${(0, import_model.effectiveGameId)(hand)}"`);
337
+ }
338
+ }
339
+ }
340
+ if (problems.length > 0) throw new Error(`bundle refused: ${problems.join("; ")}`);
341
+ };
272
342
  var Engine = class _Engine {
273
343
  internals;
274
344
  seed;
@@ -285,6 +355,7 @@ var Engine = class _Engine {
285
355
  * hotSwap puts this engine back exactly as it was. */
286
356
  sharedMounts = [];
287
357
  constructor(bundle, opts = {}) {
358
+ refuseUnreadableBundle(bundle);
288
359
  this.creationOptions = opts;
289
360
  this.seed = opts.seed ?? 0;
290
361
  this.onReplacedFlow = opts.onReplacedFlow;
@@ -368,6 +439,10 @@ var Engine = class _Engine {
368
439
  indexOwner(internals.owners.hand, hand);
369
440
  }
370
441
  }
442
+ if (bundle.map !== void 0) {
443
+ internals.groupsById.set(bundle.map.group.id, { group: bundle.map.group });
444
+ if (bundle.map.group.required === true) internals.requiredGroups.add(bundle.map.group.id);
445
+ }
371
446
  this.initLadders();
372
447
  const declSet = (half) => ({
373
448
  story: half("story", bundle.story.properties),
@@ -378,9 +453,9 @@ var Engine = class _Engine {
378
453
  hand: new Map(bundle.boxes.flatMap((box) => box.hands.map(
379
454
  (hand) => [hand.id, half("hand", handDeclsOf(internals, hand))]
380
455
  ))),
381
- value: new Map(bundle.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
456
+ value: new Map((0, import_model.allTagGroups)(bundle).flatMap((group) => group.tags.map(
382
457
  (tag) => [tag.id, half("value", tag.properties ?? [])]
383
- ))))
458
+ )))
384
459
  });
385
460
  internals.flowDecls = declSet(flowHalf);
386
461
  internals.sharedDecls = declSet(sharedHalf);
@@ -481,6 +556,7 @@ var Engine = class _Engine {
481
556
  }
482
557
  for (const hand of box.hands) internals.ladders.hand.set(hand.id, grab(handDeclsOf(internals, hand)));
483
558
  }
559
+ for (const tag of b.map?.group.tags ?? []) internals.ladders.value.set(tag.id, grab(tag.properties));
484
560
  const any = (m) => [...m.values()].some((x) => x.size > 0);
485
561
  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);
486
562
  }
@@ -804,11 +880,35 @@ var Engine = class _Engine {
804
880
  return structuredClone({
805
881
  schema: import_model2.SAVE_SCHEMA,
806
882
  content: this.internals.bundle.content,
807
- ...this.internals.ownsRegistry ? { registry: this.internals.registry.save() } : {},
883
+ ...this.internals.ownsRegistry ? { registry: this.registrySection() } : {},
808
884
  shared: { spent: [...this.spent].sort() },
809
885
  flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
810
886
  });
811
887
  }
888
+ /** The registry's values in CANONICAL order, the order a load rebuilds them
889
+ * in: the engine-wide keys as the constructor registered them, then each
890
+ * flow's keys in `flows()` order (each flow's own registration order), then
891
+ * anything else the registry holds (values still waiting for a key), as the
892
+ * registry lists it. The registry itself lists keys in registration order,
893
+ * and a flow replaced in place (`open()` above keeps its slot in
894
+ * `flowsById`) re-registers its keys at the END, so `openFlow("a");
895
+ * openFlow("b"); openFlow("a")` saved b's keys before a's while a load
896
+ * rebuilt a's first: the same run, different `.storyletsave` bytes, and a
897
+ * save loaded and saved again no longer equal to itself. It is the
898
+ * 2026-08-29 rule carried into the section save@2 moved the per-flow values
899
+ * to (2026-10-01). Order does not matter on READ (`partitionsFromSections`
900
+ * sorts by key shape), so a save written in the old order loads as before. */
901
+ registrySection() {
902
+ const all = this.internals.registry.save();
903
+ const out = {};
904
+ const take = (key) => {
905
+ if (Object.prototype.hasOwnProperty.call(all, key) && !Object.prototype.hasOwnProperty.call(out, key)) out[key] = all[key];
906
+ };
907
+ for (const { key } of this.sharedMounts) take(key);
908
+ for (const flow of this.flowsById.values()) for (const key of flow.registeredKeys()) take(key);
909
+ for (const key of Object.keys(all)) take(key);
910
+ return out;
911
+ }
812
912
  /** ONE flow's blob, to park a visit that is walking away: the same shape
813
913
  * the envelope carries per flow, and the same shape `openFlow`'s `restore`
814
914
  * option takes back (design/engine-server.md 4.1). Saving the whole
@@ -1138,9 +1238,16 @@ var Flow = class {
1138
1238
  /** Tag group names are box-scoped: two boxes may name a group the same way
1139
1239
  * (schema 1 - boxes namespace their groups), so a name is only ever
1140
1240
  * resolved inside the box being asked, never bundle-wide. Ids are
1141
- * project-unique and accepted here too, still confined to the box. */
1241
+ * project-unique and accepted here too, still confined to the box.
1242
+ *
1243
+ * A box on the project map sees ONE namespace: its own groups, then the
1244
+ * map's group (design/project-map-contract.md 3.1). A box that has not
1245
+ * opted in does not see the map's name at all, so a peek naming it there
1246
+ * is the ordinary unknown-group refusal. Own groups first is stated for
1247
+ * determinism only: a bundle that loads never has the two share a name. */
1142
1248
  groupInBox(box, ref) {
1143
- return box.tagGroups.find((g) => (0, import_model.effectiveGameId)(g) === ref) ?? box.tagGroups.find((g) => g.id === ref);
1249
+ const groups = (0, import_model.groupsOfBox)(this.internals.bundle, box);
1250
+ return groups.find((g) => (0, import_model.effectiveGameId)(g) === ref) ?? groups.find((g) => g.id === ref);
1144
1251
  }
1145
1252
  /** Fold one play into the indexes. O(the card's tags), not O(the log). */
1146
1253
  indexPlay(record) {
@@ -1150,7 +1257,7 @@ var Flow = class {
1150
1257
  if (!entry) return;
1151
1258
  for (const [groupId, tagIds] of Object.entries(entry.card.tags ?? {})) {
1152
1259
  for (const tagId of tagIds) {
1153
- const key = tagKey(groupId, tagId);
1260
+ const key = tagKey(entry.box.id, groupId, tagId);
1154
1261
  this.tagPlayCount.set(key, (this.tagPlayCount.get(key) ?? 0) + 1);
1155
1262
  this.lastPlayInTag.set(key, record);
1156
1263
  }
@@ -1166,8 +1273,10 @@ var Flow = class {
1166
1273
  for (const record of this.playLog) this.indexPlay(record);
1167
1274
  }
1168
1275
  /** `box` is the box whose ask is being evaluated: the play-history
1169
- * functions take a bare group name, so it resolves there (a card's tags
1170
- * reference its own box's group, which keeps the counts box-local).
1276
+ * functions take a bare group name, so it resolves there, and they count
1277
+ * only that box's own plays (the box is in the index key). That was
1278
+ * automatic while every group was a box's; a project-map zone is shared,
1279
+ * and its history is still not (design/project-map-contract.md 3.7, D7).
1171
1280
  * History is THIS flow's: countPlayed answers "have I done this". */
1172
1281
  /** One host per box, built once.
1173
1282
  *
@@ -1192,7 +1301,7 @@ var Flow = class {
1192
1301
  const keyOf = (group, tag) => {
1193
1302
  const found = this.groupInBox(box, group);
1194
1303
  const t = found?.tags.find((v) => v.gameId === tag);
1195
- return found && t ? tagKey(found.id, t.id) : void 0;
1304
+ return found && t ? tagKey(box.id, found.id, t.id) : void 0;
1196
1305
  };
1197
1306
  const since = (record) => {
1198
1307
  const entry = this.internals.cardsByGameId.get(record.card);
@@ -1399,7 +1508,7 @@ var Flow = class {
1399
1508
  * is what says otherwise.
1400
1509
  */
1401
1510
  bindStateGroups(box, boundTags, askNames) {
1402
- for (const group of box.tagGroups) {
1511
+ for (const group of (0, import_model.groupsOfBox)(this.internals.bundle, box)) {
1403
1512
  if (group.boundBy === void 0 || boundTags.has(group.id)) continue;
1404
1513
  const ref = /^@(world|story)\.([a-z][a-z0-9_-]*)$/.exec(group.boundBy);
1405
1514
  if (!ref) {
@@ -2074,12 +2183,12 @@ var handDecls = (hand, box) => {
2074
2183
  }
2075
2184
  return hand.properties ?? [];
2076
2185
  };
2077
- var movableHoles = (hand, box) => {
2186
+ var movableHoles = (bundle, hand, box) => {
2078
2187
  const filled = hand.template !== void 0 ? hand.chosen : hand.rule?.bindings;
2079
2188
  const out = [];
2080
2189
  for (const [groupId, value] of Object.entries(filled ?? {})) {
2081
2190
  if (!(0, import_model3.isHoleRef)(value)) continue;
2082
- const group = box.tagGroups.find((g) => g.id === groupId);
2191
+ const group = (0, import_model3.groupsOfBox)(bundle, box).find((g) => g.id === groupId);
2083
2192
  if (group === void 0) continue;
2084
2193
  out.push({ group: (0, import_model3.effectiveGameId)(group), from: value });
2085
2194
  }
@@ -2104,6 +2213,7 @@ function describeBundle(bundle) {
2104
2213
  boxes.push({
2105
2214
  gameId: boxGameId,
2106
2215
  ...box.title !== void 0 ? { title: box.title } : {},
2216
+ ...box.usesMap === true ? { usesMap: true } : {},
2107
2217
  ranking: { specificity: box.ranking.specificity },
2108
2218
  ...box.turn !== void 0 ? { turn: { seconds: box.turn.seconds } } : {},
2109
2219
  ...durableCardCount(box) > 0 ? { durableCards: durableCardCount(box) } : {},
@@ -2127,7 +2237,7 @@ function describeBundle(bundle) {
2127
2237
  totals.tagGroups += box.tagGroups.length;
2128
2238
  for (const hand of box.hands) {
2129
2239
  const template = hand.template !== void 0 ? box.handTemplates.find((t) => t.id === hand.template) : void 0;
2130
- const movable = movableHoles(hand, box);
2240
+ const movable = movableHoles(bundle, hand, box);
2131
2241
  hands.push({
2132
2242
  gameId: (0, import_model3.effectiveGameId)(hand),
2133
2243
  ...hand.title !== void 0 ? { title: hand.title } : {},
@@ -2156,6 +2266,15 @@ function describeBundle(bundle) {
2156
2266
  }
2157
2267
  }
2158
2268
  }
2269
+ const map = bundle.map;
2270
+ if (map !== void 0) {
2271
+ const group = (0, import_model3.effectiveGameId)(map.group);
2272
+ for (const tag of map.group.tags) {
2273
+ const decls = tag.properties ?? [];
2274
+ if (decls.length > 0) properties.push({ scope: "tag", owner: (0, import_model3.effectiveGameId)(tag), group, properties: summarise(decls) });
2275
+ }
2276
+ totals.tagGroups += 1;
2277
+ }
2159
2278
  return {
2160
2279
  identity: {
2161
2280
  schema: bundle.schema,
@@ -2168,13 +2287,16 @@ function describeBundle(bundle) {
2168
2287
  boxes,
2169
2288
  hands,
2170
2289
  properties,
2171
- maps: (bundle.maps ?? []).map((map) => ({
2172
- box: map.box,
2173
- group: map.group,
2174
- zones: map.zones.length,
2175
- backgrounds: map.backgrounds?.length ?? 0,
2176
- sites: map.sites?.length ?? 0
2177
- }))
2290
+ ...map !== void 0 ? {
2291
+ map: {
2292
+ group: (0, import_model3.effectiveGameId)(map.group),
2293
+ tags: map.group.tags.map((tag) => (0, import_model3.effectiveGameId)(tag)),
2294
+ boxes: bundle.boxes.filter((box) => box.usesMap === true).map((box) => (0, import_model3.effectiveGameId)(box)),
2295
+ zones: map.geometry?.zones.length ?? 0,
2296
+ backgrounds: map.geometry?.backgrounds?.length ?? 0,
2297
+ sites: Object.fromEntries(Object.entries(map.geometry?.sites ?? {}).map(([box, sites]) => [box, sites.length]))
2298
+ }
2299
+ } : {}
2178
2300
  };
2179
2301
  }
2180
2302
  // Annotate the CommonJS export names for ESM import in node: