@patterkit/runtime 0.17.0 → 0.19.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 +66 -0
- package/dist/index.cjs +133 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -10
- package/dist/index.d.ts +55 -10
- package/dist/index.js +133 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -33,6 +33,72 @@ version number always means the same runtime behaviour. This package is versione
|
|
|
33
33
|
|
|
34
34
|
## [Unreleased]
|
|
35
35
|
|
|
36
|
+
## [0.19.0] - 2026-10-06
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`EngineOptions.onError` and `PlayError`.** Each condition or effect that fails while the story plays is
|
|
41
|
+
reported to `onError` with the flow, what failed (`condition`, `effect`, or `best-match`), the node, the
|
|
42
|
+
expression's source, and the error. With no `onError`, each goes to `console.warn`. With the log on, each is
|
|
43
|
+
also a `diagnostic` entry in the decision log.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **A condition or effect that fails no longer stops the story.** A failing condition counts as false, and a
|
|
48
|
+
failing effect is skipped while the rest of its list still runs, including a story write a read-only `@world`
|
|
49
|
+
value refuses. This is the same on all four runtimes; JS used to throw out of `advance()`.
|
|
50
|
+
- **A part of a Best-match condition that fails scores as false**, as on the other three runtimes. JS threw
|
|
51
|
+
while scoring.
|
|
52
|
+
- **A selector evaluates each condition once.** It evaluated each child's condition twice, so a condition
|
|
53
|
+
calling `random(a, b)` drew twice and the logged verdict could disagree with the pick.
|
|
54
|
+
- **A shuffle draws only from the children in its bag that are still eligible.** A child whose condition went
|
|
55
|
+
false after the bag was filled could be drawn, and the group then played nothing. When no child left in the
|
|
56
|
+
bag is eligible, the pass ends as if the bag were empty. Seeded results do not change while every child in the
|
|
57
|
+
bag stays eligible.
|
|
58
|
+
- **`openFlow`, `goto` and the engine's lookups read an address the same way:** a scene's game id first, then
|
|
59
|
+
its internal id, and a block only within its scene. `openFlow` tried the internal id first, so the two could
|
|
60
|
+
land in different scenes when one scene's id was another's game id.
|
|
61
|
+
- **A choice whose every remaining option is greyed out runs dry**, as a choice with no options does: the
|
|
62
|
+
fallback follows if there is one, otherwise the flow moves on and `onDryChoice` fires. It used to be offered
|
|
63
|
+
with nothing the player could take.
|
|
64
|
+
- **An option's prompt beat carries tags**: its own and the option's. A replayed prompt and the outline lost
|
|
65
|
+
them.
|
|
66
|
+
- **`loadGame` checks a save before changing anything.** A save missing its flows used to close every flow and
|
|
67
|
+
load the registry before failing, leaving the engine half-loaded.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **`@wildwinter/expr` 0.5.1 and `@wildwinter/scoperegistry` 0.8.1 (kernel `k492cf234`):** a listener's own
|
|
72
|
+
error is no longer reported as a read-only refusal, every listener registered when a write starts hears it
|
|
73
|
+
exactly once, and the hot path allocates less, with no leaks.
|
|
74
|
+
- **A rollback after a load keeps a scene's saved `@scene` values.** After a load, a scene the flow is not
|
|
75
|
+
standing in keeps its saved values waiting until the flow enters it. Entering it inside a checkpoint and then
|
|
76
|
+
rolling back used to drop those values, so the next real entry found the defaults and the next save left them
|
|
77
|
+
out. A new corpus case pins this on all four runtimes.
|
|
78
|
+
- **The decision log's `seq` keeps counting after a clear.** The engine's log numbered entries by its length, so
|
|
79
|
+
after `clearLog()` the numbers started again at 0 and entries read either side of the clear could not be put
|
|
80
|
+
in order.
|
|
81
|
+
|
|
82
|
+
## [0.18.0] - 2026-10-05
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **`replayPromptOnChoose` speaks back only an authored prompt, and exactly what the choice showed.** A game that
|
|
87
|
+
turns the option on will see the difference, which is why this is a minor release rather than a patch:
|
|
88
|
+
- An option whose prompt the choice borrowed from its own first content line (a bare-snippet option,
|
|
89
|
+
or an Option group with no prompt of its own) is no longer spoken back. That line used to play twice
|
|
90
|
+
in a row, once as the replay and once as content. It now plays once.
|
|
91
|
+
- The replayed prompt carries the text, `character`, `characterName`, and `direction` the choice
|
|
92
|
+
displayed, instead of resolving the prompt again when it is delivered. So if choosing runs effects
|
|
93
|
+
first (an Option group that picks a snippet runs its onEnter at once), or the language changes
|
|
94
|
+
between `choose()` and the next `advance()`, the replay still says what the player saw. In a voiced
|
|
95
|
+
project a replayed line prompt is now interpolated, as the choice showed it.
|
|
96
|
+
- A save taken between `choose()` and the next `advance()` carries that prompt as shown, in a new
|
|
97
|
+
optional `pendingPrompt` on the flow's cursor, so the replay after a load is the same. Every
|
|
98
|
+
Patterplay runtime writes and reads it. A save from an earlier version, without it, still loads and
|
|
99
|
+
resolves the prompt when it is delivered, as before.
|
|
100
|
+
The save shape's `FlowCursor.pendingPrompt` is typed in `@patterkit/model`.
|
|
101
|
+
|
|
36
102
|
## [0.17.0] - 2026-10-05
|
|
37
103
|
|
|
38
104
|
### Fixed
|
package/dist/index.cjs
CHANGED
|
@@ -54,6 +54,7 @@ function buildTagIndex(bundle) {
|
|
|
54
54
|
const visit = (node, inherited) => {
|
|
55
55
|
const acc = dedupe([...inherited, ...node.tags ?? []]);
|
|
56
56
|
index.set(node.id, acc);
|
|
57
|
+
if (node.type === "group" && node.prompt) index.set(node.prompt.id, dedupe([...acc, ...node.prompt.tags ?? []]));
|
|
57
58
|
if (node.type === "group") {
|
|
58
59
|
for (const child of node.children) visit(child, acc);
|
|
59
60
|
} else {
|
|
@@ -118,6 +119,8 @@ var Engine = class _Engine {
|
|
|
118
119
|
/** The run's ordered stream: every flow's events, each naming its flow. Empty and
|
|
119
120
|
* unwritten unless `options.log` asked for it. */
|
|
120
121
|
engineLog = [];
|
|
122
|
+
/** The next engine-log `seq`: its own counter, since the log's length restarts after a clear. */
|
|
123
|
+
engineSeq = 0;
|
|
121
124
|
engineTraceHandlers = /* @__PURE__ */ new Set();
|
|
122
125
|
constructor(bundle, options = {}) {
|
|
123
126
|
this.creationOptions = options;
|
|
@@ -215,6 +218,7 @@ var Engine = class _Engine {
|
|
|
215
218
|
journal: null,
|
|
216
219
|
customRng: options.rng,
|
|
217
220
|
onDryChoice: options.onDryChoice,
|
|
221
|
+
onError: options.onError ?? ((e) => console.warn(`Patterplay: ${e.kind} on ${e.node} in flow '${e.flow}' failed, played through: ${e.message}`)),
|
|
218
222
|
replayPromptOnChoose: options.replayPromptOnChoose ?? false,
|
|
219
223
|
captionsOn: options.closedCaptions ?? true,
|
|
220
224
|
// captions shown by default (full text)
|
|
@@ -408,10 +412,10 @@ var Engine = class _Engine {
|
|
|
408
412
|
*/
|
|
409
413
|
resolveOpenAddress(scene, block) {
|
|
410
414
|
if (scene !== void 0) {
|
|
411
|
-
const sceneId = this.
|
|
412
|
-
if (
|
|
415
|
+
const sceneId = resolveScene(this.host, scene);
|
|
416
|
+
if (sceneId === void 0) throw new Error(`unknown scene: ${scene}`);
|
|
413
417
|
if (block === void 0) return { sceneId };
|
|
414
|
-
const blockId =
|
|
418
|
+
const blockId = resolveBlock(this.host, sceneId, block);
|
|
415
419
|
if (blockId === void 0) throw new Error(`unknown block: ${block}`);
|
|
416
420
|
return { sceneId, blockId };
|
|
417
421
|
}
|
|
@@ -422,21 +426,19 @@ var Engine = class _Engine {
|
|
|
422
426
|
if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
|
|
423
427
|
return {};
|
|
424
428
|
}
|
|
425
|
-
/** Resolve a scene reference
|
|
429
|
+
/** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
|
|
430
|
+
* unknown one passes through, for the caller to find nothing. */
|
|
426
431
|
resolveSceneRef(ref) {
|
|
427
432
|
if (ref == null) return void 0;
|
|
428
|
-
|
|
429
|
-
return this.sceneGameIdToId.get(ref) ?? ref;
|
|
433
|
+
return resolveScene(this.host, ref) ?? ref;
|
|
430
434
|
}
|
|
431
|
-
/** Resolve a block reference
|
|
435
|
+
/** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
|
|
436
|
+
* scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
|
|
437
|
+
* since a block's gameId is only unique within its scene. */
|
|
432
438
|
resolveBlockRef(sceneId, ref) {
|
|
433
439
|
if (ref == null) return void 0;
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
const id = this.blockGameIdToId.get(sceneId)?.get(ref);
|
|
437
|
-
if (id) return id;
|
|
438
|
-
}
|
|
439
|
-
return ref;
|
|
440
|
+
if (sceneId == null) return ref;
|
|
441
|
+
return resolveBlock(this.host, sceneId, ref);
|
|
440
442
|
}
|
|
441
443
|
/** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
|
|
442
444
|
* The inverse of the resolve helpers - for a host that wants to display / log the address. */
|
|
@@ -763,7 +765,7 @@ var Engine = class _Engine {
|
|
|
763
765
|
emitEngine(flow, event, scene) {
|
|
764
766
|
for (const h of this.engineTraceHandlers) h(flow, event);
|
|
765
767
|
if (!this.host.logEnabled) return;
|
|
766
|
-
this.engineLog.push({ ...event, flow, seq: this.
|
|
768
|
+
this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
|
|
767
769
|
}
|
|
768
770
|
listProperties() {
|
|
769
771
|
return this.host.patterSharedDecls.map((d) => ({
|
|
@@ -829,6 +831,8 @@ var Engine = class _Engine {
|
|
|
829
831
|
this.refuseInCheckpoint("loadGame");
|
|
830
832
|
const version = save.version;
|
|
831
833
|
if (version !== 2 && version !== import_model2.SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
|
|
834
|
+
const flows = save.flows;
|
|
835
|
+
if (typeof flows !== "object" || flows === null || Array.isArray(flows)) throw new Error("malformed save: no flows");
|
|
832
836
|
this.assertExternalScopes();
|
|
833
837
|
const reg = this.host.registry;
|
|
834
838
|
for (const [id, flow] of this.flowsById) {
|
|
@@ -876,10 +880,13 @@ var Flow = class {
|
|
|
876
880
|
activeSnippet = null;
|
|
877
881
|
beatIndex = 0;
|
|
878
882
|
pendingChoice = null;
|
|
879
|
-
/** When `replayPromptOnChoose`, the chosen option's prompt
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
*
|
|
883
|
+
/** When `replayPromptOnChoose`, the chosen option's AUTHORED prompt to deliver before its content: its
|
|
884
|
+
* beat (id, gameData, tags) and the prompt exactly as the choice showed it (text and speaker fields).
|
|
885
|
+
* `shown` is missing only after loading a save written before saves carried it: the beat is then
|
|
886
|
+
* resolved when it is delivered, as it always was. */
|
|
887
|
+
pendingPrompt = null;
|
|
888
|
+
/** The chosen option that owns `pendingPrompt`, so a save taken between choose() and the next
|
|
889
|
+
* advance() can find the prompt beat again on load (it isn't otherwise reachable by id). */
|
|
883
890
|
pendingPromptOwnerId = null;
|
|
884
891
|
selectors = /* @__PURE__ */ new Map();
|
|
885
892
|
/** Per-node entry counts for this flow (node id -> times entered). */
|
|
@@ -1041,7 +1048,7 @@ var Flow = class {
|
|
|
1041
1048
|
if (scene === "END") {
|
|
1042
1049
|
this.started = true;
|
|
1043
1050
|
this.pendingChoice = null;
|
|
1044
|
-
this.
|
|
1051
|
+
this.pendingPrompt = null;
|
|
1045
1052
|
this.pendingPromptOwnerId = null;
|
|
1046
1053
|
this.activeSnippet = null;
|
|
1047
1054
|
this.beatIndex = 0;
|
|
@@ -1049,11 +1056,11 @@ var Flow = class {
|
|
|
1049
1056
|
this.stack = [];
|
|
1050
1057
|
return true;
|
|
1051
1058
|
}
|
|
1052
|
-
const sceneId =
|
|
1059
|
+
const sceneId = resolveScene(this.host, scene);
|
|
1053
1060
|
if (sceneId === void 0) return false;
|
|
1054
1061
|
let blockId;
|
|
1055
1062
|
if (block !== void 0) {
|
|
1056
|
-
blockId =
|
|
1063
|
+
blockId = resolveBlock(this.host, sceneId, block);
|
|
1057
1064
|
if (blockId === void 0) return false;
|
|
1058
1065
|
}
|
|
1059
1066
|
if (!this.started) {
|
|
@@ -1061,7 +1068,7 @@ var Flow = class {
|
|
|
1061
1068
|
return true;
|
|
1062
1069
|
}
|
|
1063
1070
|
this.pendingChoice = null;
|
|
1064
|
-
this.
|
|
1071
|
+
this.pendingPrompt = null;
|
|
1065
1072
|
this.pendingPromptOwnerId = null;
|
|
1066
1073
|
this.activeSnippet = null;
|
|
1067
1074
|
this.beatIndex = 0;
|
|
@@ -1088,7 +1095,7 @@ var Flow = class {
|
|
|
1088
1095
|
this.activeSnippet = null;
|
|
1089
1096
|
this.beatIndex = 0;
|
|
1090
1097
|
this.pendingChoice = null;
|
|
1091
|
-
this.
|
|
1098
|
+
this.pendingPrompt = null;
|
|
1092
1099
|
this.pendingPromptOwnerId = null;
|
|
1093
1100
|
}
|
|
1094
1101
|
/** Inside a checkpoint, the first change to this flow records its cursor and PRNG, so a rollback can
|
|
@@ -1098,10 +1105,10 @@ var Flow = class {
|
|
|
1098
1105
|
const journal = this.host.journal;
|
|
1099
1106
|
if (!journal || journal.flows.has(this)) return;
|
|
1100
1107
|
journal.flows.add(this);
|
|
1101
|
-
const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice,
|
|
1108
|
+
const { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPrompt, pendingPromptOwnerId } = this;
|
|
1102
1109
|
const stack = this.stack.map((f) => ({ ...f }));
|
|
1103
1110
|
journal.undo.push(() => {
|
|
1104
|
-
Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice,
|
|
1111
|
+
Object.assign(this, { rngState, started, flowEnded, currentSceneId, activeSnippet, beatIndex, pendingChoice, pendingPrompt, pendingPromptOwnerId, stack });
|
|
1105
1112
|
});
|
|
1106
1113
|
}
|
|
1107
1114
|
/** True once the engine has closed this flow (closed, dropped by `reset()`, or replaced by name). */
|
|
@@ -1119,11 +1126,11 @@ var Flow = class {
|
|
|
1119
1126
|
if (this.closed) return { type: "end" };
|
|
1120
1127
|
if (!this.started) throw new Error("flow has not been started");
|
|
1121
1128
|
this.touch();
|
|
1122
|
-
if (this.
|
|
1123
|
-
const
|
|
1124
|
-
this.
|
|
1129
|
+
if (this.pendingPrompt) {
|
|
1130
|
+
const p = this.pendingPrompt;
|
|
1131
|
+
this.pendingPrompt = null;
|
|
1125
1132
|
this.pendingPromptOwnerId = null;
|
|
1126
|
-
return this.beatResult(
|
|
1133
|
+
return p.shown ? this.promptResult(p.beat, p.shown) : this.beatResult(p.beat);
|
|
1127
1134
|
}
|
|
1128
1135
|
this.settle();
|
|
1129
1136
|
if (this.flowEnded) return { type: "end" };
|
|
@@ -1163,7 +1170,7 @@ var Flow = class {
|
|
|
1163
1170
|
if (this.flowEnded || this.pendingChoice) return;
|
|
1164
1171
|
if (this.activeSnippet) {
|
|
1165
1172
|
if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
|
|
1166
|
-
this.runEffects(this.activeSnippet.onExit);
|
|
1173
|
+
this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
|
|
1167
1174
|
const jump = this.activeSnippet.jump;
|
|
1168
1175
|
this.activeSnippet = null;
|
|
1169
1176
|
this.beatIndex = 0;
|
|
@@ -1242,8 +1249,9 @@ var Flow = class {
|
|
|
1242
1249
|
const node = choice.byId.get(id);
|
|
1243
1250
|
this.emit({ type: "chose", group: choice.groupId, option: id });
|
|
1244
1251
|
this.pendingChoice = null;
|
|
1245
|
-
|
|
1246
|
-
this.
|
|
1252
|
+
const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
|
|
1253
|
+
this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
|
|
1254
|
+
this.pendingPromptOwnerId = this.pendingPrompt ? node.id : null;
|
|
1247
1255
|
this.enterChild(node);
|
|
1248
1256
|
}
|
|
1249
1257
|
isEnded() {
|
|
@@ -1309,6 +1317,8 @@ var Flow = class {
|
|
|
1309
1317
|
beatIndex: this.beatIndex,
|
|
1310
1318
|
pendingChoice: this.pendingChoice ? { groupId: this.pendingChoice.groupId, options: this.pendingChoice.options.map((o) => ({ ...o })) } : null,
|
|
1311
1319
|
pendingPromptOwnerId: this.pendingPromptOwnerId,
|
|
1320
|
+
// The prompt still to be replayed, as the choice showed it: present only in the choose -> advance window.
|
|
1321
|
+
...this.pendingPrompt?.shown ? { pendingPrompt: savedPrompt(this.pendingPrompt.shown) } : {},
|
|
1312
1322
|
selectors: serialiseSelectors(this.selectors)
|
|
1313
1323
|
}
|
|
1314
1324
|
};
|
|
@@ -1359,12 +1369,13 @@ var Flow = class {
|
|
|
1359
1369
|
}
|
|
1360
1370
|
if (options.length > 0) this.pendingChoice = { groupId: c.pendingChoice.groupId, options, byId };
|
|
1361
1371
|
}
|
|
1362
|
-
this.
|
|
1372
|
+
this.pendingPrompt = null;
|
|
1363
1373
|
this.pendingPromptOwnerId = c.pendingPromptOwnerId ?? null;
|
|
1364
1374
|
if (this.pendingPromptOwnerId) {
|
|
1365
1375
|
const owner = this.host.nodeIndex.get(this.pendingPromptOwnerId);
|
|
1366
|
-
|
|
1367
|
-
if (
|
|
1376
|
+
const beat = owner ? this.authoredPromptOf(owner) : void 0;
|
|
1377
|
+
if (beat) this.pendingPrompt = { beat, ...c.pendingPrompt ? { shown: { ...c.pendingPrompt } } : {} };
|
|
1378
|
+
else this.pendingPromptOwnerId = null;
|
|
1368
1379
|
}
|
|
1369
1380
|
}
|
|
1370
1381
|
// -- Scene / block / node entry ------------------------------------------
|
|
@@ -1375,7 +1386,7 @@ var Flow = class {
|
|
|
1375
1386
|
this.currentSceneId = sceneId;
|
|
1376
1387
|
this.enter(sceneId);
|
|
1377
1388
|
this.seedScene(scene);
|
|
1378
|
-
this.runEffects(scene.onEntry);
|
|
1389
|
+
this.runEffects(scene.onEntry, scene.id);
|
|
1379
1390
|
}
|
|
1380
1391
|
/**
|
|
1381
1392
|
* Play one child of the active run. A snippet begins delivering. A group is
|
|
@@ -1411,7 +1422,7 @@ var Flow = class {
|
|
|
1411
1422
|
return void 0;
|
|
1412
1423
|
}
|
|
1413
1424
|
beginSnippet(snippet) {
|
|
1414
|
-
this.runEffects(snippet.onEnter);
|
|
1425
|
+
this.runEffects(snippet.onEnter, snippet.id);
|
|
1415
1426
|
this.activeSnippet = snippet;
|
|
1416
1427
|
this.beatIndex = 0;
|
|
1417
1428
|
}
|
|
@@ -1431,7 +1442,7 @@ var Flow = class {
|
|
|
1431
1442
|
options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
|
|
1432
1443
|
byId.set(child.id, child);
|
|
1433
1444
|
}
|
|
1434
|
-
if (options.
|
|
1445
|
+
if (options.some((o) => o.eligible)) {
|
|
1435
1446
|
this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
|
|
1436
1447
|
this.pendingChoice = { groupId: group.id, options, byId };
|
|
1437
1448
|
return;
|
|
@@ -1489,7 +1500,7 @@ var Flow = class {
|
|
|
1489
1500
|
// -- Selectors ------------------------------------------------------------
|
|
1490
1501
|
selectChild(group) {
|
|
1491
1502
|
const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
|
|
1492
|
-
const eligible = group.children.filter((
|
|
1503
|
+
const eligible = group.children.filter((_, i) => verdicts[i].eligible);
|
|
1493
1504
|
const order = group.options?.order ?? "sequential";
|
|
1494
1505
|
const exhaust = group.options?.exhaust ?? "once";
|
|
1495
1506
|
const trace = (picked) => {
|
|
@@ -1536,8 +1547,9 @@ var Flow = class {
|
|
|
1536
1547
|
const len = eligible.length;
|
|
1537
1548
|
const stick = exhaust === "stick";
|
|
1538
1549
|
const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
|
|
1550
|
+
const eligibleIds = new Set(eligible.map((c) => c.id));
|
|
1539
1551
|
if (st.bag === void 0) st.bag = fill();
|
|
1540
|
-
if (st.bag.
|
|
1552
|
+
if (!st.bag.some((id2) => eligibleIds.has(id2))) {
|
|
1541
1553
|
if (exhaust === "once") return null;
|
|
1542
1554
|
if (stick) {
|
|
1543
1555
|
const last = eligible[len - 1];
|
|
@@ -1545,13 +1557,14 @@ var Flow = class {
|
|
|
1545
1557
|
return last;
|
|
1546
1558
|
}
|
|
1547
1559
|
st.bag = fill();
|
|
1560
|
+
if (st.bag.length === 0) return null;
|
|
1548
1561
|
}
|
|
1549
|
-
const pool = st.bag;
|
|
1562
|
+
const pool = st.bag.filter((id2) => eligibleIds.has(id2));
|
|
1550
1563
|
const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
|
|
1551
1564
|
let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
|
|
1552
1565
|
if (p >= 0 && i >= p) i++;
|
|
1553
1566
|
const id = pool[i];
|
|
1554
|
-
|
|
1567
|
+
st.bag.splice(st.bag.indexOf(id), 1);
|
|
1555
1568
|
st.last = id;
|
|
1556
1569
|
return eligible.find((c) => c.id === id);
|
|
1557
1570
|
}
|
|
@@ -1600,7 +1613,7 @@ var Flow = class {
|
|
|
1600
1613
|
/** A child's Best-match score against the current state: 0 when it has no condition (the filler
|
|
1601
1614
|
* tier), else the specificity of its (already-passing) condition. */
|
|
1602
1615
|
specScore(node) {
|
|
1603
|
-
return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
|
|
1616
|
+
return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
|
|
1604
1617
|
}
|
|
1605
1618
|
/**
|
|
1606
1619
|
* The **matched-specificity** metric (parity contract): how many atomic constraints are actively
|
|
@@ -1610,8 +1623,15 @@ var Flow = class {
|
|
|
1610
1623
|
* to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
|
|
1611
1624
|
* node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
|
|
1612
1625
|
*/
|
|
1613
|
-
matchedSpec(node, want) {
|
|
1614
|
-
const evalTruthy = (n) =>
|
|
1626
|
+
matchedSpec(node, want, owner) {
|
|
1627
|
+
const evalTruthy = (n) => {
|
|
1628
|
+
try {
|
|
1629
|
+
return truthy((0, import_expr.evaluate)(n, this.context(), import_dialect.patterDialect));
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
this.reportError("best-match", owner.id, owner.condition, err);
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1615
1635
|
return (0, import_expr_specificity.matchedSpecificity)(node, evalTruthy, { want });
|
|
1616
1636
|
}
|
|
1617
1637
|
/** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
|
|
@@ -1643,17 +1663,36 @@ var Flow = class {
|
|
|
1643
1663
|
return st;
|
|
1644
1664
|
}
|
|
1645
1665
|
// -- Effects + expressions ------------------------------------------------
|
|
1646
|
-
|
|
1666
|
+
/** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
|
|
1667
|
+
runEffects(effects, owner) {
|
|
1647
1668
|
for (const e of effects ?? []) {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1669
|
+
let value;
|
|
1670
|
+
try {
|
|
1671
|
+
value = this.evalExpr(e.value);
|
|
1672
|
+
const prev = this.host.logEnabled ? this.getProperty(e.target) : void 0;
|
|
1673
|
+
this.writeProperty(e.target, value, false);
|
|
1674
|
+
this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
|
|
1675
|
+
} catch (err) {
|
|
1676
|
+
this.reportError("effect", owner, e.value, err);
|
|
1677
|
+
}
|
|
1652
1678
|
}
|
|
1653
1679
|
}
|
|
1680
|
+
/** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
|
|
1654
1681
|
eligible(node) {
|
|
1655
1682
|
if (!node.condition) return true;
|
|
1656
|
-
|
|
1683
|
+
try {
|
|
1684
|
+
return truthy(this.evalExpr(node.condition));
|
|
1685
|
+
} catch (err) {
|
|
1686
|
+
this.reportError("condition", node.id, node.condition, err);
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
/** Report a content error the engine is playing through: to the host's onError, and to the log. */
|
|
1691
|
+
reportError(kind, node, expr, err) {
|
|
1692
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1693
|
+
const source = expr?.src;
|
|
1694
|
+
this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
|
|
1695
|
+
this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
|
|
1657
1696
|
}
|
|
1658
1697
|
evalExpr(expr) {
|
|
1659
1698
|
return (0, import_expr.evaluate)(this.conditionAst(expr), this.context(), import_dialect.patterDialect);
|
|
@@ -1759,6 +1798,27 @@ var Flow = class {
|
|
|
1759
1798
|
const text = this.interpolate(this.resolveString(beat.id));
|
|
1760
1799
|
return beat.kind === "line" ? { kind: "line", text: this.captionLine(text), character: beat.character, characterName: this.resolveCharacterName(beat.character), direction: beat.direction } : { kind: "text", text };
|
|
1761
1800
|
}
|
|
1801
|
+
/** An option's AUTHORED prompt beat: an Option group's own `prompt`. The only prompt a replay speaks. */
|
|
1802
|
+
authoredPromptOf(node) {
|
|
1803
|
+
return node.type === "group" ? node.prompt : void 0;
|
|
1804
|
+
}
|
|
1805
|
+
/** A replayed prompt as a step: the beat's id, gameData, and tags, and the text and speaker fields the
|
|
1806
|
+
* choice showed. */
|
|
1807
|
+
promptResult(beat, shown) {
|
|
1808
|
+
const tags = this.host.tagIndex.get(beat.id);
|
|
1809
|
+
const withTags = tags && tags.length ? { tags } : {};
|
|
1810
|
+
if (shown.kind === "text") return { type: "text", id: beat.id, text: shown.text, gameData: beat.gameData, ...withTags };
|
|
1811
|
+
return {
|
|
1812
|
+
type: "line",
|
|
1813
|
+
id: beat.id,
|
|
1814
|
+
text: shown.text,
|
|
1815
|
+
character: shown.character,
|
|
1816
|
+
characterName: shown.characterName,
|
|
1817
|
+
direction: shown.direction,
|
|
1818
|
+
gameData: beat.gameData,
|
|
1819
|
+
...withTags
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1762
1822
|
/** The prompt BEAT of an option: the Option group's `prompt`, else (tolerance) its first content line. */
|
|
1763
1823
|
promptBeatOf(node) {
|
|
1764
1824
|
if (node.type === "group" && node.prompt) return node.prompt;
|
|
@@ -1828,23 +1888,23 @@ var Flow = class {
|
|
|
1828
1888
|
if (!this.sceneBags.has(s)) {
|
|
1829
1889
|
const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
|
|
1830
1890
|
const key = keys.flowScene(this.id, s);
|
|
1831
|
-
this.host.registry
|
|
1891
|
+
const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
|
|
1832
1892
|
this.registered.add(key);
|
|
1833
1893
|
this.sceneBags.set(s, bag);
|
|
1834
1894
|
if (journal) journal.undo.push(() => {
|
|
1835
1895
|
if (this.sceneBags.get(s) !== bag) return;
|
|
1836
|
-
this.host.registry.remove(key);
|
|
1896
|
+
this.host.registry.remove(key, claimed ? { keep: true } : void 0);
|
|
1837
1897
|
this.registered.delete(key);
|
|
1838
1898
|
this.sceneBags.delete(s);
|
|
1839
1899
|
});
|
|
1840
1900
|
}
|
|
1841
1901
|
if (!this.host.stageBags.has(s)) {
|
|
1842
1902
|
const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
|
|
1843
|
-
this.host.registry
|
|
1903
|
+
const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
|
|
1844
1904
|
this.host.stageBags.set(s, bag);
|
|
1845
1905
|
if (journal) journal.undo.push(() => {
|
|
1846
1906
|
if (this.host.stageBags.get(s) !== bag) return;
|
|
1847
|
-
this.host.registry.remove(keys.stage(s));
|
|
1907
|
+
this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
|
|
1848
1908
|
this.host.stageBags.delete(s);
|
|
1849
1909
|
});
|
|
1850
1910
|
}
|
|
@@ -1882,6 +1942,13 @@ function collectCast(nodes, out) {
|
|
|
1882
1942
|
function gameDataField(gameData) {
|
|
1883
1943
|
return gameData && Object.keys(gameData).length ? { gameData } : {};
|
|
1884
1944
|
}
|
|
1945
|
+
function savedPrompt(p) {
|
|
1946
|
+
const out = { kind: p.kind, text: p.text };
|
|
1947
|
+
if (p.character !== void 0) out.character = p.character;
|
|
1948
|
+
if (p.characterName !== void 0) out.characterName = p.characterName;
|
|
1949
|
+
if (p.direction !== void 0) out.direction = p.direction;
|
|
1950
|
+
return out;
|
|
1951
|
+
}
|
|
1885
1952
|
function serialiseSelectors(map) {
|
|
1886
1953
|
const out = {};
|
|
1887
1954
|
for (const [id, st] of map) {
|
|
@@ -1904,6 +1971,11 @@ function deserialiseSelectors(rec) {
|
|
|
1904
1971
|
}
|
|
1905
1972
|
return map;
|
|
1906
1973
|
}
|
|
1974
|
+
function mountClaims(registry, key, bag, ask) {
|
|
1975
|
+
const before = ask ? JSON.stringify(bag.save()) : "";
|
|
1976
|
+
registry.mountOwned(key, bag, { owner: OWNER });
|
|
1977
|
+
return ask && JSON.stringify(bag.save()) !== before;
|
|
1978
|
+
}
|
|
1907
1979
|
function toDecl(decl) {
|
|
1908
1980
|
return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
|
|
1909
1981
|
}
|
|
@@ -1915,6 +1987,12 @@ function selfBackedDecl(decl, scopeWritable) {
|
|
|
1915
1987
|
const writable = decl.writable ?? scopeWritable;
|
|
1916
1988
|
return writable === void 0 ? d : { ...d, writable };
|
|
1917
1989
|
}
|
|
1990
|
+
function resolveScene(host, ref) {
|
|
1991
|
+
return host.sceneGameIdToId.get(ref) ?? (host.bundle.scenes[ref] ? ref : void 0);
|
|
1992
|
+
}
|
|
1993
|
+
function resolveBlock(host, sceneId, ref) {
|
|
1994
|
+
return host.blockGameIdToId.get(sceneId)?.get(ref) ?? (host.blockIndex.get(ref)?.sceneId === sceneId ? ref : void 0);
|
|
1995
|
+
}
|
|
1918
1996
|
function splitHostRef(host, ref) {
|
|
1919
1997
|
if (host.refSplitRevision !== host.registry.revision) {
|
|
1920
1998
|
host.refSplitCache.clear();
|