@yagejs-addons/dialogue 0.1.0 → 0.2.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/dist/index.js ADDED
@@ -0,0 +1,2048 @@
1
+ import {
2
+ EMPTY_PARSED,
3
+ LineReveal,
4
+ firstUnknownTag,
5
+ parseMarkup,
6
+ splitGraphemes,
7
+ stripMarkup
8
+ } from "./chunk-CU47RPEB.js";
9
+ import {
10
+ DialogueExprError,
11
+ DialoguePlayError,
12
+ DialogueScriptError,
13
+ IdentityI18n,
14
+ MemoryVariableStorage,
15
+ analyzeScript,
16
+ cells,
17
+ compose,
18
+ createScope,
19
+ evalCondition,
20
+ evaluate,
21
+ holds,
22
+ interpolate,
23
+ isExpr,
24
+ loadScript,
25
+ materialize,
26
+ parseExpr,
27
+ validatePlay
28
+ } from "./chunk-GJQKZCOL.js";
29
+ import {
30
+ __name
31
+ } from "./chunk-7QVYU63E.js";
32
+
33
+ // src/core/formats/compact.ts
34
+ function parseCompact(text) {
35
+ const lines = text.split(/\r\n|\r|\n/);
36
+ const speakers = {};
37
+ lines.forEach((raw, i) => {
38
+ const line = raw.trim();
39
+ if (!line.startsWith("@")) return;
40
+ const def = parseSpeaker(line, i + 1);
41
+ if (Object.hasOwn(speakers, def.id)) {
42
+ fail(i + 1, `duplicate speaker "${def.id}"`);
43
+ }
44
+ speakers[def.id] = def;
45
+ });
46
+ let id;
47
+ const nodes = {};
48
+ const nodeOrder = [];
49
+ let current = null;
50
+ let choiceRun = null;
51
+ const declares = {};
52
+ const flushChoice = /* @__PURE__ */ __name(() => {
53
+ if (choiceRun && current) current.steps.push({ kind: "choice", options: choiceRun });
54
+ choiceRun = null;
55
+ }, "flushChoice");
56
+ lines.forEach((raw, i) => {
57
+ const lineNo = i + 1;
58
+ const line = raw.trim();
59
+ if (line === "" || line.startsWith("//")) return;
60
+ if (line.startsWith("@")) return;
61
+ if (line.startsWith("#")) {
62
+ flushChoice();
63
+ const newId = line.slice(1).trim();
64
+ if (!newId) fail(lineNo, "'#' script id directive needs an id");
65
+ if (id !== void 0) fail(lineNo, `duplicate '#' script id directive (already "${id}")`);
66
+ id = newId;
67
+ return;
68
+ }
69
+ if (line.startsWith("::")) {
70
+ flushChoice();
71
+ const nodeId = line.slice(2).trim();
72
+ if (!nodeId) fail(lineNo, "':: ' node directive needs an id");
73
+ if (Object.hasOwn(nodes, nodeId)) fail(lineNo, `duplicate node "${nodeId}"`);
74
+ current = { id: nodeId, steps: [] };
75
+ nodes[nodeId] = current;
76
+ nodeOrder.push(nodeId);
77
+ return;
78
+ }
79
+ if (line.startsWith("?")) {
80
+ if (!current) fail(lineNo, "choice '?' appears before any ':: <node>'");
81
+ (choiceRun ??= []).push(parseChoice(line.slice(1).trim(), lineNo));
82
+ return;
83
+ }
84
+ flushChoice();
85
+ const decl = parseDeclare(line);
86
+ if (decl) {
87
+ declares[decl.name] = decl.value;
88
+ return;
89
+ }
90
+ const node = current;
91
+ if (!node) fail(lineNo, `dialogue line appears before any ':: <node>' ("${line}")`);
92
+ if (line.startsWith("->")) {
93
+ node.steps.push(parseGoto(line, lineNo));
94
+ return;
95
+ }
96
+ const set = parseSet(line);
97
+ if (set) {
98
+ node.steps.push(set);
99
+ return;
100
+ }
101
+ const cmd = parseDo(line, lineNo);
102
+ if (cmd) {
103
+ node.steps.push(cmd);
104
+ return;
105
+ }
106
+ if (line === "end") {
107
+ node.steps.push({ kind: "end" });
108
+ return;
109
+ }
110
+ node.steps.push(parseSay(line, lineNo, speakers));
111
+ });
112
+ flushChoice();
113
+ if (id === void 0) throw new DialogueScriptError("compact: missing '# <id>' script directive");
114
+ if (nodeOrder.length === 0) {
115
+ throw new DialogueScriptError(`compact: script "${id}" has no ':: <node>' nodes`);
116
+ }
117
+ return {
118
+ id,
119
+ start: nodeOrder[0],
120
+ nodes,
121
+ ...Object.keys(speakers).length > 0 ? { speakers } : {},
122
+ ...Object.keys(declares).length > 0 ? { declare: declares } : {}
123
+ };
124
+ }
125
+ __name(parseCompact, "parseCompact");
126
+ function loadCompact(text) {
127
+ return loadScript(parseCompact(text));
128
+ }
129
+ __name(loadCompact, "loadCompact");
130
+ function parseSpeaker(line, lineNo) {
131
+ const tokens = line.slice(1).trim().split(/\s+/).filter(Boolean);
132
+ const id = tokens[0];
133
+ if (!id) fail(lineNo, "'@' speaker directive needs an id");
134
+ let nameTokens = tokens.slice(1);
135
+ let color;
136
+ const last = nameTokens[nameTokens.length - 1];
137
+ if (last !== void 0 && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(last)) {
138
+ color = hexColor(last);
139
+ nameTokens = nameTokens.slice(0, -1);
140
+ }
141
+ return {
142
+ id,
143
+ name: nameTokens.length > 0 ? nameTokens.join(" ") : id,
144
+ ...color !== void 0 ? { color } : {}
145
+ };
146
+ }
147
+ __name(parseSpeaker, "parseSpeaker");
148
+ function hexColor(token) {
149
+ let hex = token.slice(1);
150
+ if (hex.length === 3) hex = hex.replace(/./g, "$&$&");
151
+ return parseInt(hex, 16);
152
+ }
153
+ __name(hexColor, "hexColor");
154
+ function parseGoto(line, lineNo) {
155
+ const m = /^->\s*(\S+)(?:\s+if:\s*(.+))?\s*$/.exec(line);
156
+ if (!m) fail(lineNo, "'->' goto needs a target node id (optionally `-> node if: cond`)");
157
+ const target = m[1];
158
+ if (m[2] !== void 0) {
159
+ return { kind: "command", commands: [], condition: parseExpr(m[2].trim()), target };
160
+ }
161
+ return { kind: "goto", target };
162
+ }
163
+ __name(parseGoto, "parseGoto");
164
+ function parseDeclare(line) {
165
+ const m = /^declare\s+([A-Za-z_$][A-Za-z0-9_.$]*)\s*=\s*(\S.*)$/.exec(line);
166
+ if (!m) return null;
167
+ return { name: m[1], value: scalar(m[2].trim()) };
168
+ }
169
+ __name(parseDeclare, "parseDeclare");
170
+ function parseSet(line) {
171
+ const m = /^set\s+([A-Za-z_$][A-Za-z0-9_.$]*)\s*=\s*(\S.*)$/.exec(line);
172
+ if (!m) return null;
173
+ return {
174
+ kind: "command",
175
+ commands: [{ type: "set", var: m[1], value: setValue(m[2].trim()) }]
176
+ };
177
+ }
178
+ __name(parseSet, "parseSet");
179
+ function setValue(rhs) {
180
+ const literal = numberBoolNull(rhs);
181
+ return literal === NOT_LITERAL ? parseExpr(rhs) : literal;
182
+ }
183
+ __name(setValue, "setValue");
184
+ function parseDo(line, lineNo) {
185
+ if (!/^do(\s|$)/.test(line)) return null;
186
+ const tokens = splitArgs(line.slice(2).trim());
187
+ const type = tokens[0];
188
+ if (type === void 0 || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(type)) return null;
189
+ const command = { type };
190
+ let typeKeyCollision = false;
191
+ for (const tok of tokens.slice(1)) {
192
+ if (tok.startsWith("#")) {
193
+ const flag = tok.slice(1);
194
+ if (!flag) return null;
195
+ if (flag === "type") typeKeyCollision = true;
196
+ else command[flag] = true;
197
+ continue;
198
+ }
199
+ const eq = tok.indexOf("=");
200
+ if (eq <= 0) return null;
201
+ const key = tok.slice(0, eq);
202
+ if (key === "type") typeKeyCollision = true;
203
+ else command[key] = scalar(tok.slice(eq + 1));
204
+ }
205
+ if (typeKeyCollision) {
206
+ fail(lineNo, `'do' data key "type" collides with the command type (the leading token); rename it`);
207
+ }
208
+ return { kind: "command", commands: [command] };
209
+ }
210
+ __name(parseDo, "parseDo");
211
+ function parseSay(line, lineNo, speakers) {
212
+ let speaker;
213
+ let expression;
214
+ let body = line;
215
+ const colon = line.indexOf(":");
216
+ if (colon !== -1) {
217
+ const header = line.slice(0, colon).trim();
218
+ const tokens = header.length > 0 ? header.split(/\s+/) : [];
219
+ const first = tokens[0];
220
+ if (first !== void 0 && Object.hasOwn(speakers, first)) {
221
+ if (tokens.length > 2) {
222
+ fail(lineNo, `speaker header "${header}" has too many tokens (use "speaker [face]: text")`);
223
+ }
224
+ speaker = first;
225
+ expression = tokens[1];
226
+ body = line.slice(colon + 1).trimStart();
227
+ }
228
+ }
229
+ const { text, fields, meta } = peelSayHints(body, lineNo);
230
+ return {
231
+ kind: "say",
232
+ ...speaker !== void 0 ? { speaker } : {},
233
+ ...expression !== void 0 ? { expression } : {},
234
+ text,
235
+ ...fields,
236
+ ...meta ? { meta } : {}
237
+ };
238
+ }
239
+ __name(parseSay, "parseSay");
240
+ function peelSayHints(body, lineNo) {
241
+ let rest = body;
242
+ const fields = {};
243
+ const meta = {};
244
+ let metaCount = 0;
245
+ for (; ; ) {
246
+ const hash = /(^|\s)#(\S+)\s*$/.exec(rest);
247
+ if (hash) {
248
+ const tag = hash[2];
249
+ const lk = lineKey(tag);
250
+ if (lk !== void 0) fields.key = lk;
251
+ else metaCount += applyHashtag(meta, tag);
252
+ rest = rest.slice(0, hash.index).replace(/\s+$/, "");
253
+ continue;
254
+ }
255
+ const hint = /(^|\s)(view|voice|speed|auto)=(\S+)\s*$/.exec(rest);
256
+ if (hint) {
257
+ applySayField(fields, hint[2], hint[3], lineNo);
258
+ rest = rest.slice(0, hint.index).replace(/\s+$/, "");
259
+ continue;
260
+ }
261
+ break;
262
+ }
263
+ return { text: rest, fields, meta: metaCount > 0 ? meta : void 0 };
264
+ }
265
+ __name(peelSayHints, "peelSayHints");
266
+ function applySayField(fields, key, value, lineNo) {
267
+ switch (key) {
268
+ case "view":
269
+ fields.view = unquote(value);
270
+ return;
271
+ case "voice":
272
+ fields.voice = unquote(value);
273
+ return;
274
+ case "speed":
275
+ fields.speed = numberHint(value, lineNo, "speed");
276
+ return;
277
+ case "auto":
278
+ fields.autoAdvanceMs = numberHint(value, lineNo, "auto");
279
+ return;
280
+ }
281
+ }
282
+ __name(applySayField, "applySayField");
283
+ function numberHint(value, lineNo, key) {
284
+ const n = Number(value);
285
+ if (!Number.isFinite(n)) fail(lineNo, `'${key}=' expects a number, got "${value}"`);
286
+ return n;
287
+ }
288
+ __name(numberHint, "numberHint");
289
+ function parseChoice(body, lineNo) {
290
+ let rest = body;
291
+ const meta = {};
292
+ let metaCount = 0;
293
+ let once = false;
294
+ let disabled = false;
295
+ let target;
296
+ let condition;
297
+ let key;
298
+ for (; ; ) {
299
+ const hash = /(^|\s)#(\S+)\s*$/.exec(rest);
300
+ if (!hash) break;
301
+ const tag = hash[2];
302
+ const lk = lineKey(tag);
303
+ if (tag === "once") once = true;
304
+ else if (tag === "disabled") disabled = true;
305
+ else if (lk !== void 0) key = lk;
306
+ else metaCount += applyHashtag(meta, tag);
307
+ rest = rest.slice(0, hash.index).replace(/\s+$/, "");
308
+ }
309
+ const arrow = /(^|\s)->\s*(\S+)\s*$/.exec(rest);
310
+ const named = arrow ? null : /(^|\s)target=(\S+)\s*$/.exec(rest);
311
+ if (arrow) {
312
+ target = arrow[2];
313
+ rest = rest.slice(0, arrow.index).replace(/\s+$/, "");
314
+ } else if (named) {
315
+ target = named[2];
316
+ rest = rest.slice(0, named.index).replace(/\s+$/, "");
317
+ }
318
+ const ifm = /(^|\s)if:\s*(.+)$/.exec(rest);
319
+ if (ifm) {
320
+ const condStr = ifm[2].trim();
321
+ if (!condStr) fail(lineNo, "choice 'if:' has no condition");
322
+ condition = parseExpr(condStr);
323
+ rest = rest.slice(0, ifm.index).replace(/\s+$/, "");
324
+ }
325
+ const text = rest.trim();
326
+ if (!text) fail(lineNo, "choice has no text");
327
+ const unknown = firstUnknownTag(text);
328
+ if (unknown !== null) {
329
+ fail(
330
+ lineNo,
331
+ `unrecognized markup tag "[${unknown}]" in choice text \u2014 '[..]' is for inline markup only; write choice attributes as 'if: \u2026', '-> node', or '#flag'`
332
+ );
333
+ }
334
+ return {
335
+ text,
336
+ ...key !== void 0 ? { key } : {},
337
+ ...condition !== void 0 ? { condition } : {},
338
+ ...target !== void 0 ? { target } : {},
339
+ ...once ? { once: true } : {},
340
+ ...disabled ? { presentation: "disabled" } : {},
341
+ ...metaCount > 0 ? { meta } : {}
342
+ };
343
+ }
344
+ __name(parseChoice, "parseChoice");
345
+ function applyHashtag(meta, tag) {
346
+ const colon = tag.indexOf(":");
347
+ if (colon === -1) meta[tag] = true;
348
+ else meta[tag.slice(0, colon)] = scalar(tag.slice(colon + 1));
349
+ return 1;
350
+ }
351
+ __name(applyHashtag, "applyHashtag");
352
+ function lineKey(tag) {
353
+ const colon = tag.indexOf(":");
354
+ return colon > 0 && tag.slice(0, colon) === "line" ? tag.slice(colon + 1) : void 0;
355
+ }
356
+ __name(lineKey, "lineKey");
357
+ var NOT_LITERAL = /* @__PURE__ */ Symbol("not-literal");
358
+ function numberBoolNull(raw) {
359
+ if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
360
+ if (raw === "true") return true;
361
+ if (raw === "false") return false;
362
+ if (raw === "null") return null;
363
+ return NOT_LITERAL;
364
+ }
365
+ __name(numberBoolNull, "numberBoolNull");
366
+ function scalar(raw) {
367
+ const lit = numberBoolNull(raw);
368
+ return lit === NOT_LITERAL ? unquote(raw) : lit;
369
+ }
370
+ __name(scalar, "scalar");
371
+ function unquote(raw) {
372
+ if (raw.length >= 2 && (raw.startsWith('"') && raw.endsWith('"') || raw.startsWith("'") && raw.endsWith("'"))) {
373
+ return raw.slice(1, -1);
374
+ }
375
+ return raw;
376
+ }
377
+ __name(unquote, "unquote");
378
+ function splitArgs(s) {
379
+ const out = [];
380
+ let i = 0;
381
+ while (i < s.length) {
382
+ while (i < s.length && /\s/.test(s[i])) i++;
383
+ if (i >= s.length) break;
384
+ let tok = "";
385
+ while (i < s.length && !/\s/.test(s[i])) {
386
+ const c = s[i];
387
+ if (c === '"' || c === "'") {
388
+ tok += c;
389
+ i++;
390
+ while (i < s.length && s[i] !== c) tok += s[i++];
391
+ if (i < s.length) tok += s[i++];
392
+ } else {
393
+ tok += c;
394
+ i++;
395
+ }
396
+ }
397
+ out.push(tok);
398
+ }
399
+ return out;
400
+ }
401
+ __name(splitArgs, "splitArgs");
402
+ function fail(lineNo, message) {
403
+ throw new DialogueScriptError(`compact: line ${lineNo}: ${message}`);
404
+ }
405
+ __name(fail, "fail");
406
+
407
+ // src/core/defineScript.ts
408
+ function defineScript(script) {
409
+ return script;
410
+ }
411
+ __name(defineScript, "defineScript");
412
+
413
+ // src/core/runner.ts
414
+ var DialogueRunner = class {
415
+ constructor(script, env, handlers) {
416
+ this.script = script;
417
+ this.handlers = handlers;
418
+ this.nodeId = script.start;
419
+ this.storage = env.storage;
420
+ this.scope = createScope(env.storage, env.functions);
421
+ this.onError = env.onError;
422
+ }
423
+ script;
424
+ handlers;
425
+ static {
426
+ __name(this, "DialogueRunner");
427
+ }
428
+ /** `option.once` keys already picked — per-conversation **cursor** state, NOT
429
+ * the variable storage. Fresh per runner, so a new `play()` starts it empty
430
+ * (a re-played conversation re-shows its `once` options; {@link getChosenOnce}
431
+ * exposes the set so a save cursor could capture/restore it). */
432
+ chosenOnce = /* @__PURE__ */ new Set();
433
+ nodeId;
434
+ stepIndex = 0;
435
+ state = "idle";
436
+ /** "play" normally; `skip()` flips it to "skip" to fast-forward the section. */
437
+ runMode = "play";
438
+ /** Storage (write through this so a read-only `cells` accessor throws) +
439
+ * functions, wrapped once as the condition/`set`-value eval scope. */
440
+ storage;
441
+ scope;
442
+ onError;
443
+ /** Snapshot of the storage's variables — the `handle.getVars()` /
444
+ * future save-cursor view. */
445
+ getVars() {
446
+ return materialize(this.storage);
447
+ }
448
+ // ── save seam (read-only cursor getters) ──────────────────────────────────
449
+ // The runner's durable cursor is (nodeId, stepIndex, chosenOnce) + getVars().
450
+ // These getters exist so a future `SnapshotContributor` can capture/restore a
451
+ // conversation WITHOUT a breaking API change. Snapshot/restore itself is
452
+ // deliberately NOT built yet — keep these read-only.
453
+ /** Current node id (durable cursor; save seam). */
454
+ getNodeId() {
455
+ return this.nodeId;
456
+ }
457
+ /** Current step index within the node (durable cursor; save seam). */
458
+ getStepIndex() {
459
+ return this.stepIndex;
460
+ }
461
+ /** One-shot choice keys already picked (`option.once`); save seam. */
462
+ getChosenOnce() {
463
+ return this.chosenOnce;
464
+ }
465
+ isEnded() {
466
+ return this.state === "ended";
467
+ }
468
+ /** Begin at the start node. Idempotent guard against double-start. The cursor
469
+ * (`nodeId`/`stepIndex`) is already at the start from the ctor + field init. */
470
+ start() {
471
+ if (this.state !== "idle") return;
472
+ void this.run();
473
+ }
474
+ /** Advance past the current `say` line. No-op unless we're awaiting it. */
475
+ advance() {
476
+ if (this.state !== "saying") return;
477
+ this.stepIndex++;
478
+ void this.run();
479
+ }
480
+ /**
481
+ * Fast-forward from the current line: run intervening commands in `skip` mode
482
+ * (so the game can reconstruct world state idempotently) without presenting
483
+ * any lines, stopping at the next choice or the end. No-op unless on a line.
484
+ */
485
+ async skip() {
486
+ if (this.state !== "saying") return;
487
+ this.runMode = "skip";
488
+ this.stepIndex++;
489
+ await this.run();
490
+ }
491
+ /**
492
+ * Public, **wait-state-free** entry the Session uses to fire a `say` line's
493
+ * commands at show / after-reveal / advance time. Handles built-in `set`,
494
+ * surfaces the rest with the current mode (or `mode`, when the Session fires the
495
+ * displayed line's batches as part of its own skip), and awaits `blocking` ones.
496
+ * Delegates to {@link executeBatch}; the runner's wait-state is untouched (the
497
+ * Session gates its own input).
498
+ */
499
+ runCommands(commands, mode) {
500
+ return this.executeBatch(commands, mode);
501
+ }
502
+ /** Pick choice `index` (the original option index). */
503
+ async choose(index) {
504
+ if (this.state !== "choosing") return;
505
+ const step = this.currentStep();
506
+ if (!step || step.kind !== "choice") return;
507
+ const option = step.options[index];
508
+ if (!option || !this.choiceEnabled(step, index, option)) return;
509
+ if (option.once) this.chosenOnce.add(this.onceKey(step, index));
510
+ this.state = "awaiting-command";
511
+ await this.fireBatch(option.commands);
512
+ if (this.isEnded()) return;
513
+ if (option.target !== void 0) this.jump(option.target);
514
+ else this.stepIndex++;
515
+ void this.run();
516
+ }
517
+ // ── internal ────────────────────────────────────────────────────────────
518
+ /** Run non-blocking steps until we hit one that needs input, or the end. */
519
+ async run() {
520
+ for (; ; ) {
521
+ if (this.isEnded()) return;
522
+ const step = this.currentStep();
523
+ if (!step) {
524
+ this.end();
525
+ return;
526
+ }
527
+ if (await this.handleStep(step)) return;
528
+ }
529
+ }
530
+ /** @returns true if the step blocks (waiting for advance/choose/command/end). */
531
+ async handleStep(step) {
532
+ switch (step.kind) {
533
+ case "say": {
534
+ if (this.runMode === "skip") {
535
+ await this.fireBatch(step.commands);
536
+ if (this.isEnded()) return true;
537
+ this.stepIndex++;
538
+ return false;
539
+ }
540
+ this.state = "saying";
541
+ this.handlers.onSay(step, this.speaker(step.speaker));
542
+ return true;
543
+ }
544
+ case "choice": {
545
+ const choices = this.resolveChoices(step);
546
+ if (!choices.some((c) => !c.disabled)) {
547
+ this.stepIndex++;
548
+ return false;
549
+ }
550
+ this.runMode = "play";
551
+ this.state = "choosing";
552
+ this.handlers.onChoice(step, choices, this.speaker(step.speaker));
553
+ return true;
554
+ }
555
+ case "command": {
556
+ await this.fireBatch(step.commands);
557
+ if (this.state === "ended") return true;
558
+ if (step.target !== void 0 && this.test(step.condition)) {
559
+ this.jump(step.target);
560
+ return false;
561
+ }
562
+ this.stepIndex++;
563
+ return false;
564
+ }
565
+ case "goto":
566
+ this.jump(step.target);
567
+ return false;
568
+ case "end":
569
+ this.end();
570
+ return true;
571
+ }
572
+ }
573
+ jump(target) {
574
+ this.nodeId = target;
575
+ this.stepIndex = 0;
576
+ }
577
+ end() {
578
+ if (this.state === "ended") return;
579
+ this.state = "ended";
580
+ this.handlers.onEnd();
581
+ }
582
+ currentStep() {
583
+ return this.script.nodes[this.nodeId]?.steps[this.stepIndex];
584
+ }
585
+ speaker(id) {
586
+ return id ? this.script.speakers?.[id] : void 0;
587
+ }
588
+ /**
589
+ * Resolve a choice step to its visible rows. A spent `once` option is always
590
+ * dropped (presentation governs condition failures only). A passing option is
591
+ * enabled; a failing one is returned as a `disabled` row when its
592
+ * `presentation` is `"disabled"`, else dropped (the default `"hidden"`).
593
+ */
594
+ resolveChoices(step) {
595
+ const out = [];
596
+ step.options.forEach((option, index) => {
597
+ if (this.isSpent(step, index)) return;
598
+ if (this.test(option.condition)) out.push({ index, option });
599
+ else if (option.presentation === "disabled") out.push({ index, option, disabled: true });
600
+ });
601
+ return out;
602
+ }
603
+ /** Whether option `index` can actually be picked — the gate `choose()` uses.
604
+ * A spent `once` option or a failing condition refuses (a `"disabled"` row is
605
+ * shown but still unpickable, so this stays the single selection authority). */
606
+ choiceEnabled(step, index, option) {
607
+ return !this.isSpent(step, index) && this.test(option.condition);
608
+ }
609
+ /** A `once` option already chosen this run — always dropped from the menu
610
+ * regardless of `presentation`. Single source of truth for the once-gate,
611
+ * shared by `resolveChoices` and `choiceEnabled`. Reads the option from
612
+ * `step.options[index]`, so `(step, index)` is the only input. */
613
+ isSpent(step, index) {
614
+ const option = step.options[index];
615
+ return option?.once === true && this.chosenOnce.has(this.onceKey(step, index));
616
+ }
617
+ onceKey(step, index) {
618
+ return `${this.nodeId}#${this.stepIndex}#${index}#${step.options[index]?.text ?? ""}`;
619
+ }
620
+ /**
621
+ * Fire an inline command batch (a `command` step or a chosen option). Manages
622
+ * wait-state: enters `awaiting-command` up front when the batch contains a
623
+ * blocking command, so a stray advance/confirm during the await is ignored; the
624
+ * caller transitions out of the state afterwards. The work itself goes through
625
+ * the wait-state-free {@link executeBatch}.
626
+ */
627
+ async fireBatch(commands) {
628
+ if (!commands || commands.length === 0) return;
629
+ if (commands.some((c) => c.blocking)) this.state = "awaiting-command";
630
+ await this.executeBatch(commands);
631
+ }
632
+ /**
633
+ * The wait-state-free command executor, shared by {@link fireBatch} (inline
634
+ * firing) and {@link runCommands} (the Session's line-timed firing). Applies
635
+ * built-in `set`; surfaces the rest to the host with the current mode; awaits
636
+ * `blocking` handlers and fire-and-forgets the others. Touches no wait-state.
637
+ */
638
+ async executeBatch(commands, mode = this.runMode) {
639
+ if (!commands) return;
640
+ for (const cmd of commands) {
641
+ if (cmd.type === "set" && typeof cmd.var === "string") {
642
+ const value = cmd.value;
643
+ const next = isExpr(value) ? evaluate(value, this.scope) : value;
644
+ try {
645
+ this.storage.set(cmd.var, next);
646
+ } catch (e) {
647
+ this.onError?.(
648
+ `ignored "set ${cmd.var}": ${e instanceof Error ? e.message : String(e)}`,
649
+ e
650
+ );
651
+ }
652
+ continue;
653
+ }
654
+ let result;
655
+ try {
656
+ result = this.handlers.onCommand(cmd, this.commandContext(mode));
657
+ } catch {
658
+ continue;
659
+ }
660
+ if (!isPromise(result)) continue;
661
+ if (cmd.blocking) {
662
+ try {
663
+ await result;
664
+ } catch {
665
+ }
666
+ if (this.isEnded()) return;
667
+ } else {
668
+ void Promise.resolve(result).catch(() => {
669
+ });
670
+ }
671
+ }
672
+ }
673
+ /** The context handed to a command handler. `setVar` writes through the
674
+ * conversation's storage (guarded by the session for staleness), the same
675
+ * path as the `set` built-in — so the skill-check seam and `set` share one
676
+ * guarded write. */
677
+ commandContext(mode) {
678
+ return { mode, setVar: /* @__PURE__ */ __name((name, value) => this.storage.set(name, value), "setVar") };
679
+ }
680
+ test(condition) {
681
+ return holds(condition, this.scope);
682
+ }
683
+ };
684
+ function isPromise(v) {
685
+ return typeof v === "object" && v !== null && typeof v.then === "function";
686
+ }
687
+ __name(isPromise, "isPromise");
688
+
689
+ // src/core/session.ts
690
+ var EMPTY_VARS = Object.freeze({});
691
+ var DialogueSession = class {
692
+ constructor(channels, opts = {}) {
693
+ this.channels = channels;
694
+ this.opts = opts;
695
+ this.i18n = opts.i18n ?? new IdentityI18n();
696
+ this.skipMul = opts.skipMultiplier ?? 4;
697
+ this.defaultStorage = opts.storage ?? new MemoryVariableStorage();
698
+ this.defaultFunctions = opts.functions ?? {};
699
+ this.defaultCommands = opts.commands ?? {};
700
+ this.defaultFallback = opts.fallbackCommand;
701
+ this.channels.text.setRevealListener(() => this.handleRevealComplete());
702
+ this.channels.text.setBeatListener((beat) => this.handleRevealBeat(beat));
703
+ this.channels.choices.onChoiceChosen = (position) => this.confirmAt(position);
704
+ }
705
+ channels;
706
+ opts;
707
+ static {
708
+ __name(this, "DialogueSession");
709
+ }
710
+ i18n;
711
+ skipMul;
712
+ /** Controller-installed environment (persists across plays); per-`play()`
713
+ * overrides are layered on top into the resolved fields below. */
714
+ defaultStorage;
715
+ defaultFunctions;
716
+ defaultCommands;
717
+ defaultFallback;
718
+ // Resolved per play() (controller install + call-site overrides).
719
+ storage;
720
+ functions = {};
721
+ commands = {};
722
+ fallbackCommand;
723
+ // Fields use explicit `| undefined` (not `?`) so reassigning `undefined`
724
+ // (e.g. `stop()` nulling the cursor) is legal under the repo's
725
+ // `exactOptionalPropertyTypes`.
726
+ runner;
727
+ script;
728
+ mode = "idle";
729
+ scriptId = "";
730
+ saying;
731
+ autoTimer;
732
+ /** Default auto-advance delay (ms) applied to lines without their own
733
+ * `autoAdvanceMs`. `null` = off (manual advance). Set via {@link setAutoAdvance}. */
734
+ autoAdvanceDefault = null;
735
+ resolved = [];
736
+ selected = 0;
737
+ /** Count of in-flight blocking line-command batches (show/afterReveal/advance).
738
+ * Input is gated while > 0. An ownership counter (not a shared boolean) so an
739
+ * overlapping batch resolving — e.g. the afterReveal batch finishing while a
740
+ * long blocking `show` command is still awaited — can't drop a gate it
741
+ * doesn't own. */
742
+ blockedCount = 0;
743
+ /** True between an advance request and the runner stepping off the line —
744
+ * guards against a second advance double-firing `advance`-timed commands. */
745
+ advancing = false;
746
+ /** True once the current line's `advance`-timed commands have fired, so a
747
+ * second advance() while the runner is still stepping (e.g. awaiting a
748
+ * blocking command step) can't re-fire them against the stale line. */
749
+ advanceFired = false;
750
+ /** True once the current line's `afterReveal`-timed commands have fired
751
+ * (normally via handleRevealComplete; skip() fires them early when the line
752
+ * hasn't finished revealing). */
753
+ afterRevealFired = false;
754
+ /** Latched by the first confirm() until the runner produces its next state
755
+ * (handleSay/handleChoice/handleEnd), so mashing confirm while the runner
756
+ * awaits the option's blocking commands can't emit duplicate onChoiceMade
757
+ * events (`mode` stays "choosing" for that whole window). */
758
+ confirming = false;
759
+ /** Bumped by every stop()/play(). A suspended async continuation from a prior
760
+ * conversation captures this and bails on resume if it changed, so it can't
761
+ * drive (advance / show the caret on) the runner of a *new* conversation. */
762
+ generation = 0;
763
+ // ── lifecycle levers — host-level, NOT conversation state ──────────────
764
+ /** `setHidden` — visual only; gates every channel's `setVisible`. Survives
765
+ * `stop()`/`play()` (a host that hides for a cutscene and forgets to unhide
766
+ * gets what it asked for) — so it is deliberately NOT reset by `stop()`. */
767
+ hidden = false;
768
+ /** `setPaused` — freezes the update loop AND the input-agnostic API. State is
769
+ * left fully intact (no generation bump); also host-level, survives replays. */
770
+ paused = false;
771
+ /** Whether the chrome / body text are part of the CURRENT choice's layout
772
+ * (false when a self-contained bubble panel owns the prompt) — remembered so
773
+ * {@link applyVisibility} can recompute after a hide toggle mid-choice. */
774
+ choiceShowsChrome = false;
775
+ choiceShowsBody = false;
776
+ /** Plain (speaker, text) of the line on screen — for the reveal-completed
777
+ * event, which fires after `present` has discarded the resolved string. */
778
+ currentLine;
779
+ /** The full {@link PresentedLine} on screen — handed to an extra channel's
780
+ * `revealComplete` (the session discards the local `line` after present). */
781
+ currentPresented;
782
+ /** Host-registered extra channels (Voice / Shop / CameraEffects / History).
783
+ * The session fans its cross-cutting stream to these alongside the typed trio
784
+ * and folds their `isRevealComplete()` into the auto-advance gate. */
785
+ extras = [];
786
+ /**
787
+ * Begin a conversation. `play(script)` is **content-only** — the storage,
788
+ * functions, and commands are installed on the session; `overrides` layers
789
+ * per-conversation specifics on top (a scoped `storage`, extra `functions` /
790
+ * `commands`). Declared defaults seed into the storage **only if absent**
791
+ * (game-linked values win); variables persist across plays. Returns a
792
+ * generation-stamped {@link DialogueHandle} for live `setVar` / `getVars`.
793
+ */
794
+ play(rawScript, overrides) {
795
+ const script = loadScript(rawScript);
796
+ const analysis = analyzeScript(script);
797
+ const storage = overrides?.storage ?? this.defaultStorage;
798
+ const functions = { ...this.defaultFunctions, ...overrides?.functions };
799
+ const commands = { ...this.defaultCommands, ...overrides?.commands };
800
+ const fallbackCommand = overrides?.fallbackCommand ?? this.defaultFallback;
801
+ validatePlay(analysis, { storage, functions, commands, fallbackCommand });
802
+ for (const [name, value] of Object.entries(script.declare ?? {})) {
803
+ if (storage.has(name)) continue;
804
+ try {
805
+ storage.set(name, value);
806
+ } catch (cause) {
807
+ const detail = cause instanceof Error ? cause.message : String(cause);
808
+ throw new DialoguePlayError(
809
+ `cannot seed declared default "${name}": ${detail} (a read-only / pure cells() storage has no writable slot \u2014 compose it with a MemoryVariableStorage)`
810
+ );
811
+ }
812
+ }
813
+ this.stop();
814
+ this.script = script;
815
+ this.scriptId = script.id;
816
+ this.storage = storage;
817
+ this.functions = functions;
818
+ this.commands = commands;
819
+ this.fallbackCommand = fallbackCommand;
820
+ const gen = this.generation;
821
+ const live = /* @__PURE__ */ __name(() => gen === this.generation, "live");
822
+ const guarded = {
823
+ get: /* @__PURE__ */ __name((name) => storage.get(name), "get"),
824
+ set: /* @__PURE__ */ __name((name, value) => {
825
+ if (live()) storage.set(name, value);
826
+ }, "set"),
827
+ has: /* @__PURE__ */ __name((name) => storage.has(name), "has"),
828
+ entries: /* @__PURE__ */ __name(() => storage.entries(), "entries")
829
+ };
830
+ const runner = new DialogueRunner(
831
+ script,
832
+ { storage: guarded, functions, onError: this.opts.onError },
833
+ {
834
+ onSay: /* @__PURE__ */ __name((step, speaker) => {
835
+ if (live()) this.handleSay(step, speaker);
836
+ }, "onSay"),
837
+ onChoice: /* @__PURE__ */ __name((step, choices, speaker) => {
838
+ if (live()) this.handleChoice(step, choices, speaker);
839
+ }, "onChoice"),
840
+ onCommand: /* @__PURE__ */ __name((command, ctx) => live() ? this.handleCommand(command, ctx) : void 0, "onCommand"),
841
+ onEnd: /* @__PURE__ */ __name(() => {
842
+ if (live()) this.handleEnd();
843
+ }, "onEnd")
844
+ }
845
+ );
846
+ this.runner = runner;
847
+ this.opts.onStarted?.({ scriptId: this.scriptId });
848
+ runner.start();
849
+ return {
850
+ setVar: /* @__PURE__ */ __name((name, value) => guarded.set(name, value), "setVar"),
851
+ getVars: /* @__PURE__ */ __name(() => gen === this.generation ? materialize(storage) : EMPTY_VARS, "getVars")
852
+ };
853
+ }
854
+ isActive() {
855
+ return this.mode === "saying" || this.mode === "choosing";
856
+ }
857
+ isChoosing() {
858
+ return this.mode === "choosing";
859
+ }
860
+ /**
861
+ * Register an extra channel (Voice / Shop / CameraEffects / History) — the
862
+ * open-ended companion to the built-in trio. It receives the cross-cutting
863
+ * stream (`present` / `command` / `clear` / `setVisible` / `setPaused` /
864
+ * `completeReveal` / `update`) and can gate auto-advance via
865
+ * `isRevealComplete()`. Returns a disposer that unregisters **and** disposes
866
+ * it. On register the channel catches up the current `setVisible` / `setPaused`
867
+ * lever state ONLY — no content replay (replaying `present` would re-trigger a
868
+ * voice clip). Safe to call mid-conversation.
869
+ */
870
+ addChannel(ch) {
871
+ this.extras.push(ch);
872
+ try {
873
+ ch.setVisible?.(!this.hidden);
874
+ } catch (error) {
875
+ this.opts.onError?.("dialogue: channel setVisible() failed", error);
876
+ }
877
+ try {
878
+ ch.setPaused?.(this.paused);
879
+ } catch (error) {
880
+ this.opts.onError?.("dialogue: channel setPaused() failed", error);
881
+ }
882
+ let disposed = false;
883
+ return () => {
884
+ if (disposed) return;
885
+ disposed = true;
886
+ const i = this.extras.indexOf(ch);
887
+ if (i >= 0) this.extras.splice(i, 1);
888
+ try {
889
+ ch.dispose?.();
890
+ } catch (error) {
891
+ this.opts.onError?.("dialogue: channel dispose() failed", error);
892
+ }
893
+ };
894
+ }
895
+ // ── lifecycle levers ──────────────────────────────────────────────────
896
+ /**
897
+ * Hide or show the whole dialogue UI — purely visual, state-preserving.
898
+ * Drives every channel's `setVisible`; the conversation keeps running
899
+ * underneath (reveal, timers, cursor intact). Host-level and **persistent**:
900
+ * it survives `stop()` and the next `play()`, so a cutscene that hides and
901
+ * forgets to unhide stays hidden — call `setHidden(false)` to restore.
902
+ */
903
+ setHidden(hidden) {
904
+ this.hidden = hidden;
905
+ this.applyVisibility();
906
+ }
907
+ /** True while the UI is hidden via {@link setHidden}. */
908
+ isHidden() {
909
+ return this.hidden;
910
+ }
911
+ /**
912
+ * Freeze or resume the conversation — `update()` no-ops (reveal,
913
+ * auto-advance clock, caret blink, avatar anim all freeze since they are
914
+ * dt-driven) and the input-agnostic API (`advance`/`confirm`/`choose`/
915
+ * `moveSelection`/`selectAt`/`skip`) no-ops. State is left fully intact: no
916
+ * generation bump, and `lineBlocked`/`advancing`/`autoTimer` survive. It does
917
+ * NOT block host-driven writes (`handle.setVar` / `ctx.setVar` / storage) —
918
+ * only player-facing time + input freeze. Host-level and persistent like hide.
919
+ */
920
+ setPaused(paused) {
921
+ this.paused = paused;
922
+ for (const ch of this.extras) {
923
+ try {
924
+ ch.setPaused?.(paused);
925
+ } catch (error) {
926
+ this.opts.onError?.("dialogue: channel setPaused() failed", error);
927
+ }
928
+ }
929
+ }
930
+ /** True while the conversation is frozen via {@link setPaused}. */
931
+ isPaused() {
932
+ return this.paused;
933
+ }
934
+ /**
935
+ * Push the current desired visibility to every channel: a channel shows when
936
+ * it has content for the current mode AND the host hasn't hidden the UI. The
937
+ * single visibility authority — `setHidden` and every line/choice transition
938
+ * route through here, so the host-hidden lever composes cleanly with per-line
939
+ * content and a custom chrome (which may not implement the optional `present`)
940
+ * is still reliably hidden by the explicit `setVisible(false)`.
941
+ */
942
+ applyVisibility() {
943
+ const shown = !this.hidden;
944
+ const saying = this.mode === "saying";
945
+ const choosing = this.mode === "choosing";
946
+ this.channels.text.setVisible(shown && (saying || choosing && this.choiceShowsBody));
947
+ this.channels.choices.setVisible(shown && choosing);
948
+ this.channels.chrome?.setVisible(shown && (saying || choosing && this.choiceShowsChrome));
949
+ this.channels.avatar?.setVisible?.(shown && (saying || choosing));
950
+ for (const ch of this.extras) {
951
+ try {
952
+ ch.setVisible?.(shown);
953
+ } catch (error) {
954
+ this.opts.onError?.("dialogue: channel setVisible() failed", error);
955
+ }
956
+ }
957
+ }
958
+ /** Clear every presentation channel's content — text, choices, chrome
959
+ * (nameplate + caret + line), the avatar, and the registered extras. The
960
+ * shared teardown for {@link stop} and the ended state; it touches no session
961
+ * bookkeeping or visibility (the caller resets its own state, then
962
+ * {@link goIdle} reasserts visibility). */
963
+ clearAllChannels() {
964
+ this.channels.text.clear();
965
+ this.channels.choices.clear();
966
+ this.channels.chrome?.setContinueVisible(false);
967
+ this.channels.chrome?.setNameplate(void 0);
968
+ this.channels.chrome?.present?.(void 0);
969
+ this.channels.avatar?.setSpeaker(void 0);
970
+ this.channels.avatar?.setSpeaking(false);
971
+ this.channels.avatar?.present?.(void 0);
972
+ for (const ch of this.extras) {
973
+ try {
974
+ ch.clear?.();
975
+ } catch (error) {
976
+ this.opts.onError?.("dialogue: channel clear() failed", error);
977
+ }
978
+ }
979
+ }
980
+ /** Drop to a quiescent presentation state (`mode` "idle" or "ended"): reset the
981
+ * per-line/choice bookkeeping, clear every channel, and reassert visibility
982
+ * (idle/ended → nothing shown, honestly via each channel's `setVisible`,
983
+ * preserving the host-hidden lever). The caller owns any further reset —
984
+ * {@link stop} also abandons the runner + timing latches. */
985
+ goIdle(mode) {
986
+ this.mode = mode;
987
+ this.saying = void 0;
988
+ this.resolved = [];
989
+ this.confirming = false;
990
+ this.currentLine = void 0;
991
+ this.currentPresented = void 0;
992
+ this.choiceShowsChrome = false;
993
+ this.choiceShowsBody = false;
994
+ this.clearAllChannels();
995
+ this.applyVisibility();
996
+ }
997
+ /** Abandon the current conversation and reset to idle (clears all visuals).
998
+ * Useful for ambient/eavesdrop dialogue that should stop when out of range. */
999
+ stop() {
1000
+ this.generation++;
1001
+ this.runner = void 0;
1002
+ this.autoTimer = void 0;
1003
+ this.blockedCount = 0;
1004
+ this.advancing = false;
1005
+ this.advanceFired = false;
1006
+ this.afterRevealFired = false;
1007
+ this.goIdle("idle");
1008
+ }
1009
+ update(dt) {
1010
+ if (this.paused) return;
1011
+ this.channels.text.update(dt);
1012
+ this.channels.chrome?.update(dt);
1013
+ this.channels.avatar?.update(dt);
1014
+ for (const ch of this.extras) {
1015
+ try {
1016
+ ch.update?.(dt);
1017
+ } catch (error) {
1018
+ this.opts.onError?.("dialogue: channel update() failed", error);
1019
+ }
1020
+ }
1021
+ if (!this.runner || this.mode !== "saying") return;
1022
+ if (this.autoTimer !== void 0 && this.allRevealsComplete()) {
1023
+ this.autoTimer -= dt;
1024
+ if (this.autoTimer <= 0 && !this.lineBlocked && !this.advancing) {
1025
+ this.autoTimer = void 0;
1026
+ this.opts.onAutoAdvance?.({ scriptId: this.scriptId });
1027
+ this.advance();
1028
+ }
1029
+ }
1030
+ }
1031
+ /** True while any blocking line-command batch is awaited (input is gated). */
1032
+ get lineBlocked() {
1033
+ return this.blockedCount > 0;
1034
+ }
1035
+ /**
1036
+ * The auto-advance gate: the text reveal AND every registered extra channel
1037
+ * that *gates* (implements `isRevealComplete`) report complete. The clock is
1038
+ * armed on the text reveal alone (see `handleRevealComplete`/`setAutoAdvance`)
1039
+ * but only counts down once this is true — so a voice clip outlasting the
1040
+ * typewriter holds the line for `max(clipEnd, revealEnd)` with no duration
1041
+ * plumbing. A channel without the method never gates (a pure observer).
1042
+ */
1043
+ allRevealsComplete() {
1044
+ if (!this.channels.text.isRevealComplete()) return false;
1045
+ for (const ch of this.extras) {
1046
+ if (ch.isRevealComplete && !ch.isRevealComplete()) return false;
1047
+ }
1048
+ return true;
1049
+ }
1050
+ /**
1051
+ * The storage read view for `{token}` interpolation at *this* present-time.
1052
+ * Materialized per evaluation so an earlier command's `set` shows up on a
1053
+ * later line; already-shown lines never re-render.
1054
+ */
1055
+ readView() {
1056
+ return this.storage ? materialize(this.storage) : {};
1057
+ }
1058
+ // ── input-agnostic API ────────────────────────────────────────────────────
1059
+ /** Primary action. Saying → reveal-all if typing, else next line. Choosing → confirm. */
1060
+ advance() {
1061
+ if (this.paused) return;
1062
+ if (this.lineBlocked || this.advancing) return;
1063
+ if (this.mode === "saying") {
1064
+ if (this.channels.text.isRevealing()) {
1065
+ this.channels.text.completeReveal();
1066
+ for (const ch of this.extras) {
1067
+ try {
1068
+ ch.completeReveal?.();
1069
+ } catch (error) {
1070
+ this.opts.onError?.("dialogue: channel completeReveal() failed", error);
1071
+ }
1072
+ }
1073
+ } else void this.advanceLine();
1074
+ } else if (this.mode === "choosing") {
1075
+ this.confirm();
1076
+ }
1077
+ }
1078
+ /** Fire any `advance`-timed line commands, then step the runner off the line.
1079
+ * `advancing` is held for the whole turn so a second advance can't re-fire
1080
+ * the (possibly non-blocking) `advance` commands before the runner steps. */
1081
+ async advanceLine() {
1082
+ const gen = this.generation;
1083
+ this.advancing = true;
1084
+ try {
1085
+ if (!this.advanceFired) {
1086
+ this.advanceFired = true;
1087
+ await this.fireLineCommands("advance");
1088
+ }
1089
+ if (gen !== this.generation || this.mode !== "saying") return;
1090
+ this.runner?.advance();
1091
+ } finally {
1092
+ if (gen === this.generation) this.advancing = false;
1093
+ }
1094
+ }
1095
+ /**
1096
+ * Fast-forward the current section: run intervening commands (in skip mode)
1097
+ * without presenting, stopping at the next choice or the end. No-op unless a
1098
+ * line is showing.
1099
+ */
1100
+ skip() {
1101
+ if (this.paused) return;
1102
+ if (this.mode !== "saying" || this.lineBlocked || this.advancing) return;
1103
+ this.opts.onSkipUsed?.({ scriptId: this.scriptId });
1104
+ for (const ch of this.extras) {
1105
+ try {
1106
+ ch.completeReveal?.();
1107
+ } catch (error) {
1108
+ this.opts.onError?.("dialogue: channel completeReveal() failed", error);
1109
+ }
1110
+ }
1111
+ void this.skipLine();
1112
+ }
1113
+ /**
1114
+ * Fire the *displayed* line's not-yet-fired batches in skip mode — the runner
1115
+ * fires every skipped line's commands for world reconstruction, so dropping
1116
+ * the current line's `afterReveal`/`advance` batches would diverge from
1117
+ * normal play — then fast-forward the runner.
1118
+ */
1119
+ async skipLine() {
1120
+ const gen = this.generation;
1121
+ this.advancing = true;
1122
+ try {
1123
+ if (!this.afterRevealFired) {
1124
+ this.afterRevealFired = true;
1125
+ await this.fireLineCommands("afterReveal", "skip");
1126
+ if (gen !== this.generation || this.mode !== "saying") return;
1127
+ }
1128
+ if (!this.advanceFired) {
1129
+ this.advanceFired = true;
1130
+ await this.fireLineCommands("advance", "skip");
1131
+ if (gen !== this.generation || this.mode !== "saying") return;
1132
+ }
1133
+ await this.runner?.skip();
1134
+ } finally {
1135
+ if (gen === this.generation) this.advancing = false;
1136
+ }
1137
+ }
1138
+ /**
1139
+ * Side-effect-free lookahead: the lines a node would show along its linear
1140
+ * path — following `goto` and conditional `command` jumps using the *current*
1141
+ * variable snapshot — stopping at the first choice or the end. Runs no
1142
+ * commands and mutates nothing. For a "skip with a summary" affordance.
1143
+ */
1144
+ preview(nodeId, limit = 64) {
1145
+ const script = this.script;
1146
+ const storage = this.storage;
1147
+ if (!script || !storage) return [];
1148
+ const view = materialize(storage);
1149
+ const scope = createScope(storage, this.functions);
1150
+ const out = [];
1151
+ let node = nodeId;
1152
+ let i = 0;
1153
+ for (let guard = 0; guard < limit; guard++) {
1154
+ const step = script.nodes[node]?.steps[i];
1155
+ if (!step) break;
1156
+ if (step.kind === "say") {
1157
+ const speaker = step.speaker ? script.speakers?.[step.speaker] : void 0;
1158
+ const name = speaker ? this.i18n.t(speaker.nameKey, speaker.name, view) : void 0;
1159
+ out.push({
1160
+ speaker: name,
1161
+ text: stripMarkup(this.i18n.t(step.key, step.text, view))
1162
+ });
1163
+ i++;
1164
+ } else if (step.kind === "command") {
1165
+ if (step.target !== void 0 && holds(step.condition, scope)) {
1166
+ node = step.target;
1167
+ i = 0;
1168
+ } else {
1169
+ i++;
1170
+ }
1171
+ } else if (step.kind === "goto") {
1172
+ node = step.target;
1173
+ i = 0;
1174
+ } else {
1175
+ break;
1176
+ }
1177
+ }
1178
+ return out;
1179
+ }
1180
+ /** Move the choice cursor by `delta`, skipping disabled rows and wrapping.
1181
+ * No-op outside a choice, and a zero `delta` is a no-op (no cursor move, no
1182
+ * event). A move that steps over disabled rows fires exactly one
1183
+ * selection-changed event, for the row it lands on. */
1184
+ moveSelection(delta) {
1185
+ if (this.paused) return;
1186
+ if (this.mode !== "choosing" || this.confirming) return;
1187
+ if (this.resolved.length === 0 || delta === 0) return;
1188
+ const dir = delta < 0 ? -1 : 1;
1189
+ let pos = this.selected;
1190
+ for (let i = 0; i < Math.abs(delta); i++) {
1191
+ pos = this.nextEnabled(pos, dir);
1192
+ }
1193
+ if (pos === this.selected) return;
1194
+ this.selected = pos;
1195
+ this.channels.choices.highlight(this.selected);
1196
+ this.emitSelectionChanged();
1197
+ }
1198
+ /** Highlight a choice by absolute position (e.g. pointer hover). No wrap;
1199
+ * a disabled row is skipped (the cursor stays put). */
1200
+ selectAt(position) {
1201
+ if (this.paused) return;
1202
+ if (this.mode !== "choosing" || this.confirming) return;
1203
+ const n = this.resolved.length;
1204
+ if (n === 0 || position < 0 || position >= n || position === this.selected)
1205
+ return;
1206
+ if (this.resolved[position]?.disabled) return;
1207
+ this.selected = position;
1208
+ this.channels.choices.highlight(this.selected);
1209
+ this.emitSelectionChanged();
1210
+ }
1211
+ /** The next enabled choice position from `from` in direction `dir` (±1),
1212
+ * wrapping. Returns `from` when no other enabled row exists (so a single
1213
+ * enabled option among disabled ones never moves). */
1214
+ nextEnabled(from, dir) {
1215
+ const n = this.resolved.length;
1216
+ let pos = from;
1217
+ for (let i = 0; i < n; i++) {
1218
+ pos = (pos + dir + n) % n;
1219
+ if (pos === from) break;
1220
+ if (!this.resolved[pos]?.disabled) return pos;
1221
+ }
1222
+ return from;
1223
+ }
1224
+ /** Fire onSelectionChanged for the currently-highlighted choice (keyboard nav
1225
+ * and pointer hover both land here — one canonical selection event). */
1226
+ emitSelectionChanged() {
1227
+ const chosen = this.resolved[this.selected];
1228
+ if (!chosen) return;
1229
+ this.opts.onSelectionChanged?.({
1230
+ index: chosen.index,
1231
+ text: stripMarkup(
1232
+ this.i18n.t(chosen.option.key, chosen.option.text, this.readView())
1233
+ )
1234
+ });
1235
+ }
1236
+ /** Commit the highlighted choice. */
1237
+ confirm() {
1238
+ this.commit(this.selected);
1239
+ }
1240
+ /** Commit by original option index (e.g. a direct pointer hit, or the
1241
+ * timed-choice recipe firing its default). Refuses an unknown or disabled
1242
+ * option. */
1243
+ choose(optionIndex) {
1244
+ this.commit(this.resolved.findIndex((c) => c.index === optionIndex));
1245
+ }
1246
+ /** Commit by display position (a pointer hit on a row). The position-keyed
1247
+ * counterpart to {@link choose}; refuses an out-of-range or disabled row.
1248
+ * Used by the pointer binding and the presenter pointer-commit seam. */
1249
+ confirmAt(position) {
1250
+ this.commit(position);
1251
+ }
1252
+ /**
1253
+ * The single choice-commit authority: every commit path routes here after
1254
+ * translating its argument to a display position. It guards (paused / not
1255
+ * choosing / already confirming / a missing or disabled row), latches against a
1256
+ * double-commit, fires `onChoiceMade`, and steps the runner. The latch (not the
1257
+ * runner) is what guarantees a single commit: `mode` stays "choosing" while the
1258
+ * runner awaits the option's blocking commands, so without it a second confirm
1259
+ * would emit a duplicate `onChoiceMade`.
1260
+ */
1261
+ commit(position) {
1262
+ if (this.paused) return;
1263
+ if (this.mode !== "choosing" || this.confirming) return;
1264
+ const chosen = this.resolved[position];
1265
+ if (!chosen || chosen.disabled) return;
1266
+ this.selected = position;
1267
+ this.confirming = true;
1268
+ const text = this.i18n.t(chosen.option.key, chosen.option.text, this.readView());
1269
+ this.opts.onChoiceMade?.({ index: chosen.index, text });
1270
+ this.runner?.choose(chosen.index);
1271
+ }
1272
+ /** Toggle hold-to-fast-forward; the text channel scales its reveal rate. */
1273
+ setFastForward(on) {
1274
+ this.channels.text.setSpeedMultiplier(on ? this.skipMul : 1);
1275
+ }
1276
+ /**
1277
+ * Default auto-advance: lines without their own `autoAdvanceMs` advance `ms`
1278
+ * after they finish revealing; `null` turns it off (manual advance). A
1279
+ * per-line `autoAdvanceMs` always overrides this. Toggling it while a line is
1280
+ * already sitting revealed arms/clears its timer immediately.
1281
+ */
1282
+ setAutoAdvance(ms) {
1283
+ this.autoAdvanceDefault = ms;
1284
+ if (this.mode === "saying" && this.saying?.autoAdvanceMs === void 0 && this.channels.text.isRevealComplete()) {
1285
+ this.autoTimer = ms ?? void 0;
1286
+ }
1287
+ }
1288
+ // ── runner handlers ─────────────────────────────────────────────────────
1289
+ handleSay(step, speaker) {
1290
+ this.mode = "saying";
1291
+ this.saying = step;
1292
+ this.autoTimer = void 0;
1293
+ this.advanceFired = false;
1294
+ this.afterRevealFired = false;
1295
+ this.confirming = false;
1296
+ const view = this.readView();
1297
+ const resolved = this.i18n.t(step.key, step.text, view);
1298
+ const line = {
1299
+ speaker: this.speakerView(speaker, view),
1300
+ text: parseMarkup(resolved),
1301
+ speed: step.speed ?? 1,
1302
+ view: step.view,
1303
+ meta: step.meta,
1304
+ voice: step.voice
1305
+ };
1306
+ this.channels.choices.clear();
1307
+ this.channels.chrome?.setContinueVisible(false);
1308
+ this.channels.chrome?.setNameplate(
1309
+ this.speakerName(speaker, view),
1310
+ speaker?.color
1311
+ );
1312
+ this.channels.avatar?.setSpeaker(speaker);
1313
+ this.channels.avatar?.setExpression(step.expression);
1314
+ this.channels.avatar?.setSpeaking(true);
1315
+ this.channels.avatar?.present?.(line);
1316
+ this.channels.chrome?.present?.(line);
1317
+ this.channels.text.present(line);
1318
+ this.currentPresented = line;
1319
+ for (const ch of this.extras) {
1320
+ try {
1321
+ ch.present?.(line);
1322
+ } catch (error) {
1323
+ this.opts.onError?.("dialogue: channel present() failed", error);
1324
+ }
1325
+ }
1326
+ const plain = stripMarkup(resolved);
1327
+ this.currentLine = { speaker: this.speakerName(speaker, view), text: plain };
1328
+ this.opts.onLine?.({ speaker: this.currentLine.speaker, text: plain });
1329
+ this.applyVisibility();
1330
+ void this.fireLineCommands("show");
1331
+ }
1332
+ handleChoice(step, choices, speaker) {
1333
+ this.mode = "choosing";
1334
+ this.resolved = choices;
1335
+ const firstEnabled = choices.findIndex((c) => !c.disabled);
1336
+ if (firstEnabled < 0) {
1337
+ throw new Error("dialogue: choice step presented with no enabled option");
1338
+ }
1339
+ this.selected = firstEnabled;
1340
+ this.confirming = false;
1341
+ const view = this.readView();
1342
+ const line = {
1343
+ speaker: this.speakerView(speaker, view),
1344
+ text: step.text ? parseMarkup(this.i18n.t(step.key, step.text, view)) : EMPTY_PARSED,
1345
+ speed: 1,
1346
+ view: step.view,
1347
+ meta: step.meta
1348
+ };
1349
+ this.channels.avatar?.setSpeaker(speaker);
1350
+ this.channels.avatar?.setExpression(void 0);
1351
+ this.channels.avatar?.setSpeaking(false);
1352
+ this.channels.avatar?.present?.(line);
1353
+ const ctx = {
1354
+ view: step.view,
1355
+ speaker: line.speaker,
1356
+ prompt: line.text,
1357
+ meta: step.meta
1358
+ };
1359
+ const presenterOwnsPrompt = this.channels.choices.ownsPrompt?.(ctx) ?? false;
1360
+ this.choiceShowsChrome = !presenterOwnsPrompt;
1361
+ this.choiceShowsBody = !presenterOwnsPrompt && Boolean(step.text);
1362
+ this.channels.chrome?.setContinueVisible(false);
1363
+ if (presenterOwnsPrompt) {
1364
+ this.channels.chrome?.present?.(void 0);
1365
+ this.channels.text.clear();
1366
+ } else {
1367
+ this.channels.chrome?.setNameplate(
1368
+ this.speakerName(speaker, view),
1369
+ speaker?.color
1370
+ );
1371
+ this.channels.chrome?.present?.(line);
1372
+ if (step.text) this.channels.text.present(line);
1373
+ else this.channels.text.clear();
1374
+ }
1375
+ const labels = choices.map(
1376
+ (c) => stripMarkup(this.i18n.t(c.option.key, c.option.text, view))
1377
+ );
1378
+ const presented = choices.map((c, i) => ({
1379
+ label: labels[i],
1380
+ meta: c.option.meta,
1381
+ disabled: c.disabled,
1382
+ // i18n-resolve the reason (interpolating {tokens}) only for disabled rows
1383
+ // that carry one; there's no separate i18n key for it.
1384
+ disabledReason: c.disabled && c.option.disabledReason !== void 0 ? stripMarkup(this.i18n.t(void 0, c.option.disabledReason, view)) : void 0
1385
+ }));
1386
+ this.channels.choices.present(presented, ctx);
1387
+ this.channels.choices.highlight(this.selected);
1388
+ this.applyVisibility();
1389
+ this.opts.onChoiceShown?.({ options: labels });
1390
+ }
1391
+ handleCommand(command, ctx) {
1392
+ this.opts.onCommand?.(command, ctx);
1393
+ for (const ch of this.extras) {
1394
+ try {
1395
+ ch.command?.(command, ctx);
1396
+ } catch (error) {
1397
+ this.opts.onError?.("dialogue: channel command() failed", error);
1398
+ }
1399
+ }
1400
+ const handler = this.commands[command.type] ?? this.fallbackCommand;
1401
+ return handler?.(command, ctx);
1402
+ }
1403
+ handleEnd() {
1404
+ this.goIdle("ended");
1405
+ this.opts.onEnded?.({ scriptId: this.scriptId });
1406
+ }
1407
+ async handleRevealComplete() {
1408
+ if (this.mode !== "saying") return;
1409
+ const gen = this.generation;
1410
+ this.channels.avatar?.setSpeaking(false);
1411
+ if (!this.afterRevealFired) {
1412
+ this.afterRevealFired = true;
1413
+ await this.fireLineCommands("afterReveal");
1414
+ }
1415
+ if (gen !== this.generation || this.mode !== "saying") return;
1416
+ this.channels.chrome?.setContinueVisible(true);
1417
+ if (this.currentLine) this.opts.onRevealCompleted?.(this.currentLine);
1418
+ const presented = this.currentPresented;
1419
+ if (presented) {
1420
+ for (const ch of this.extras) {
1421
+ try {
1422
+ ch.revealComplete?.(presented);
1423
+ } catch (error) {
1424
+ this.opts.onError?.("dialogue: channel revealComplete() failed", error);
1425
+ }
1426
+ }
1427
+ }
1428
+ const auto = this.saying?.autoAdvanceMs ?? this.autoAdvanceDefault;
1429
+ if (auto !== null) this.autoTimer = auto;
1430
+ }
1431
+ /**
1432
+ * Fan one reveal beat out — the per-line typewriter stream. Extras (Voice /
1433
+ * CameraEffects / a typewriter-SFX channel) see the WHOLE stream via
1434
+ * `revealBeat?`. A `tick` then reaches the host's `onRevealTick` callback; a
1435
+ * `marker` reaches the avatar channel (which interprets `[expression=…/]`
1436
+ * itself — the Session name-matches nothing) and the host's `onRevealMarker`.
1437
+ */
1438
+ handleRevealBeat(beat) {
1439
+ for (const ch of this.extras) {
1440
+ try {
1441
+ ch.revealBeat?.(beat);
1442
+ } catch (error) {
1443
+ this.opts.onError?.("dialogue: channel revealBeat() failed", error);
1444
+ }
1445
+ }
1446
+ if (beat.kind === "tick") {
1447
+ this.opts.onRevealTick?.(beat.index);
1448
+ return;
1449
+ }
1450
+ this.channels.avatar?.marker?.(beat.marker);
1451
+ this.opts.onRevealMarker?.(beat.marker, beat.viaSkip);
1452
+ }
1453
+ /**
1454
+ * Fire the current line's commands matching `at`, via the runner's command
1455
+ * pipeline (so `set`/blocking behave identically). While a blocking one is
1456
+ * awaited, `lineBlocked` gates input so the player can't advance through it.
1457
+ * `mode` overrides the runner's run mode (skip() fires the displayed line's
1458
+ * batches in skip mode).
1459
+ */
1460
+ async fireLineCommands(at, mode) {
1461
+ const all = this.saying?.commands;
1462
+ if (!all || !this.runner) return;
1463
+ const batch = all.filter((c) => (c.at ?? "show") === at);
1464
+ if (batch.length === 0) return;
1465
+ const gen = this.generation;
1466
+ const blocking = batch.some((c) => c.blocking);
1467
+ if (blocking) this.blockedCount++;
1468
+ try {
1469
+ await this.runner.runCommands(batch, mode);
1470
+ } finally {
1471
+ if (blocking && gen === this.generation) this.blockedCount--;
1472
+ }
1473
+ }
1474
+ speakerName(speaker, view) {
1475
+ if (!speaker) return void 0;
1476
+ return this.i18n.t(speaker.nameKey, speaker.name, view);
1477
+ }
1478
+ speakerView(speaker, view) {
1479
+ if (!speaker) return void 0;
1480
+ return {
1481
+ id: speaker.id,
1482
+ name: this.speakerName(speaker, view),
1483
+ color: speaker.color
1484
+ };
1485
+ }
1486
+ };
1487
+
1488
+ // src/core/channels/voice.ts
1489
+ function createVoiceChannel(opts) {
1490
+ const { play, onSkip = "cut", livenessMs, onError } = opts;
1491
+ const pauseClip = opts.pauseWithConversation ?? true;
1492
+ let active;
1493
+ let done = true;
1494
+ let startToken = 0;
1495
+ let elapsed = 0;
1496
+ const stop = /* @__PURE__ */ __name(() => {
1497
+ active?.stop();
1498
+ active = void 0;
1499
+ done = true;
1500
+ }, "stop");
1501
+ return {
1502
+ present(line) {
1503
+ active?.stop();
1504
+ active = void 0;
1505
+ startToken++;
1506
+ elapsed = 0;
1507
+ const id = line.voice;
1508
+ if (id === void 0) {
1509
+ done = true;
1510
+ return;
1511
+ }
1512
+ done = false;
1513
+ const token = startToken;
1514
+ try {
1515
+ active = play(id, () => {
1516
+ if (token !== startToken) return;
1517
+ done = true;
1518
+ active = void 0;
1519
+ });
1520
+ } catch (error) {
1521
+ done = true;
1522
+ throw error;
1523
+ }
1524
+ },
1525
+ completeReveal() {
1526
+ if (onSkip === "ring") return;
1527
+ stop();
1528
+ },
1529
+ setPaused(paused) {
1530
+ if (!pauseClip) return;
1531
+ if (paused) active?.pause?.();
1532
+ else active?.resume?.();
1533
+ },
1534
+ update(dt) {
1535
+ if (done || !livenessMs) return;
1536
+ elapsed += dt;
1537
+ if (elapsed >= livenessMs) {
1538
+ done = true;
1539
+ onError?.(
1540
+ `voice clip exceeded the ${livenessMs}ms liveness budget without reporting its end; releasing the auto-advance gate`,
1541
+ new Error("voice clip liveness cap")
1542
+ );
1543
+ }
1544
+ },
1545
+ clear() {
1546
+ stop();
1547
+ },
1548
+ dispose() {
1549
+ stop();
1550
+ },
1551
+ isRevealComplete() {
1552
+ return done;
1553
+ }
1554
+ };
1555
+ }
1556
+ __name(createVoiceChannel, "createVoiceChannel");
1557
+
1558
+ // src/DialogueController.ts
1559
+ import { Component, LoggerKey } from "@yagejs/core";
1560
+ import { InputManagerKey } from "@yagejs/input";
1561
+
1562
+ // src/input/InputBinding.ts
1563
+ var DEFAULT_ACTIONS = {
1564
+ advance: ["interact"],
1565
+ speed: ["attack"],
1566
+ up: ["move-up"],
1567
+ down: ["move-down"]
1568
+ };
1569
+ var FULL_ACTIONS = {
1570
+ ...DEFAULT_ACTIONS,
1571
+ skip: ["skip"]
1572
+ };
1573
+ var CompositeInputBinding = class {
1574
+ constructor(bindings) {
1575
+ this.bindings = bindings;
1576
+ }
1577
+ bindings;
1578
+ static {
1579
+ __name(this, "CompositeInputBinding");
1580
+ }
1581
+ bind(input, session) {
1582
+ for (const b of this.bindings) b.bind(input, session);
1583
+ }
1584
+ poll() {
1585
+ for (const b of this.bindings) b.poll();
1586
+ }
1587
+ dispose() {
1588
+ for (const b of this.bindings) b.dispose?.();
1589
+ }
1590
+ };
1591
+ var KeyboardInputBinding = class {
1592
+ /**
1593
+ * @param skipHoldMs Hold the `skip` action this long before it fires (the
1594
+ * classic "hold to skip" confirm). `0` (default) fires on press.
1595
+ */
1596
+ constructor(actions = DEFAULT_ACTIONS, skipHoldMs = 0) {
1597
+ this.actions = actions;
1598
+ this.skipHoldMs = skipHoldMs;
1599
+ }
1600
+ actions;
1601
+ skipHoldMs;
1602
+ static {
1603
+ __name(this, "KeyboardInputBinding");
1604
+ }
1605
+ input;
1606
+ session;
1607
+ /** Latch so a held skip fires once per hold, not every frame past threshold. */
1608
+ skipFired = false;
1609
+ bind(input, session) {
1610
+ this.input = input;
1611
+ this.session = session;
1612
+ }
1613
+ poll() {
1614
+ const { input, session } = this;
1615
+ if (!input || !session) return;
1616
+ session.setFastForward(held(input, this.actions.speed));
1617
+ this.pollSkip(input, session);
1618
+ if (justPressed(input, this.actions.advance)) session.advance();
1619
+ if (justPressed(input, this.actions.up)) session.moveSelection(-1);
1620
+ else if (justPressed(input, this.actions.down)) session.moveSelection(1);
1621
+ }
1622
+ /** Fire skip once the action has been held `skipHoldMs` (hold-to-confirm),
1623
+ * re-arming only after it's released. */
1624
+ pollSkip(input, session) {
1625
+ const skip = this.actions.skip;
1626
+ if (!skip) return;
1627
+ const ready = skip.some(
1628
+ (a) => input.isPressed(a) && input.isHeldFor(a, this.skipHoldMs)
1629
+ );
1630
+ if (ready) {
1631
+ if (!this.skipFired) {
1632
+ session.skip();
1633
+ this.skipFired = true;
1634
+ }
1635
+ } else if (!held(input, skip)) {
1636
+ this.skipFired = false;
1637
+ }
1638
+ }
1639
+ };
1640
+ function justPressed(input, actions) {
1641
+ return actions.some((a) => input.isJustPressed(a));
1642
+ }
1643
+ __name(justPressed, "justPressed");
1644
+ function held(input, actions) {
1645
+ return actions.some((a) => input.isPressed(a));
1646
+ }
1647
+ __name(held, "held");
1648
+ var PointerInputBinding = class {
1649
+ static {
1650
+ __name(this, "PointerInputBinding");
1651
+ }
1652
+ input;
1653
+ session;
1654
+ // Explicit `| undefined` so `dispose()` can null it (exactOptionalPropertyTypes).
1655
+ unsub;
1656
+ /** A primary-button press happened since the last poll (consumed in poll). */
1657
+ clicked = false;
1658
+ // Explicit `| undefined` (not `?:`) so the ctor can assign the possibly-
1659
+ // undefined argument under `exactOptionalPropertyTypes`.
1660
+ choices;
1661
+ /** Pointer position at the last hover hit-test, so an unmoved pointer
1662
+ * doesn't re-run the hit-test every frame. */
1663
+ lastX = Number.NaN;
1664
+ lastY = Number.NaN;
1665
+ /** Whether the previous poll saw a choice up (a fresh choice set must be
1666
+ * hit-tested even under a stationary pointer). */
1667
+ wasChoosing = false;
1668
+ constructor(choices) {
1669
+ this.choices = choices;
1670
+ }
1671
+ bind(input, session) {
1672
+ this.unsub?.();
1673
+ this.input = input;
1674
+ this.session = session;
1675
+ this.unsub = input.onPointerDown((info) => {
1676
+ if (info.button === 0) this.clicked = true;
1677
+ });
1678
+ }
1679
+ /** Pointer position in the choice presenter's coordinate space. */
1680
+ pointer(input) {
1681
+ return this.choices?.pointerSpace === "world" ? input.getPointerPosition() : input.getPointerScreenPosition();
1682
+ }
1683
+ poll() {
1684
+ const { input, session } = this;
1685
+ if (!input || !session) return;
1686
+ const choosing = session.isChoosing();
1687
+ if (choosing && this.choices?.choiceAtPoint) {
1688
+ const p = this.pointer(input);
1689
+ if (!this.wasChoosing || p.x !== this.lastX || p.y !== this.lastY) {
1690
+ this.lastX = p.x;
1691
+ this.lastY = p.y;
1692
+ const hovered = this.choices.choiceAtPoint(p.x, p.y);
1693
+ if (hovered !== void 0) session.selectAt(hovered);
1694
+ }
1695
+ }
1696
+ this.wasChoosing = choosing;
1697
+ const clicked = this.clicked;
1698
+ this.clicked = false;
1699
+ if (!clicked) return;
1700
+ if (choosing) {
1701
+ const p = this.pointer(input);
1702
+ const hit = this.choices?.choiceAtPoint?.(p.x, p.y);
1703
+ if (hit !== void 0) session.confirmAt(hit);
1704
+ } else {
1705
+ session.advance();
1706
+ }
1707
+ }
1708
+ dispose() {
1709
+ this.unsub?.();
1710
+ this.unsub = void 0;
1711
+ }
1712
+ };
1713
+ function fullControls(choices, options = {}) {
1714
+ const { actions = FULL_ACTIONS, skipHoldMs = 0 } = options;
1715
+ return new CompositeInputBinding([
1716
+ new KeyboardInputBinding(actions, skipHoldMs),
1717
+ new PointerInputBinding(choices)
1718
+ ]);
1719
+ }
1720
+ __name(fullControls, "fullControls");
1721
+
1722
+ // src/events.ts
1723
+ import { defineEvent } from "@yagejs/core";
1724
+ var DialogueStartedEvent = defineEvent("dialogue:started");
1725
+ var DialogueLineEvent = defineEvent("dialogue:line");
1726
+ var DialogueChoiceShownEvent = defineEvent(
1727
+ "dialogue:choice-shown"
1728
+ );
1729
+ var DialogueChoiceMadeEvent = defineEvent(
1730
+ "dialogue:choice-made"
1731
+ );
1732
+ var DialogueCommandEvent = defineEvent(
1733
+ "dialogue:command"
1734
+ );
1735
+ var DialogueEndedEvent = defineEvent("dialogue:ended");
1736
+ var DialogueRevealCompletedEvent = defineEvent("dialogue:reveal-completed");
1737
+ var DialogueSelectionChangedEvent = defineEvent(
1738
+ "dialogue:selection-changed"
1739
+ );
1740
+ var DialogueSkipUsedEvent = defineEvent(
1741
+ "dialogue:skip-used"
1742
+ );
1743
+ var DialogueAutoAdvanceEvent = defineEvent(
1744
+ "dialogue:auto-advance"
1745
+ );
1746
+ var DialogueRevealMarkerEvent = defineEvent("dialogue:reveal-marker");
1747
+
1748
+ // src/DialogueController.ts
1749
+ var DialogueController = class extends Component {
1750
+ constructor(opts) {
1751
+ super();
1752
+ this.opts = opts;
1753
+ this.binding = opts.input ?? new KeyboardInputBinding();
1754
+ }
1755
+ opts;
1756
+ static {
1757
+ __name(this, "DialogueController");
1758
+ }
1759
+ input = this.service(InputManagerKey);
1760
+ binding;
1761
+ session;
1762
+ /** Captured at onAdd (the scene is gone by the time a stale play() arrives). */
1763
+ logger;
1764
+ /** Set by onDestroy — the presenters are disposed, so play() must refuse. */
1765
+ destroyed = false;
1766
+ /** Input focus. When false, `update()` keeps pumping the session (an
1767
+ * ambient conversation stays alive) but the binding is NOT polled, so this
1768
+ * instance doesn't consume device input. NOT `Component.enabled` (which would
1769
+ * also freeze the session). */
1770
+ inputEnabled = true;
1771
+ /** Pause. Mirrors the session's pause so the binding poll is also gated
1772
+ * while frozen — a paused conversation neither updates nor consumes input.
1773
+ * Also the source of truth re-applied to the session in `onAdd` when a host
1774
+ * set it before the component was added (the session didn't exist yet). */
1775
+ paused = false;
1776
+ /** Hidden. Mirrors the session's hide so a `setHidden` issued before the
1777
+ * component was added isn't lost — it's re-applied once the session exists. */
1778
+ hidden = false;
1779
+ /** Disposers for every registered extra channel (ctor `channels` + live
1780
+ * `addChannel`). `onDestroy` runs them all — each idempotent — to unregister
1781
+ * and dispose (unmounting the Mountable ones). */
1782
+ channelDisposers = /* @__PURE__ */ new Set();
1783
+ onAdd() {
1784
+ this.logger = this.context.tryResolve(LoggerKey);
1785
+ const warn = /* @__PURE__ */ __name((message) => this.logger?.warn("dialogue", message), "warn");
1786
+ this.opts.chrome.mount(this.scene);
1787
+ this.opts.choices.mount(this.scene);
1788
+ this.opts.avatar?.mount(this.scene);
1789
+ this.opts.text.mount(this.scene);
1790
+ this.opts.chrome.setDiagnostics?.(warn);
1791
+ this.opts.text.setDiagnostics?.(warn);
1792
+ this.opts.choices.setDiagnostics?.(warn);
1793
+ this.session = new DialogueSession(
1794
+ {
1795
+ text: this.opts.text,
1796
+ choices: this.opts.choices,
1797
+ avatar: this.opts.avatar,
1798
+ chrome: this.opts.chrome
1799
+ },
1800
+ {
1801
+ i18n: this.opts.i18n,
1802
+ skipMultiplier: this.opts.skipMultiplier,
1803
+ // Controller-installed environment — persists across plays.
1804
+ storage: this.opts.storage,
1805
+ functions: this.opts.functions,
1806
+ commands: this.opts.commands,
1807
+ fallbackCommand: this.opts.fallbackCommand,
1808
+ onStarted: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueStartedEvent, e), "onStarted"),
1809
+ onLine: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueLineEvent, e), "onLine"),
1810
+ onChoiceShown: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueChoiceShownEvent, { options: e.options }), "onChoiceShown"),
1811
+ onChoiceMade: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueChoiceMadeEvent, e), "onChoiceMade"),
1812
+ // Observation only — the `commands` map does the work; this mirrors
1813
+ // every non-built-in command onto the scene event bus.
1814
+ onCommand: /* @__PURE__ */ __name((command, ctx) => this.entity.emit(DialogueCommandEvent, { command, mode: ctx.mode }), "onCommand"),
1815
+ // Route non-fatal runtime diagnostics (e.g. a `set` to a read-only cell)
1816
+ // through the engine logger rather than crashing or silently dropping.
1817
+ onError: /* @__PURE__ */ __name((message) => warn(message), "onError"),
1818
+ onEnded: /* @__PURE__ */ __name((e) => {
1819
+ this.entity.emit(DialogueEndedEvent, e);
1820
+ this.opts.onEnded?.();
1821
+ }, "onEnded"),
1822
+ // Observation events — the controller is the one canonical path that
1823
+ // turns the session's callbacks into entity→scene events (no matching
1824
+ // controller callback options).
1825
+ onRevealCompleted: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueRevealCompletedEvent, e), "onRevealCompleted"),
1826
+ onSelectionChanged: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueSelectionChangedEvent, e), "onSelectionChanged"),
1827
+ onSkipUsed: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueSkipUsedEvent, e), "onSkipUsed"),
1828
+ onAutoAdvance: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueAutoAdvanceEvent, e), "onAutoAdvance"),
1829
+ // Inline markers fan to an entity event; per-grapheme ticks stay a direct
1830
+ // callback (forwarded verbatim — undefined when the host wires none).
1831
+ onRevealMarker: /* @__PURE__ */ __name((marker, viaSkip) => this.entity.emit(DialogueRevealMarkerEvent, { marker, viaSkip }), "onRevealMarker"),
1832
+ onRevealTick: this.opts.onRevealTick
1833
+ }
1834
+ );
1835
+ this.binding.bind(this.input, this.session);
1836
+ if (this.paused) this.session.setPaused(true);
1837
+ if (this.hidden) this.session.setHidden(true);
1838
+ for (const ch of this.opts.channels ?? []) this.addChannel(ch);
1839
+ }
1840
+ onDestroy() {
1841
+ this.destroyed = true;
1842
+ this.session?.stop();
1843
+ for (const dispose of [...this.channelDisposers]) dispose();
1844
+ this.binding.dispose?.();
1845
+ this.opts.text.dispose();
1846
+ this.opts.choices.dispose();
1847
+ this.opts.chrome.dispose();
1848
+ this.opts.avatar?.dispose();
1849
+ }
1850
+ /**
1851
+ * Begin a conversation. `play(script)` is **content-only** — storage,
1852
+ * functions, and commands are installed on the controller. `overrides` layers
1853
+ * per-conversation specifics on top (a scoped `storage`, extra
1854
+ * `functions`/`commands`). Returns a {@link DialogueHandle} for live `setVar` /
1855
+ * `getVars`, or `undefined` if the controller was removed.
1856
+ */
1857
+ play(script, overrides) {
1858
+ if (this.destroyed) {
1859
+ this.logger?.warn(
1860
+ "dialogue",
1861
+ "DialogueController.play() ignored: the component has been removed/destroyed.",
1862
+ { scriptId: script.id }
1863
+ );
1864
+ return void 0;
1865
+ }
1866
+ if (!this.session) {
1867
+ throw new Error(
1868
+ "DialogueController.play() called before the component was added to an entity (onAdd has not run yet)."
1869
+ );
1870
+ }
1871
+ return this.session.play(script, overrides);
1872
+ }
1873
+ isActive() {
1874
+ return this.session?.isActive() ?? false;
1875
+ }
1876
+ /** Abandon the current conversation and reset to idle. */
1877
+ stop() {
1878
+ this.session?.stop();
1879
+ }
1880
+ /**
1881
+ * Register an extra channel live — Voice / Shop / CameraEffects / History.
1882
+ * Mounts it if it needs the scene ({@link Mountable}), hands it to the session
1883
+ * (where it joins the cross-cutting stream and can gate auto-advance), and
1884
+ * returns a disposer that unregisters + disposes it. The `channels` ctor option
1885
+ * registers a bundle the same way at mount. Returns a no-op disposer if the
1886
+ * controller was destroyed; **throws** if called before the component is added
1887
+ * to an entity (use the `channels` ctor option to pre-wire a channel) — mirrors
1888
+ * {@link play}.
1889
+ */
1890
+ addChannel(ch) {
1891
+ if (this.destroyed) {
1892
+ this.logger?.warn(
1893
+ "dialogue",
1894
+ "DialogueController.addChannel() ignored: the component has been removed/destroyed."
1895
+ );
1896
+ return () => {
1897
+ };
1898
+ }
1899
+ if (!this.session) {
1900
+ throw new Error(
1901
+ "DialogueController.addChannel() called before the component was added to an entity (onAdd has not run yet). Use the `channels` constructor option to pre-wire a channel."
1902
+ );
1903
+ }
1904
+ if (isMountable(ch)) {
1905
+ try {
1906
+ ch.mount(this.scene);
1907
+ } catch (error) {
1908
+ this.logger?.warn(
1909
+ "dialogue",
1910
+ `extra channel mount() failed: ${error instanceof Error ? error.message : String(error)}`
1911
+ );
1912
+ }
1913
+ }
1914
+ const unregister = this.session.addChannel(ch);
1915
+ const dispose = /* @__PURE__ */ __name(() => {
1916
+ if (!this.channelDisposers.delete(dispose)) return;
1917
+ unregister();
1918
+ }, "dispose");
1919
+ this.channelDisposers.add(dispose);
1920
+ return dispose;
1921
+ }
1922
+ /** Fast-forward the current section to the next choice or the end. */
1923
+ skip() {
1924
+ this.session?.skip();
1925
+ }
1926
+ /**
1927
+ * Auto-advance lines after they finish revealing (`ms`), or `null` to disable
1928
+ * (manual advance). A per-line `autoAdvanceMs` still overrides this. Toggle it
1929
+ * live for a VN-style "auto" control.
1930
+ */
1931
+ setAutoAdvance(ms) {
1932
+ this.session?.setAutoAdvance(ms);
1933
+ }
1934
+ /**
1935
+ * Hide or show the whole dialogue UI without ending the conversation —
1936
+ * for a cutscene takeover (`setHidden(true)` while the camera pans, then
1937
+ * `setHidden(false)` to restore the exact line + caret). Purely visual; the
1938
+ * conversation keeps its state. **Persistent**: it survives `stop()`/`play()`,
1939
+ * so a host that hides and forgets to unhide stays hidden.
1940
+ */
1941
+ setHidden(hidden) {
1942
+ this.hidden = hidden;
1943
+ this.session?.setHidden(hidden);
1944
+ }
1945
+ /**
1946
+ * Freeze or resume the conversation — a pause menu. While paused the
1947
+ * reveal, auto-advance, caret blink, and avatar anim all halt, input is inert,
1948
+ * and no state is lost (an in-flight blocking command keeps running). Also
1949
+ * gates this controller's input binding so a frozen conversation consumes no
1950
+ * device input. Does NOT block host-driven `handle.setVar` / storage writes.
1951
+ */
1952
+ setPaused(paused) {
1953
+ this.paused = paused;
1954
+ this.session?.setPaused(paused);
1955
+ }
1956
+ /**
1957
+ * Set whether this controller consumes device input — the focus seam for
1958
+ * the multi-instance story. `setInputEnabled(false)` keeps the conversation
1959
+ * fully alive (it still updates, reveals, auto-advances) but stops polling its
1960
+ * binding, so an ambient conversation doesn't steal the advance key. Switch
1961
+ * focus between two conversations with `a.setInputEnabled(true);
1962
+ * b.setInputEnabled(false)`. (YAGE input is non-consuming, so two *enabled*
1963
+ * controllers both advance on one press — focus is the game's policy.)
1964
+ */
1965
+ setInputEnabled(enabled) {
1966
+ this.inputEnabled = enabled;
1967
+ }
1968
+ /**
1969
+ * Primary action, host-driven (the input-agnostic seam): while saying,
1970
+ * reveal-all if still typing else advance to the next line; while choosing,
1971
+ * confirm the highlighted option. Lets a host (cutscene script, custom input,
1972
+ * or a test) drive the conversation without synthesising device input — the
1973
+ * same call the default {@link InputBinding} makes.
1974
+ */
1975
+ advance() {
1976
+ this.session?.advance();
1977
+ }
1978
+ /** Move the choice cursor by `delta` (wraps). No-op outside a choice. */
1979
+ moveSelection(delta) {
1980
+ this.session?.moveSelection(delta);
1981
+ }
1982
+ /** Commit a choice by its original option index. No-op outside a choice. */
1983
+ choose(optionIndex) {
1984
+ this.session?.choose(optionIndex);
1985
+ }
1986
+ /** True while a choice is being presented. */
1987
+ isChoosing() {
1988
+ return this.session?.isChoosing() ?? false;
1989
+ }
1990
+ /** Side-effect-free lookahead of the lines a node would show. */
1991
+ preview(nodeId) {
1992
+ return this.session?.preview(nodeId) ?? [];
1993
+ }
1994
+ update(dt) {
1995
+ this.session?.update(dt);
1996
+ if (this.inputEnabled && !this.paused) this.binding.poll();
1997
+ }
1998
+ };
1999
+ function isMountable(ch) {
2000
+ return typeof ch.mount === "function";
2001
+ }
2002
+ __name(isMountable, "isMountable");
2003
+ export {
2004
+ CompositeInputBinding,
2005
+ DEFAULT_ACTIONS,
2006
+ DialogueAutoAdvanceEvent,
2007
+ DialogueChoiceMadeEvent,
2008
+ DialogueChoiceShownEvent,
2009
+ DialogueCommandEvent,
2010
+ DialogueController,
2011
+ DialogueEndedEvent,
2012
+ DialogueExprError,
2013
+ DialogueLineEvent,
2014
+ DialoguePlayError,
2015
+ DialogueRevealCompletedEvent,
2016
+ DialogueRevealMarkerEvent,
2017
+ DialogueRunner,
2018
+ DialogueScriptError,
2019
+ DialogueSelectionChangedEvent,
2020
+ DialogueSession,
2021
+ DialogueSkipUsedEvent,
2022
+ DialogueStartedEvent,
2023
+ EMPTY_PARSED,
2024
+ FULL_ACTIONS,
2025
+ IdentityI18n,
2026
+ KeyboardInputBinding,
2027
+ LineReveal,
2028
+ MemoryVariableStorage,
2029
+ PointerInputBinding,
2030
+ cells,
2031
+ compose,
2032
+ createVoiceChannel,
2033
+ defineScript,
2034
+ evalCondition,
2035
+ evaluate,
2036
+ fullControls,
2037
+ interpolate,
2038
+ isExpr,
2039
+ loadCompact,
2040
+ loadScript,
2041
+ materialize,
2042
+ parseCompact,
2043
+ parseExpr,
2044
+ parseMarkup,
2045
+ splitGraphemes,
2046
+ stripMarkup
2047
+ };
2048
+ //# sourceMappingURL=index.js.map