@patterkit/runtime 0.16.0 → 0.18.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
@@ -33,6 +33,37 @@ version number always means the same runtime behaviour. This package is versione
33
33
 
34
34
  ## [Unreleased]
35
35
 
36
+ ## [0.18.0] - 2026-10-05
37
+
38
+ ### Changed
39
+
40
+ - **`replayPromptOnChoose` speaks back only an authored prompt, and exactly what the choice showed.** A game that
41
+ turns the option on will see the difference, which is why this is a minor release rather than a patch:
42
+ - An option whose prompt the choice borrowed from its own first content line (a bare-snippet option,
43
+ or an Option group with no prompt of its own) is no longer spoken back. That line used to play twice
44
+ in a row, once as the replay and once as content. It now plays once.
45
+ - The replayed prompt carries the text, `character`, `characterName`, and `direction` the choice
46
+ displayed, instead of resolving the prompt again when it is delivered. So if choosing runs effects
47
+ first (an Option group that picks a snippet runs its onEnter at once), or the language changes
48
+ between `choose()` and the next `advance()`, the replay still says what the player saw. In a voiced
49
+ project a replayed line prompt is now interpolated, as the choice showed it.
50
+ - A save taken between `choose()` and the next `advance()` carries that prompt as shown, in a new
51
+ optional `pendingPrompt` on the flow's cursor, so the replay after a load is the same. Every
52
+ Patterplay runtime writes and reads it. A save from an earlier version, without it, still loads and
53
+ resolves the prompt when it is delivered, as before.
54
+ The save shape's `FlowCursor.pendingPrompt` is typed in `@patterkit/model`.
55
+
56
+ ## [0.17.0] - 2026-10-05
57
+
58
+ ### Fixed
59
+
60
+ - **`openFlow` at an address that does not resolve changes nothing.** It threw, but only after closing
61
+ any flow already open under that name and putting a broken one in its place. It now throws before
62
+ anything changes. The block must be in the scene you named, as `goto` has always required: a block
63
+ from another scene used to open there, and now throws. Held across all four runtimes by the
64
+ conformance corpus, which now also pins a choice step whole (its `groupId` and each option's
65
+ `prompt`), as this runtime already gave it.
66
+
36
67
  ## [0.16.0] - 2026-10-04
37
68
 
38
69
  ### Added
package/dist/index.cjs CHANGED
@@ -332,11 +332,14 @@ var Engine = class _Engine {
332
332
  *
333
333
  * Contrast {@link runFlow}, which REUSES a flow of the same name instead of replacing it - that is the
334
334
  * call to reach for when you want a speaker's variation state to carry on.
335
+ *
336
+ * THROWS on an address that does not resolve (an unknown scene, or a block that is not in the named
337
+ * scene), before anything changes: nothing is opened, and a flow already open under `id` carries on
338
+ * exactly as it was.
335
339
  */
336
340
  openFlow(id, opts = {}) {
337
341
  this.assertExternalScopes();
338
- const sceneId = this.resolveSceneRef(opts.scene);
339
- const blockId = this.resolveBlockRef(sceneId, opts.block);
342
+ const { sceneId, blockId } = this.resolveOpenAddress(opts.scene, opts.block);
340
343
  const prior = this.flowsById.get(id);
341
344
  const journal = this.host.journal;
342
345
  if (journal && prior && !prior.isClosed) throw new Error(`openFlow would replace the open flow '${id}', which can't be undone while a checkpoint is open`);
@@ -397,6 +400,28 @@ var Engine = class _Engine {
397
400
  }
398
401
  }
399
402
  }
403
+ /**
404
+ * `openFlow`'s address as internal ids, or a throw when it does not resolve. With a scene, the block is
405
+ * scene-scoped exactly as {@link Flow.goto} resolves it (a gameId address in that scene, or the internal
406
+ * id of a block in that scene), so a block from another scene does not resolve. With no scene, the block
407
+ * is an internal id from any scene. Neither: the bundle's first scene. The same rule on every runtime.
408
+ */
409
+ resolveOpenAddress(scene, block) {
410
+ if (scene !== void 0) {
411
+ const sceneId = this.resolveSceneRef(scene);
412
+ if (!this.host.bundle.scenes[sceneId]) throw new Error(`unknown scene: ${scene}`);
413
+ if (block === void 0) return { sceneId };
414
+ const blockId = this.blockGameIdToId.get(sceneId)?.get(block) ?? (this.host.blockIndex.get(block)?.sceneId === sceneId ? block : void 0);
415
+ if (blockId === void 0) throw new Error(`unknown block: ${block}`);
416
+ return { sceneId, blockId };
417
+ }
418
+ if (block !== void 0) {
419
+ if (!this.host.blockIndex.has(block)) throw new Error(`unknown block: ${block}`);
420
+ return { blockId: block };
421
+ }
422
+ if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
423
+ return {};
424
+ }
400
425
  /** Resolve a scene reference (a gameId address OR an internal id) to its internal id. */
401
426
  resolveSceneRef(ref) {
402
427
  if (ref == null) return void 0;
@@ -851,10 +876,13 @@ var Flow = class {
851
876
  activeSnippet = null;
852
877
  beatIndex = 0;
853
878
  pendingChoice = null;
854
- /** When `replayPromptOnChoose`, the chosen option's prompt beat to deliver before its content. */
855
- pendingPromptBeat = null;
856
- /** The chosen option that owns `pendingPromptBeat`, so a save taken between choose() and the next
857
- * advance() can re-derive the prompt on load (the beat isn't otherwise reachable by id). */
879
+ /** When `replayPromptOnChoose`, the chosen option's AUTHORED prompt to deliver before its content: its
880
+ * beat (id, gameData, tags) and the prompt exactly as the choice showed it (text and speaker fields).
881
+ * `shown` is missing only after loading a save written before saves carried it: the beat is then
882
+ * resolved when it is delivered, as it always was. */
883
+ pendingPrompt = null;
884
+ /** The chosen option that owns `pendingPrompt`, so a save taken between choose() and the next
885
+ * advance() can find the prompt beat again on load (it isn't otherwise reachable by id). */
858
886
  pendingPromptOwnerId = null;
859
887
  selectors = /* @__PURE__ */ new Map();
860
888
  /** Per-node entry counts for this flow (node id -> times entered). */
@@ -1016,7 +1044,7 @@ var Flow = class {
1016
1044
  if (scene === "END") {
1017
1045
  this.started = true;
1018
1046
  this.pendingChoice = null;
1019
- this.pendingPromptBeat = null;
1047
+ this.pendingPrompt = null;
1020
1048
  this.pendingPromptOwnerId = null;
1021
1049
  this.activeSnippet = null;
1022
1050
  this.beatIndex = 0;
@@ -1036,7 +1064,7 @@ var Flow = class {
1036
1064
  return true;
1037
1065
  }
1038
1066
  this.pendingChoice = null;
1039
- this.pendingPromptBeat = null;
1067
+ this.pendingPrompt = null;
1040
1068
  this.pendingPromptOwnerId = null;
1041
1069
  this.activeSnippet = null;
1042
1070
  this.beatIndex = 0;
@@ -1063,7 +1091,7 @@ var Flow = class {
1063
1091
  this.activeSnippet = null;
1064
1092
  this.beatIndex = 0;
1065
1093
  this.pendingChoice = null;
1066
- this.pendingPromptBeat = null;
1094
+ this.pendingPrompt = null;
1067
1095
  this.pendingPromptOwnerId = null;
1068
1096
  }
1069
1097
  /** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
@@ -1073,10 +1101,10 @@ var Flow = class {
1073
1101
  const journal = this.host.journal;
1074
1102
  if (!journal || journal.flows.has(this)) return;
1075
1103
  journal.flows.add(this);
1076
- const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId } = this;
1104
+ const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPrompt, pendingPromptOwnerId } = this;
1077
1105
  const stack = this.stack.map((f) => ({ ...f }));
1078
1106
  journal.undo.push(() => {
1079
- Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPromptBeat, pendingPromptOwnerId, stack });
1107
+ Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPrompt, pendingPromptOwnerId, stack });
1080
1108
  });
1081
1109
  }
1082
1110
  /** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
@@ -1094,11 +1122,11 @@ var Flow = class {
1094
1122
  if (this.closed) return { type: "end" };
1095
1123
  if (!this.started) throw new Error("flow has not been started");
1096
1124
  this.touch();
1097
- if (this.pendingPromptBeat) {
1098
- const b = this.pendingPromptBeat;
1099
- this.pendingPromptBeat = null;
1125
+ if (this.pendingPrompt) {
1126
+ const p = this.pendingPrompt;
1127
+ this.pendingPrompt = null;
1100
1128
  this.pendingPromptOwnerId = null;
1101
- return this.beatResult(b);
1129
+ return p.shown ? this.promptResult(p.beat, p.shown) : this.beatResult(p.beat);
1102
1130
  }
1103
1131
  this.settle();
1104
1132
  if (this.flowEnded) return { type: "end" };
@@ -1217,8 +1245,9 @@ var Flow = class {
1217
1245
  const node = choice.byId.get(id);
1218
1246
  this.emit({ type: "chose", group: choice.groupId, option: id });
1219
1247
  this.pendingChoice = null;
1220
- this.pendingPromptBeat = this.host.replayPromptOnChoose ? this.promptBeatOf(node) ?? null : null;
1221
- this.pendingPromptOwnerId = this.pendingPromptBeat ? node.id : null;
1248
+ const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
1249
+ this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
1250
+ this.pendingPromptOwnerId = this.pendingPrompt ? node.id : null;
1222
1251
  this.enterChild(node);
1223
1252
  }
1224
1253
  isEnded() {
@@ -1284,6 +1313,8 @@ var Flow = class {
1284
1313
  beatIndex: this.beatIndex,
1285
1314
  pendingChoice: this.pendingChoice ? { groupId: this.pendingChoice.groupId, options: this.pendingChoice.options.map((o) => ({ ...o })) } : null,
1286
1315
  pendingPromptOwnerId: this.pendingPromptOwnerId,
1316
+ // The prompt still to be replayed, as the choice showed it: present only in the choose -> advance window.
1317
+ ...this.pendingPrompt?.shown ? { pendingPrompt: savedPrompt(this.pendingPrompt.shown) } : {},
1287
1318
  selectors: serialiseSelectors(this.selectors)
1288
1319
  }
1289
1320
  };
@@ -1334,12 +1365,13 @@ var Flow = class {
1334
1365
  }
1335
1366
  if (options.length > 0) this.pendingChoice = { groupId: c.pendingChoice.groupId, options, byId };
1336
1367
  }
1337
- this.pendingPromptBeat = null;
1368
+ this.pendingPrompt = null;
1338
1369
  this.pendingPromptOwnerId = c.pendingPromptOwnerId ?? null;
1339
1370
  if (this.pendingPromptOwnerId) {
1340
1371
  const owner = this.host.nodeIndex.get(this.pendingPromptOwnerId);
1341
- this.pendingPromptBeat = owner ? this.promptBeatOf(owner) ?? null : null;
1342
- if (!this.pendingPromptBeat) this.pendingPromptOwnerId = null;
1372
+ const beat = owner ? this.authoredPromptOf(owner) : void 0;
1373
+ if (beat) this.pendingPrompt = { beat, ...c.pendingPrompt ? { shown: { ...c.pendingPrompt } } : {} };
1374
+ else this.pendingPromptOwnerId = null;
1343
1375
  }
1344
1376
  }
1345
1377
  // -- Scene / block / node entry ------------------------------------------
@@ -1734,6 +1766,27 @@ var Flow = class {
1734
1766
  const text = this.interpolate(this.resolveString(beat.id));
1735
1767
  return beat.kind === "line" ? { kind: "line", text: this.captionLine(text), character: beat.character, characterName: this.resolveCharacterName(beat.character), direction: beat.direction } : { kind: "text", text };
1736
1768
  }
1769
+ /** An option's AUTHORED prompt beat: an Option group's own `prompt`. The only prompt a replay speaks. */
1770
+ authoredPromptOf(node) {
1771
+ return node.type === "group" ? node.prompt : void 0;
1772
+ }
1773
+ /** A replayed prompt as a step: the beat's id, gameData, and tags, and the text and speaker fields the
1774
+ * choice showed. */
1775
+ promptResult(beat, shown) {
1776
+ const tags = this.host.tagIndex.get(beat.id);
1777
+ const withTags = tags && tags.length ? { tags } : {};
1778
+ if (shown.kind === "text") return { type: "text", id: beat.id, text: shown.text, gameData: beat.gameData, ...withTags };
1779
+ return {
1780
+ type: "line",
1781
+ id: beat.id,
1782
+ text: shown.text,
1783
+ character: shown.character,
1784
+ characterName: shown.characterName,
1785
+ direction: shown.direction,
1786
+ gameData: beat.gameData,
1787
+ ...withTags
1788
+ };
1789
+ }
1737
1790
  /** The prompt BEAT of an option: the Option group's `prompt`, else (tolerance) its first content line. */
1738
1791
  promptBeatOf(node) {
1739
1792
  if (node.type === "group" && node.prompt) return node.prompt;
@@ -1857,6 +1910,13 @@ function collectCast(nodes, out) {
1857
1910
  function gameDataField(gameData) {
1858
1911
  return gameData && Object.keys(gameData).length ? { gameData } : {};
1859
1912
  }
1913
+ function savedPrompt(p) {
1914
+ const out = { kind: p.kind, text: p.text };
1915
+ if (p.character !== void 0) out.character = p.character;
1916
+ if (p.characterName !== void 0) out.characterName = p.characterName;
1917
+ if (p.direction !== void 0) out.direction = p.direction;
1918
+ return out;
1919
+ }
1860
1920
  function serialiseSelectors(map) {
1861
1921
  const out = {};
1862
1922
  for (const [id, st] of map) {