@patterkit/runtime 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -33,6 +33,29 @@ version number always means the same runtime behaviour. This package is versione
33
33
 
34
34
  ## [Unreleased]
35
35
 
36
+ ## [0.26.0] - 2026-10-14
37
+
38
+ ### Added
39
+
40
+ - **`flow.stepFor(beatId)`**: the step this flow would deliver for a beat right now, built by the same function
41
+ `advance` uses, so it carries the text in the active locale (interpolated, unless the project is voiced, with
42
+ closed captions applied) and the speaker's names and label, its pause, Game Data, and tags. It doesn't move the
43
+ flow, count a visit, or run an effect. Undefined for an id that is neither a beat nor an option's prompt. The
44
+ conformance corpus pins it on every runtime.
45
+
46
+ ## [0.25.0] - 2026-10-14
47
+
48
+ ### Added
49
+
50
+ - **Speaker labels**: a line step, a line choice prompt, and a beat's info carry `speakerLabel`, the name to
51
+ show for the speaker, localised. With a qualifier it is the character's own label for that pair when the
52
+ project gives one (`speaker:<NAME>:<qualifier>`), else the project's speaker pattern (`speaker:pattern`,
53
+ `{name} ({qualifier})` by default) filled with the shown name and the qualifier's shown name; with none, the
54
+ shown name (the token when the cast gives none). Each piece follows the active locale, then the default, then
55
+ the authored value. A translated pair label counts only for a pair the project overrides. Absent with no
56
+ speaker, on a silenced line, and in IDs-only mode. Saved choice prompts carry it, and an older save works it
57
+ out again on load.
58
+
36
59
  ## [0.24.0] - 2026-10-11
37
60
 
38
61
  ### 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/dist/index.cjs CHANGED
@@ -134,6 +134,25 @@ function padIndexFor(bundle) {
134
134
  }
135
135
  return index;
136
136
  }
137
+ var beatIndexCache = /* @__PURE__ */ new WeakMap();
138
+ function beatIndexFor(bundle) {
139
+ let index = beatIndexCache.get(bundle);
140
+ if (index) return index;
141
+ index = /* @__PURE__ */ new Map();
142
+ for (const scene of Object.values(bundle.scenes)) {
143
+ for (const block of scene.blocks) {
144
+ (0, import_model2.walkNodes)(block.children, (n) => {
145
+ if (n.type === "group") {
146
+ if (n.prompt) index.set(n.prompt.id, n.prompt);
147
+ return;
148
+ }
149
+ for (const beat of n.beats ?? []) index.set(beat.id, beat);
150
+ });
151
+ }
152
+ }
153
+ beatIndexCache.set(bundle, index);
154
+ return index;
155
+ }
137
156
  function tagsOf(host, id) {
138
157
  return host.tagIndex.get(id)?.slice();
139
158
  }
@@ -202,6 +221,9 @@ var Engine = class _Engine {
202
221
  for (const c of bundle.cast ?? []) if (c.displayName) castDisplay.set(c.name, c.displayName);
203
222
  const qualifierDisplay = /* @__PURE__ */ new Map();
204
223
  for (const q of bundle.qualifiers ?? []) if (q?.gameId) qualifierDisplay.set(q.gameId, q.name);
224
+ const speakerLabels = /* @__PURE__ */ new Map();
225
+ 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);
226
+ const speakerPattern = bundle.speakerPattern ?? import_model2.DEFAULT_SPEAKER_PATTERN;
205
227
  this.defaultSeed = (options.seed ?? 2654435769) >>> 0;
206
228
  const nodeIndex = /* @__PURE__ */ new Map();
207
229
  const blockIndex = /* @__PURE__ */ new Map();
@@ -263,6 +285,8 @@ var Engine = class _Engine {
263
285
  defaultStrings,
264
286
  castDisplay,
265
287
  qualifierDisplay,
288
+ speakerLabels,
289
+ speakerPattern,
266
290
  nodeIndex,
267
291
  blockIndex,
268
292
  blockById,
@@ -673,6 +697,8 @@ var Engine = class _Engine {
673
697
  const name = this.host.defaultStrings[(0, import_model2.qualifierStringKey)(beat.qualifier)] ?? this.host.qualifierDisplay.get(beat.qualifier);
674
698
  if (name !== void 0) info.qualifierName = name;
675
699
  }
700
+ const label = speakerLabelOf(this.host, beat.character, beat.qualifier, true);
701
+ if (label !== void 0) info.speakerLabel = label;
676
702
  }
677
703
  if (beat.kind === "line" || beat.kind === "text") {
678
704
  const pad = this.host.padIndex.get(beat.id);
@@ -1379,6 +1405,18 @@ var Flow = class {
1379
1405
  getVisitCounts() {
1380
1406
  return Object.fromEntries(this.visitCounts);
1381
1407
  }
1408
+ /**
1409
+ * The step this flow would deliver for beat `beatId` right now, as `advance` would give it: its text in the
1410
+ * active locale, interpolated against this flow's values (unless the project is voiced), closed captions
1411
+ * applied, and the speaker's names and label, its pause, Game Data, and tags. Built by the same function that
1412
+ * builds a delivered step, so the two can't differ. It doesn't move the flow, count a visit, or run an effect.
1413
+ * Undefined for an id that is neither a beat nor an option's prompt. For a game that keeps lines by id and
1414
+ * shows them again (after a load, or a language switch).
1415
+ */
1416
+ stepFor(beatId) {
1417
+ const beat = beatIndexFor(this.host.bundle).get(beatId);
1418
+ return beat ? this.beatResult(beat) : void 0;
1419
+ }
1382
1420
  /** The options of a pending choice (empty when not at a choice point). */
1383
1421
  getChoices() {
1384
1422
  return this.pendingChoice?.options ?? [];
@@ -1511,7 +1549,8 @@ var Flow = class {
1511
1549
  if (!node) continue;
1512
1550
  byId.set(o.id, node);
1513
1551
  const { prompt: saved, ...rest } = o;
1514
- options.push({ ...rest, ...saved ? { prompt: { ...saved, padAfter: saved.padAfter ?? this.promptPadOf(node) } } : {} });
1552
+ const label = saved?.kind === "line" && saved.speakerLabel === void 0 ? speakerLabelOf(this.host, saved.character, saved.qualifier) : void 0;
1553
+ options.push({ ...rest, ...saved ? { prompt: { ...saved, padAfter: saved.padAfter ?? this.promptPadOf(node), ...label !== void 0 ? { speakerLabel: label } : {} } } : {} });
1515
1554
  }
1516
1555
  if (options.length > 0) this.pendingChoice = { groupId: c.pendingChoice.groupId, options, byId };
1517
1556
  }
@@ -1520,7 +1559,9 @@ var Flow = class {
1520
1559
  if (this.pendingPromptOwnerId) {
1521
1560
  const owner = this.host.nodeIndex.get(this.pendingPromptOwnerId);
1522
1561
  const beat = owner ? this.authoredPromptOf(owner) : void 0;
1523
- if (beat) this.pendingPrompt = { beat, ...c.pendingPrompt ? { shown: { ...c.pendingPrompt, padAfter: c.pendingPrompt.padAfter ?? this.padOf(beat.id) } } : {} };
1562
+ const saved = c.pendingPrompt;
1563
+ const label = saved?.kind === "line" && saved.speakerLabel === void 0 ? speakerLabelOf(this.host, saved.character, saved.qualifier) : void 0;
1564
+ if (beat) this.pendingPrompt = { beat, ...saved ? { shown: { ...saved, padAfter: saved.padAfter ?? this.padOf(beat.id), ...label !== void 0 ? { speakerLabel: label } : {} } } : {} };
1524
1565
  else this.pendingPromptOwnerId = null;
1525
1566
  }
1526
1567
  }
@@ -1906,6 +1947,7 @@ var Flow = class {
1906
1947
  direction: silent ? void 0 : beat.direction,
1907
1948
  qualifier: silent ? void 0 : beat.qualifier,
1908
1949
  qualifierName: silent ? void 0 : this.resolveQualifierName(beat.qualifier),
1950
+ speakerLabel: silent ? void 0 : speakerLabelOf(this.host, beat.character, beat.qualifier),
1909
1951
  padAfter: this.padOf(beat.id),
1910
1952
  // a silent line still fires, so it still carries its pause
1911
1953
  gameData: beat.gameData,
@@ -1955,6 +1997,7 @@ var Flow = class {
1955
1997
  direction: beat.direction,
1956
1998
  qualifier: beat.qualifier,
1957
1999
  qualifierName: this.resolveQualifierName(beat.qualifier),
2000
+ speakerLabel: speakerLabelOf(this.host, beat.character, beat.qualifier),
1958
2001
  padAfter
1959
2002
  } : { kind: "text", text, padAfter };
1960
2003
  }
@@ -1977,6 +2020,7 @@ var Flow = class {
1977
2020
  direction: shown.direction,
1978
2021
  qualifier: shown.qualifier,
1979
2022
  qualifierName: shown.qualifierName,
2023
+ speakerLabel: shown.speakerLabel,
1980
2024
  padAfter: this.padOf(beat.id),
1981
2025
  gameData: beat.gameData,
1982
2026
  ...withTags
@@ -2122,6 +2166,16 @@ function collectCast(nodes, out) {
2122
2166
  for (const beat of n.beats ?? []) if (beat.kind === "line" && beat.character) out.add(beat.character);
2123
2167
  });
2124
2168
  }
2169
+ function speakerLabelOf(host, character, qualifier, source = false) {
2170
+ if (!character || host.emitIds) return void 0;
2171
+ const lookup = (key) => (source ? void 0 : host.strings[key]) ?? host.defaultStrings[key];
2172
+ const name = lookup((0, import_model2.castStringKey)(character)) ?? host.castDisplay.get(character) ?? character;
2173
+ if (qualifier === void 0) return name;
2174
+ const own = host.speakerLabels.get(`${character}\0${qualifier}`);
2175
+ if (own !== void 0) return lookup((0, import_model2.speakerLabelKey)(character, qualifier)) ?? own;
2176
+ const shown = lookup((0, import_model2.qualifierStringKey)(qualifier)) ?? host.qualifierDisplay.get(qualifier) ?? qualifier;
2177
+ return (0, import_model2.formatSpeakerLabel)(lookup(import_model2.SPEAKER_PATTERN_KEY) ?? host.speakerPattern, name, shown);
2178
+ }
2125
2179
  function gameDataField(gameData) {
2126
2180
  return gameData && Object.keys(gameData).length ? { gameData } : {};
2127
2181
  }
@@ -2132,6 +2186,7 @@ function savedPrompt(p) {
2132
2186
  if (p.direction !== void 0) out.direction = p.direction;
2133
2187
  if (p.qualifier !== void 0) out.qualifier = p.qualifier;
2134
2188
  if (p.qualifierName !== void 0) out.qualifierName = p.qualifierName;
2189
+ if (p.speakerLabel !== void 0) out.speakerLabel = p.speakerLabel;
2135
2190
  if (typeof p.padAfter === "number") out.padAfter = p.padAfter;
2136
2191
  return out;
2137
2192
  }