@storylet-studio/runtime 0.2.0 → 0.4.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.d.cts CHANGED
@@ -2,6 +2,7 @@ import { ScalarValue, ScopeResolver, ExprNode } from '@wildwinter/expr';
2
2
  export { Prng, makePrng, shuffleInPlace } from '@wildwinter/expr';
3
3
  import { Bundle, Card, Expression, Deck, Box, Hand, HandTemplate, TagGroup, PropertyDecl, FlowSave, SaveEnvelope, PropertyType, ScalarValue as ScalarValue$1 } from '@storylet-studio/model';
4
4
  import { PropertyBag, PropertyRow } from '@wildwinter/scoperegistry';
5
+ export { PropertyRow } from '@wildwinter/scoperegistry';
5
6
 
6
7
  interface EngineOptions {
7
8
  /** Default seed for each flow's PRNG; override per flow in openFlow
@@ -23,6 +24,16 @@ interface EngineOptions {
23
24
  * once, each engine saves its own envelope (design/flows.md).
24
25
  */
25
26
  world?: ScopeResolver;
27
+ /**
28
+ * Diagnostics hook (opt-in, dev tooling only): fired when `openFlow` REPLACES
29
+ * a flow that still had cards dealt, with the flow id and how many. The
30
+ * behaviour is unchanged - replacing is deliberate and the same in Patter -
31
+ * this only makes it observable, because the case it catches is a host
32
+ * calling `openFlow` straight after `loadGame` to "re-take" its handle and
33
+ * silently discarding the hand the save just restored (`getFlow` is the
34
+ * call). Zero cost when unset; leave it unset in shipped games.
35
+ */
36
+ onReplacedFlow?: (id: string, dealt: number) => void;
26
37
  }
27
38
  interface OpenFlowOptions {
28
39
  /** Seed for this flow's PRNG (defaults to the engine's `seed`). */
@@ -142,11 +153,6 @@ interface CardEntry {
142
153
  deck: Deck<Expression>;
143
154
  box: Box<Expression>;
144
155
  }
145
- /** One examiner row, addressed by the property-path grammar
146
- * (getProperty / setProperty take the same `path`). */
147
- interface PropertyView extends PropertyRow {
148
- path: string;
149
- }
150
156
  /** One kernel bag with its store path prefix (story / box.<id> / deck.<id>
151
157
  * / hand.<id> / value.<id>): the state logger's mount surface
152
158
  * (design/engine-runtimes.md 3.4 - the logger builds on the PropertyBag
@@ -228,6 +234,9 @@ interface Internals {
228
234
  shared: Partition;
229
235
  /** @world: the host's resolver, or the self-backed bag's. */
230
236
  worldResolver: ScopeResolver;
237
+ /** @world names declared `writable: false`: the story's promise, kept at
238
+ * runtime as the compiler keeps it at publish (Reboot.md 10). */
239
+ worldReadOnly: Set<string>;
231
240
  /** `turn` is the box clock the event happened on, where the caller knows it
232
241
  * - the same stamp the flow's own log carries. Unity and Unreal passed it
233
242
  * from the start; JS and Godot dropped it, so their examiners printed "[-]"
@@ -239,6 +248,7 @@ interface Internals {
239
248
  declare class Engine {
240
249
  private readonly internals;
241
250
  private readonly seed;
251
+ private readonly onReplacedFlow;
242
252
  private readonly flowsById;
243
253
  private readonly engineTraceHandlers;
244
254
  /** The host's @world binding, if the engine was built with one: it
@@ -308,7 +318,7 @@ declare class Engine {
308
318
  private resolveShared;
309
319
  /** The shared surface as examiner rows: @world (read through the
310
320
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
311
- listProperties(): PropertyView[];
321
+ listProperties(): PropertyRow[];
312
322
  /** The SHARED kernel bags with their store path prefixes (the state
313
323
  * logger's mount surface). The @world container is the host's own bag -
314
324
  * the host mounts it itself. */
@@ -525,7 +535,7 @@ declare class Flow {
525
535
  * resolver, then per scope the shared values and this flow's own.
526
536
  * Bundle order: world, story, then per-box / per-deck / per-hand /
527
537
  * per-tag stores. */
528
- listProperties(): PropertyView[];
538
+ listProperties(): PropertyRow[];
529
539
  /** Read by path: "world.x", "story.gold", "value.v_docks.danger",
530
540
  * "box.b_x.heat", "deck.k_main.n", "hand.h_board.owner" - the flow's
531
541
  * merged view, routed by the declaration's sharing. */
@@ -653,4 +663,4 @@ interface BundleDescription {
653
663
  * throughout; the same shape every runtime returns. */
654
664
  declare function describeBundle(bundle: Bundle): BundleDescription;
655
665
 
656
- export { type BagMount, type BoxSummary, type BoxView, type BundleDescription, type BundleIdentity, type DealtCard, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, Flow, type HandSummary, type LogEntry, type MapSummary, type OpenFlowOptions, type OutcomeView, type PlayOptions, type PropertyScopeKind, type PropertyScopeSummary, type PropertySummary, type PropertyView, type RankedList, type TagGroupSummary, type TraceEvent, type TraceHandler, type TraceVerdict, describeBundle };
666
+ export { type BagMount, type BoxSummary, type BoxView, type BundleDescription, type BundleIdentity, type DealtCard, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, Flow, type HandSummary, type LogEntry, type MapSummary, type OpenFlowOptions, type OutcomeView, type PlayOptions, type PropertyScopeKind, type PropertyScopeSummary, type PropertySummary, type RankedList, type TagGroupSummary, type TraceEvent, type TraceHandler, type TraceVerdict, describeBundle };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { ScalarValue, ScopeResolver, ExprNode } from '@wildwinter/expr';
2
2
  export { Prng, makePrng, shuffleInPlace } from '@wildwinter/expr';
3
3
  import { Bundle, Card, Expression, Deck, Box, Hand, HandTemplate, TagGroup, PropertyDecl, FlowSave, SaveEnvelope, PropertyType, ScalarValue as ScalarValue$1 } from '@storylet-studio/model';
4
4
  import { PropertyBag, PropertyRow } from '@wildwinter/scoperegistry';
5
+ export { PropertyRow } from '@wildwinter/scoperegistry';
5
6
 
6
7
  interface EngineOptions {
7
8
  /** Default seed for each flow's PRNG; override per flow in openFlow
@@ -23,6 +24,16 @@ interface EngineOptions {
23
24
  * once, each engine saves its own envelope (design/flows.md).
24
25
  */
25
26
  world?: ScopeResolver;
27
+ /**
28
+ * Diagnostics hook (opt-in, dev tooling only): fired when `openFlow` REPLACES
29
+ * a flow that still had cards dealt, with the flow id and how many. The
30
+ * behaviour is unchanged - replacing is deliberate and the same in Patter -
31
+ * this only makes it observable, because the case it catches is a host
32
+ * calling `openFlow` straight after `loadGame` to "re-take" its handle and
33
+ * silently discarding the hand the save just restored (`getFlow` is the
34
+ * call). Zero cost when unset; leave it unset in shipped games.
35
+ */
36
+ onReplacedFlow?: (id: string, dealt: number) => void;
26
37
  }
27
38
  interface OpenFlowOptions {
28
39
  /** Seed for this flow's PRNG (defaults to the engine's `seed`). */
@@ -142,11 +153,6 @@ interface CardEntry {
142
153
  deck: Deck<Expression>;
143
154
  box: Box<Expression>;
144
155
  }
145
- /** One examiner row, addressed by the property-path grammar
146
- * (getProperty / setProperty take the same `path`). */
147
- interface PropertyView extends PropertyRow {
148
- path: string;
149
- }
150
156
  /** One kernel bag with its store path prefix (story / box.<id> / deck.<id>
151
157
  * / hand.<id> / value.<id>): the state logger's mount surface
152
158
  * (design/engine-runtimes.md 3.4 - the logger builds on the PropertyBag
@@ -228,6 +234,9 @@ interface Internals {
228
234
  shared: Partition;
229
235
  /** @world: the host's resolver, or the self-backed bag's. */
230
236
  worldResolver: ScopeResolver;
237
+ /** @world names declared `writable: false`: the story's promise, kept at
238
+ * runtime as the compiler keeps it at publish (Reboot.md 10). */
239
+ worldReadOnly: Set<string>;
231
240
  /** `turn` is the box clock the event happened on, where the caller knows it
232
241
  * - the same stamp the flow's own log carries. Unity and Unreal passed it
233
242
  * from the start; JS and Godot dropped it, so their examiners printed "[-]"
@@ -239,6 +248,7 @@ interface Internals {
239
248
  declare class Engine {
240
249
  private readonly internals;
241
250
  private readonly seed;
251
+ private readonly onReplacedFlow;
242
252
  private readonly flowsById;
243
253
  private readonly engineTraceHandlers;
244
254
  /** The host's @world binding, if the engine was built with one: it
@@ -308,7 +318,7 @@ declare class Engine {
308
318
  private resolveShared;
309
319
  /** The shared surface as examiner rows: @world (read through the
310
320
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
311
- listProperties(): PropertyView[];
321
+ listProperties(): PropertyRow[];
312
322
  /** The SHARED kernel bags with their store path prefixes (the state
313
323
  * logger's mount surface). The @world container is the host's own bag -
314
324
  * the host mounts it itself. */
@@ -525,7 +535,7 @@ declare class Flow {
525
535
  * resolver, then per scope the shared values and this flow's own.
526
536
  * Bundle order: world, story, then per-box / per-deck / per-hand /
527
537
  * per-tag stores. */
528
- listProperties(): PropertyView[];
538
+ listProperties(): PropertyRow[];
529
539
  /** Read by path: "world.x", "story.gold", "value.v_docks.danger",
530
540
  * "box.b_x.heat", "deck.k_main.n", "hand.h_board.owner" - the flow's
531
541
  * merged view, routed by the declaration's sharing. */
@@ -653,4 +663,4 @@ interface BundleDescription {
653
663
  * throughout; the same shape every runtime returns. */
654
664
  declare function describeBundle(bundle: Bundle): BundleDescription;
655
665
 
656
- export { type BagMount, type BoxSummary, type BoxView, type BundleDescription, type BundleIdentity, type DealtCard, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, Flow, type HandSummary, type LogEntry, type MapSummary, type OpenFlowOptions, type OutcomeView, type PlayOptions, type PropertyScopeKind, type PropertyScopeSummary, type PropertySummary, type PropertyView, type RankedList, type TagGroupSummary, type TraceEvent, type TraceHandler, type TraceVerdict, describeBundle };
666
+ export { type BagMount, type BoxSummary, type BoxView, type BundleDescription, type BundleIdentity, type DealtCard, Engine, type EngineLogEntry, type EngineOptions, type EngineTraceHandler, Flow, type HandSummary, type LogEntry, type MapSummary, type OpenFlowOptions, type OutcomeView, type PlayOptions, type PropertyScopeKind, type PropertyScopeSummary, type PropertySummary, type RankedList, type TagGroupSummary, type TraceEvent, type TraceHandler, type TraceVerdict, describeBundle };
package/dist/index.js CHANGED
@@ -423,8 +423,12 @@ var PropertyBag = class _PropertyBag {
423
423
  * long-standing contract); a product whose names are case-significant
424
424
  * passes identity. */
425
425
  norm;
426
+ /** The address prefix this bag's rows carry, separator included (`@`,
427
+ * `@scene.`, `world.`, `deck.<id>.`). Empty means a row's path is its name. */
428
+ pathPrefix;
426
429
  constructor(declarations = [], opts) {
427
430
  this.norm = opts?.normalise ?? ((n) => n.toLowerCase());
431
+ this.pathPrefix = opts?.pathPrefix ?? "";
428
432
  this.seed(declarations);
429
433
  }
430
434
  seed(declarations) {
@@ -468,7 +472,7 @@ var PropertyBag = class _PropertyBag {
468
472
  /** Examiner rows: the declared surface only (stray values are storage,
469
473
  * not surface). */
470
474
  rows() {
471
- return [...this.decls.entries()].map(([name, d]) => rowFor(d, this.get(name), void 0, name));
475
+ return [...this.decls.entries()].map(([name, d]) => rowFor(d, this.get(name), void 0, name, this.pathPrefix));
472
476
  }
473
477
  declarations() {
474
478
  return [...this.decls.values()];
@@ -477,7 +481,7 @@ var PropertyBag = class _PropertyBag {
477
481
  * duplicated, the normalisation policy carried, subscriptions NOT
478
482
  * carried. */
479
483
  clone() {
480
- const c = new _PropertyBag([], { normalise: this.norm });
484
+ const c = new _PropertyBag([], { normalise: this.norm, pathPrefix: this.pathPrefix });
481
485
  c.decls = new Map(this.decls);
482
486
  Object.assign(c.values, structuredClone(this.values));
483
487
  return c;
@@ -500,13 +504,19 @@ var PropertyBag = class _PropertyBag {
500
504
  for (const [k, v] of Object.entries(values)) this.values[this.norm(k)] = v;
501
505
  }
502
506
  };
503
- function rowFor(d, value, writable, name) {
507
+ function rowFor(d, value, writable, name, pathPrefix = "") {
508
+ const rowName = name ?? d.name.toLowerCase();
504
509
  return {
505
- name: name ?? d.name.toLowerCase(),
510
+ name: rowName,
511
+ path: pathPrefix + rowName,
506
512
  type: d.type,
507
513
  value,
508
514
  default: d.default ?? defaultFor(d),
509
515
  ...d.values !== void 0 ? { values: d.values } : {},
516
+ // `stages` was added to the row so an examiner could offer a quality's ladder
517
+ // instead of a free-text box, and then never populated here: every quality row
518
+ // this function built came out without one. Fixed 2026-09-02.
519
+ ...d.stages !== void 0 ? { stages: d.stages } : {},
510
520
  writable: writable ?? d.writable ?? true
511
521
  };
512
522
  }
@@ -526,6 +536,13 @@ function defaultFor(d) {
526
536
  // A quality starts at the first rung of its ladder.
527
537
  case "quality":
528
538
  return d.stages?.[0] ?? "";
539
+ // Unreachable for a well-typed declaration, and deliberately present anyway: a bundle
540
+ // is DATA, and a hand-edited or newer-than-this-build one can carry a type string the
541
+ // union does not have. Falling off the switch would seed `undefined`, which is not a
542
+ // ScalarValue and travels a long way before it fails. Patterplay's copy of this had the
543
+ // guard and this one did not, which is the drift you only find by removing a duplicate.
544
+ default:
545
+ return false;
529
546
  }
530
547
  }
531
548
 
@@ -533,7 +550,7 @@ function defaultFor(d) {
533
550
  var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
534
551
  var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
535
552
  var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
536
- var bagFromDecls = (decls) => new PropertyBag(decls, { normalise: (n) => n });
553
+ var bagFromDecls = (decls, pathPrefix) => new PropertyBag(decls, { normalise: (n) => n, pathPrefix });
537
554
  function conditionPasses(v) {
538
555
  if (typeof v === "boolean") return v;
539
556
  if (typeof v === "number") return v !== 0;
@@ -553,18 +570,18 @@ var handDeclsOf = (internals, hand) => {
553
570
  var buildPartition = (internals, half) => {
554
571
  const b = internals.bundle;
555
572
  return {
556
- story: bagFromDecls(half("story", b.story.properties)),
557
- box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties))])),
573
+ story: bagFromDecls(half("story", b.story.properties), "story."),
574
+ box: new Map(b.boxes.map((box) => [box.id, bagFromDecls(half("box", box.properties), `box.${box.id}.`)])),
558
575
  deck: new Map(b.boxes.flatMap((box) => box.decks.map(
559
- (deck) => [deck.id, bagFromDecls(half("deck", deck.properties))]
576
+ (deck) => [deck.id, bagFromDecls(half("deck", deck.properties), `deck.${deck.id}.`)]
560
577
  ))),
561
578
  // A template instance inherits the template's property declarations;
562
579
  // a standalone hand declares its own (schema 2.6).
563
580
  hand: new Map(b.boxes.flatMap((box) => box.hands.map(
564
- (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)))]
581
+ (hand) => [hand.id, bagFromDecls(half("hand", handDeclsOf(internals, hand)), `hand.${hand.id}.`)]
565
582
  ))),
566
583
  value: new Map(b.boxes.flatMap((box) => box.tagGroups.flatMap((group) => group.tags.map(
567
- (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []))]
584
+ (tag) => [tag.id, bagFromDecls(half("value", tag.properties ?? []), `value.${tag.id}.`)]
568
585
  ))))
569
586
  };
570
587
  };
@@ -586,6 +603,7 @@ var loadPartition = (p, values) => {
586
603
  var Engine = class {
587
604
  internals;
588
605
  seed;
606
+ onReplacedFlow;
589
607
  flowsById = /* @__PURE__ */ new Map();
590
608
  engineTraceHandlers = /* @__PURE__ */ new Set();
591
609
  /** The host's @world binding, if the engine was built with one: it
@@ -594,6 +612,7 @@ var Engine = class {
594
612
  hostWorld;
595
613
  constructor(bundle, opts = {}) {
596
614
  this.seed = opts.seed ?? 0;
615
+ this.onReplacedFlow = opts.onReplacedFlow;
597
616
  if (opts.world !== void 0) this.hostWorld = opts.world;
598
617
  const internals = {
599
618
  bundle,
@@ -614,6 +633,7 @@ var Engine = class {
614
633
  flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
615
634
  shared: void 0,
616
635
  worldResolver: void 0,
636
+ worldReadOnly: /* @__PURE__ */ new Set(),
617
637
  emitEngine: (flow, event, turn) => {
618
638
  if (this.internals.logCap !== void 0) {
619
639
  this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...turn !== void 0 ? { turn } : {} });
@@ -671,10 +691,11 @@ var Engine = class {
671
691
  initShared(hostWorld) {
672
692
  const internals = this.internals;
673
693
  internals.shared = buildPartition(internals, sharedHalf);
694
+ internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
674
695
  if (hostWorld !== void 0) {
675
696
  internals.worldResolver = hostWorld;
676
697
  } else {
677
- const bag = bagFromDecls(internals.bundle.world.properties);
698
+ const bag = bagFromDecls(internals.bundle.world.properties, "world.");
678
699
  internals.worldResolver = {
679
700
  get: (n) => bag.get(n),
680
701
  set: (n, v) => {
@@ -714,7 +735,12 @@ var Engine = class {
714
735
  * state; shared state is untouched. There is no default flow: "main" is
715
736
  * a caller convention, not an engine rule. */
716
737
  openFlow(id, opts = {}) {
717
- this.flowsById.get(id)?.markClosed();
738
+ const existing = this.flowsById.get(id);
739
+ if (existing) {
740
+ const dealt = existing.heldCardIds().length;
741
+ if (dealt > 0) this.onReplacedFlow?.(id, dealt);
742
+ existing.markClosed();
743
+ }
718
744
  const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
719
745
  this.flowsById.set(id, flow);
720
746
  return flow;
@@ -850,11 +876,16 @@ var Engine = class {
850
876
  value: value ?? d.default,
851
877
  default: d.default,
852
878
  ...d.values !== void 0 ? { values: d.values } : {},
853
- ...d.stages !== void 0 ? { stages: d.stages } : {}
879
+ ...d.stages !== void 0 ? { stages: d.stages } : {},
880
+ // @world is FOREIGN - a host resolver backs it - so writability is whether that
881
+ // resolver can be written at all, which is the shared registry's own rule for a
882
+ // foreign scope. The `as PropertyView` cast this replaced was hiding the field's
883
+ // absence: the row type has always required it, and these rows shipped without one.
884
+ writable: this.internals.worldResolver.set !== void 0
854
885
  });
855
886
  }
856
- const add = (prefix, bag) => {
857
- for (const row of bag.rows()) out.push({ path: `${prefix}.${row.name}`, ...row });
887
+ const add = (_prefix, bag) => {
888
+ for (const row of bag.rows()) out.push(row);
858
889
  };
859
890
  add("story", this.internals.shared.story);
860
891
  for (const [id, bag] of this.internals.shared.box) add(`box.${id}`, bag);
@@ -1675,6 +1706,7 @@ var Flow = class {
1675
1706
  case "world": {
1676
1707
  const resolver = this.internals.worldResolver;
1677
1708
  if (!resolver.set) throw new Error(`@world.${name} cannot be written: the host bound @world read-only`);
1709
+ if (this.internals.worldReadOnly.has(name)) throw new Error(`'@world.${name}' is read-only (writable: false)`);
1678
1710
  const prev = resolver.get(name);
1679
1711
  resolver.set(name, value);
1680
1712
  return { path: `world.${name}`, ...prev !== void 0 ? { prev } : {} };
@@ -1746,13 +1778,18 @@ var Flow = class {
1746
1778
  value: value ?? d.default,
1747
1779
  default: d.default,
1748
1780
  ...d.values !== void 0 ? { values: d.values } : {},
1749
- ...d.stages !== void 0 ? { stages: d.stages } : {}
1781
+ ...d.stages !== void 0 ? { stages: d.stages } : {},
1782
+ // @world is FOREIGN - a host resolver backs it - so writability is whether that
1783
+ // resolver can be written at all, which is the shared registry's own rule for a
1784
+ // foreign scope. The `as PropertyView` cast this replaced was hiding the field's
1785
+ // absence: the row type has always required it, and these rows shipped without one.
1786
+ writable: this.internals.worldResolver.set !== void 0
1750
1787
  });
1751
1788
  }
1752
- const add = (prefix, shared, own) => {
1789
+ const add = (_prefix, shared, own) => {
1753
1790
  for (const bag of [shared, own]) {
1754
1791
  if (bag === void 0) continue;
1755
- for (const row of bag.rows()) out.push({ path: `${prefix}.${row.name}`, ...row });
1792
+ for (const row of bag.rows()) out.push(row);
1756
1793
  }
1757
1794
  };
1758
1795
  add("story", this.internals.shared.story, this.stores.story);