@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.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ScalarValue, ScopeResolver, ExprNode } from '@wildwinter/expr';
2
2
  export { Prng, makePrng, shuffleInPlace } from '@wildwinter/expr';
3
- import { Bundle, Card, Expression, Deck, Box, Hand, HandTemplate, TagGroup, PropertyDecl, FlowSave, SaveEnvelope, PropertyType, ScalarValue as ScalarValue$1 } from '@storylet-studio/model';
3
+ import { Bundle, FlowSave, LoadReport, Card, Expression, Deck, Box, Hand, HandTemplate, TagGroup, PropertyDecl, SaveEnvelope, PropertyType, ScalarValue as ScalarValue$1 } from '@storylet-studio/model';
4
4
  import { PropertyBag, PropertyRow } from '@wildwinter/scoperegistry';
5
5
  export { PropertyRow } from '@wildwinter/scoperegistry';
6
6
 
@@ -38,6 +38,25 @@ interface EngineOptions {
38
38
  interface OpenFlowOptions {
39
39
  /** Seed for this flow's PRNG (defaults to the engine's `seed`). */
40
40
  seed?: number;
41
+ /**
42
+ * Open this flow AS IT WAS: a blob from `saveFlow`, applied to the freshly
43
+ * opened (or replaced) flow before the handle comes back
44
+ * (design/engine-server.md 4.1).
45
+ *
46
+ * An option on `openFlow` rather than a `Flow.restore` verb on purpose:
47
+ * restoring INTO a running flow is the trap hosts keep falling into
48
+ * (openFlow REPLACES), and "open this flow as it was" is one act. Drift is
49
+ * tolerated exactly as `loadGame` tolerates it, with one addition, because
50
+ * this restore lands in a LIVE engine: a shared card whose world copies are
51
+ * all held by the OTHER open flows is not put back, and is reported as
52
+ * `claimed-elsewhere`. Ask `previewFlowRestore` first to see that coming.
53
+ */
54
+ restore?: FlowSave;
55
+ /** Handed the `restore`'s LoadReport as it happens - the same report
56
+ * `previewFlowRestore` returns for the same blob. Ignored without
57
+ * `restore`; the report has nowhere else to go, since `openFlow` returns
58
+ * the handle. */
59
+ onRestoreReport?: (report: LoadReport) => void;
41
60
  }
42
61
  /** A card view in a dealt hand or a peeked list. Carries NO outcome
43
62
  * availability - ask `outcomes()` for current truth (schema 5). */
@@ -63,7 +82,8 @@ interface RankedList {
63
82
  cards: DealtCard[];
64
83
  }
65
84
  interface PlayOptions {
66
- /** Turn advance override; default settings.playAdvancesTurns. */
85
+ /** Turn advance override; default settings.playAdvancesTurns, or 0 when the
86
+ * card's box is timed (design/engine-server.md 4.8). */
67
87
  advanceTurns?: number;
68
88
  }
69
89
  /** Why a card did or did not make an ask, in availability order (schema 3.1). */
@@ -71,11 +91,18 @@ type TraceVerdict = "dealt" | "capped" | "cooldown" | "deck-gate" | "tags" | "co
71
91
  /** One event on the deal/play log - "why did Ambush at the ford get dealt
72
92
  * here?" is answered by the ask event's per-card verdicts and keys. The
73
93
  * verb is the event type, so a peek is distinguishable from a deal when
74
- * reading a run back. */
94
+ * reading a run back.
95
+ *
96
+ * IDENTITY IS BY GAMEID throughout (design/engine-server.md 4.4). It was
97
+ * mixed until then: `deal.hand` and `peek.box` were gameIds while
98
+ * `evict.hand`, `play.card` and every `cards[].id` were internal ids, so
99
+ * every consumer outside the engine - the Board, the four examiners, the
100
+ * Live Link, a wire a kiosk reads - mapped one to the other itself. */
75
101
  type TraceEvent = {
76
102
  type: "deal";
77
103
  /** Hand gameId. */
78
104
  hand: string;
105
+ /** `id` is the card's GAMEID (design/engine-server.md 4.4). */
79
106
  cards: {
80
107
  id: string;
81
108
  verdict: TraceVerdict;
@@ -87,24 +114,31 @@ type TraceEvent = {
87
114
  /** Box gameId. */
88
115
  box: string;
89
116
  criteria: Record<string, string>;
117
+ /** `id` is the card's GAMEID (design/engine-server.md 4.4). */
90
118
  cards: {
91
119
  id: string;
92
120
  verdict: TraceVerdict;
93
121
  priority?: number;
94
122
  specificity?: number;
95
123
  }[];
96
- } | {
124
+ }
125
+ /** Hand and card gameIds. A card the build no longer has (`vanished`) has
126
+ * no gameId left and is named by the id the board carried. */
127
+ | {
97
128
  type: "evict";
98
129
  hand: string;
99
130
  card: string;
100
131
  reason: TraceVerdict | "hand-condition" | "vanished";
101
- } | {
132
+ }
133
+ /** Card and outcome gameIds. */
134
+ | {
102
135
  type: "play";
103
136
  card: string;
104
137
  outcome: string;
105
138
  turn: number;
106
139
  }
107
- /** One landed outcome change; `path` is the resolved store location (a
140
+ /** One landed outcome change; `path` is the resolved store location, in the
141
+ * address grammar `getProperty` takes - the owner segment is its gameId (a
108
142
  * routed @hand write shows where it actually went, schema 3.6). `prev`
109
143
  * is the value it replaced, so a log can read "0 -> 1". */
110
144
  | {
@@ -153,8 +187,8 @@ interface CardEntry {
153
187
  deck: Deck<Expression>;
154
188
  box: Box<Expression>;
155
189
  }
156
- /** One kernel bag with its store path prefix (story / box.<id> / deck.<id>
157
- * / hand.<id> / value.<id>): the state logger's mount surface
190
+ /** One kernel bag with its store path prefix (story / box.<gameId> / deck.<gameId>
191
+ * / hand.<gameId> / value.<gameId>): the state logger's mount surface
158
192
  * (design/engine-runtimes.md 3.4 - the logger builds on the PropertyBag
159
193
  * audit hook, so it needs the bags themselves, not just their rows).
160
194
  * The Engine lists the shared bags, a Flow its own; the @world container
@@ -172,6 +206,50 @@ interface BoxView {
172
206
  title?: string;
173
207
  turn: number;
174
208
  }
209
+ /** One side's five declaration lists, keyed by owner id where the scope has
210
+ * owners. The bags are built from these; so is the load report's answer to
211
+ * "what does this build declare that the save does not carry". */
212
+ interface DeclSet {
213
+ story: PropertyDecl[];
214
+ box: Map<string, PropertyDecl[]>;
215
+ deck: Map<string, PropertyDecl[]>;
216
+ hand: Map<string, PropertyDecl[]>;
217
+ value: Map<string, PropertyDecl[]>;
218
+ }
219
+ /** The four owned property scopes: the ones whose address carries an owner
220
+ * segment. `story` has no owner and `world` is the host's. */
221
+ type OwnedScope = "box" | "deck" | "hand" | "value";
222
+ /** The owner segment of a property address, both ways round
223
+ * (design/engine-server.md 4.4).
224
+ *
225
+ * `gameId` is the segment the ADDRESS uses; `id` is the internal id everything
226
+ * inside the engine is keyed by - the bags, the save envelope, the ladders.
227
+ * Both maps are built in bundle order and a repeated gameId does NOT
228
+ * overwrite the first.
229
+ *
230
+ * Box, deck, hand and card gameIds are unique bundle-wide, so for three of the
231
+ * four scopes the segment is simply the gameId. A TAG's is unique only within
232
+ * its group, and a group's only within its box, so two boxes may each name a
233
+ * tag "docks": the value scope's segment is box-qualified,
234
+ * `value.<boxGameId>/<tagGameId>.<name>`, wherever a gameId repeats, and the
235
+ * short form is REFUSED there rather than resolved to the first in bundle
236
+ * order. `valueAddresses` in the model is the one definition of that rule -
237
+ * the Board draws these addresses from the bundle while the engine builds them
238
+ * from this index, and the two have to agree - and `repeated` is what it
239
+ * found, so a refusal can name the candidates.
240
+ *
241
+ * Two GROUPS in one box naming the same tag is the case the box qualifier
242
+ * cannot separate, and it is closing at the source rather than here (question
243
+ * 16, ruled 2026-09-06): the compiler warns that a tag gameId must be unique
244
+ * within its box, and refuses it from the next release. Until then the first
245
+ * in bundle order answers, as it always did.
246
+ */
247
+ interface OwnerIndex {
248
+ gameId: Map<string, string>;
249
+ id: Map<string, string>;
250
+ repeated: Map<string, string[]>;
251
+ }
252
+ type OwnerIndexes = Record<OwnedScope, OwnerIndex>;
175
253
  /** One side's five stores (shared on the engine, per-flow on each flow). */
176
254
  interface Partition {
177
255
  story: PropertyBag;
@@ -199,6 +277,8 @@ interface Internals {
199
277
  hand: Hand<Expression>;
200
278
  box: Box<Expression>;
201
279
  }>;
280
+ /** The owner segment of a property address, both ways round (4.4). */
281
+ owners: OwnerIndexes;
202
282
  templatesById: Map<string, HandTemplate<Expression>>;
203
283
  groupsById: Map<string, {
204
284
  group: TagGroup;
@@ -223,17 +303,26 @@ interface Internals {
223
303
  hasShared: boolean;
224
304
  /** The per-flow halves of every declaration list, precomputed once: each
225
305
  * new flow builds its bags from these. */
226
- flowDecls: {
227
- story: PropertyDecl[];
228
- box: Map<string, PropertyDecl[]>;
229
- deck: Map<string, PropertyDecl[]>;
230
- hand: Map<string, PropertyDecl[]>;
231
- value: Map<string, PropertyDecl[]>;
232
- };
306
+ flowDecls: DeclSet;
307
+ /** The shared halves, the same way. Not used to build anything - the shared
308
+ * bags are built straight from the bundle - but a load report has to say
309
+ * what the shared side WOULD hold without building a bag, which is what
310
+ * makes previewLoad pure. */
311
+ sharedDecls: DeclSet;
233
312
  /** The shared stores. Reassigned wholesale by loadGame/reset. */
234
313
  shared: Partition;
235
314
  /** @world: the host's resolver, or the self-backed bag's. */
236
315
  worldResolver: ScopeResolver;
316
+ /** The @world WRITE seam. `host` says the caller is the GAME's own surface -
317
+ * setProperty, the coverage harness, the CLI's --set - which the shared
318
+ * kernel lets past a `writable: false` (scoperegistry 0.6.0): that flag is
319
+ * the story's promise, not the game's. The story's refusal is the
320
+ * worldReadOnly table below, consulted before this seam is reached. A BOUND
321
+ * resolver is opaque - it takes a name and a value and keeps whatever rule
322
+ * the game has - so the flag only ever reaches the self-backed bag.
323
+ * Undefined when @world cannot be written at all (a resolver bound with no
324
+ * `set`). */
325
+ worldSet?: (name: string, value: ScalarValue, host?: boolean) => void;
237
326
  /** @world names declared `writable: false`: the story's promise, kept at
238
327
  * runtime as the compiler keeps it at publish (Reboot.md 10). */
239
328
  worldReadOnly: Set<string>;
@@ -307,15 +396,24 @@ declare class Engine {
307
396
  * Derived, which is what makes closeFlow and the openFlow replace release
308
397
  * what a flow was holding: its board leaves the map with it. */
309
398
  sharedClaims(): Map<string, number>;
399
+ /** The same ledger with one name left out: what the REST of the world
400
+ * holds, which is the question a resume under that name has to ask. */
401
+ private sharedClaimsExcept;
310
402
  /**
311
403
  * Read shared state by path: "world.x", "story.gold" (when shared),
312
- * "box.b_x.heat" (when shared). A ref that resolves PER-FLOW throws,
404
+ * "box.village.heat" (when shared) - the owner segment is its GAMEID
405
+ * (design/engine-server.md 4.4). A ref that resolves PER-FLOW throws,
313
406
  * naming the fix - silently answering with some flow's copy (or a junk
314
407
  * default) was the bug Patter's engine.getProperty guard exists to stop.
315
408
  */
316
409
  getProperty(path: string): ScalarValue;
317
410
  setProperty(path: string, value: ScalarValue): void;
318
411
  private resolveShared;
412
+ /** The engine's own surface has no flow, so an engine-level diagnostic
413
+ * carries the empty flow id - the same way a LoadReport's shared half
414
+ * carries no flow. It reaches the run log and the engine tap; there is
415
+ * nowhere else for it to go, and it fires only on a legacy address. */
416
+ private diagnose;
319
417
  /** The shared surface as examiner rows: @world (read through the
320
418
  * resolver) then the shared partitions. Per-flow rows live on each Flow. */
321
419
  listProperties(): PropertyRow[];
@@ -329,10 +427,40 @@ declare class Engine {
329
427
  * every live flow keyed by its id. @world is NEVER here - the host
330
428
  * saves its container, each engine saves its own envelope. */
331
429
  saveGame(): SaveEnvelope;
430
+ /** ONE flow's blob, to park a visit that is walking away: the same shape
431
+ * the envelope carries per flow, and the same shape `openFlow`'s `restore`
432
+ * option takes back (design/engine-server.md 4.1). Saving the whole
433
+ * envelope to park one of four hundred players is wrong in cost and in
434
+ * meaning. Throws for a name that is not open - a closed flow has nothing
435
+ * left to save. */
436
+ saveFlow(id: string): FlowSave;
437
+ /** What `loadGame(envelope)` would do that is not a plain restore, without
438
+ * doing any of it (design/engine-server.md 4.9). Pure: nothing on this
439
+ * engine moves. A project mismatch is refused here exactly as `loadGame`
440
+ * refuses it - it is the one thing neither call will tolerate. */
441
+ previewLoad(envelope: SaveEnvelope): LoadReport;
442
+ /** What `openFlow(id, { restore: saved })` would do to a flow of that name,
443
+ * without doing it: the same report shape, since a visit parked under one
444
+ * build and resumed under the next raises the same questions. Pure. */
445
+ previewFlowRestore(id: string, saved: FlowSave): LoadReport;
332
446
  /** Restore: shared state once, then every flow REBUILT from its blob.
333
447
  * Handles held from before the load are closed and inert (Patter's
334
- * rule); take fresh ones from getFlow()/flows(). */
335
- loadGame(envelope: SaveEnvelope): void;
448
+ * rule); take fresh ones from getFlow()/flows().
449
+ *
450
+ * Returns the report `previewLoad` would have given for this envelope: the
451
+ * drift tolerance that makes a load forgiving is what hides its cost, so
452
+ * the cost comes back with the load whether or not anybody looked first. */
453
+ loadGame(envelope: SaveEnvelope): LoadReport;
454
+ private assertSameProject;
455
+ /** The whole-envelope walk: the report, and the cleaned state the apply
456
+ * half writes. Nothing here touches the engine, which is what lets
457
+ * previewLoad and loadGame share it. */
458
+ private planLoad;
459
+ /** One flow's walk. `otherClaims` is the rest of the world's shared ledger
460
+ * and is present only for a SINGLE-flow restore into a live engine: a
461
+ * whole-envelope load rebuilds every flow from one consistent moment, so
462
+ * there is nobody else to compete with. */
463
+ private planFlowRestore;
336
464
  }
337
465
  declare class Flow {
338
466
  readonly id: string;
@@ -432,6 +560,23 @@ declare class Flow {
432
560
  /** A peek's ask: raw criteria ({group gameId: tag gameId}), bindings only,
433
561
  * no condition slot (schema 3.1; the boundary, Reboot 4). */
434
562
  private askForPeek;
563
+ /**
564
+ * Fill one hole from the property its value names: the hand that moves
565
+ * (design/engine-server.md 4.6).
566
+ *
567
+ * The semantics are `bindStateGroups`' below, word for word, applied per
568
+ * HOLE instead of per group: resolved at ask time, and a value naming no tag
569
+ * leaves the hole UNBOUND (a wildcard) with a diagnostic rather than dealing
570
+ * a silently empty hand. What is added is the `@hand` scope - the asking
571
+ * hand's OWN declared state, read here from the flow's merged view (the
572
+ * shared half under the flow's own, so a `shared: true` declaration moves
573
+ * the hole for every flow and a per-flow one moves it for this flow alone).
574
+ *
575
+ * Read BEFORE tag composition, which is the whole reason it is safe: the
576
+ * @hand bag a card sees is built from the bound tags, so resolving a hole
577
+ * from it would be circular. A hand's own declarations are not, so they are.
578
+ */
579
+ private fillHoleFromProperty;
435
580
  /**
436
581
  * Bind every `boundBy` group in the box from the property it names.
437
582
  *
@@ -477,7 +622,9 @@ declare class Flow {
477
622
  * null for available. `trace` (when a subscriber exists) collects the
478
623
  * per-card verdicts. */
479
624
  private runAsk;
480
- /** Flip eligible-but-not-taken trace entries to "capped". */
625
+ /** Flip eligible-but-not-taken trace entries to "capped". `taken` is keyed
626
+ * by GAMEID, as the trace rows are (4.4): the two must move together or
627
+ * every dealt card silently reads as capped. */
481
628
  private capTrace;
482
629
  private view;
483
630
  private handCapacity;
@@ -512,6 +659,8 @@ declare class Flow {
512
659
  * board (you never play a card from inside the deck). Throws before any
513
660
  * mutation on a gated-shut outcome or a bad write target. */
514
661
  play(cardId: string, outcomeGameId: string, from: string, opts?: PlayOptions): void;
662
+ /** One owned property's address, owner segment and all (4.4). */
663
+ private address;
515
664
  /** Land one change in whichever partition declares the name: the flow's
516
665
  * bag when the property is per-flow, the shared bag when it is shared -
517
666
  * the union/partition invariant made executable. */
@@ -536,9 +685,14 @@ declare class Flow {
536
685
  * Bundle order: world, story, then per-box / per-deck / per-hand /
537
686
  * per-tag stores. */
538
687
  listProperties(): PropertyRow[];
539
- /** Read by path: "world.x", "story.gold", "value.v_docks.danger",
540
- * "box.b_x.heat", "deck.k_main.n", "hand.h_board.owner" - the flow's
541
- * merged view, routed by the declaration's sharing. */
688
+ /** Read by path: "world.x", "story.gold", "value.docks.danger",
689
+ * "box.village.heat", "deck.wares.n", "hand.the-elder.zone" - the flow's
690
+ * merged view, routed by the declaration's sharing.
691
+ *
692
+ * The owner segment is the entity's GAMEID, the name it is called by
693
+ * everywhere else (4.4). Its internal id is accepted for this release and
694
+ * earns a `diagnostic` naming the address to move to; the next lockstep
695
+ * release refuses it. */
542
696
  getProperty(path: string): ScalarValue;
543
697
  setProperty(path: string, value: ScalarValue): void;
544
698
  private resolvePath;
@@ -562,6 +716,13 @@ interface BundleIdentity {
562
716
  /** "full" | "stripped": whether authoring metadata (titles) survived. */
563
717
  metadata: string;
564
718
  }
719
+ /** One hole this hand fills from a property rather than with a tag: the hand
720
+ * MOVES when that property is written (design/engine-server.md 4.6). `group`
721
+ * is the tag group's gameId, `from` the reference exactly as authored. */
722
+ interface MovableHole {
723
+ group: string;
724
+ from: string;
725
+ }
565
726
  /** One hand: the deal() surface. `gameId` is the name deal() is called with. */
566
727
  interface HandSummary {
567
728
  gameId: string;
@@ -573,6 +734,16 @@ interface HandSummary {
573
734
  slots: number | "unbounded";
574
735
  /** The hand template's gameId; absent for a standalone (inline-rule) hand. */
575
736
  template?: string;
737
+ /**
738
+ * The holes filled from a property, in bundle order. Absent when the hand
739
+ * has none, which is the ordinary case.
740
+ *
741
+ * Reported because it is the one thing about a hand an integrator cannot see
742
+ * from its name: a movable hole means writing that property MOVES the hand,
743
+ * so it is the difference between a fixed kiosk and a performer who walks
744
+ * about. `setProperty` is the whole verb; there is no other.
745
+ */
746
+ movable?: MovableHole[];
576
747
  }
577
748
  /** One tag group and its tags, by gameId: the peek() criteria surface (a
578
749
  * criteria entry is `{ [group gameId]: tag gameId }`). */
@@ -588,6 +759,19 @@ interface BoxSummary {
588
759
  ranking: {
589
760
  specificity: boolean;
590
761
  };
762
+ /** Present on a TIMED box (design/engine-server.md 4.8): how long one of
763
+ * its turns lasts. An integrator reading a bundle needs it to know which
764
+ * boxes their host must tick, and how often. Absent is the ordinary box. */
765
+ turn?: {
766
+ seconds: number;
767
+ };
768
+ /** How many cards in this box are DURABLE (design/engine-server.md 4.2):
769
+ * their `redraw: "never"` spend outlives the run, and a server has to lift
770
+ * and restore it. A count rather than a list, like every other number here:
771
+ * an integrator needs to know whether this box has any such cards at all,
772
+ * and which ones is the authoring tool's question. Absent when there are
773
+ * none, which is the ordinary bundle. */
774
+ durableCards?: number;
591
775
  tagGroups: TagGroupSummary[];
592
776
  counts: {
593
777
  decks: number;
@@ -604,6 +788,11 @@ interface PropertySummary {
604
788
  default: ScalarValue$1;
605
789
  /** Enum / flags options, where declared. */
606
790
  values?: string[];
791
+ /** Declared DURABLE (design/engine-server.md 4.2): the value survives a run,
792
+ * and a server lifts and restores it across one. The engine never reads it;
793
+ * it is reported because it is the difference between a value an integrator
794
+ * may reset and one somebody is going to expect back. Absent = run-scoped. */
795
+ durable?: true;
607
796
  purpose?: string;
608
797
  }
609
798
  /** The scope a declaration block belongs to. `tag` declarations compose into
@@ -634,6 +823,9 @@ interface MapSummary {
634
823
  group: string;
635
824
  zones: number;
636
825
  backgrounds: number;
826
+ /** Placed hands standing on this map (design/engine-server.md 4.3): where the
827
+ * kiosks are, in a bundle that carries geometry at all. */
828
+ sites: number;
637
829
  }
638
830
  /** What a bundle offers a host, read from the asset alone. */
639
831
  interface BundleDescription {
@@ -663,4 +855,4 @@ interface BundleDescription {
663
855
  * throughout; the same shape every runtime returns. */
664
856
  declare function describeBundle(bundle: Bundle): BundleDescription;
665
857
 
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 };
858
+ 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 MovableHole, type OpenFlowOptions, type OutcomeView, type PlayOptions, type PropertyScopeKind, type PropertyScopeSummary, type PropertySummary, type RankedList, type TagGroupSummary, type TraceEvent, type TraceHandler, type TraceVerdict, describeBundle };