@patterkit/runtime 0.2.2 → 0.3.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/CHANGELOG.md CHANGED
@@ -17,6 +17,29 @@ version number always means the same runtime behaviour. This package is versione
17
17
 
18
18
  ## [Unreleased]
19
19
 
20
+ ## [0.3.0] - 2026-07-21
21
+
22
+ ### Added
23
+ - **Host navigation.** `flow.goto(scene, block?)` sends a running flow to a Game ID address, behaving
24
+ exactly like an authored `go` jump: the target scene's `onEntry` runs, arriving counts as a visit, and
25
+ the callstack is replaced (pending call-returns discarded). Being a host action it lands immediately -
26
+ the rest of the snippet being delivered is abandoned and a pending choice dropped - and it MOVES the
27
+ cursor without resetting the flow, so variation, visit counts and per-flow properties carry on. Returns
28
+ `false` with the cursor untouched when the address does not resolve; a block address is scene-scoped.
29
+ - **`engine.runFlow(name, scene, block?)`**, the one-call form: opens the named flow if it does not
30
+ exist, moves it if it does, runs to the next stop and returns the beats played. Reusing the name is the
31
+ point - a flow owns its selector cursors, so a shuffle keeps its bag and a "once each" list keeps its
32
+ place from call to call. `[]` means the address has nothing left to give; an unresolvable address
33
+ throws, so the two are never confused.
34
+ - `flow.isClosed`, and `engine.sceneAddress` / `engine.blockAddress` are now matched by all four runtimes
35
+ (they were JS-only).
36
+
37
+ ### Changed
38
+ - Dropping a flow now FINISHES it. `closeFlow`, `engine.reset()` and re-opening a name all leave the old
39
+ `Flow` inert (`advance()` reports the end, `goto()` refuses), so a stale reference a game still holds
40
+ can no longer keep running scene entry effects and moving shared state. Re-opening a name still
41
+ replaces (and so resets) that flow - use `runFlow` when a speaker's variation state should carry on.
42
+
20
43
  ## [0.2.2] - 2026-07-13
21
44
 
22
45
  ### Changed
package/dist/index.cjs CHANGED
@@ -153,6 +153,9 @@ var Engine = class _Engine {
153
153
  nodeIndex,
154
154
  blockIndex,
155
155
  blockById,
156
+ sceneGameIdToId: this.sceneGameIdToId,
157
+ blockGameIdToId: this.blockGameIdToId,
158
+ // same instances the engine resolves with
156
159
  tagIndex: buildTagIndex(bundle),
157
160
  shared,
158
161
  patterSharedDecls,
@@ -261,16 +264,56 @@ var Engine = class _Engine {
261
264
  /**
262
265
  * Open (and start) a named flow. Each flow has its own cursor, PRNG, and per-flow
263
266
  * half of the scopes (not-shared `@patter`/`@scene`); all flows share the shared
264
- * half. Re-opening an existing id replaces it with a fresh flow.
267
+ * half.
268
+ *
269
+ * Re-opening an existing id REPLACES it with a fresh flow, and CLOSES the old one
270
+ * ({@link Flow.close}) so a host still holding it cannot keep driving the shared world. Replacing is
271
+ * therefore a reset: that name's cursor, visit counts, selector cursors (so any shuffle / once-each
272
+ * position) and per-flow properties all start over.
273
+ *
274
+ * Contrast {@link runFlow}, which REUSES a flow of the same name instead of replacing it - that is the
275
+ * call to reach for when you want a speaker's variation state to carry on.
265
276
  */
266
277
  openFlow(id, opts = {}) {
267
278
  const sceneId = this.resolveSceneRef(opts.scene);
268
279
  const blockId = this.resolveBlockRef(sceneId, opts.block);
280
+ this.flowsById.get(id)?.close();
269
281
  const flow = new Flow(id, this.host, opts.seed ?? this.defaultSeed);
270
282
  this.flowsById.set(id, flow);
271
283
  flow.start(sceneId, blockId);
272
284
  return flow;
273
285
  }
286
+ /**
287
+ * "Play this address and give me everything it produced" - the one-call form of the bark / one-shot
288
+ * pattern. The NAMED flow is reused if it already exists (moved with {@link Flow.goto}) and opened at
289
+ * the address if not, then run to its next stop, returning every beat it played.
290
+ *
291
+ * Calling it again with the SAME NAME does NOT replace the flow - it reuses it, and that is the whole
292
+ * point. A flow owns its selector cursors, visit counts and per-flow properties, so reusing one lets a
293
+ * **shuffle keep its bag** and an **"once each" list keep its place**: successive calls give the next
294
+ * variation instead of replaying the first forever. (A fresh flow each time would reset all of it,
295
+ * unless every such group happened to be authored `shared`.) Use one name per independent speaker;
296
+ * different names never share per-flow state.
297
+ *
298
+ * This is exactly where it differs from {@link openFlow}, which REPLACES a flow of the same name and
299
+ * so resets that variation state. Never mix the two on one name unless you mean to start over.
300
+ *
301
+ * Returns the played beats in order - `[]` means the address had nothing left to give (an exhausted
302
+ * variation list, say), which is the signal to fall back to other content. It THROWS on an address
303
+ * that does not resolve: unlike `goto` (a navigation primitive, where probing is legitimate), naming a
304
+ * location here asserts it exists, and keeping `[]` unambiguous is worth more than a soft failure.
305
+ *
306
+ * A run that stops at a CHOICE returns the beats up to it and leaves the choice pending on the flow -
307
+ * fetch it with `engine.getFlow(name)?.getChoices()`.
308
+ */
309
+ runFlow(flow, scene, block) {
310
+ const existing = this.flowsById.get(flow);
311
+ if (!existing) return this.openFlow(flow, { scene, block }).advanceToStop().played;
312
+ if (!existing.goto(scene, block)) {
313
+ throw new Error(`runFlow: address not found: ${scene}${block === void 0 ? "" : ` / ${block}`}`);
314
+ }
315
+ return existing.advanceToStop().played;
316
+ }
274
317
  /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
275
318
  resolveSceneRef(ref) {
276
319
  if (ref == null) return void 0;
@@ -408,8 +451,10 @@ var Engine = class _Engine {
408
451
  flows() {
409
452
  return [...this.flowsById.values()];
410
453
  }
411
- /** Close (remove) a flow. */
454
+ /** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
455
+ * holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
412
456
  closeFlow(id) {
457
+ this.flowsById.get(id)?.close();
413
458
  this.flowsById.delete(id);
414
459
  }
415
460
  /**
@@ -419,6 +464,7 @@ var Engine = class _Engine {
419
464
  * After reset, open fresh flows with `openFlow`.
420
465
  */
421
466
  reset() {
467
+ for (const flow of this.flowsById.values()) flow.close();
422
468
  this.flowsById.clear();
423
469
  this.host.shared.reseedOwned("patter", this.host.patterSharedDecls);
424
470
  this.host.sharedVisits.clear();
@@ -510,6 +556,9 @@ var Flow = class {
510
556
  // `activeSnippet`/`beatIndex`.
511
557
  started = false;
512
558
  flowEnded = false;
559
+ /** Closed by the engine (see `close()`). Terminal, and distinct from `flowEnded`: an ENDED flow is
560
+ * merely out of content and `goto` revives it; a CLOSED one is finished for good. */
561
+ closed = false;
513
562
  currentSceneId = null;
514
563
  stack = [];
515
564
  activeSnippet = null;
@@ -616,6 +665,81 @@ var Flow = class {
616
665
  reset(sceneId, blockId) {
617
666
  this.start(sceneId, blockId);
618
667
  }
668
+ /**
669
+ * Send this flow's cursor to an ADDRESS, exactly as an authored `go` jump would: the target scene's
670
+ * `onEntry` effects run, entering counts as a visit, and the callstack is REPLACED - any pending
671
+ * `call` returns are discarded, just as a goto inside a call does.
672
+ *
673
+ * `scene` and `block` are host-facing gameIds (spec §6) or internal ids; `block` is scene-scoped, so
674
+ * it is looked up within `scene`. `"END"` ends the flow. To move within the current scene, pass the
675
+ * current scene's address again (`flow.currentScene` -> `engine.sceneAddress`).
676
+ *
677
+ * This is HOST navigation, not authoring, and it takes effect IMMEDIATELY: any beats left in the
678
+ * snippet being delivered are abandoned, and a pending choice is dropped. The format stops an AUTHOR
679
+ * writing a divert into the middle of a snippet; a host teleport is out-of-band, like `reset()` or
680
+ * `loadGame()`. A flow that never started starts here; one that already ended resumes here.
681
+ *
682
+ * Returns false - leaving the cursor exactly where it was - if the address does not resolve. Per-flow
683
+ * state (properties, visit counts, selector cursors) is untouched either way: this MOVES, never resets.
684
+ */
685
+ goto(scene, block) {
686
+ if (this.closed) return false;
687
+ if (scene === "END") {
688
+ this.started = true;
689
+ this.pendingChoice = null;
690
+ this.pendingPromptBeat = null;
691
+ this.pendingPromptOwnerId = null;
692
+ this.activeSnippet = null;
693
+ this.beatIndex = 0;
694
+ this.flowEnded = true;
695
+ this.stack = [];
696
+ return true;
697
+ }
698
+ const sceneId = this.host.sceneGameIdToId.get(scene) ?? (this.host.bundle.scenes[scene] ? scene : void 0);
699
+ if (sceneId === void 0) return false;
700
+ let blockId;
701
+ if (block !== void 0) {
702
+ blockId = this.host.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
703
+ if (blockId === void 0) return false;
704
+ }
705
+ if (!this.started) {
706
+ this.start(sceneId, blockId);
707
+ return true;
708
+ }
709
+ this.pendingChoice = null;
710
+ this.pendingPromptBeat = null;
711
+ this.pendingPromptOwnerId = null;
712
+ this.activeSnippet = null;
713
+ this.beatIndex = 0;
714
+ this.flowEnded = false;
715
+ this.enterTarget(blockId ?? sceneId, "jump");
716
+ this.settle();
717
+ return true;
718
+ }
719
+ /**
720
+ * Finish this flow for good. Engine-managed: `engine.closeFlow(id)`, `engine.reset()`, and re-opening
721
+ * a name with `engine.openFlow` all call it on the flow being dropped.
722
+ *
723
+ * A dropped flow used to stay fully live: unregistered and invisible to `engine.flows()`, but a host
724
+ * still holding the object could keep advancing it, and every scene `onEntry`, shared property, world
725
+ * visit count and shared selector cursor it touched still landed on the engine. Closing makes that
726
+ * stale reference inert - `advance()` reports the end and `goto()` refuses - so a forgotten reference
727
+ * cannot quietly mutate the world. Terminal: unlike ending, a close is never revived.
728
+ */
729
+ close() {
730
+ this.closed = true;
731
+ this.flowEnded = true;
732
+ this.stack = [];
733
+ this.activeSnippet = null;
734
+ this.beatIndex = 0;
735
+ this.pendingChoice = null;
736
+ this.pendingPromptBeat = null;
737
+ this.pendingPromptOwnerId = null;
738
+ }
739
+ /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
740
+ get isClosed() {
741
+ return this.closed;
742
+ }
619
743
  /** The scene the cursor is currently in - set on entry and whenever a jump crosses scenes. Read
620
744
  * right after `advance()` to know which scene the just-played beat lives in (tooling that mirrors
621
745
  * the playhead, e.g. an editor following a cross-scene jump). `null` before the flow has started. */
@@ -624,6 +748,7 @@ var Flow = class {
624
748
  }
625
749
  /** Run until the next line, game event, choice, or the end of the flow. */
626
750
  advance() {
751
+ if (this.closed) return { type: "end" };
627
752
  if (!this.started) throw new Error("flow has not been started");
628
753
  if (this.pendingPromptBeat) {
629
754
  const b = this.pendingPromptBeat;