@patterkit/runtime 0.18.0 → 0.20.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 +86 -0
- package/README.md +4 -3
- package/dist/index.cjs +162 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +93 -23
- package/dist/index.d.ts +93 -23
- package/dist/index.js +162 -72
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -33,6 +33,92 @@ version number always means the same runtime behaviour. This package is versione
|
|
|
33
33
|
|
|
34
34
|
## [Unreleased]
|
|
35
35
|
|
|
36
|
+
## [0.20.0] - 2026-10-06
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`hostScopes`, the engine option every other runtime takes**: the scopes the game binds, by token, each a `{
|
|
41
|
+
get, set? }` over values it owns, as in `hostScopes: { world: { get, set } }`. A resolver's type is
|
|
42
|
+
`HostScope`.
|
|
43
|
+
- **`engine.buildId`**, as the other runtimes have: the bundle's content hash, the build a debug link handshakes
|
|
44
|
+
with.
|
|
45
|
+
- **A bundle-description section in the conformance corpus.** What `describeBundle` says about one bundle, field
|
|
46
|
+
for field, held on every runtime.
|
|
47
|
+
- **`getVisitCounts()` on the engine and on a flow**, on every runtime: how many times each node has been
|
|
48
|
+
entered, across every flow or in this one, read without a save.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **Less work per step.** A decision's log entry is built only while the log is on or something is tracing, and
|
|
53
|
+
a quality's stage ladder is looked up once per set of declarations rather than scanned for on every
|
|
54
|
+
comparison.
|
|
55
|
+
|
|
56
|
+
### Deprecated
|
|
57
|
+
|
|
58
|
+
- **`flow.start()` is deprecated: use `flow.reset()`.** They were the same call under two names. `flow.reset()`
|
|
59
|
+
is the one public name for beginning a flow again on every runtime (opening a flow begins a new one);
|
|
60
|
+
`flow.start()` goes in a later release.
|
|
61
|
+
- **The `world` option and the `WorldResolver` type: use `hostScopes: { world }` and `HostScope`.** `world` is
|
|
62
|
+
still the world scope, and goes in a later release.
|
|
63
|
+
- **The `TraceHandler` type.** Nothing takes one: `onTrace` takes an `EngineTraceHandler`. It goes in a later
|
|
64
|
+
release.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **`reset()` no longer replays a prompt from the run it abandoned.** With prompt replay on, `choose()` leaves
|
|
69
|
+
the chosen option's prompt waiting for the next `advance()`. `reset()` in between cleared only the choice, so
|
|
70
|
+
the old prompt played as the restarted run's first beat. Every move that leaves the flow's place (`reset()`,
|
|
71
|
+
`goto`, `close`) now drops both, through one helper.
|
|
72
|
+
- **`onTrace` hears every decision with the log off.** A `run` walk past a snippet whose condition failed, and a
|
|
73
|
+
write's previous value, were only worked out when the log was on, so a tool tracing a run without the log
|
|
74
|
+
missed the one and got a write without the other. They are now worked out whenever anything is tracing.
|
|
75
|
+
|
|
76
|
+
## [0.19.0] - 2026-10-06
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **`EngineOptions.onError` and `PlayError`.** Each condition or effect that fails while the story plays is
|
|
81
|
+
reported to `onError` with the flow, what failed (`condition`, `effect`, or `best-match`), the node, the
|
|
82
|
+
expression's source, and the error. With no `onError`, each goes to `console.warn`. With the log on, each is
|
|
83
|
+
also a `diagnostic` entry in the decision log.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **A condition or effect that fails no longer stops the story.** A failing condition counts as false, and a
|
|
88
|
+
failing effect is skipped while the rest of its list still runs, including a story write a read-only `@world`
|
|
89
|
+
value refuses. This is the same on all four runtimes; JS used to throw out of `advance()`.
|
|
90
|
+
- **A part of a Best-match condition that fails scores as false**, as on the other three runtimes. JS threw
|
|
91
|
+
while scoring.
|
|
92
|
+
- **A selector evaluates each condition once.** It evaluated each child's condition twice, so a condition
|
|
93
|
+
calling `random(a, b)` drew twice and the logged verdict could disagree with the pick.
|
|
94
|
+
- **A shuffle draws only from the children in its bag that are still eligible.** A child whose condition went
|
|
95
|
+
false after the bag was filled could be drawn, and the group then played nothing. When no child left in the
|
|
96
|
+
bag is eligible, the pass ends as if the bag were empty. Seeded results do not change while every child in the
|
|
97
|
+
bag stays eligible.
|
|
98
|
+
- **`openFlow`, `goto` and the engine's lookups read an address the same way:** a scene's game id first, then
|
|
99
|
+
its internal id, and a block only within its scene. `openFlow` tried the internal id first, so the two could
|
|
100
|
+
land in different scenes when one scene's id was another's game id.
|
|
101
|
+
- **A choice whose every remaining option is greyed out runs dry**, as a choice with no options does: the
|
|
102
|
+
fallback follows if there is one, otherwise the flow moves on and `onDryChoice` fires. It used to be offered
|
|
103
|
+
with nothing the player could take.
|
|
104
|
+
- **An option's prompt beat carries tags**: its own and the option's. A replayed prompt and the outline lost
|
|
105
|
+
them.
|
|
106
|
+
- **`loadGame` checks a save before changing anything.** A save missing its flows used to close every flow and
|
|
107
|
+
load the registry before failing, leaving the engine half-loaded.
|
|
108
|
+
|
|
109
|
+
### Fixed
|
|
110
|
+
|
|
111
|
+
- **`@wildwinter/expr` 0.5.1 and `@wildwinter/scoperegistry` 0.8.1 (kernel `k492cf234`):** a listener's own
|
|
112
|
+
error is no longer reported as a read-only refusal, every listener registered when a write starts hears it
|
|
113
|
+
exactly once, and the hot path allocates less, with no leaks.
|
|
114
|
+
- **A rollback after a load keeps a scene's saved `@scene` values.** After a load, a scene the flow is not
|
|
115
|
+
standing in keeps its saved values waiting until the flow enters it. Entering it inside a checkpoint and then
|
|
116
|
+
rolling back used to drop those values, so the next real entry found the defaults and the next save left them
|
|
117
|
+
out. A new corpus case pins this on all four runtimes.
|
|
118
|
+
- **The decision log's `seq` keeps counting after a clear.** The engine's log numbered entries by its length, so
|
|
119
|
+
after `clearLog()` the numbers started again at 0 and entries read either side of the clear could not be put
|
|
120
|
+
in order.
|
|
121
|
+
|
|
36
122
|
## [0.18.0] - 2026-10-05
|
|
37
123
|
|
|
38
124
|
### Changed
|
package/README.md
CHANGED
|
@@ -44,9 +44,10 @@ for (;;) {
|
|
|
44
44
|
}
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`new Engine(bundle, options)` takes `{ rng?, seed?, locale?,
|
|
48
|
-
- `
|
|
49
|
-
|
|
47
|
+
`new Engine(bundle, options)` takes `{ rng?, seed?, locale?, hostScopes?, registry?, replayPromptOnChoose?, closedCaptions? }`:
|
|
48
|
+
- `hostScopes` binds the scopes your game owns, by token, each `{ get(name), set?(name, value) }`: for World
|
|
49
|
+
Properties, `hostScopes: { world: { get, set } }`. Leave `world` unbound and the runtime self-backs `@world`
|
|
50
|
+
from the declared defaults, as a property it saves.
|
|
50
51
|
- `registry` is the game's one `ScopeRegistry` (from `@wildwinter/scoperegistry`), holding every
|
|
51
52
|
engine's properties. Omit it and the engine makes its own. See *One registry per game* below.
|
|
52
53
|
- `locale` plays a non-default language (embedded localisation; an IDs-only bundle ignores it).
|
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 {
|
|
@@ -113,11 +114,13 @@ var Engine = class _Engine {
|
|
|
113
114
|
sceneGameIdToId = /* @__PURE__ */ new Map();
|
|
114
115
|
blockGameIdToId = /* @__PURE__ */ new Map();
|
|
115
116
|
/** The options this engine was built with - reused verbatim by `hotSwap` so the replacement
|
|
116
|
-
* engine keeps the same
|
|
117
|
+
* engine keeps the same host scopes, custom RNG, and diagnostic hooks. */
|
|
117
118
|
creationOptions;
|
|
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;
|
|
@@ -163,12 +166,12 @@ var Engine = class _Engine {
|
|
|
163
166
|
registry.mountOwned("patter", patterBag, { owner: OWNER });
|
|
164
167
|
registered.push("patter");
|
|
165
168
|
const hostBound = /* @__PURE__ */ new Set();
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const decls = (
|
|
169
|
-
registry.defineForeign(
|
|
170
|
-
registered.push(
|
|
171
|
-
hostBound.add(
|
|
169
|
+
for (const [token, resolver] of Object.entries(boundScopes(options))) {
|
|
170
|
+
const spec = bundle.scopeRegistry?.scopes.find((s) => s.token === token);
|
|
171
|
+
const decls = (spec?.declarations ?? []).map(toForeignDecl);
|
|
172
|
+
registry.defineForeign(token, resolver, decls, { writable: spec?.writable ?? true, owner: OWNER });
|
|
173
|
+
registered.push(token);
|
|
174
|
+
hostBound.add(token);
|
|
172
175
|
}
|
|
173
176
|
for (const spec of ownsRegistry ? bundle.scopeRegistry?.scopes ?? [] : []) {
|
|
174
177
|
if (hostBound.has(spec.token) || registry.has(spec.token)) continue;
|
|
@@ -188,6 +191,7 @@ var Engine = class _Engine {
|
|
|
188
191
|
}
|
|
189
192
|
this.host = {
|
|
190
193
|
logEnabled: options.log ?? false,
|
|
194
|
+
tracing: options.log ?? false,
|
|
191
195
|
emitEngine: (flow, event, scene) => this.emitEngine(flow, event, scene),
|
|
192
196
|
bundle,
|
|
193
197
|
emitIds,
|
|
@@ -210,11 +214,13 @@ var Engine = class _Engine {
|
|
|
210
214
|
patterSharedNames,
|
|
211
215
|
sceneSharedNames,
|
|
212
216
|
sharedVisits: /* @__PURE__ */ new Map(),
|
|
217
|
+
qualityLadders: /* @__PURE__ */ new Map(),
|
|
213
218
|
sharedSelectors: /* @__PURE__ */ new Map(),
|
|
214
219
|
stageBags: /* @__PURE__ */ new Map(),
|
|
215
220
|
journal: null,
|
|
216
221
|
customRng: options.rng,
|
|
217
222
|
onDryChoice: options.onDryChoice,
|
|
223
|
+
onError: options.onError ?? ((e) => console.warn(`Patterplay: ${e.kind} on ${e.node} in flow '${e.flow}' failed, played through: ${e.message}`)),
|
|
218
224
|
replayPromptOnChoose: options.replayPromptOnChoose ?? false,
|
|
219
225
|
captionsOn: options.closedCaptions ?? true,
|
|
220
226
|
// captions shown by default (full text)
|
|
@@ -235,6 +241,11 @@ var Engine = class _Engine {
|
|
|
235
241
|
get isSourceDebug() {
|
|
236
242
|
return this.sourceDebug;
|
|
237
243
|
}
|
|
244
|
+
/** The bundle's build identity (its content hash), the one a debug link handshakes with, or an empty
|
|
245
|
+
* string for a bundle compiled without one. */
|
|
246
|
+
get buildId() {
|
|
247
|
+
return this.host.bundle.content?.hash ?? "";
|
|
248
|
+
}
|
|
238
249
|
/**
|
|
239
250
|
* Switch the active locale LIVE - a real game's "language" setting can change mid-session. Subsequent
|
|
240
251
|
* string lookups (new beats, re-resolved character names, `{@ref}` interpolation) render in the new
|
|
@@ -355,7 +366,7 @@ var Engine = class _Engine {
|
|
|
355
366
|
});
|
|
356
367
|
}
|
|
357
368
|
this.flowsById.set(id, flow);
|
|
358
|
-
flow.
|
|
369
|
+
flow.begin(sceneId, blockId);
|
|
359
370
|
return flow;
|
|
360
371
|
}
|
|
361
372
|
/**
|
|
@@ -408,10 +419,10 @@ var Engine = class _Engine {
|
|
|
408
419
|
*/
|
|
409
420
|
resolveOpenAddress(scene, block) {
|
|
410
421
|
if (scene !== void 0) {
|
|
411
|
-
const sceneId = this.
|
|
412
|
-
if (
|
|
422
|
+
const sceneId = resolveScene(this.host, scene);
|
|
423
|
+
if (sceneId === void 0) throw new Error(`unknown scene: ${scene}`);
|
|
413
424
|
if (block === void 0) return { sceneId };
|
|
414
|
-
const blockId =
|
|
425
|
+
const blockId = resolveBlock(this.host, sceneId, block);
|
|
415
426
|
if (blockId === void 0) throw new Error(`unknown block: ${block}`);
|
|
416
427
|
return { sceneId, blockId };
|
|
417
428
|
}
|
|
@@ -422,21 +433,19 @@ var Engine = class _Engine {
|
|
|
422
433
|
if (Object.keys(this.host.bundle.scenes).length === 0) throw new Error("no scenes in bundle");
|
|
423
434
|
return {};
|
|
424
435
|
}
|
|
425
|
-
/** Resolve a scene reference
|
|
436
|
+
/** Resolve a scene reference to its internal id by the one address rule (see resolveScene); an
|
|
437
|
+
* unknown one passes through, for the caller to find nothing. */
|
|
426
438
|
resolveSceneRef(ref) {
|
|
427
439
|
if (ref == null) return void 0;
|
|
428
|
-
|
|
429
|
-
return this.sceneGameIdToId.get(ref) ?? ref;
|
|
440
|
+
return resolveScene(this.host, ref) ?? ref;
|
|
430
441
|
}
|
|
431
|
-
/** Resolve a block reference
|
|
442
|
+
/** Resolve a block reference to its internal id by the one address rule (see resolveBlock): within its
|
|
443
|
+
* scene, so a block of another scene resolves to nothing. With no scene, only an internal id resolves,
|
|
444
|
+
* since a block's gameId is only unique within its scene. */
|
|
432
445
|
resolveBlockRef(sceneId, ref) {
|
|
433
446
|
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;
|
|
447
|
+
if (sceneId == null) return ref;
|
|
448
|
+
return resolveBlock(this.host, sceneId, ref);
|
|
440
449
|
}
|
|
441
450
|
/** The host-facing address (gameId) of a scene / block by internal id, or undefined if unknown.
|
|
442
451
|
* The inverse of the resolve helpers - for a host that wants to display / log the address. */
|
|
@@ -721,7 +730,7 @@ var Engine = class _Engine {
|
|
|
721
730
|
for (const s of this.host.stageBags.keys()) reg.remove(keys.stage(s), { keep });
|
|
722
731
|
this.host.stageBags.clear();
|
|
723
732
|
for (const t of ["patter", ...this.host.hostScopes]) if (reg.has(t)) reg.remove(t, { keep });
|
|
724
|
-
|
|
733
|
+
for (const t of Object.keys(boundScopes(this.creationOptions))) if (reg.has(t)) reg.remove(t);
|
|
725
734
|
}
|
|
726
735
|
/** Read a shared (`@patter` / foreign) property by ref. `@scene` refs are rejected (flow-level). */
|
|
727
736
|
getProperty(ref) {
|
|
@@ -758,12 +767,21 @@ var Engine = class _Engine {
|
|
|
758
767
|
* retained. Returns its own unsubscribe. */
|
|
759
768
|
onTrace(handler) {
|
|
760
769
|
this.engineTraceHandlers.add(handler);
|
|
761
|
-
|
|
770
|
+
this.host.tracing = true;
|
|
771
|
+
return () => {
|
|
772
|
+
this.engineTraceHandlers.delete(handler);
|
|
773
|
+
this.host.tracing = this.host.logEnabled || this.engineTraceHandlers.size > 0;
|
|
774
|
+
};
|
|
762
775
|
}
|
|
763
776
|
emitEngine(flow, event, scene) {
|
|
764
777
|
for (const h of this.engineTraceHandlers) h(flow, event);
|
|
765
778
|
if (!this.host.logEnabled) return;
|
|
766
|
-
this.engineLog.push({ ...event, flow, seq: this.
|
|
779
|
+
this.engineLog.push({ ...event, flow, seq: this.engineSeq++, ...scene ? { scene } : {} });
|
|
780
|
+
}
|
|
781
|
+
/** How many times each node has been entered across every flow, by node id: the shared count. With a
|
|
782
|
+
* flow's own {@link Flow.getVisitCounts}, every visit count the run keeps, read without a save. */
|
|
783
|
+
getVisitCounts() {
|
|
784
|
+
return Object.fromEntries(this.host.sharedVisits);
|
|
767
785
|
}
|
|
768
786
|
listProperties() {
|
|
769
787
|
return this.host.patterSharedDecls.map((d) => ({
|
|
@@ -829,6 +847,8 @@ var Engine = class _Engine {
|
|
|
829
847
|
this.refuseInCheckpoint("loadGame");
|
|
830
848
|
const version = save.version;
|
|
831
849
|
if (version !== 2 && version !== import_model2.SAVE_VERSION) throw new Error(`unsupported save version: ${String(version)}`);
|
|
850
|
+
const flows = save.flows;
|
|
851
|
+
if (typeof flows !== "object" || flows === null || Array.isArray(flows)) throw new Error("malformed save: no flows");
|
|
832
852
|
this.assertExternalScopes();
|
|
833
853
|
const reg = this.host.registry;
|
|
834
854
|
for (const [id, flow] of this.flowsById) {
|
|
@@ -967,19 +987,21 @@ var Flow = class {
|
|
|
967
987
|
* lowercase, as the compiler emits references (the selfBackedResolver lesson). */
|
|
968
988
|
stagesFor(scope, name) {
|
|
969
989
|
const key = name.toLowerCase();
|
|
970
|
-
const fromDecls = (decls) => decls?.find((d) => d.name.toLowerCase() === key && d.type === "quality")?.stages;
|
|
971
990
|
if (scope === "patter") {
|
|
972
|
-
return
|
|
991
|
+
return ladders(this.host, "patter", () => [...this.host.patterSharedDecls, ...this.host.patterLocalDecls]).get(key);
|
|
973
992
|
}
|
|
974
993
|
if (scope === "scene") {
|
|
975
|
-
const
|
|
976
|
-
|
|
994
|
+
const sceneId = this.currentSceneId;
|
|
995
|
+
if (sceneId == null) return void 0;
|
|
996
|
+
return ladders(this.host, `scene/${sceneId}`, () => this.host.bundle.scenes[sceneId]?.sceneProps).get(key);
|
|
977
997
|
}
|
|
978
|
-
return this.registryQualities?.(scope, name) ??
|
|
998
|
+
return this.registryQualities?.(scope, name) ?? ladders(this.host, `host/${scope}`, () => this.host.bundle.scopeRegistry?.scopes.find((s) => s.token === scope)?.declarations).get(key);
|
|
979
999
|
}
|
|
980
1000
|
// -- Host API -------------------------------------------------------------
|
|
981
|
-
/** Begin this flow at a scene (and optionally a specific block within it).
|
|
982
|
-
|
|
1001
|
+
/** Begin this flow at a scene (and optionally a specific block within it). The engine's own entry point:
|
|
1002
|
+
* openFlow and a goto on an unstarted flow begin a flow here. A game calls {@link reset}.
|
|
1003
|
+
* @internal */
|
|
1004
|
+
begin(sceneId, blockId) {
|
|
983
1005
|
if (this.host.journal && !this.host.journal.opened.has(this)) throw new Error("a flow can't be started or reset while a checkpoint is open");
|
|
984
1006
|
this.releaseBags(false);
|
|
985
1007
|
this.host.registry.discardParked(keys.flow(this.id));
|
|
@@ -991,7 +1013,7 @@ var Flow = class {
|
|
|
991
1013
|
this.flowEnded = false;
|
|
992
1014
|
this.activeSnippet = null;
|
|
993
1015
|
this.beatIndex = 0;
|
|
994
|
-
this.
|
|
1016
|
+
this.clearPending();
|
|
995
1017
|
this.started = true;
|
|
996
1018
|
if (blockId) {
|
|
997
1019
|
const loc = this.host.blockIndex.get(blockId);
|
|
@@ -1016,10 +1038,15 @@ var Flow = class {
|
|
|
1016
1038
|
* Forget everything in this flow and begin again - its per-flow state (not-shared
|
|
1017
1039
|
* `@patter` globals + `@scene` props), cursor, callstack, selector cursors, and
|
|
1018
1040
|
* visit counts. Shared state (shared `@patter` / `@scene`, world visit counts) is
|
|
1019
|
-
* untouched.
|
|
1041
|
+
* untouched. The one public way to begin a flow again (openFlow begins a new one).
|
|
1020
1042
|
*/
|
|
1021
1043
|
reset(sceneId, blockId) {
|
|
1022
|
-
this.
|
|
1044
|
+
this.begin(sceneId, blockId);
|
|
1045
|
+
}
|
|
1046
|
+
/** @deprecated Use {@link reset}, the one public name for beginning a flow again on every runtime. `start`
|
|
1047
|
+
* was the same call under a second name, and goes in a later release. */
|
|
1048
|
+
start(sceneId, blockId) {
|
|
1049
|
+
this.reset(sceneId, blockId);
|
|
1023
1050
|
}
|
|
1024
1051
|
/**
|
|
1025
1052
|
* Send this flow's cursor to an ADDRESS, exactly as an authored `go` jump would: the target scene's
|
|
@@ -1043,29 +1070,25 @@ var Flow = class {
|
|
|
1043
1070
|
this.touch();
|
|
1044
1071
|
if (scene === "END") {
|
|
1045
1072
|
this.started = true;
|
|
1046
|
-
this.
|
|
1047
|
-
this.pendingPrompt = null;
|
|
1048
|
-
this.pendingPromptOwnerId = null;
|
|
1073
|
+
this.clearPending();
|
|
1049
1074
|
this.activeSnippet = null;
|
|
1050
1075
|
this.beatIndex = 0;
|
|
1051
1076
|
this.flowEnded = true;
|
|
1052
1077
|
this.stack = [];
|
|
1053
1078
|
return true;
|
|
1054
1079
|
}
|
|
1055
|
-
const sceneId =
|
|
1080
|
+
const sceneId = resolveScene(this.host, scene);
|
|
1056
1081
|
if (sceneId === void 0) return false;
|
|
1057
1082
|
let blockId;
|
|
1058
1083
|
if (block !== void 0) {
|
|
1059
|
-
blockId =
|
|
1084
|
+
blockId = resolveBlock(this.host, sceneId, block);
|
|
1060
1085
|
if (blockId === void 0) return false;
|
|
1061
1086
|
}
|
|
1062
1087
|
if (!this.started) {
|
|
1063
|
-
this.
|
|
1088
|
+
this.begin(sceneId, blockId);
|
|
1064
1089
|
return true;
|
|
1065
1090
|
}
|
|
1066
|
-
this.
|
|
1067
|
-
this.pendingPrompt = null;
|
|
1068
|
-
this.pendingPromptOwnerId = null;
|
|
1091
|
+
this.clearPending();
|
|
1069
1092
|
this.activeSnippet = null;
|
|
1070
1093
|
this.beatIndex = 0;
|
|
1071
1094
|
this.flowEnded = false;
|
|
@@ -1090,6 +1113,15 @@ var Flow = class {
|
|
|
1090
1113
|
this.stack = [];
|
|
1091
1114
|
this.activeSnippet = null;
|
|
1092
1115
|
this.beatIndex = 0;
|
|
1116
|
+
this.clearPending();
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Drop everything waiting to be delivered: an open choice, and a chosen option's prompt still to be
|
|
1120
|
+
* replayed. Every move that abandons the flow's place (start and reset, goto, close) does this, so none
|
|
1121
|
+
* leaves a stale prompt behind. start() cleared only the choice, so a reset between choose() and the
|
|
1122
|
+
* next advance() replayed the abandoned run's prompt as the new run's first beat.
|
|
1123
|
+
*/
|
|
1124
|
+
clearPending() {
|
|
1093
1125
|
this.pendingChoice = null;
|
|
1094
1126
|
this.pendingPrompt = null;
|
|
1095
1127
|
this.pendingPromptOwnerId = null;
|
|
@@ -1166,7 +1198,7 @@ var Flow = class {
|
|
|
1166
1198
|
if (this.flowEnded || this.pendingChoice) return;
|
|
1167
1199
|
if (this.activeSnippet) {
|
|
1168
1200
|
if (this.beatIndex < (this.activeSnippet.beats?.length ?? 0)) return;
|
|
1169
|
-
this.runEffects(this.activeSnippet.onExit);
|
|
1201
|
+
this.runEffects(this.activeSnippet.onExit, this.activeSnippet.id);
|
|
1170
1202
|
const jump = this.activeSnippet.jump;
|
|
1171
1203
|
this.activeSnippet = null;
|
|
1172
1204
|
this.beatIndex = 0;
|
|
@@ -1186,7 +1218,7 @@ var Flow = class {
|
|
|
1186
1218
|
}
|
|
1187
1219
|
const from = frame.index;
|
|
1188
1220
|
while (frame.index < children.length && !this.eligible(children[frame.index])) frame.index++;
|
|
1189
|
-
if (this.host.
|
|
1221
|
+
if (this.host.tracing && frame.index !== from) {
|
|
1190
1222
|
this.emit({
|
|
1191
1223
|
type: "select",
|
|
1192
1224
|
group: frame.containerId,
|
|
@@ -1222,14 +1254,18 @@ var Flow = class {
|
|
|
1222
1254
|
clearLog() {
|
|
1223
1255
|
this.flowLog.length = 0;
|
|
1224
1256
|
}
|
|
1225
|
-
/** Record one decision, on this flow's log and the engine's
|
|
1226
|
-
*
|
|
1257
|
+
/** Record one decision, on this flow's log and the engine's, and hand it to any `onTrace`. Each call
|
|
1258
|
+
* site checks `host.tracing` first, so with nothing logging or tracing the entry is never built. */
|
|
1227
1259
|
emit(event) {
|
|
1228
1260
|
const scene = this.currentSceneId ?? void 0;
|
|
1229
1261
|
this.host.emitEngine(this.id, event, scene);
|
|
1230
1262
|
if (!this.host.logEnabled) return;
|
|
1231
1263
|
this.flowLog.push({ ...event, seq: this.flowSeq++, ...scene ? { scene } : {} });
|
|
1232
1264
|
}
|
|
1265
|
+
/** How many times this flow has entered each node, by node id. */
|
|
1266
|
+
getVisitCounts() {
|
|
1267
|
+
return Object.fromEntries(this.visitCounts);
|
|
1268
|
+
}
|
|
1233
1269
|
/** The options of a pending choice (empty when not at a choice point). */
|
|
1234
1270
|
getChoices() {
|
|
1235
1271
|
return this.pendingChoice?.options ?? [];
|
|
@@ -1243,7 +1279,7 @@ var Flow = class {
|
|
|
1243
1279
|
if (!option.eligible) throw new Error(`choice option is not eligible: ${id}`);
|
|
1244
1280
|
this.touch();
|
|
1245
1281
|
const node = choice.byId.get(id);
|
|
1246
|
-
this.emit({ type: "chose", group: choice.groupId, option: id });
|
|
1282
|
+
if (this.host.tracing) this.emit({ type: "chose", group: choice.groupId, option: id });
|
|
1247
1283
|
this.pendingChoice = null;
|
|
1248
1284
|
const authored = this.host.replayPromptOnChoose ? this.authoredPromptOf(node) : void 0;
|
|
1249
1285
|
this.pendingPrompt = authored && option.prompt ? { beat: authored, shown: { ...option.prompt } } : null;
|
|
@@ -1382,7 +1418,7 @@ var Flow = class {
|
|
|
1382
1418
|
this.currentSceneId = sceneId;
|
|
1383
1419
|
this.enter(sceneId);
|
|
1384
1420
|
this.seedScene(scene);
|
|
1385
|
-
this.runEffects(scene.onEntry);
|
|
1421
|
+
this.runEffects(scene.onEntry, scene.id);
|
|
1386
1422
|
}
|
|
1387
1423
|
/**
|
|
1388
1424
|
* Play one child of the active run. A snippet begins delivering. A group is
|
|
@@ -1418,7 +1454,7 @@ var Flow = class {
|
|
|
1418
1454
|
return void 0;
|
|
1419
1455
|
}
|
|
1420
1456
|
beginSnippet(snippet) {
|
|
1421
|
-
this.runEffects(snippet.onEnter);
|
|
1457
|
+
this.runEffects(snippet.onEnter, snippet.id);
|
|
1422
1458
|
this.activeSnippet = snippet;
|
|
1423
1459
|
this.beatIndex = 0;
|
|
1424
1460
|
}
|
|
@@ -1438,8 +1474,8 @@ var Flow = class {
|
|
|
1438
1474
|
options.push({ id: child.id, prompt: this.promptFor(child), eligible, gameData: child.gameData });
|
|
1439
1475
|
byId.set(child.id, child);
|
|
1440
1476
|
}
|
|
1441
|
-
if (options.
|
|
1442
|
-
this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
|
|
1477
|
+
if (options.some((o) => o.eligible)) {
|
|
1478
|
+
if (this.host.tracing) this.emit({ type: "choice", group: group.id, options: options.map((o) => ({ id: o.id, eligible: o.eligible })) });
|
|
1443
1479
|
this.pendingChoice = { groupId: group.id, options, byId };
|
|
1444
1480
|
return;
|
|
1445
1481
|
}
|
|
@@ -1448,7 +1484,7 @@ var Flow = class {
|
|
|
1448
1484
|
this.enterChild(fallback);
|
|
1449
1485
|
return;
|
|
1450
1486
|
}
|
|
1451
|
-
this.emit({ type: "dry", group: group.id });
|
|
1487
|
+
if (this.host.tracing) this.emit({ type: "dry", group: group.id });
|
|
1452
1488
|
this.host.onDryChoice?.(group.id);
|
|
1453
1489
|
}
|
|
1454
1490
|
// -- Jumps (jump / call-return) ----------------------------------------
|
|
@@ -1463,7 +1499,7 @@ var Flow = class {
|
|
|
1463
1499
|
* hard-ends the flow regardless of the callstack.
|
|
1464
1500
|
*/
|
|
1465
1501
|
enterTarget(to, mode) {
|
|
1466
|
-
this.emit({ type: "jump", to, mode });
|
|
1502
|
+
if (this.host.tracing) this.emit({ type: "jump", to, mode });
|
|
1467
1503
|
if (to === "END") {
|
|
1468
1504
|
this.flowEnded = true;
|
|
1469
1505
|
this.stack = [];
|
|
@@ -1496,11 +1532,11 @@ var Flow = class {
|
|
|
1496
1532
|
// -- Selectors ------------------------------------------------------------
|
|
1497
1533
|
selectChild(group) {
|
|
1498
1534
|
const verdicts = group.children.map((c) => ({ id: c.id, eligible: this.eligible(c) }));
|
|
1499
|
-
const eligible = group.children.filter((
|
|
1535
|
+
const eligible = group.children.filter((_, i) => verdicts[i].eligible);
|
|
1500
1536
|
const order = group.options?.order ?? "sequential";
|
|
1501
1537
|
const exhaust = group.options?.exhaust ?? "once";
|
|
1502
1538
|
const trace = (picked) => {
|
|
1503
|
-
this.emit({
|
|
1539
|
+
if (this.host.tracing) this.emit({
|
|
1504
1540
|
type: "select",
|
|
1505
1541
|
group: group.id,
|
|
1506
1542
|
selector: group.selector ?? "default",
|
|
@@ -1543,8 +1579,9 @@ var Flow = class {
|
|
|
1543
1579
|
const len = eligible.length;
|
|
1544
1580
|
const stick = exhaust === "stick";
|
|
1545
1581
|
const fill = () => (stick ? eligible.slice(0, len - 1) : eligible).map((c) => c.id);
|
|
1582
|
+
const eligibleIds = new Set(eligible.map((c) => c.id));
|
|
1546
1583
|
if (st.bag === void 0) st.bag = fill();
|
|
1547
|
-
if (st.bag.
|
|
1584
|
+
if (!st.bag.some((id2) => eligibleIds.has(id2))) {
|
|
1548
1585
|
if (exhaust === "once") return null;
|
|
1549
1586
|
if (stick) {
|
|
1550
1587
|
const last = eligible[len - 1];
|
|
@@ -1552,13 +1589,14 @@ var Flow = class {
|
|
|
1552
1589
|
return last;
|
|
1553
1590
|
}
|
|
1554
1591
|
st.bag = fill();
|
|
1592
|
+
if (st.bag.length === 0) return null;
|
|
1555
1593
|
}
|
|
1556
|
-
const pool = st.bag;
|
|
1594
|
+
const pool = st.bag.filter((id2) => eligibleIds.has(id2));
|
|
1557
1595
|
const p = st.last !== void 0 && pool.length > 1 ? pool.indexOf(st.last) : -1;
|
|
1558
1596
|
let i = Math.floor(this.rng() * (p >= 0 ? pool.length - 1 : pool.length));
|
|
1559
1597
|
if (p >= 0 && i >= p) i++;
|
|
1560
1598
|
const id = pool[i];
|
|
1561
|
-
|
|
1599
|
+
st.bag.splice(st.bag.indexOf(id), 1);
|
|
1562
1600
|
st.last = id;
|
|
1563
1601
|
return eligible.find((c) => c.id === id);
|
|
1564
1602
|
}
|
|
@@ -1607,7 +1645,7 @@ var Flow = class {
|
|
|
1607
1645
|
/** A child's Best-match score against the current state: 0 when it has no condition (the filler
|
|
1608
1646
|
* tier), else the specificity of its (already-passing) condition. */
|
|
1609
1647
|
specScore(node) {
|
|
1610
|
-
return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
|
|
1648
|
+
return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true, node) : 0;
|
|
1611
1649
|
}
|
|
1612
1650
|
/**
|
|
1613
1651
|
* The **matched-specificity** metric (parity contract): how many atomic constraints are actively
|
|
@@ -1617,8 +1655,15 @@ var Flow = class {
|
|
|
1617
1655
|
* to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
|
|
1618
1656
|
* node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
|
|
1619
1657
|
*/
|
|
1620
|
-
matchedSpec(node, want) {
|
|
1621
|
-
const evalTruthy = (n) =>
|
|
1658
|
+
matchedSpec(node, want, owner) {
|
|
1659
|
+
const evalTruthy = (n) => {
|
|
1660
|
+
try {
|
|
1661
|
+
return truthy((0, import_expr.evaluate)(n, this.context(), import_dialect.patterDialect));
|
|
1662
|
+
} catch (err) {
|
|
1663
|
+
this.reportError("best-match", owner.id, owner.condition, err);
|
|
1664
|
+
return false;
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1622
1667
|
return (0, import_expr_specificity.matchedSpecificity)(node, evalTruthy, { want });
|
|
1623
1668
|
}
|
|
1624
1669
|
/** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
|
|
@@ -1650,17 +1695,36 @@ var Flow = class {
|
|
|
1650
1695
|
return st;
|
|
1651
1696
|
}
|
|
1652
1697
|
// -- Effects + expressions ------------------------------------------------
|
|
1653
|
-
|
|
1698
|
+
/** Run an effect list. `owner` is the snippet or scene it belongs to, for an error report. */
|
|
1699
|
+
runEffects(effects, owner) {
|
|
1654
1700
|
for (const e of effects ?? []) {
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1701
|
+
let value;
|
|
1702
|
+
try {
|
|
1703
|
+
value = this.evalExpr(e.value);
|
|
1704
|
+
const prev = this.host.tracing ? this.getProperty(e.target) : void 0;
|
|
1705
|
+
this.writeProperty(e.target, value, false);
|
|
1706
|
+
if (this.host.tracing) this.emit({ type: "write", target: e.target, value, ...prev !== void 0 ? { prev } : {} });
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
this.reportError("effect", owner, e.value, err);
|
|
1709
|
+
}
|
|
1659
1710
|
}
|
|
1660
1711
|
}
|
|
1712
|
+
/** Whether a node's condition holds. A condition that fails to evaluate counts as false (see PlayError). */
|
|
1661
1713
|
eligible(node) {
|
|
1662
1714
|
if (!node.condition) return true;
|
|
1663
|
-
|
|
1715
|
+
try {
|
|
1716
|
+
return truthy(this.evalExpr(node.condition));
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
this.reportError("condition", node.id, node.condition, err);
|
|
1719
|
+
return false;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/** Report a content error the engine is playing through: to the host's onError, and to the log. */
|
|
1723
|
+
reportError(kind, node, expr, err) {
|
|
1724
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1725
|
+
const source = expr?.src;
|
|
1726
|
+
this.host.onError({ flow: this.id, kind, node, ...source ? { source } : {}, message });
|
|
1727
|
+
if (this.host.tracing) this.emit({ type: "diagnostic", kind, node, ...source ? { source } : {}, message });
|
|
1664
1728
|
}
|
|
1665
1729
|
evalExpr(expr) {
|
|
1666
1730
|
return (0, import_expr.evaluate)(this.conditionAst(expr), this.context(), import_dialect.patterDialect);
|
|
@@ -1856,23 +1920,23 @@ var Flow = class {
|
|
|
1856
1920
|
if (!this.sceneBags.has(s)) {
|
|
1857
1921
|
const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => !shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
|
|
1858
1922
|
const key = keys.flowScene(this.id, s);
|
|
1859
|
-
this.host.registry
|
|
1923
|
+
const claimed = mountClaims(this.host.registry, key, bag, journal !== null);
|
|
1860
1924
|
this.registered.add(key);
|
|
1861
1925
|
this.sceneBags.set(s, bag);
|
|
1862
1926
|
if (journal) journal.undo.push(() => {
|
|
1863
1927
|
if (this.sceneBags.get(s) !== bag) return;
|
|
1864
|
-
this.host.registry.remove(key);
|
|
1928
|
+
this.host.registry.remove(key, claimed ? { keep: true } : void 0);
|
|
1865
1929
|
this.registered.delete(key);
|
|
1866
1930
|
this.sceneBags.delete(s);
|
|
1867
1931
|
});
|
|
1868
1932
|
}
|
|
1869
1933
|
if (!this.host.stageBags.has(s)) {
|
|
1870
1934
|
const bag = new import_scoperegistry2.PropertyBag(props.filter((d) => shared.has(d.name.toLowerCase())), { pathPrefix: "@scene." });
|
|
1871
|
-
this.host.registry
|
|
1935
|
+
const claimed = mountClaims(this.host.registry, keys.stage(s), bag, journal !== null);
|
|
1872
1936
|
this.host.stageBags.set(s, bag);
|
|
1873
1937
|
if (journal) journal.undo.push(() => {
|
|
1874
1938
|
if (this.host.stageBags.get(s) !== bag) return;
|
|
1875
|
-
this.host.registry.remove(keys.stage(s));
|
|
1939
|
+
this.host.registry.remove(keys.stage(s), claimed ? { keep: true } : void 0);
|
|
1876
1940
|
this.host.stageBags.delete(s);
|
|
1877
1941
|
});
|
|
1878
1942
|
}
|
|
@@ -1939,9 +2003,29 @@ function deserialiseSelectors(rec) {
|
|
|
1939
2003
|
}
|
|
1940
2004
|
return map;
|
|
1941
2005
|
}
|
|
2006
|
+
function mountClaims(registry, key, bag, ask) {
|
|
2007
|
+
const before = ask ? JSON.stringify(bag.save()) : "";
|
|
2008
|
+
registry.mountOwned(key, bag, { owner: OWNER });
|
|
2009
|
+
return ask && JSON.stringify(bag.save()) !== before;
|
|
2010
|
+
}
|
|
1942
2011
|
function toDecl(decl) {
|
|
1943
2012
|
return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default };
|
|
1944
2013
|
}
|
|
2014
|
+
function boundScopes(options) {
|
|
2015
|
+
return { ...options.world ? { world: options.world } : {}, ...options.hostScopes };
|
|
2016
|
+
}
|
|
2017
|
+
function ladders(host, cacheKey, decls) {
|
|
2018
|
+
let found = host.qualityLadders.get(cacheKey);
|
|
2019
|
+
if (!found) {
|
|
2020
|
+
found = /* @__PURE__ */ new Map();
|
|
2021
|
+
for (const d of decls() ?? []) {
|
|
2022
|
+
const k = d.name.toLowerCase();
|
|
2023
|
+
if (d.type === "quality" && d.stages && !found.has(k)) found.set(k, d.stages);
|
|
2024
|
+
}
|
|
2025
|
+
host.qualityLadders.set(cacheKey, found);
|
|
2026
|
+
}
|
|
2027
|
+
return found;
|
|
2028
|
+
}
|
|
1945
2029
|
function toForeignDecl(decl) {
|
|
1946
2030
|
return { name: decl.name, type: decl.type, values: decl.values, stages: decl.stages, default: decl.default, writable: decl.writable };
|
|
1947
2031
|
}
|
|
@@ -1950,6 +2034,12 @@ function selfBackedDecl(decl, scopeWritable) {
|
|
|
1950
2034
|
const writable = decl.writable ?? scopeWritable;
|
|
1951
2035
|
return writable === void 0 ? d : { ...d, writable };
|
|
1952
2036
|
}
|
|
2037
|
+
function resolveScene(host, ref) {
|
|
2038
|
+
return host.sceneGameIdToId.get(ref) ?? (host.bundle.scenes[ref] ? ref : void 0);
|
|
2039
|
+
}
|
|
2040
|
+
function resolveBlock(host, sceneId, ref) {
|
|
2041
|
+
return host.blockGameIdToId.get(sceneId)?.get(ref) ?? (host.blockIndex.get(ref)?.sceneId === sceneId ? ref : void 0);
|
|
2042
|
+
}
|
|
1953
2043
|
function splitHostRef(host, ref) {
|
|
1954
2044
|
if (host.refSplitRevision !== host.registry.revision) {
|
|
1955
2045
|
host.refSplitCache.clear();
|