@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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Engine-agnostic dialogue model. Nothing in this folder imports `@yagejs/*`
3
+ * — it's plain TypeScript so it can be unit-tested headless and lifted into a
4
+ * standalone `@yagejs/dialogue` package later. Engine/UI/i18n touch points all
5
+ * live behind adapters (see ../chrome, ../render, ../avatar, ./i18n).
6
+ */
7
+ type NodeId = string;
8
+ type SpeakerId = string;
9
+ /**
10
+ * A side effect attached to a step or a choice. The runner handles a couple of
11
+ * built-ins (`set` mutates branching vars, `goto` jumps) and surfaces the rest
12
+ * to the host via the command event, so the game decides what `give-item` or
13
+ * `play-sfx` actually mean. Keep it a flat tagged record so scripts stay JSON.
14
+ */
15
+ /**
16
+ * When a command attached to a `say` line fires, relative to its reveal:
17
+ * `show` (as the line appears — default), `afterReveal` (once fully typed), or
18
+ * `advance` (as the player leaves the line). Ignored for `command`/choice
19
+ * commands, which fire inline. Skipping fires everything at show-time.
20
+ */
21
+ type CommandTiming = "show" | "afterReveal" | "advance";
22
+ interface Command {
23
+ readonly type: string;
24
+ /**
25
+ * If true, a handler that returns a promise *pauses* the conversation until it
26
+ * resolves (the runner enters its `awaiting-command` wait-state). Use for
27
+ * cinematic sequencing — "wait for the NPC to walk off, then continue". A
28
+ * non-blocking handler's promise is fire-and-forget.
29
+ */
30
+ readonly blocking?: boolean;
31
+ /** Reveal-relative firing time for a `say`-line command. Default `show`. */
32
+ readonly at?: CommandTiming;
33
+ readonly [key: string]: unknown;
34
+ }
35
+ /** Whether the runner is playing normally or fast-forwarding through a skip. */
36
+ type RunMode = "play" | "skip";
37
+ /** Context handed to every command handler. */
38
+ interface CommandContext {
39
+ readonly mode: RunMode;
40
+ /**
41
+ * Write a variable through the conversation's {@link VariableStorage} — the
42
+ * skill-check seam: a result a blocking command computes (`ctx.setVar("passed",
43
+ * true)`) is read by a later condition. Routes through the same guarded write
44
+ * as the `set` built-in, so a read-only `cells` accessor throws. A stale ctx
45
+ * (after stop/replay) no-ops.
46
+ */
47
+ setVar(name: string, value: VarValue): void;
48
+ }
49
+ /**
50
+ * A command handler. Return a promise from a `blocking` command to pause the
51
+ * conversation until it resolves (cinematic sequencing). Handlers are installed
52
+ * on the controller (`commands` map + optional `fallbackCommand`), with optional
53
+ * per-`play()` overrides.
54
+ */
55
+ type CommandHandler = (command: Command, ctx: CommandContext) => void | Promise<void>;
56
+ /**
57
+ * A boolean guard on a choice or step. Resolved against the conversation's
58
+ * {@link VariableStorage} + installed functions:
59
+ *
60
+ * - a bare name (`"greeted"`) → truthy check on that variable;
61
+ * - an atomic comparison `{ var, op, value }` (the degenerate one-level tree);
62
+ * - a full {@link Expr} tree (`and`/`or`, arithmetic, `has_item("key")`, …);
63
+ * - a `(vars) => boolean` predicate — TS-only, receives a materialized snapshot
64
+ * of the readable variables (doesn't survive JSON).
65
+ */
66
+ type Condition = string | {
67
+ readonly var: string;
68
+ readonly op: CompareOp;
69
+ readonly value: unknown;
70
+ } | Expr | ((vars: VarMap) => boolean);
71
+ /** Operators for the atomic `{ var, op, value }` condition (the degenerate
72
+ * comparison tree). Full expression trees use {@link BinaryOp}/{@link UnaryOp}. */
73
+ type CompareOp = "==" | "!=" | ">" | ">=" | "<" | "<=" | "truthy" | "falsy";
74
+ type VarValue = string | number | boolean | null;
75
+ type VarMap = Record<string, VarValue>;
76
+ /** Comparison operators (symbol + Yarn word forms). `is`/`eq` ≡ `==`. */
77
+ type ComparisonOp = "==" | "!=" | ">" | "<" | ">=" | "<=" | "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "is";
78
+ /** Boolean operators (symbol + word forms). */
79
+ type LogicalOp = "and" | "&&" | "or" | "||" | "xor" | "^";
80
+ /** Arithmetic operators. `+` concatenates when either operand is a string. */
81
+ type ArithmeticOp = "+" | "-" | "*" | "/" | "%";
82
+ type BinaryOp = ComparisonOp | LogicalOp | ArithmeticOp;
83
+ /** Unary operators: logical negation (`not`/`!`) and numeric negation (`-`). */
84
+ type UnaryOp = "not" | "!" | "-";
85
+ /** An expression node. Evaluates to a {@link VarValue} against an eval scope
86
+ * (variable reads + installed functions). */
87
+ type Expr = {
88
+ readonly kind: "literal";
89
+ readonly value: VarValue;
90
+ } | {
91
+ readonly kind: "varRef";
92
+ readonly name: string;
93
+ } | {
94
+ readonly kind: "call";
95
+ readonly fn: string;
96
+ readonly args?: readonly Expr[];
97
+ } | {
98
+ readonly kind: "unary";
99
+ readonly op: UnaryOp;
100
+ readonly operand: Expr;
101
+ } | {
102
+ readonly kind: "binary";
103
+ readonly op: BinaryOp;
104
+ readonly left: Expr;
105
+ readonly right: Expr;
106
+ } | {
107
+ readonly kind: "group";
108
+ readonly expr: Expr;
109
+ };
110
+ /**
111
+ * The read/write bridge between a conversation and game state (Yarn's
112
+ * `VariableStorage` shape). Names are **opaque** — the runtime imposes no
113
+ * meaning on a name's characters; scoping/prefixing/nesting is the host's
114
+ * policy. The addon ships a zero-config {@link MemoryVariableStorage}; a host
115
+ * can supply its own or {@link compose} several (a {@link cells} accessor over
116
+ * game state, an in-memory default for dialogue-locals + seeds, …). Storage
117
+ * **persists** across `play()`s.
118
+ */
119
+ interface VariableStorage {
120
+ /** Read a variable, or `undefined` if absent. */
121
+ get(name: string): VarValue | undefined;
122
+ /** Write a variable. A read-only accessor (a `cells` getter without a setter)
123
+ * throws. */
124
+ set(name: string, value: VarValue): void;
125
+ /** Whether the storage currently holds `name` (drives seed-if-absent). */
126
+ has(name: string): boolean;
127
+ /**
128
+ * Enumerate the readable `(name, value)` pairs — backs `{token}` interpolation
129
+ * params, `handle.getVars()`, and the `(vars) => boolean` predicate. A fully
130
+ * opaque game store may enumerate fewer names; those then won't interpolate or
131
+ * appear in `getVars()`, but direct `get`/`set`/`has` still work.
132
+ */
133
+ entries(): Iterable<readonly [string, VarValue]>;
134
+ }
135
+ /**
136
+ * A pure, argument-capable read installed on the controller (`functions`). The
137
+ * read-only counterpart to a `command`: `has_item("rusty-key")` in a condition.
138
+ * Zero-arg reads need no function — a bare name reads {@link VariableStorage}.
139
+ * MUST be cheap + side-effect-free (called on every condition test).
140
+ */
141
+ type DialogueFunction = (...args: VarValue[]) => VarValue;
142
+ /**
143
+ * Per-`play()` overrides, layered over the controller-installed
144
+ * storage/functions/commands for entity-specifics. `storage` replaces the
145
+ * controller's (use {@link compose} to layer); `functions`/`commands` merge
146
+ * key-by-key with the call site winning; `fallbackCommand` wins when set.
147
+ */
148
+ interface DialoguePlayOptions {
149
+ readonly storage?: VariableStorage | undefined;
150
+ readonly functions?: Readonly<Record<string, DialogueFunction>> | undefined;
151
+ readonly commands?: Readonly<Record<string, CommandHandler>> | undefined;
152
+ readonly fallbackCommand?: CommandHandler | undefined;
153
+ }
154
+ /**
155
+ * The typed, per-conversation handle returned by `play()`. Lets the host poke
156
+ * variables live (`setVar`) and read them back (`getVars`) without growing
157
+ * string-keyed methods on the controller. Generation-stamped: after
158
+ * `stop()`/replay a stale handle no-ops (`setVar`) / returns an empty snapshot
159
+ * (`getVars`).
160
+ */
161
+ interface DialogueHandle<Vars extends VarMap = VarMap> {
162
+ /** Write a variable through the conversation's storage (guarded). On the
163
+ * {@link defineScript} path both the name AND the value are typed to the
164
+ * script's declared variables — a wrong-typed value is a compile error.
165
+ * (`ctx.setVar` stays loosely typed: command handlers are installed on the
166
+ * controller, not bound to any one script's variable types.) */
167
+ setVar<K extends keyof Vars & string>(name: K, value: Vars[K]): void;
168
+ /** Snapshot of the storage's enumerable variables. */
169
+ getVars(): Readonly<Vars>;
170
+ }
171
+ /** A single line of dialogue spoken by an optional speaker. */
172
+ interface SayStep {
173
+ readonly kind: "say";
174
+ readonly speaker?: SpeakerId;
175
+ /** Literal text (default-locale), and/or an i18n `key`. Markup allowed. */
176
+ readonly text: string;
177
+ readonly key?: string;
178
+ /** Expression variant for the speaker's avatar (e.g. "happy", "angry"). */
179
+ readonly expression?: string;
180
+ /** Reveal-speed multiplier for this whole line (1 = base). */
181
+ readonly speed?: number;
182
+ /** If set, the line auto-advances after this many ms once fully revealed. */
183
+ readonly autoAdvanceMs?: number;
184
+ readonly commands?: readonly Command[];
185
+ /** Opaque preset name for per-line layout/variant (presenter interprets). */
186
+ readonly view?: string;
187
+ /** Opaque per-line hint bag (presenter/chrome interprets). */
188
+ readonly meta?: Readonly<Record<string, unknown>>;
189
+ /** Voice-clip id (audio handler interprets; reveal may sync to it). */
190
+ readonly voice?: string;
191
+ }
192
+ interface ChoiceOption {
193
+ readonly text: string;
194
+ readonly key?: string;
195
+ /** Node to jump to when picked. Omit to just continue the current node. */
196
+ readonly target?: NodeId;
197
+ readonly condition?: Condition;
198
+ /** Hide this option once it has been picked. Tracked as per-conversation
199
+ * cursor state (resets on a fresh `play()`; the future save cursor captures
200
+ * it), NOT in the variable storage. */
201
+ readonly once?: boolean;
202
+ /**
203
+ * What to do when this option's {@link condition} is **false**. Default
204
+ * `"hidden"` (the option is filtered out). `"disabled"` keeps it on screen as
205
+ * a non-selectable, greyed-out row (the Disco-Elysium "[Strength 8] Force the
206
+ * door" pattern — the player learns the gate exists). Governs condition
207
+ * failures only: a spent `once` option is **always** hidden regardless. A step
208
+ * whose only-enabled count drops to zero is skipped, so a disabled row never
209
+ * causes a soft-lock.
210
+ */
211
+ readonly presentation?: "hidden" | "disabled";
212
+ /** Short reason shown beside a `"disabled"` row where the layout allows (e.g.
213
+ * "Requires the rusty key"). Resolved through the i18n adapter, so `{token}`s
214
+ * interpolate; there is no separate i18n `key` for it. */
215
+ readonly disabledReason?: string;
216
+ readonly commands?: readonly Command[];
217
+ /** Opaque per-choice hint bag (tone/icon/position for fancy choice UIs). */
218
+ readonly meta?: Readonly<Record<string, unknown>>;
219
+ }
220
+ interface ChoiceStep {
221
+ readonly kind: "choice";
222
+ /** Optional prompt shown above the options. */
223
+ readonly text?: string;
224
+ readonly key?: string;
225
+ readonly speaker?: SpeakerId;
226
+ readonly options: readonly ChoiceOption[];
227
+ /** Presentation preset for the prompt/chrome (e.g. "box"/"bubble"), like a
228
+ * say's `view` — lets a composite chrome route the choice the same way. */
229
+ readonly view?: string;
230
+ readonly meta?: Readonly<Record<string, unknown>>;
231
+ }
232
+ /** Fire commands without showing anything (set a var, emit a game event). */
233
+ interface CommandStep {
234
+ readonly kind: "command";
235
+ readonly commands: readonly Command[];
236
+ /** Optional conditional jump; if `condition` passes, jump to `target`. */
237
+ readonly condition?: Condition;
238
+ readonly target?: NodeId;
239
+ }
240
+ /** Unconditional jump to another node. */
241
+ interface GotoStep {
242
+ readonly kind: "goto";
243
+ readonly target: NodeId;
244
+ }
245
+ /** Ends the conversation immediately. */
246
+ interface EndStep {
247
+ readonly kind: "end";
248
+ }
249
+ type Step = SayStep | ChoiceStep | CommandStep | GotoStep | EndStep;
250
+ interface DialogueNode {
251
+ readonly id: NodeId;
252
+ readonly steps: readonly Step[];
253
+ }
254
+ /** How a speaker's avatar is presented (the presenter implementation decides). */
255
+ interface AvatarRef {
256
+ /** "portrait" → a sprite beside the box; "scene" → an existing world entity. */
257
+ readonly kind: "portrait" | "scene";
258
+ /** Presenter-specific handle: a texture id for portraits, an entity name for scene. */
259
+ readonly ref: string;
260
+ /** Map of expression id → presenter-specific variant (texture/frame/anim). */
261
+ readonly expressions?: Record<string, string>;
262
+ /** Side the portrait sits on. Default "left". */
263
+ readonly side?: "left" | "right";
264
+ }
265
+ interface SpeakerDef {
266
+ readonly id: SpeakerId;
267
+ /** Display name (literal). */
268
+ readonly name: string;
269
+ /** i18n key for the name. */
270
+ readonly nameKey?: string;
271
+ /** Name-plate tint (0xRRGGBB). */
272
+ readonly color?: number;
273
+ readonly avatar?: AvatarRef;
274
+ }
275
+ interface DialogueScript {
276
+ readonly id: string;
277
+ readonly start: NodeId;
278
+ readonly nodes: Record<NodeId, DialogueNode>;
279
+ readonly speakers?: Record<SpeakerId, SpeakerDef>;
280
+ /**
281
+ * Declared variable **defaults** (Yarn `<<declare>>` / `InitialValues`). On
282
+ * `play()`, each applies **only if the installed storage doesn't already
283
+ * `has` the name** (seed-if-absent) — a game-linked value always wins, the
284
+ * addon never clobbers. The default's value also fixes the variable's inferred
285
+ * type on the {@link defineScript} path. Variables **persist** in storage
286
+ * across plays (cycling-NPC counters, quest progress); a script re-inits a
287
+ * value explicitly to reset it. (A choice's `once` flag is per-conversation
288
+ * cursor state, not a stored variable — see {@link ChoiceOption.once}.)
289
+ */
290
+ readonly declare?: VarMap;
291
+ }
292
+ interface RunStyle {
293
+ readonly bold?: boolean;
294
+ readonly italic?: boolean;
295
+ /** 0xRRGGBB. */
296
+ readonly color?: number;
297
+ /**
298
+ * Animated effect applied to the whole run — an **open vocabulary** the
299
+ * presenter interprets, like an inline marker name. The bundled text presenter
300
+ * animates the {@link BuiltinEffectId}s; a custom text channel can animate any
301
+ * name; an effect a presenter doesn't recognize renders as plain styled text.
302
+ * `bold`/`italic`/`color`/`speed` are the typed, universal set — `effect` is
303
+ * the one open dimension.
304
+ */
305
+ readonly effect?: string;
306
+ /** Reveal-speed multiplier for characters in this run. */
307
+ readonly speed?: number;
308
+ }
309
+ /** The text effects the bundled renderer presenter animates out of the box.
310
+ * {@link RunStyle.effect} is an open string; these are just the built-ins — a
311
+ * documented reference, not a closed set the parser enforces. */
312
+ type BuiltinEffectId = "wave" | "shake" | "pulse" | "rainbow";
313
+ /** A contiguous span of text sharing one style. */
314
+ interface TextRun {
315
+ readonly text: string;
316
+ readonly style: RunStyle;
317
+ /**
318
+ * Number of graphemes (user-perceived characters) in `text` — NOT
319
+ * `text.length`. All reveal bookkeeping counts graphemes, because that is
320
+ * the unit the renderer splits into glyph nodes (one per grapheme), so an
321
+ * emoji / ZWJ sequence / combining mark counts as 1.
322
+ */
323
+ readonly graphemeCount: number;
324
+ }
325
+ /**
326
+ * A self-closing `[pause=600/]` token — a zero-width timing hold the reveal
327
+ * arms when the cursor reaches its offset. One member of the unified
328
+ * {@link RevealToken} stream; `kind` discriminates it from a {@link MarkerToken}.
329
+ */
330
+ interface PauseToken {
331
+ readonly kind: "pause";
332
+ /** Grapheme index into the flattened text where the pause occurs. */
333
+ readonly atChar: number;
334
+ /** Hold duration in ms (always > 0; a non-positive `[pause=0/]` emits no token). */
335
+ readonly ms: number;
336
+ }
337
+ /**
338
+ * A self-closing inline marker (`[name k=v/]`) that fires as a **reveal event**
339
+ * when the typewriter cursor reaches its char offset — the sibling of
340
+ * {@link PauseToken}, but a one-shot consequence rather than a timing hold. The
341
+ * canonical use is positional SFX (`[sfx=ding/]`) and a mid-line face change
342
+ * (`[expression=happy/]`); the addon interprets none of the names (the avatar
343
+ * channel reads `[expression]`, the host reads the rest). The Yarn self-named
344
+ * shortcut `[name=val/]` ≡ `[name name=val/]`, so it composes with explicit
345
+ * props (`[shake=500 amount=3/]` → `{ shake: "500", amount: "3" }`).
346
+ */
347
+ interface MarkerToken {
348
+ readonly kind: "marker";
349
+ /** Grapheme index into the flattened text where the marker fires. */
350
+ readonly atChar: number;
351
+ /** Marker name (lower-cased), e.g. `expression`, `sfx`, `shake`. */
352
+ readonly name: string;
353
+ /** Parsed `key=value` props (all string-valued). `[expression=happy/]` →
354
+ * `{ expression: "happy" }` via the self-named shortcut. */
355
+ readonly props: Readonly<Record<string, string>>;
356
+ }
357
+ /**
358
+ * One inline control token interleaved between text runs during reveal — a
359
+ * timing {@link PauseToken} or a fire-and-forget {@link MarkerToken}. They share
360
+ * one ordered stream on {@link ParsedText.tokens}, so **source order is drain
361
+ * order**: `[pause=600/][shake/]` holds then fires; `[shake/][pause=600/]` fires
362
+ * then holds.
363
+ */
364
+ type RevealToken = PauseToken | MarkerToken;
365
+ /** Result of parsing one line's markup. */
366
+ interface ParsedText {
367
+ readonly runs: readonly TextRun[];
368
+ /** Inline pause + marker tokens in source (left-to-right) order — the order
369
+ * the reveal drains them. Empty when the line has none. */
370
+ readonly tokens: readonly RevealToken[];
371
+ /** Total grapheme count across all runs (the reveal denominator). */
372
+ readonly length: number;
373
+ }
374
+
375
+ export type { ArithmeticOp as A, BinaryOp as B, Condition as C, DialogueScript as D, Expr as E, GotoStep as G, LogicalOp as L, MarkerToken as M, NodeId as N, ParsedText as P, RunMode as R, SayStep as S, TextRun as T, UnaryOp as U, VarValue as V, VariableStorage as a, VarMap as b, DialogueFunction as c, SpeakerDef as d, ChoiceStep as e, ChoiceOption as f, Command as g, CommandContext as h, AvatarRef as i, BuiltinEffectId as j, CommandHandler as k, CommandStep as l, CommandTiming as m, CompareOp as n, ComparisonOp as o, DialogueHandle as p, DialogueNode as q, DialoguePlayOptions as r, EndStep as s, PauseToken as t, RevealToken as u, RunStyle as v, SpeakerId as w, Step as x };