@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 +31 -0
- package/dist/index.cjs +80 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -4
- package/dist/index.d.ts +23 -4
- package/dist/index.js +80 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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.
|
|
1098
|
-
const
|
|
1099
|
-
this.
|
|
1125
|
+
if (this.pendingPrompt) {
|
|
1126
|
+
const p = this.pendingPrompt;
|
|
1127
|
+
this.pendingPrompt = null;
|
|
1100
1128
|
this.pendingPromptOwnerId = null;
|
|
1101
|
-
return this.beatResult(
|
|
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
|
-
|
|
1221
|
-
this.
|
|
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.
|
|
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
|
-
|
|
1342
|
-
if (
|
|
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) {
|