@patterkit/runtime 0.23.0 → 0.25.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 +25 -0
- package/LICENSE +21 -0
- package/README.md +4 -0
- package/dist/index.cjs +83 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +84 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -33,6 +33,31 @@ version number always means the same runtime behaviour. This package is versione
|
|
|
33
33
|
|
|
34
34
|
## [Unreleased]
|
|
35
35
|
|
|
36
|
+
## [0.25.0] - 2026-10-14
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **Speaker labels**: a line step, a line choice prompt, and a beat's info carry `speakerLabel`, the name to
|
|
41
|
+
show for the speaker, localised. With a qualifier it is the character's own label for that pair when the
|
|
42
|
+
project gives one (`speaker:<NAME>:<qualifier>`), else the project's speaker pattern (`speaker:pattern`,
|
|
43
|
+
`{name} ({qualifier})` by default) filled with the shown name and the qualifier's shown name; with none, the
|
|
44
|
+
shown name (the token when the cast gives none). Each piece follows the active locale, then the default, then
|
|
45
|
+
the authored value. A translated pair label counts only for a pair the project overrides. Absent with no
|
|
46
|
+
speaker, on a silenced line, and in IDs-only mode. Saved choice prompts carry it, and an older save works it
|
|
47
|
+
out again on load.
|
|
48
|
+
|
|
49
|
+
## [0.24.0] - 2026-10-11
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **Flow capture and restore**: `engine.captureFlow(id)` takes one flow out as a `FlowState`, plain data in the
|
|
54
|
+
save's own spelling (its snapshot, a `version`, and its own values as a version 2 save's per-flow `scopes`
|
|
55
|
+
and `sceneBags`, without its id), and `engine.restoreFlow(id, state)` opens it again under any id, as
|
|
56
|
+
`loadGame` restores each flow. Shared state isn't in it, so a restore rewinds nothing else, and anything a
|
|
57
|
+
load left waiting under the id is dropped first, as when a flow starts. Refused over an open id, while a
|
|
58
|
+
checkpoint is open, and for a state with no cursor, changing nothing; capturing is allowed in a checkpoint.
|
|
59
|
+
The conformance corpus holds every runtime to it.
|
|
60
|
+
|
|
36
61
|
## [0.23.0] - 2026-10-10
|
|
37
62
|
|
|
38
63
|
### Added
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ian Thomas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -110,6 +110,10 @@ value); `engine.loadGame(blob)` restores it. Saves from before property values m
|
|
|
110
110
|
registry (version 2) still load. [@patterkit/play-helpers](../play-helpers) wraps these as
|
|
111
111
|
`serializeState` / `deserializeState` with a tagged envelope.
|
|
112
112
|
|
|
113
|
+
To set one flow aside instead, `engine.captureFlow(id)` returns its part of a save as plain data (its
|
|
114
|
+
position, PRNG, visits, selectors, and its own property values, without its id); close it after capturing,
|
|
115
|
+
and `engine.restoreFlow(newId, state)` opens it again later, under any id, without rewinding shared state.
|
|
116
|
+
|
|
113
117
|
## One registry per game
|
|
114
118
|
|
|
115
119
|
A game has one `ScopeRegistry` holding every property from every engine, except values the game
|
package/dist/index.cjs
CHANGED
|
@@ -139,6 +139,7 @@ function tagsOf(host, id) {
|
|
|
139
139
|
}
|
|
140
140
|
var OWNER = "Patter";
|
|
141
141
|
var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
|
|
142
|
+
var unesc = (key) => key.replace(/%2F/g, "/").replace(/%25/g, "%");
|
|
142
143
|
var OWNS_REGISTRY = /* @__PURE__ */ Symbol("ownsRegistry");
|
|
143
144
|
var keys = {
|
|
144
145
|
/** A scene's SHARED `@scene` props (one bag per scene, every flow's). */
|
|
@@ -201,6 +202,9 @@ var Engine = class _Engine {
|
|
|
201
202
|
for (const c of bundle.cast ?? []) if (c.displayName) castDisplay.set(c.name, c.displayName);
|
|
202
203
|
const qualifierDisplay = /* @__PURE__ */ new Map();
|
|
203
204
|
for (const q of bundle.qualifiers ?? []) if (q?.gameId) qualifierDisplay.set(q.gameId, q.name);
|
|
205
|
+
const speakerLabels = /* @__PURE__ */ new Map();
|
|
206
|
+
for (const c of bundle.cast ?? []) for (const [q, label] of Object.entries(c.qualifierLabels ?? {})) if (typeof label === "string") speakerLabels.set(`${c.name}\0${q}`, label);
|
|
207
|
+
const speakerPattern = bundle.speakerPattern ?? import_model2.DEFAULT_SPEAKER_PATTERN;
|
|
204
208
|
this.defaultSeed = (options.seed ?? 2654435769) >>> 0;
|
|
205
209
|
const nodeIndex = /* @__PURE__ */ new Map();
|
|
206
210
|
const blockIndex = /* @__PURE__ */ new Map();
|
|
@@ -262,6 +266,8 @@ var Engine = class _Engine {
|
|
|
262
266
|
defaultStrings,
|
|
263
267
|
castDisplay,
|
|
264
268
|
qualifierDisplay,
|
|
269
|
+
speakerLabels,
|
|
270
|
+
speakerPattern,
|
|
265
271
|
nodeIndex,
|
|
266
272
|
blockIndex,
|
|
267
273
|
blockById,
|
|
@@ -672,6 +678,8 @@ var Engine = class _Engine {
|
|
|
672
678
|
const name = this.host.defaultStrings[(0, import_model2.qualifierStringKey)(beat.qualifier)] ?? this.host.qualifierDisplay.get(beat.qualifier);
|
|
673
679
|
if (name !== void 0) info.qualifierName = name;
|
|
674
680
|
}
|
|
681
|
+
const label = speakerLabelOf(this.host, beat.character, beat.qualifier, true);
|
|
682
|
+
if (label !== void 0) info.speakerLabel = label;
|
|
675
683
|
}
|
|
676
684
|
if (beat.kind === "line" || beat.kind === "text") {
|
|
677
685
|
const pad = this.host.padIndex.get(beat.id);
|
|
@@ -767,6 +775,45 @@ var Engine = class _Engine {
|
|
|
767
775
|
refuseInCheckpoint(what) {
|
|
768
776
|
if (this.host.journal) throw new Error(`${what} can't be undone, so it isn't allowed while a checkpoint is open`);
|
|
769
777
|
}
|
|
778
|
+
/**
|
|
779
|
+
* Take flow `id` out of the engine as plain data, to be put back later with {@link restoreFlow}: its snapshot as
|
|
780
|
+
* `saveGame` writes it, and its own property values without its id (so it can come back under another). Shared
|
|
781
|
+
* state is never in it. Doesn't close the flow; close it with {@link closeFlow} AFTER capturing, since closing
|
|
782
|
+
* drops its values. Only reads, so it is allowed while a checkpoint is open.
|
|
783
|
+
*/
|
|
784
|
+
captureFlow(id) {
|
|
785
|
+
const flow = this.flowsById.get(id);
|
|
786
|
+
if (!flow) throw new Error(`no open flow '${id}' to capture`);
|
|
787
|
+
return { version: import_model3.SAVE_VERSION, ...flow.snapshot(), ...flowValuesFrom(id, this.host.registry.save()) };
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Open flow `id` from a {@link captureFlow} state, the per-flow half of `loadGame`: its values go into the
|
|
791
|
+
* registry as a load's do, and the flow is restored as a load restores each flow. `id` needn't be the id it was
|
|
792
|
+
* captured from. Against an edited bundle it re-finds its place by id, as a load does. Refused if a flow of
|
|
793
|
+
* that id is open, or while a checkpoint is.
|
|
794
|
+
*/
|
|
795
|
+
restoreFlow(id, state) {
|
|
796
|
+
this.refuseInCheckpoint("restoreFlow");
|
|
797
|
+
const version = state.version;
|
|
798
|
+
if (version !== 2 && version !== import_model3.SAVE_VERSION) throw new Error(`unsupported flow state version: ${String(version)}`);
|
|
799
|
+
const cursor = state.cursor;
|
|
800
|
+
if (typeof cursor !== "object" || cursor === null) throw new Error("malformed flow state: no cursor");
|
|
801
|
+
if (this.flowsById.has(id)) throw new Error(`a flow '${id}' is already open; close it before restoring another under that id`);
|
|
802
|
+
this.assertExternalScopes();
|
|
803
|
+
const parked = keys.flow(id);
|
|
804
|
+
this.host.registry.discardParked(parked);
|
|
805
|
+
this.host.registry.load(flowSections(id, state), { keepParked: true });
|
|
806
|
+
const flow = new Flow(id, this.host, this.defaultSeed);
|
|
807
|
+
try {
|
|
808
|
+
flow.restore(state);
|
|
809
|
+
} catch (e) {
|
|
810
|
+
flow.close();
|
|
811
|
+
this.host.registry.discardParked(parked);
|
|
812
|
+
throw e;
|
|
813
|
+
}
|
|
814
|
+
this.flowsById.set(id, flow);
|
|
815
|
+
return flow;
|
|
816
|
+
}
|
|
770
817
|
/** Close (remove) a flow. The flow object is FINISHED, not merely unregistered, so a host still
|
|
771
818
|
* holding it cannot keep advancing it into the shared world (see {@link Flow.close}). */
|
|
772
819
|
closeFlow(id) {
|
|
@@ -1471,7 +1518,8 @@ var Flow = class {
|
|
|
1471
1518
|
if (!node) continue;
|
|
1472
1519
|
byId.set(o.id, node);
|
|
1473
1520
|
const { prompt: saved, ...rest } = o;
|
|
1474
|
-
|
|
1521
|
+
const label = saved?.kind === "line" && saved.speakerLabel === void 0 ? speakerLabelOf(this.host, saved.character, saved.qualifier) : void 0;
|
|
1522
|
+
options.push({ ...rest, ...saved ? { prompt: { ...saved, padAfter: saved.padAfter ?? this.promptPadOf(node), ...label !== void 0 ? { speakerLabel: label } : {} } } : {} });
|
|
1475
1523
|
}
|
|
1476
1524
|
if (options.length > 0) this.pendingChoice = { groupId: c.pendingChoice.groupId, options, byId };
|
|
1477
1525
|
}
|
|
@@ -1480,7 +1528,9 @@ var Flow = class {
|
|
|
1480
1528
|
if (this.pendingPromptOwnerId) {
|
|
1481
1529
|
const owner = this.host.nodeIndex.get(this.pendingPromptOwnerId);
|
|
1482
1530
|
const beat = owner ? this.authoredPromptOf(owner) : void 0;
|
|
1483
|
-
|
|
1531
|
+
const saved = c.pendingPrompt;
|
|
1532
|
+
const label = saved?.kind === "line" && saved.speakerLabel === void 0 ? speakerLabelOf(this.host, saved.character, saved.qualifier) : void 0;
|
|
1533
|
+
if (beat) this.pendingPrompt = { beat, ...saved ? { shown: { ...saved, padAfter: saved.padAfter ?? this.padOf(beat.id), ...label !== void 0 ? { speakerLabel: label } : {} } } : {} };
|
|
1484
1534
|
else this.pendingPromptOwnerId = null;
|
|
1485
1535
|
}
|
|
1486
1536
|
}
|
|
@@ -1866,6 +1916,7 @@ var Flow = class {
|
|
|
1866
1916
|
direction: silent ? void 0 : beat.direction,
|
|
1867
1917
|
qualifier: silent ? void 0 : beat.qualifier,
|
|
1868
1918
|
qualifierName: silent ? void 0 : this.resolveQualifierName(beat.qualifier),
|
|
1919
|
+
speakerLabel: silent ? void 0 : speakerLabelOf(this.host, beat.character, beat.qualifier),
|
|
1869
1920
|
padAfter: this.padOf(beat.id),
|
|
1870
1921
|
// a silent line still fires, so it still carries its pause
|
|
1871
1922
|
gameData: beat.gameData,
|
|
@@ -1915,6 +1966,7 @@ var Flow = class {
|
|
|
1915
1966
|
direction: beat.direction,
|
|
1916
1967
|
qualifier: beat.qualifier,
|
|
1917
1968
|
qualifierName: this.resolveQualifierName(beat.qualifier),
|
|
1969
|
+
speakerLabel: speakerLabelOf(this.host, beat.character, beat.qualifier),
|
|
1918
1970
|
padAfter
|
|
1919
1971
|
} : { kind: "text", text, padAfter };
|
|
1920
1972
|
}
|
|
@@ -1937,6 +1989,7 @@ var Flow = class {
|
|
|
1937
1989
|
direction: shown.direction,
|
|
1938
1990
|
qualifier: shown.qualifier,
|
|
1939
1991
|
qualifierName: shown.qualifierName,
|
|
1992
|
+
speakerLabel: shown.speakerLabel,
|
|
1940
1993
|
padAfter: this.padOf(beat.id),
|
|
1941
1994
|
gameData: beat.gameData,
|
|
1942
1995
|
...withTags
|
|
@@ -2082,6 +2135,16 @@ function collectCast(nodes, out) {
|
|
|
2082
2135
|
for (const beat of n.beats ?? []) if (beat.kind === "line" && beat.character) out.add(beat.character);
|
|
2083
2136
|
});
|
|
2084
2137
|
}
|
|
2138
|
+
function speakerLabelOf(host, character, qualifier, source = false) {
|
|
2139
|
+
if (!character || host.emitIds) return void 0;
|
|
2140
|
+
const lookup = (key) => (source ? void 0 : host.strings[key]) ?? host.defaultStrings[key];
|
|
2141
|
+
const name = lookup((0, import_model2.castStringKey)(character)) ?? host.castDisplay.get(character) ?? character;
|
|
2142
|
+
if (qualifier === void 0) return name;
|
|
2143
|
+
const own = host.speakerLabels.get(`${character}\0${qualifier}`);
|
|
2144
|
+
if (own !== void 0) return lookup((0, import_model2.speakerLabelKey)(character, qualifier)) ?? own;
|
|
2145
|
+
const shown = lookup((0, import_model2.qualifierStringKey)(qualifier)) ?? host.qualifierDisplay.get(qualifier) ?? qualifier;
|
|
2146
|
+
return (0, import_model2.formatSpeakerLabel)(lookup(import_model2.SPEAKER_PATTERN_KEY) ?? host.speakerPattern, name, shown);
|
|
2147
|
+
}
|
|
2085
2148
|
function gameDataField(gameData) {
|
|
2086
2149
|
return gameData && Object.keys(gameData).length ? { gameData } : {};
|
|
2087
2150
|
}
|
|
@@ -2092,6 +2155,7 @@ function savedPrompt(p) {
|
|
|
2092
2155
|
if (p.direction !== void 0) out.direction = p.direction;
|
|
2093
2156
|
if (p.qualifier !== void 0) out.qualifier = p.qualifier;
|
|
2094
2157
|
if (p.qualifierName !== void 0) out.qualifierName = p.qualifierName;
|
|
2158
|
+
if (p.speakerLabel !== void 0) out.speakerLabel = p.speakerLabel;
|
|
2095
2159
|
if (typeof p.padAfter === "number") out.padAfter = p.padAfter;
|
|
2096
2160
|
return out;
|
|
2097
2161
|
}
|
|
@@ -2170,9 +2234,23 @@ function sectionsFromV2(save) {
|
|
|
2170
2234
|
const out = {};
|
|
2171
2235
|
if (save.shared?.patter) out["patter"] = save.shared.patter;
|
|
2172
2236
|
for (const [s, v] of Object.entries(save.stageBags ?? {})) out[keys.stage(s)] = v;
|
|
2173
|
-
for (const [id, f] of Object.entries(save.flows ?? {}))
|
|
2174
|
-
|
|
2175
|
-
|
|
2237
|
+
for (const [id, f] of Object.entries(save.flows ?? {})) Object.assign(out, flowSections(id, f));
|
|
2238
|
+
return out;
|
|
2239
|
+
}
|
|
2240
|
+
function flowSections(id, f) {
|
|
2241
|
+
const out = {};
|
|
2242
|
+
if (f.scopes?.patter) out[keys.flowGlobals(id)] = f.scopes.patter;
|
|
2243
|
+
for (const [s, v] of Object.entries(f.sceneBags ?? {})) out[keys.flowScene(id, s)] = v;
|
|
2244
|
+
return out;
|
|
2245
|
+
}
|
|
2246
|
+
function flowValuesFrom(id, saved) {
|
|
2247
|
+
const out = { scopes: {}, sceneBags: {} };
|
|
2248
|
+
const prefix = keys.flow(id);
|
|
2249
|
+
for (const [key, values] of Object.entries(saved)) {
|
|
2250
|
+
if (!key.startsWith(prefix)) continue;
|
|
2251
|
+
const rest = key.slice(prefix.length);
|
|
2252
|
+
if (rest === "patter") out.scopes.patter = values;
|
|
2253
|
+
else if (rest.startsWith("scene/")) out.sceneBags[unesc(rest.slice("scene/".length))] = values;
|
|
2176
2254
|
}
|
|
2177
2255
|
return out;
|
|
2178
2256
|
}
|