Compare commits

...

9 Commits

Author SHA1 Message Date
hypercross a226a9516c feat: yarn return 2026-03-03 11:49:11 +08:00
hypercross 4d3faa3e4e refactor: dead code 2026-03-03 11:19:40 +08:00
hypercross 4280da9fec refactor: reorg 2026-03-03 10:54:20 +08:00
hypercross 6b77653d27 refactor: layout 2026-03-03 00:48:13 +08:00
hypercross a02edabc41 fix: yarn spinner runner 2026-03-02 17:54:40 +08:00
hypercross 0594fef9ac feat: ai's attempt 2026-03-02 16:49:55 +08:00
hypercross 2a9281c9dc feat: yarn runner 2026-03-02 16:22:54 +08:00
hypercross f64775e735 feat: yarn spinner parser 2026-03-02 16:18:10 +08:00
hypercross f27a6a8dfc feat: yarn spinner 2026-03-02 15:23:12 +08:00
21 changed files with 2956 additions and 3 deletions

View File

@ -1,6 +1,7 @@
# 要求 # 要求
编写ttrpg冒险。冒险将要使用./system.md描述的规则运行。 编写ttrpg冒险。冒险将要使用./system.md描述的规则运行。
若为yarn spinner编写参考./yarn-spinner.md的内容。
每个冒险应该包含以下要素: 每个冒险应该包含以下要素:
@ -59,6 +60,10 @@
- 路径与环境:描述连接地点的路径特征及旅行中的风险。 - 路径与环境:描述连接地点的路径特征及旅行中的风险。
- 地点标注明确主线遭遇M1-M6和支线遭遇S1-S6所在的地理位置。 - 地点标注明确主线遭遇M1-M6和支线遭遇S1-S6所在的地理位置。
设计多种路径连接地点,以允许玩家在探索中有选择的空间。
为路径设计多种特性,如隐藏、上锁、单向、经过之后坍塌等,以允许探索时有多样的体验。
使用`mermaid`语法绘制地图,并插入冒险文件中。 使用`mermaid`语法绘制地图,并插入冒险文件中。
## 遭遇的设计 ## 遭遇的设计

79
content/yarn-spinner.md Normal file
View File

@ -0,0 +1,79 @@
# Yarn Spinner 编写
Yarn Spinner是一种文字冒险文本格式可用于描述ttrpg冒险。
不要使用`markdown`格式语法,如标题,加粗等。冒号也有特殊含义(代表台词)。只使用纯文本描述冒险内容。
## 基本结构
每个`主线`和`支线`遭遇使用一个`节点`来描述。然后,为每个遭遇里的每个交互创建一个`节点`。
```yarn-spinner
title: 节点名称
---
描述文本
角色: 角色台词
-> 选项描述1
<<命令1>>
-> 选项描述2
<<命令2>>
===
```
对于`随机`遭遇而言,使用多个`title`相同的节点。
可以使用`when: `来为节点触发添加条件。
若需要节点只触发一次,可以结合`变量`。
```yarn-spinner
title: 随机遭遇
when: $villager_encountered == false
---
村民: 森林里有一只大狼!
<<set $villager_encountered to true>>
===
```
## 跳转
`<<jump 节点名称>>`:跳转节点
`<<detour 节点名称>>`:跳转节点,并在节点执行结束后返回
`<<return>>`:返回`detour`跳转来源节点
玩家在主线/支线地点之间移动时,根据路径内容,插入`<<detour 随机遭遇>>`来触发随机遭遇。
随机遭遇可以使用`<<return>>`来返回主线。
## 休息与睡觉
在合适的地方可以休息。如果时间在晚0点到早6点之间可以睡觉。
## 命令和函数
使用命令读写时间。不要创建变量。
- ```<<time_pass>>```:时间流逝,若休息则使用`<<time_pass true>>`。若在夜间休息则休息至第二天6点否则休息1小时。
- ```<<if time_of_day() <= 6>>```读取24小时制当前时间。午夜为0点。
- ```<<if time_of_game() >= 72>>```:总共流逝的时间。
使用以下命令操作角色属性:
- ```<<damage str 4>>```:造成角色属性损伤。若可耐受,则使用```<<damage str 4 true>>```。
- ```<<heal str 4>>```:解除属性创伤,并恢复角色属性损伤。
- ```<<buff str 4 4>>```:施加属性修改值,持续一定小时数。
- ```<<set $result to check("nodeId:checkId", "wis")>> ```:检定角色属性。成功返回正值代表成功进度,失败返回负值代表失败进度。
- ```<<if $result >= 3>>```:处理检定成功进度。
- ```<<if $result <= -2>>```:处理检定失败进度。
- ```<<if $result > 0>>```:如果既没有成功也没有失败,暗示玩家应该继续尝试。
使用以下命令操作角色物品:
- ```<<add_item light_armor 1>>```:添加物品。
- ```<<if consume_item("gold", 100)>>```:消耗物品。
- ```<<if has_item("gold", 100)>>```:检查物品。
## 文件分割
可以把遭遇拆分为多个文件。每个文件不要超过300行。
只要使用匹配的遭遇title就可以跳转到其他文件内的遭遇。
每个交互机会应当有自己的节点。若检定既没有成功也没有失败,则应当允许玩家再次尝试或者返回遭遇。
使用变量来存储其他世界状态。

1
content/yarn-test.md Normal file
View File

@ -0,0 +1 @@
:md-yarn-spinner[./adventures/test.yarn]

View File

@ -6,6 +6,7 @@ import './md-pins';
import './md-bg'; import './md-bg';
import './md-deck'; import './md-deck';
import './md-commander/index'; import './md-commander/index';
import './md-yarn-spinner';
// 导出组件 // 导出组件
export { Article } from './Article'; export { Article } from './Article';
@ -18,6 +19,7 @@ export { FileTreeNode, HeadingNode } from './FileTree';
export type { DiceProps } from './md-dice'; export type { DiceProps } from './md-dice';
export type { TableProps } from './md-table'; export type { TableProps } from './md-table';
export type { BgProps } from './md-bg'; export type { BgProps } from './md-bg';
export type { YarnSpinnerProps } from './md-yarn-spinner';
// 导出 md-commander 相关 // 导出 md-commander 相关
export type { export type {

View File

@ -14,7 +14,7 @@ customElement<MdCommanderProps>(
{ placeholder: "", class: "", height: "", commandTemplates: "" }, { placeholder: "", class: "", height: "", commandTemplates: "" },
(props, { element }) => { (props, { element }) => {
noShadowDOM(); noShadowDOM();
const { articlePath, rawSrc } = loadElementSrc(element as any); const { articlePath, rawSrc } = loadElementSrc(element);
// 初始化命令 // 初始化命令
const commands = initializeCommands(props.commands); const commands = initializeCommands(props.commands);

View File

@ -0,0 +1,112 @@
import { customElement, noShadowDOM } from 'solid-element';
import { For, Show, createEffect } from 'solid-js';
import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results';
import { createYarnStore } from './stores/yarnStore';
import {RunnerOptions} from "../yarn-spinner/runtime/runner";
customElement<RunnerOptions>('md-yarn-spinner', {startAt: 'start'}, (props, { element }) => {
noShadowDOM();
let historyContainer: HTMLDivElement | undefined;
const { store, advance, restart } = createYarnStore(element as any, props);
// 滚动到底部
const scrollToBottom = () => {
if (historyContainer) {
setTimeout(() => {
historyContainer!.scrollTop = historyContainer!.scrollHeight;
}, 0);
}
};
// 监听对话历史变化,自动滚动到底部
createEffect(() => {
store.dialogueHistory;
scrollToBottom();
});
// 渲染对话历史项
const renderEntry = (result: RuntimeResult | OptionsResult['options'][0]) => {
if(!('type' in result)){
return <div class="dialogue-entry text-entry">
<span class="text"> {"->"} {result.text}</span>
</div>
}
if (result.type === 'text') {
const textResult = result as TextResult;
return (
<div class="dialogue-entry text-entry">
<Show when={textResult.speaker}>
<span class="speaker font-bold text-blue-600">{textResult.speaker}: </span>
</Show>
<span class="text">{textResult.text}</span>
</div>
);
}
if (result.type === 'command') {
return (
<div class="dialogue-entry command-entry text-gray-500 italic">
[{result.command}]
</div>
);
}
return null;
};
return (
<div class="w-full max-w-2xl mx-auto shadow-sm relative">
{/* 对话历史 */}
<div
ref={historyContainer}
class="dialogue-history p-4 h-64 overflow-y-auto bg-gray-50"
>
<Show when={store.dialogueHistory.length === 0 && !store.runnerInstance}>
<div class="text-gray-400 text-center py-8"></div>
</Show>
<For each={store.dialogueHistory}>
{renderEntry}
</For>
</div>
{/* 浮动工具栏 */}
<div class="toolbar absolute right-0 rounded-bl-lg shadow-sm flex">
<button
onClick={restart}
class="restart-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
rounded-bl-md transition-colors cursor-pointer"
title="重新开始"
>
🔄
</button>
<button
onClick={() => advance()}
class="advance-button px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300
transition-colors cursor-pointer"
title="继续"
>
</button>
</div>
{/* 当前选项 */}
<div class="current-options p-4 border-t border-gray-300 bg-white min-h-34">
<div class="options-list flex flex-col gap-2">
<For each={store.currentOptions?.options || []}>
{(option, index) => (
<button
onClick={() => advance(index())}
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
rounded border border-blue-200 transition-colors cursor-pointer"
>
{option.text}
</button>
)}
</For>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,122 @@
import {createEffect, createResource } from "solid-js";
import type {OptionsResult, RuntimeResult} from "../../yarn-spinner/runtime/results";
import {compile, parseYarn, YarnRunner} from "../../yarn-spinner";
import {loadElementSrc, resolvePath} from "../utils/path";
import {createStore} from "solid-js/store";
import {RunnerOptions} from "../../yarn-spinner/runtime/runner";
type YarnSpinnerStore = {
dialogueHistory: (RuntimeResult | OptionsResult['options'][0])[],
currentOptions: OptionsResult | null,
isEnded: boolean,
runnerInstance: YarnRunner | null,
}
export function createYarnStore(element: HTMLElement, props: RunnerOptions){
const [store, setStore] = createStore<YarnSpinnerStore>({
dialogueHistory: [],
currentOptions: null,
isEnded: false,
runnerInstance: null
});
// 获取文件路径
const {articlePath, rawSrc} = loadElementSrc(element);
const yarnPaths = (rawSrc || '').split(',')
.map((s: string) => resolvePath(articlePath, s.trim()))
.filter(Boolean);
// 加载 yarn 内容
const [yarnContent] = createResource(() => yarnPaths, loadYarnFiles);
// 创建 runner
const createRunner = () => {
const content = yarnContent();
if (!content) return null;
try {
const ast = parseYarn(content);
const program = compile(ast);
return new YarnRunner(program, props);
} catch (error) {
console.error('Failed to initialize YarnRunner:', error);
return null;
}
};
function advance(index?: number){
const runner = store.runnerInstance;
if(!runner) return;
if(index === undefined && runner.currentResult?.isDialogueEnd) return;
if(runner.currentResult?.type === 'options'){
if(index === undefined)return;
const option = runner.currentResult.options[index];
setStore('dialogueHistory', [...store.dialogueHistory, option]);
}
runner.advance(index);
processRunnerOutput();
}
createEffect(() => {
if(!yarnContent()) return;
setStore('runnerInstance', createRunner());
requestAnimationFrame(function(){
advance();
});
});
// 处理 runner 输出
const processRunnerOutput = () => {
const runner = store.runnerInstance;
if(!runner)return;
const result = runner.currentResult;
if (!result) return;
if(result.type === 'options'){
setStore('currentOptions', result);
}else{
setStore('currentOptions', null);
}
setStore('dialogueHistory', [...store.dialogueHistory, result]);
};
// 重新开始
const restart = () => {
setStore({
dialogueHistory: [],
currentOptions: null,
isEnded: false,
runnerInstance: createRunner(),
});
processRunnerOutput();
};
return {
store,
advance,
restart,
}
}
// 缓存已加载的 yarn 文件内容
const yarnCache = new Map<string, string>();
// 加载 yarn 文件内容
async function loadYarnFile(path: string): Promise<string> {
if (yarnCache.has(path)) {
return yarnCache.get(path)!;
}
const response = await fetch(path);
const content = await response.text();
yarnCache.set(path, content);
return content;
}
// 加载多个 yarn 文件并拼接
async function loadYarnFiles(paths: string[]): Promise<string> {
const contents = await Promise.all(paths.map(path => loadYarnFile(path)));
return contents.join('\n');
}

View File

@ -6,6 +6,7 @@
* @returns * @returns
*/ */
export function resolvePath(base: string, relative: string): string { export function resolvePath(base: string, relative: string): string {
if(!relative) return relative;
if (relative.startsWith('/')) { if (relative.startsWith('/')) {
return relative; return relative;
} }
@ -39,7 +40,8 @@ export function resolvePath(base: string, relative: string): string {
} }
export function loadElementSrc(element?: { textContent: string, closest: (arg0: string) => HTMLElement }){ // export function loadElementSrc(element?: { textContent: string, closest: (arg0: string) => HTMLElement }){
export function loadElementSrc(element?: any | HTMLElement){
const rawSrc = element?.textContent; const rawSrc = element?.textContent;
if(element) element.textContent = ""; if(element) element.textContent = "";

View File

@ -79,4 +79,4 @@ icon{
.col-2{ @apply lg:flex-2; } .col-2{ @apply lg:flex-2; }
.col-3{ @apply lg:flex-3; } .col-3{ @apply lg:flex-3; }
.col-4{ @apply lg:flex-4; } .col-4{ @apply lg:flex-4; }
.col-5{ @apply lg:flex-5; } .col-5{ @apply lg:flex-5; }

View File

@ -0,0 +1,136 @@
import type {YarnDocument, Statement, Line, Option, YarnNode} from "../model/ast";
import type { IRProgram, IRNode, IRNodeGroup, IRInstruction } from "./ir";
export interface CompileOptions {
generateOnceIds?: (ctx: { node: string; index: number }) => string;
}
export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram {
const program: IRProgram = { enums: {}, nodes: {} };
// Store enum definitions
for (const enumDef of doc.enums) {
program.enums[enumDef.name] = enumDef.cases;
}
const genOnce = opts.generateOnceIds ?? ((x) => `${x.node}#once#${x.index}`);
let globalLineCounter = 0;
function ensureLineId(tags?: string[]): string[] | undefined {
const t = tags ? [...tags] : [];
if (!t.some((x) => x.startsWith("line:"))) {
t.push(`line:${(globalLineCounter++).toString(16)}`);
}
return t;
}
// Group nodes by title to handle node groups
const nodesByTitle = new Map<string, typeof doc.nodes>();
for (const node of doc.nodes) {
if (!nodesByTitle.has(node.title)) {
nodesByTitle.set(node.title, []);
}
nodesByTitle.get(node.title)!.push(node);
}
let onceCounter = 0;
function emitBlock(stmts: Statement[], node: YarnNode): IRInstruction[] {
const block: IRInstruction[] = [];
for (const s of stmts) {
switch (s.type) {
case "Line":
{
const line = s as Line;
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
}
break;
case "Command":
block.push({ op: "command", content: s.content });
break;
case "Jump":
block.push({ op: "jump", target: s.target });
break;
case "Detour":
block.push({ op: "detour", target: s.target });
break;
case "Return":
block.push({ op: "return" });
break;
case "OptionGroup": {
// Add #lastline tag to the most recent line, if present
for (let i = block.length - 1; i >= 0; i--) {
const ins = block[i];
if (ins.op === "line") {
const tags = new Set(ins.tags ?? []);
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
tags.add("lastline");
}
ins.tags = Array.from(tags);
break;
}
if (ins.op !== "command") break; // stop if non-line non-command before options
}
block.push({
op: "options",
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body, node) })),
});
break;
}
case "If":
block.push({
op: "if",
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body, node) })),
});
break;
case "Once":
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body, node) });
break;
case "Enum":
// Enums are metadata, skip during compilation (already stored in program.enums)
break;
}
}
return block;
}
for (const [title, nodesWithSameTitle] of nodesByTitle) {
// If only one node with this title, treat as regular node
if (nodesWithSameTitle.length === 1) {
const node = nodesWithSameTitle[0];
const instructions: IRInstruction[] = [];
onceCounter = 0;
instructions.push(...emitBlock(node.body, node));
const irNode: IRNode = {
title: node.title,
instructions,
when: node.when,
css: (node as any).css,
scene: node.headers.scene?.trim() || undefined
};
program.nodes[node.title] = irNode;
} else {
// Multiple nodes with same title - create node group
const groupNodes: IRNode[] = [];
for (const node of nodesWithSameTitle) {
const instructions: IRInstruction[] = [];
onceCounter = 0;
instructions.push(...emitBlock(node.body, node));
groupNodes.push({
title: node.title,
instructions,
when: node.when,
css: (node as any).css,
scene: node.headers.scene?.trim() || undefined
});
}
const group: IRNodeGroup = {
title,
nodes: groupNodes
};
program.nodes[title] = group;
}
}
return program;
}

View File

@ -0,0 +1,29 @@
import type { MarkupParseResult } from "../markup/types";
export type IRProgram = {
enums: Record<string, string[]>; // enum name -> cases
nodes: Record<string, IRNode | IRNodeGroup>; // can be single node or group
};
export type IRNode = {
title: string;
instructions: IRInstruction[];
when?: string[]; // Array of when conditions
css?: string;
scene?: string; // Scene name from node header
};
export type IRNodeGroup = {
title: string;
nodes: IRNode[]; // Multiple nodes with same title, different when conditions
};
export type IRInstruction =
| { op: "line"; speaker?: string; text: string; tags?: string[]; markup?: MarkupParseResult }
| { op: "command"; content: string }
| { op: "jump"; target: string }
| { op: "detour"; target: string }
| { op: "return"; }
| { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; condition?: string; block: IRInstruction[] }> }
| { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
| { op: "once"; id: string; block: IRInstruction[] };

View File

@ -0,0 +1,3 @@
export {parseYarn} from './parse/parser';
export {compile} from './compile/compiler';
export {YarnRunner} from "./runtime/runner";

View File

@ -0,0 +1,381 @@
import type { MarkupParseResult, MarkupSegment, MarkupValue, MarkupWrapper } from "./types";
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
const SELF_CLOSING_TAGS = new Set(["br"]);
interface StackEntry {
name: string;
type: MarkupWrapper["type"];
properties: Record<string, MarkupValue>;
originalText: string;
}
interface ParsedTag {
kind: "open" | "close" | "self";
name: string;
properties: Record<string, MarkupValue>;
}
const SELF_CLOSING_SPACE_REGEX = /\s+\/$/;
const ATTRIBUTE_REGEX =
/^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/;
export function parseMarkup(input: string): MarkupParseResult {
const segments: MarkupSegment[] = [];
const stack: StackEntry[] = [];
const chars: string[] = [];
let currentSegment: MarkupSegment | null = null;
let nomarkupDepth = 0;
const pushSegment = (segment: MarkupSegment) => {
if (segment.selfClosing || segment.end > segment.start) {
segments.push(segment);
}
};
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const wa = a[i];
const wb = b[i];
if (wa.name !== wb.name || wa.type !== wb.type) return false;
const keysA = Object.keys(wa.properties);
const keysB = Object.keys(wb.properties);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (wa.properties[key] !== wb.properties[key]) return false;
}
}
return true;
};
const flushCurrentSegment = () => {
if (currentSegment) {
segments.push(currentSegment);
currentSegment = null;
}
};
const cloneWrappers = (): MarkupWrapper[] =>
stack.map((entry) => ({
name: entry.name,
type: entry.type,
properties: { ...entry.properties },
}));
const appendChar = (char: string) => {
const index = chars.length;
chars.push(char);
const wrappers = cloneWrappers();
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappers)) {
currentSegment.end = index + 1;
} else {
flushCurrentSegment();
currentSegment = {
start: index,
end: index + 1,
wrappers,
};
}
};
const appendLiteral = (literal: string) => {
for (const ch of literal) {
appendChar(ch);
}
};
const parseTag = (contentRaw: string): ParsedTag | null => {
let content = contentRaw.trim();
if (!content) return null;
if (content.startsWith("/")) {
const name = content.slice(1).trim().toLowerCase();
if (!name) return null;
return { kind: "close", name, properties: {} };
}
let kind: ParsedTag["kind"] = "open";
if (content.endsWith("/")) {
content = content.replace(SELF_CLOSING_SPACE_REGEX, "").trim();
if (content.endsWith("/")) {
content = content.slice(0, -1).trim();
}
kind = "self";
}
const nameMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
if (!nameMatch) return null;
const name = nameMatch[1].toLowerCase();
let rest = content.slice(nameMatch[0].length).trim();
const properties: Record<string, MarkupValue> = {};
while (rest.length > 0) {
const attrMatch = rest.match(ATTRIBUTE_REGEX);
if (!attrMatch) {
break;
}
const [, keyRaw, doubleQuoted, singleQuoted, bare] = attrMatch;
const key = keyRaw.toLowerCase();
let value: MarkupValue = true;
const rawValue = doubleQuoted ?? singleQuoted ?? bare;
if (rawValue !== undefined) {
value = parseAttributeValue(rawValue);
}
properties[key] = value;
rest = rest.slice(attrMatch[0].length).trim();
}
const finalKind: ParsedTag["kind"] = kind === "self" || SELF_CLOSING_TAGS.has(name) ? "self" : kind;
return { kind: finalKind, name, properties };
};
const parseAttributeValue = (raw: string): MarkupValue => {
const trimmed = raw.trim();
if (/^(true|false)$/i.test(trimmed)) {
return /^true$/i.test(trimmed);
}
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (!Number.isNaN(num)) {
return num;
}
}
return trimmed;
};
const handleSelfClosing = (tag: ParsedTag) => {
const wrapper: MarkupWrapper = {
name: tag.name,
type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
properties: tag.properties,
};
const position = chars.length;
pushSegment({
start: position,
end: position,
wrappers: [wrapper],
selfClosing: true,
});
};
let i = 0;
while (i < input.length) {
const char = input[i];
if (char === "\\" && i + 1 < input.length) {
const next = input[i + 1];
if (next === "[" || next === "]" || next === "\\") {
appendChar(next);
i += 2;
continue;
}
}
if (char === "[") {
const closeIndex = findClosingBracket(input, i + 1);
if (closeIndex === -1) {
appendChar(char);
i += 1;
continue;
}
const content = input.slice(i + 1, closeIndex);
const originalText = input.slice(i, closeIndex + 1);
const parsed = parseTag(content);
if (!parsed) {
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
if (parsed.name === "nomarkup") {
if (parsed.kind === "open") {
nomarkupDepth += 1;
} else if (parsed.kind === "close" && nomarkupDepth > 0) {
nomarkupDepth -= 1;
}
i = closeIndex + 1;
continue;
}
if (nomarkupDepth > 0) {
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
if (parsed.kind === "open") {
const entry: StackEntry = {
name: parsed.name,
type: DEFAULT_HTML_TAGS.has(parsed.name) ? "default" : "custom",
properties: parsed.properties,
originalText,
};
stack.push(entry);
flushCurrentSegment();
i = closeIndex + 1;
continue;
}
if (parsed.kind === "self") {
handleSelfClosing(parsed);
i = closeIndex + 1;
continue;
}
// closing tag
if (stack.length === 0) {
if (SELF_CLOSING_TAGS.has(parsed.name)) {
i = closeIndex + 1;
continue;
}
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
const top = stack[stack.length - 1];
if (top.name === parsed.name) {
flushCurrentSegment();
stack.pop();
i = closeIndex + 1;
continue;
}
if (SELF_CLOSING_TAGS.has(parsed.name)) {
i = closeIndex + 1;
continue;
}
// mismatched closing; treat as literal
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
appendChar(char);
i += 1;
}
flushCurrentSegment();
// If any tags remain open, treat them as literal text appended at end
while (stack.length > 0) {
const entry = stack.pop()!;
appendLiteral(entry.originalText);
}
flushCurrentSegment();
const text = chars.join("");
return {
text,
segments: mergeSegments(segments, text.length),
};
}
function mergeSegments(segments: MarkupSegment[], textLength: number): MarkupSegment[] {
const sorted = [...segments].sort((a, b) => a.start - b.start || a.end - b.end);
const merged: MarkupSegment[] = [];
let last: MarkupSegment | null = null;
for (const seg of sorted) {
if (seg.start === seg.end && !seg.selfClosing) {
continue;
}
if (last && !seg.selfClosing && last.end === seg.start && wrappersMatch(last.wrappers, seg.wrappers)) {
last.end = seg.end;
} else {
last = {
start: seg.start,
end: seg.end,
wrappers: seg.wrappers,
selfClosing: seg.selfClosing,
};
merged.push(last);
}
}
if (merged.length === 0 && textLength > 0) {
merged.push({
start: 0,
end: textLength,
wrappers: [],
});
}
return merged;
}
function wrappersMatch(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].name !== b[i].name || a[i].type !== b[i].type) return false;
const keysA = Object.keys(a[i].properties);
const keysB = Object.keys(b[i].properties);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (a[i].properties[key] !== b[i].properties[key]) return false;
}
}
return true;
}
function findClosingBracket(text: string, start: number): number {
for (let i = start; i < text.length; i++) {
if (text[i] === "]") {
let backslashCount = 0;
let j = i - 1;
while (j >= 0 && text[j] === "\\") {
backslashCount++;
j--;
}
if (backslashCount % 2 === 0) {
return i;
}
}
}
return -1;
}
export function sliceMarkup(result: MarkupParseResult, start: number, end?: number): MarkupParseResult {
const textLength = result.text.length;
const sliceStart = Math.max(0, Math.min(start, textLength));
const sliceEnd = end === undefined ? textLength : Math.max(sliceStart, Math.min(end, textLength));
const slicedSegments: MarkupSegment[] = [];
for (const seg of result.segments) {
const segStart = Math.max(seg.start, sliceStart);
const segEnd = Math.min(seg.end, sliceEnd);
if (seg.selfClosing) {
if (segStart >= sliceStart && segStart <= sliceEnd) {
slicedSegments.push({
start: segStart - sliceStart,
end: segStart - sliceStart,
wrappers: seg.wrappers,
selfClosing: true,
});
}
continue;
}
if (segEnd <= segStart) continue;
slicedSegments.push({
start: segStart - sliceStart,
end: segEnd - sliceStart,
wrappers: seg.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
});
}
if (slicedSegments.length === 0 && sliceEnd - sliceStart > 0) {
slicedSegments.push({
start: 0,
end: sliceEnd - sliceStart,
wrappers: [],
});
}
return {
text: result.text.slice(sliceStart, sliceEnd),
segments: mergeSegments(slicedSegments, sliceEnd - sliceStart),
};
}

View File

@ -0,0 +1,21 @@
export type MarkupValue = string | number | boolean;
export type MarkupWrapperType = "default" | "custom";
export interface MarkupWrapper {
name: string;
type: MarkupWrapperType;
properties: Record<string, MarkupValue>;
}
export interface MarkupSegment {
start: number;
end: number;
wrappers: MarkupWrapper[];
selfClosing?: boolean;
}
export interface MarkupParseResult {
text: string;
segments: MarkupSegment[];
}

View File

@ -0,0 +1,103 @@
export type Position = { line: number; column: number };
export interface NodeHeaderMap {
[key: string]: string;
}
export interface YarnDocument {
type: "Document";
enums: EnumDefinition[];
nodes: YarnNode[];
}
export interface EnumDefinition {
type: "Enum";
name: string;
cases: string[];
}
export interface YarnNode {
type: "Node";
title: string;
headers: NodeHeaderMap;
nodeTags?: string[];
when?: string[]; // Array of when conditions (can be "once", "always", or expression like "$has_sword")
css?: string; // Custom CSS style for node
body: Statement[];
}
export type Statement =
| Line
| Command
| OptionGroup
| IfBlock
| OnceBlock
| Jump
| Detour
| Return
| EnumBlock;
import type { MarkupParseResult } from "../markup/types.js";
export interface Line {
type: "Line";
speaker?: string;
text: string;
tags?: string[];
markup?: MarkupParseResult;
}
export interface Command {
type: "Command";
content: string; // inside << >>
}
export interface Jump {
type: "Jump";
target: string;
}
export interface Detour {
type: "Detour";
target: string;
}
export interface Return {
type: "Return";
target: string;
}
export interface OptionGroup {
type: "OptionGroup";
options: Option[];
}
export interface Option {
type: "Option";
text: string;
body: Statement[]; // executed if chosen
tags?: string[];
css?: string; // Custom CSS style for option
markup?: MarkupParseResult;
condition?: string;
}
export interface IfBlock {
type: "If";
branches: Array<{
condition: string | null; // null for else
body: Statement[];
}>;
}
export interface OnceBlock {
type: "Once";
body: Statement[];
}
export interface EnumBlock {
type: "Enum";
name: string;
cases: string[];
}

View File

@ -0,0 +1,107 @@
export interface Token {
type:
| "HEADER_KEY"
| "HEADER_VALUE"
| "NODE_START" // ---
| "NODE_END" // ===
| "OPTION" // ->
| "COMMAND" // <<...>> (single-line)
| "TEXT" // any non-empty content line
| "EMPTY"
| "INDENT"
| "DEDENT"
| "EOF";
text: string;
line: number;
column: number;
}
// Minimal indentation-sensitive lexer to support options and their bodies.
export function lex(input: string): Token[] {
const lines = input.replace(/\r\n?/g, "\n").split("\n");
const tokens: Token[] = [];
const indentStack: number[] = [0];
let inHeaders = true;
function push(type: Token["type"], text: string, line: number, column: number) {
tokens.push({ type, text, line, column });
}
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const lineNum = i + 1;
const indent = raw.match(/^[ \t]*/)?.[0] ?? "";
const content = raw.slice(indent.length);
if (content.trim() === "") {
push("EMPTY", "", lineNum, 1);
continue;
}
// Manage indentation tokens only within node bodies and on non-empty lines
if (!inHeaders) {
const prev = indentStack[indentStack.length - 1];
if (indent.length > prev) {
indentStack.push(indent.length);
push("INDENT", "", lineNum, 1);
} else if (indent.length < prev) {
while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
indentStack.pop();
push("DEDENT", "", lineNum, 1);
}
}
}
if (content === "---") {
inHeaders = false;
push("NODE_START", content, lineNum, indent.length + 1);
continue;
}
if (content === "===") {
inHeaders = true;
// flush indentation to root
while (indentStack.length > 1) {
indentStack.pop();
push("DEDENT", "", lineNum, 1);
}
push("NODE_END", content, lineNum, indent.length + 1);
continue;
}
// Header: key: value (only valid while inHeaders)
if (inHeaders) {
const m = content.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/);
if (m) {
push("HEADER_KEY", m[1], lineNum, indent.length + 1);
push("HEADER_VALUE", m[2], lineNum, indent.length + 1 + m[0].indexOf(m[2]));
continue;
}
}
if (content.startsWith("->")) {
push("OPTION", content.slice(2).trim(), lineNum, indent.length + 1);
continue;
}
// Commands like <<...>> (single line)
const cmd = content.match(/^<<(.+?)>>\s*$/);
if (cmd) {
push("COMMAND", cmd[1].trim(), lineNum, indent.length + 1);
continue;
}
// Plain text line
push("TEXT", content, lineNum, indent.length + 1);
}
// close remaining indentation at EOF
while (indentStack.length > 1) {
indentStack.pop();
tokens.push({ type: "DEDENT", text: "", line: lines.length, column: 1 });
}
tokens.push({ type: "EOF", text: "", line: lines.length + 1, column: 1 });
return tokens;
}

View File

@ -0,0 +1,515 @@
import { lex, Token } from "./lexer";
import { parseMarkup, sliceMarkup } from "../markup/parser";
import type { MarkupParseResult } from "../markup/types";
import type {
YarnDocument,
YarnNode,
Statement,
Line,
Command,
OptionGroup,
Option,
IfBlock,
OnceBlock,
Jump,
Detour,
Return,
EnumBlock,
} from "../model/ast";
export class ParseError extends Error {}
export function parseYarn(text: string): YarnDocument {
const tokens = lex(text);
const p = new Parser(tokens);
try{
return p.parseDocument();
}catch(e){
console.log(`parser status: `, p.status());
throw e;
}
}
class Parser {
private i = 0;
constructor(private readonly tokens: Token[]) {}
private peek(offset = 0) {
return this.tokens[this.i + offset];
}
private at(type: Token["type"]) {
return this.peek()?.type === type;
}
private take(type: Token["type"], err?: string): Token {
const t = this.peek();
if (!t || t.type !== type) throw new ParseError(err ?? `Expected ${type}, got ${t?.type}`);
this.i++;
return t;
}
private takeIf(type: Token["type"]) {
if (this.at(type)) return this.take(type);
return null;
}
parseDocument(): YarnDocument {
const enums: EnumBlock[] = [];
const nodes: YarnNode[] = [];
while (!this.at("EOF")) {
// Skip empties
while (this.at("EMPTY")) this.i++;
if (this.at("EOF")) break;
// Check if this is an enum definition (top-level)
if (this.at("COMMAND")) {
const cmd = this.peek().text.trim();
if (cmd.startsWith("enum ")) {
const enumCmd = this.take("COMMAND").text; // consume the enum command
const enumName = enumCmd.slice(5).trim();
const enumDef = this.parseEnumBlock(enumName);
enums.push(enumDef);
continue;
}
}
nodes.push(this.parseNode());
}
return { type: "Document", enums, nodes };
}
private parseNode(): YarnNode {
const headers: Record<string, string> = {};
let title: string | null = null;
let nodeTags: string[] | undefined;
let whenConditions: string[] = [];
let nodeCss: string | undefined;
// headers
while (!this.at("NODE_START")) {
const keyTok = this.take("HEADER_KEY", "Expected node header before '---'");
const valTok = this.take("HEADER_VALUE", "Expected header value");
if (keyTok.text === "title") title = valTok.text.trim();
if (keyTok.text === "tags") {
const raw = valTok.text.trim();
nodeTags = raw.split(/\s+/).filter(Boolean);
}
if (keyTok.text === "when") {
// Each when: header adds one condition (can have multiple when: headers)
const raw = valTok.text.trim();
whenConditions.push(raw);
}
// Capture &css{ ... } styles in any header value
const rawVal = valTok.text.trim();
if (rawVal.startsWith("&css{")) {
// Collect until closing '}' possibly spanning multiple lines before '---'
let cssContent = rawVal.replace(/^&css\{/, "");
let closed = cssContent.includes("}");
if (closed) {
cssContent = cssContent.split("}")[0];
} else {
// Consume subsequent TEXT or HEADER_VALUE tokens until we find a '}'
while (!this.at("NODE_START") && !this.at("EOF")) {
const next = this.peek();
if (next.type === "TEXT" || next.type === "HEADER_VALUE") {
const t = this.take(next.type).text;
if (t.includes("}")) {
cssContent += (cssContent ? "\n" : "") + t.split("}")[0];
closed = true;
break;
} else {
cssContent += (cssContent ? "\n" : "") + t;
}
} else if (next.type === "EMPTY") {
this.i++;
} else {
break;
}
}
}
nodeCss = (cssContent || "").trim();
}
headers[keyTok.text] = valTok.text;
// allow empty lines
while (this.at("EMPTY")) this.i++;
}
if (!title) throw new ParseError("Every node must have a title header");
this.take("NODE_START");
// allow optional empties after ---
while (this.at("EMPTY")) this.i++;
const body: Statement[] = this.parseStatementsUntil("NODE_END");
this.take("NODE_END", "Expected node end '==='");
return {
type: "Node",
title,
headers,
nodeTags,
when: whenConditions.length > 0 ? whenConditions : undefined,
css: nodeCss,
body
};
}
private parseStatementsUntil(endType: Token["type"]): Statement[] {
const out: Statement[] = [];
while (!this.at(endType) && !this.at("EOF")) {
// skip extra empties
while (this.at("EMPTY")) this.i++;
if (this.at(endType) || this.at("EOF")) break;
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
const stmt = this.parseStatement();
out.push(stmt);
}
return out;
}
private parseStatement(): Statement {
const t = this.peek();
if (!t) throw new ParseError("Unexpected EOF");
if (t.type === "COMMAND") {
const cmd = this.take("COMMAND").text;
if (cmd.startsWith("jump ")) return { type: "Jump", target: cmd.slice(5).trim() } as Jump;
if (cmd.startsWith("detour ")) return { type: "Detour", target: cmd.slice(7).trim() } as Detour;
if (cmd.startsWith("return")) return { type: "Return" } as Return;
if (cmd.startsWith("if ")) return this.parseIfCommandBlock(cmd);
if (cmd === "once") return this.parseOnceBlock();
if (cmd.startsWith("enum ")) {
const enumName = cmd.slice(5).trim();
return this.parseEnumBlock(enumName);
}
return { type: "Command", content: cmd } as Command;
}
if (t.type === "TEXT") {
const raw = this.take("TEXT").text;
const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
const markup = parseMarkup(textWithoutTags);
const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
if (speakerMatch) {
const messageText = speakerMatch[2];
const messageOffset = markup.text.length - messageText.length;
const slicedMarkup = sliceMarkup(markup, messageOffset);
const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
return {
type: "Line",
speaker: speakerMatch[1].trim(),
text: messageText,
tags,
markup: normalizedMarkup,
} as Line;
}
// If/Else blocks use inline markup {if ...}
const trimmed = markup.text.trim();
if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
return this.parseIfFromText(markup.text);
}
return {
type: "Line",
text: markup.text,
tags,
markup: this.normalizeMarkup(markup),
} as Line;
}
throw new ParseError(`Unexpected token ${t.type}`);
}
private parseOptionGroup(): OptionGroup {
const options: Option[] = [];
// One or more OPTION lines, with bodies under INDENT
while (this.at("OPTION")) {
const raw = this.take("OPTION").text;
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
const { text: textWithCondition, css } = this.extractCss(textWithAttrs);
const { text: optionText, condition } = this.extractOptionCondition(textWithCondition);
const markup = parseMarkup(optionText);
let body: Statement[] = [];
if (this.at("INDENT")) {
this.take("INDENT");
body = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
while (this.at("EMPTY")) this.i++;
}
options.push({
type: "Option",
text: markup.text,
body,
tags,
css,
markup: this.normalizeMarkup(markup),
condition,
});
// Consecutive options belong to the same group; break on non-OPTION
while (this.at("EMPTY")) this.i++;
}
return { type: "OptionGroup", options };
}
private normalizeMarkup(result: MarkupParseResult): MarkupParseResult | undefined {
if (!result) return undefined;
if (result.segments.length === 0) {
return undefined;
}
const hasFormatting = result.segments.some(
(segment) => segment.wrappers.length > 0 || segment.selfClosing
);
if (!hasFormatting) {
return undefined;
}
return {
text: result.text,
segments: result.segments.map((segment) => ({
start: segment.start,
end: segment.end,
wrappers: segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
selfClosing: segment.selfClosing,
})),
};
}
private extractTags(input: string): { cleanText: string; tags?: string[] } {
const tags: string[] = [];
// Match tags that are space-separated and not part of hex colors or CSS
// Tags are like "#tag" preceded by whitespace and not followed by hex digits
const re = /\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;
let text = input;
let m: RegExpExecArray | null;
while ((m = re.exec(input))) {
tags.push(m[1]);
}
if (tags.length > 0) {
// Only remove tags that match the pattern (not hex colors in CSS)
text = input.replace(/\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g, "").trimEnd();
return { cleanText: text, tags };
}
return { cleanText: input };
}
private extractCss(input: string): { text: string; css?: string } {
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
if (cssMatch) {
const css = cssMatch[1].trim();
const text = input.replace(cssMatch[0], "").trimEnd();
return { text, css };
}
return { text: input };
}
private extractOptionCondition(input: string): { text: string; condition?: string } {
const match = input.match(/\s\[\s*if\s+([^\]]+)\]\s*$/i);
if (match) {
const text = input.slice(0, match.index).trimEnd();
return { text, condition: match[1].trim() };
}
return { text: input };
}
private parseStatementsUntilStop(shouldStop: () => boolean): Statement[] {
const out: Statement[] = [];
while (!this.at("EOF")) {
// Check stop condition at root level only
if (shouldStop()) break;
while (this.at("EMPTY")) this.i++;
if (this.at("EOF") || shouldStop()) break;
// Handle indentation - if we see INDENT, parse the indented block
if (this.at("INDENT")) {
this.take("INDENT");
// Parse statements at this indent level until DEDENT (don't check stop condition inside)
while (!this.at("DEDENT") && !this.at("EOF")) {
while (this.at("EMPTY")) this.i++;
if (this.at("DEDENT") || this.at("EOF")) break;
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
if (this.at("DEDENT")) {
this.take("DEDENT");
while (this.at("EMPTY")) this.i++;
}
continue;
}
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
return out;
}
private parseOnceBlock(): OnceBlock {
// Already consumed <<once>>; expect body under INDENT then <<endonce>> as COMMAND
let body: Statement[] = [];
if (this.at("INDENT")) {
this.take("INDENT");
body = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
} else {
// Alternatively, body until explicit <<endonce>> command on single line
body = [];
}
// consume closing command if present on own line
if (this.at("COMMAND") && this.peek().text === "endonce") {
this.take("COMMAND");
}
return { type: "Once", body };
}
private parseIfFromText(firstLine: string): IfBlock {
const branches: IfBlock["branches"] = [];
// expecting state not required in current implementation
let cursor = firstLine.trim();
function parseCond(text: string) {
const mIf = text.match(/^\{if\s+(.+?)\}$/);
if (mIf) return mIf[1];
const mElIf = text.match(/^\{else\s+if\s+(.+?)\}$/);
if (mElIf) return mElIf[1];
return null;
}
while (true) {
const cond = parseCond(cursor);
if (cursor === "{else}") {
branches.push({ condition: null, body: this.parseIfBlockBody() });
// next must be {endif}
const endLine = this.take("TEXT", "Expected {endif}").text.trim();
if (endLine !== "{endif}") throw new ParseError("Expected {endif}");
break;
} else if (cond) {
branches.push({ condition: cond, body: this.parseIfBlockBody() });
// next control line
const next = this.take("TEXT", "Expected {else}, {else if}, or {endif}").text.trim();
if (next === "{endif}") break;
cursor = next;
continue;
} else if (cursor === "{endif}") {
break;
} else {
throw new ParseError("Invalid if/else control line");
}
}
return { type: "If", branches };
}
private parseEnumBlock(enumName: string): EnumBlock {
const cases: string[] = [];
// Parse cases until <<endenum>>
while (!this.at("EOF")) {
while (this.at("EMPTY")) this.i++;
if (this.at("COMMAND")) {
const cmd = this.peek().text.trim();
if (cmd === "endenum") {
this.take("COMMAND");
break;
}
if (cmd.startsWith("case ")) {
this.take("COMMAND");
const caseName = cmd.slice(5).trim();
cases.push(caseName);
} else {
// Unknown command, might be inside enum block - skip or break?
break;
}
} else {
// Skip non-command lines
if (this.at("TEXT")) this.take("TEXT");
}
}
return { type: "Enum", name: enumName, cases };
}
private parseIfCommandBlock(firstCmd: string): IfBlock {
const branches: IfBlock["branches"] = [];
const firstCond = firstCmd.slice(3).trim();
// Body until next elseif/else/endif command (check at root level, not inside indented blocks)
const firstBody = this.parseStatementsUntilStop(() => {
// Only stop at root level commands, not inside indented blocks
return this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text);
});
branches.push({ condition: firstCond, body: firstBody });
while (!this.at("EOF")) {
if (!this.at("COMMAND")) break;
const t = this.peek();
const txt = t.text.trim();
if (txt.startsWith("elseif ")) {
this.take("COMMAND");
const cond = txt.slice(7).trim();
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text));
branches.push({ condition: cond, body });
continue;
}
if (txt === "else") {
this.take("COMMAND");
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(endif$)/.test(this.peek().text));
branches.push({ condition: null, body });
// require endif after else body
if (this.at("COMMAND") && this.peek().text.trim() === "endif") {
this.take("COMMAND");
}
break;
}
if (txt === "endif") {
this.take("COMMAND");
break;
}
break;
}
return { type: "If", branches };
}
private parseIfBlockBody(): Statement[] {
// Body is indented lines until next control line or DEDENT boundary; to keep this simple
// we consume subsequent lines until encountering a control TEXT or EOF/OPTION/NODE_END.
const body: Statement[] = [];
while (!this.at("EOF") && !this.at("NODE_END")) {
// Stop when next TEXT is a control or when OPTION starts (new group)
if (this.at("TEXT")) {
const look = this.peek().text.trim();
if (look === "{else}" || look === "{endif}" || look.startsWith("{else if ") || look.startsWith("{if ")) break;
}
if (this.at("OPTION")) break;
// Support indented bodies inside if-branches
if (this.at("INDENT")) {
this.take("INDENT");
const nested = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
body.push(...nested);
// continue scanning after dedent
while (this.at("EMPTY")) this.i++;
continue;
}
if (this.at("EMPTY")) {
this.i++;
continue;
}
body.push(this.parseStatement());
}
return body;
}
public status(){
// find the first title before the current token
let closestNode = this.tokens.slice(0, this.i).reverse().findIndex(t => t.type === "HEADER_KEY" && t.text === "title");
return {
i: this.i,
tokens: this.tokens,
token: this.peek(),
closestNode: this.tokens[this.i - closestNode]
}
}
}

View File

@ -0,0 +1,192 @@
/**
* Command parser and handler utilities for Yarn Spinner commands.
* Commands like <<command_name arg1 arg2>> or <<command_name "arg with spaces">>
*/
import type { ExpressionEvaluator as Evaluator } from "./evaluator";
export interface ParsedCommand {
name: string;
args: string[];
raw: string;
}
/**
* Parse a command string like "command_name arg1 arg2" or "set variable value"
*/
export function parseCommand(content: string): ParsedCommand {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Empty command");
}
const parts: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar = "";
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if ((char === '"' || char === "'") && !inQuotes) {
// If we have accumulated non-quoted content (e.g. a function name and "(")
// push it as its own part before entering quoted mode. This prevents the
// surrounding text from being merged into the quoted content when we
// later push the quoted value.
if (current.trim()) {
parts.push(current.trim());
current = "";
}
inQuotes = true;
quoteChar = char;
continue;
}
if (char === quoteChar && inQuotes) {
inQuotes = false;
// Preserve the surrounding quotes in the parsed part so callers that
// reassemble the expression (e.g. declare handlers) keep string literals
// intact instead of losing quote characters.
parts.push(quoteChar + current + quoteChar);
quoteChar = "";
current = "";
continue;
}
if (char === " " && !inQuotes) {
if (current.trim()) {
parts.push(current.trim());
current = "";
}
continue;
}
current += char;
}
if (current.trim()) {
parts.push(current.trim());
}
if (parts.length === 0) {
throw new Error("No command name found");
}
return {
name: parts[0],
args: parts.slice(1),
raw: content,
};
}
/**
* Built-in command handlers for common Yarn Spinner commands.
*/
export class CommandHandler {
private handlers = new Map<string, (args: string[], evaluator?: Evaluator) => void | Promise<void>>();
private variables: Record<string, unknown>;
constructor(variables: Record<string, unknown> = {}) {
this.variables = variables;
this.registerBuiltins();
}
/**
* Register a command handler.
*/
register(name: string, handler: (args: string[], evaluator?: Evaluator) => void | Promise<void>): void {
this.handlers.set(name.toLowerCase(), handler);
}
/**
* Execute a parsed command.
*/
async execute(parsed: ParsedCommand, evaluator?: Evaluator): Promise<void> {
const handler = this.handlers.get(parsed.name.toLowerCase());
if (handler) {
await handler(parsed.args, evaluator);
} else {
console.warn(`Unknown command: ${parsed.name}`);
}
}
private registerBuiltins(): void {
// <<set $var to expr>> or <<set $var = expr>> or <<set $var expr>>
this.register("set", (args, evaluator) => {
if (!evaluator) return;
if (args.length < 2) return;
const varNameRaw = args[0];
let exprParts = args.slice(1);
if (exprParts[0] === "to") exprParts = exprParts.slice(1);
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
const expr = exprParts.join(" ");
let value = evaluator.evaluateExpression(expr);
// If value is a string starting with ".", try to resolve as enum shorthand
if (typeof value === "string" && value.startsWith(".")) {
const enumType = evaluator.getEnumTypeForVariable(varNameRaw);
if (enumType) {
value = evaluator.resolveEnumValue(value, enumType);
}
}
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
// Setting a variable converts it from smart to regular
this.variables[key] = value;
evaluator.setVariable(key, value);
});
// <<declare $var = expr>>
this.register("declare", (args, evaluator) => {
if (!evaluator) return;
if (args.length < 3) return; // name, '=', expr
const varNameRaw = args[0];
let exprParts = args.slice(1);
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
const expr = exprParts.join(" ");
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
// Check if expression is "smart" (contains operators, comparisons, or variable references)
// Smart variables: expressions with operators, comparisons, logical ops, or function calls
const isSmart = /[+\-*/%<>=!&|]/.test(expr) ||
/\$\w+/.test(expr) || // references other variables
/[a-zA-Z_]\w*\s*\(/.test(expr); // function calls
if (isSmart) {
// Store as smart variable - will recalculate on each access
evaluator.setSmartVariable(key, expr);
// Also store initial value in variables for immediate use
const initialValue = evaluator.evaluateExpression(expr);
this.variables[key] = initialValue;
} else {
// Regular variable - evaluate once and store
let value = evaluator.evaluateExpression(expr);
// Check if expr is an enum value (EnumName.CaseName or .CaseName)
if (typeof value === "string") {
// Try to extract enum name from EnumName.CaseName
const enumMatch = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (enumMatch) {
const enumName = enumMatch[1];
value = evaluator.resolveEnumValue(expr, enumName);
} else if (value.startsWith(".")) {
// Shorthand - we can't infer enum type from declaration alone
// Store as-is, will be resolved on first use if variable has enum type
// Value is already set correctly above
}
}
this.variables[key] = value;
evaluator.setVariable(key, value);
}
});
// <<stop>> - no-op, just a marker
this.register("stop", () => {
// Dialogue stop marker
});
}
}

View File

@ -0,0 +1,502 @@
/**
* Safe expression evaluator for Yarn Spinner conditions.
* Supports variables, functions, comparisons, and logical operators.
*/
export class ExpressionEvaluator {
private smartVariables: Record<string, string> = {}; // variable name -> expression
constructor(
private variables: Record<string, unknown> = {},
private functions: Record<string, (...args: unknown[]) => unknown> = {},
private enums: Record<string, string[]> = {} // enum name -> cases
) {}
/**
* Evaluate a condition expression and return a boolean result.
* Supports: variables, literals (numbers, strings, booleans), comparisons, logical ops, function calls.
*/
evaluate(expr: string): boolean {
try {
const result = this.evaluateExpression(expr);
return !!result;
} catch {
return false;
}
}
/**
* Evaluate an expression that can return any value (not just boolean).
*/
evaluateExpression(expr: string): unknown {
const trimmed = this.preprocess(expr.trim());
if (!trimmed) return false;
// Handle function calls like `functionName(arg1, arg2)`
if (this.looksLikeFunctionCall(trimmed)) {
return this.evaluateFunctionCall(trimmed);
}
// Handle comparisons
if (this.containsComparison(trimmed)) {
return this.evaluateComparison(trimmed);
}
// Handle logical operators
if (trimmed.includes("&&") || trimmed.includes("||")) {
return this.evaluateLogical(trimmed);
}
// Handle negation
if (trimmed.startsWith("!")) {
return !this.evaluateExpression(trimmed.slice(1).trim());
}
// Handle arithmetic expressions (+, -, *, /, %)
if (this.containsArithmetic(trimmed)) {
return this.evaluateArithmetic(trimmed);
}
// Simple variable or literal
return this.resolveValue(trimmed);
}
private preprocess(expr: string): string {
// Normalize operator word aliases to JS-like symbols
// Whole word replacements only
return expr
.replace(/\bnot\b/gi, "!")
.replace(/\band\b/gi, "&&")
.replace(/\bor\b/gi, "||")
.replace(/\bxor\b/gi, "^")
.replace(/\beq\b|\bis\b/gi, "==")
.replace(/\bneq\b/gi, "!=")
.replace(/\bgte\b/gi, ">=")
.replace(/\blte\b/gi, "<=")
.replace(/\bgt\b/gi, ">")
.replace(/\blt\b/gi, "<");
}
private evaluateFunctionCall(expr: string): unknown {
const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
if (!match) throw new Error(`Invalid function call: ${expr}`);
const [, name, argsStr] = match;
const func = this.functions[name];
if (!func) throw new Error(`Function not found: ${name}`);
const args = this.parseArguments(argsStr);
const evaluatedArgs = args.map((arg) => this.evaluateExpression(arg.trim()));
return func(...evaluatedArgs);
}
private parseArguments(argsStr: string): string[] {
if (!argsStr.trim()) return [];
const args: string[] = [];
let depth = 0;
let current = "";
for (const char of argsStr) {
if (char === "(") depth++;
else if (char === ")") depth--;
else if (char === "," && depth === 0) {
args.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) args.push(current.trim());
return args;
}
private containsComparison(expr: string): boolean {
return /[<>=!]/.test(expr);
}
private looksLikeFunctionCall(expr: string): boolean {
return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr);
}
private containsArithmetic(expr: string): boolean {
// Remove quoted strings to avoid false positives on "-" or "+" inside literals
const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, "");
return /[+\-*/%]/.test(unquoted);
}
private evaluateArithmetic(expr: string): number {
const input = expr;
let index = 0;
const skipWhitespace = () => {
while (index < input.length && /\s/.test(input[index])) {
index++;
}
};
const toNumber = (value: unknown): number => {
if (typeof value === "number") return value;
if (typeof value === "boolean") return value ? 1 : 0;
if (value == null || value === "") return 0;
const num = Number(value);
if (Number.isNaN(num)) {
throw new Error(`Cannot convert ${String(value)} to number`);
}
return num;
};
const readToken = (): string => {
skipWhitespace();
const start = index;
let depth = 0;
let inQuotes = false;
let quoteChar = "";
while (index < input.length) {
const char = input[index];
if (inQuotes) {
if (char === quoteChar) {
inQuotes = false;
quoteChar = "";
}
index++;
continue;
}
if (char === '"' || char === "'") {
inQuotes = true;
quoteChar = char;
index++;
continue;
}
if (char === "(") {
depth++;
index++;
continue;
}
if (char === ")") {
if (depth === 0) break;
depth--;
index++;
continue;
}
if (depth === 0 && "+-*/%".includes(char)) {
break;
}
if (depth === 0 && /\s/.test(char)) {
break;
}
index++;
}
return input.slice(start, index).trim();
};
const parsePrimary = (): unknown => {
skipWhitespace();
if (index >= input.length) {
throw new Error("Unexpected end of expression");
}
const char = input[index];
if (char === "(") {
index++;
const value = parseAddSub();
skipWhitespace();
if (input[index] !== ")") {
throw new Error("Unmatched parenthesis in expression");
}
index++;
return value;
}
const token = readToken();
if (!token) {
throw new Error("Invalid expression token");
}
return this.evaluateExpression(token);
};
const parseUnary = (): number => {
skipWhitespace();
if (input[index] === "+") {
index++;
return parseUnary();
}
if (input[index] === "-") {
index++;
return -parseUnary();
}
return toNumber(parsePrimary());
};
const parseMulDiv = (): number => {
let value = parseUnary();
while (true) {
skipWhitespace();
const char = input[index];
if (char === "*" || char === "/" || char === "%") {
index++;
const right = parseUnary();
if (char === "*") {
value = value * right;
} else if (char === "/") {
value = value / right;
} else {
value = value % right;
}
continue;
}
break;
}
return value;
};
const parseAddSub = (): number => {
let value = parseMulDiv();
while (true) {
skipWhitespace();
const char = input[index];
if (char === "+" || char === "-") {
index++;
const right = parseMulDiv();
if (char === "+") {
value = value + right;
} else {
value = value - right;
}
continue;
}
break;
}
return value;
};
const result = parseAddSub();
skipWhitespace();
if (index < input.length) {
throw new Error(`Unexpected token "${input.slice(index)}" in expression`);
}
return result;
}
private evaluateComparison(expr: string): boolean {
// Match comparison operators (avoid matching !=, <=, >=)
const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
if (!match) throw new Error(`Invalid comparison: ${expr}`);
const [, left, rawOp, right] = match;
const op = rawOp === "=" ? "==" : rawOp;
const leftVal = this.evaluateExpression(left.trim());
const rightVal = this.evaluateExpression(right.trim());
switch (op) {
case "===":
case "==":
return this.deepEquals(leftVal, rightVal);
case "!==":
case "!=":
return !this.deepEquals(leftVal, rightVal);
case "<":
return Number(leftVal) < Number(rightVal);
case ">":
return Number(leftVal) > Number(rightVal);
case "<=":
return Number(leftVal) <= Number(rightVal);
case ">=":
return Number(leftVal) >= Number(rightVal);
default:
throw new Error(`Unknown operator: ${op}`);
}
}
private evaluateLogical(expr: string): boolean {
// Split by && or ||, respecting parentheses
const parts: Array<{ expr: string; op: "&&" | "||" | null }> = [];
let depth = 0;
let current = "";
let lastOp: "&&" | "||" | null = null;
for (const char of expr) {
if (char === "(") depth++;
else if (char === ")") depth--;
else if (depth === 0 && expr.includes(char === "&" ? "&&" : char === "|" ? "||" : "")) {
// Check for && or ||
const remaining = expr.slice(expr.indexOf(char));
if (remaining.startsWith("&&")) {
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
current = "";
}
lastOp = "&&";
// skip &&
continue;
} else if (remaining.startsWith("||")) {
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
current = "";
}
lastOp = "||";
// skip ||
continue;
}
}
current += char;
}
if (current.trim()) parts.push({ expr: current.trim(), op: lastOp });
// Simple case: single expression
if (parts.length === 0) return !!this.evaluateExpression(expr);
// Evaluate parts (supports &&, ||, ^ as xor)
let result = this.evaluateExpression(parts[0].expr);
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const val = this.evaluateExpression(part.expr);
if (part.op === "&&") {
result = result && val;
} else if (part.op === "||") {
result = result || val;
}
}
return !!result;
}
private resolveValue(expr: string): unknown {
// Try enum syntax: EnumName.CaseName or .CaseName
const enumMatch = expr.match(/^\.?([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (enumMatch) {
const [, enumName, caseName] = enumMatch;
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
return `${enumName}.${caseName}`; // Store as "EnumName.CaseName" string
}
}
// Try shorthand enum: .CaseName (requires context from variables)
if (expr.startsWith(".") && expr.length > 1) {
// Try to infer enum from variable types - for now, return as-is and let validation handle it
return expr;
}
// Try as variable first
const key = expr.startsWith("$") ? expr.slice(1) : expr;
// Check if this is a smart variable (has stored expression)
if (Object.prototype.hasOwnProperty.call(this.smartVariables, key)) {
// Re-evaluate the expression each time it's accessed
return this.evaluateExpression(this.smartVariables[key]);
}
if (Object.prototype.hasOwnProperty.call(this.variables, key)) {
return this.variables[key];
}
// Try as number
const num = Number(expr);
if (!isNaN(num) && expr.trim() === String(num)) {
return num;
}
// Try as boolean
if (expr === "true") return true;
if (expr === "false") return false;
// Try as string (quoted)
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1);
}
// Default: treat as variable (may be undefined)
return this.variables[key];
}
/**
* Resolve shorthand enum (.CaseName) when setting a variable with known enum type
*/
resolveEnumValue(expr: string, enumName?: string): string {
if (expr.startsWith(".") && enumName) {
const caseName = expr.slice(1);
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
return `${enumName}.${caseName}`;
}
throw new Error(`Invalid enum case ${caseName} for enum ${enumName}`);
}
// Check if it's already EnumName.CaseName format
const match = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (match) {
const [, name, caseName] = match;
if (this.enums[name] && this.enums[name].includes(caseName)) {
return expr;
}
throw new Error(`Invalid enum case ${caseName} for enum ${name}`);
}
return expr;
}
/**
* Get enum type for a variable (if it was declared with enum type)
*/
getEnumTypeForVariable(varName: string): string | undefined {
// Check if variable value matches EnumName.CaseName pattern
const key = varName.startsWith("$") ? varName.slice(1) : varName;
const value = this.variables[key];
if (typeof value === "string") {
const match = value.match(/^([A-Za-z_][A-Za-z0-9_]*)\./);
if (match) {
return match[1];
}
}
return undefined;
}
private deepEquals(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a === "object") {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}
/**
* Update variables. Can be used to mutate state during dialogue.
*/
setVariable(name: string, value: unknown): void {
// If setting a smart variable, remove it (converting to regular variable)
if (Object.prototype.hasOwnProperty.call(this.smartVariables, name)) {
delete this.smartVariables[name];
}
this.variables[name] = value;
}
/**
* Register a smart variable (variable with expression that recalculates on access).
*/
setSmartVariable(name: string, expression: string): void {
// Remove from regular variables if it exists
if (Object.prototype.hasOwnProperty.call(this.variables, name)) {
delete this.variables[name];
}
this.smartVariables[name] = expression;
}
/**
* Check if a variable is a smart variable.
*/
isSmartVariable(name: string): boolean {
return Object.prototype.hasOwnProperty.call(this.smartVariables, name);
}
/**
* Get variable value.
*/
getVariable(name: string): unknown {
return this.variables[name];
}
}

View File

@ -0,0 +1,28 @@
import type { MarkupParseResult } from "../markup/types";
export type TextResult = {
type: "text";
text: string;
speaker?: string;
tags?: string[];
markup?: MarkupParseResult;
nodeCss?: string; // Node-level CSS from &css{} header
scene?: string; // Scene name from node header
isDialogueEnd: boolean;
};
export type OptionsResult = {
type: "options";
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
nodeCss?: string; // Node-level CSS from &css{} header
scene?: string; // Scene name from node header
isDialogueEnd: boolean;
};
export type CommandResult = {
type: "command";
command: string;
isDialogueEnd: boolean;
};
export type RuntimeResult = TextResult | OptionsResult | CommandResult;

View File

@ -0,0 +1,613 @@
import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types";
import type { RuntimeResult } from "./results";
import { ExpressionEvaluator } from "./evaluator";
import { CommandHandler, parseCommand } from "./commands";
export interface RunnerOptions {
startAt: string;
variables?: Record<string, unknown>;
functions?: Record<string, (...args: unknown[]) => unknown>;
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
commandHandler?: CommandHandler;
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
}
const globalOnceSeen = new Set<string>();
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
type CompiledOption = {
text: string;
tags?: string[];
css?: string;
markup?: MarkupParseResult;
condition?: string;
block: IRInstruction[];
};
export class YarnRunner {
private readonly program: IRProgram;
private readonly variables: Record<string, unknown>;
private readonly functions: Record<string, (...args: unknown[]) => unknown>;
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
private readonly commandHandler: CommandHandler;
private readonly evaluator: ExpressionEvaluator;
private readonly onceSeen = globalOnceSeen;
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
private storyEnded = false;
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
private readonly visitCounts: Record<string, number> = {};
private pendingOptions: CompiledOption[] | null = null;
private nodeTitle: string;
private ip = 0; // instruction pointer within node
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
private callStack: Array<
| ({ title: string; ip: number } & { kind: "detour" })
| ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" })
> = [];
currentResult: RuntimeResult | null = null;
history: RuntimeResult[] = [];
constructor(program: IRProgram, opts: RunnerOptions) {
this.program = program;
this.variables = {};
if (opts.variables) {
for (const [key, value] of Object.entries(opts.variables)) {
const normalizedKey = key.startsWith("$") ? key.slice(1) : key;
this.variables[normalizedKey] = value;
}
}
this.functions = {
// Default conversion helpers
string: (v: unknown) => String(v ?? ""),
number: (v: unknown) => Number(v),
bool: (v: unknown) => Boolean(v),
visited: (nodeName: unknown) => {
const name = String(nodeName ?? "");
return (this.visitCounts[name] ?? 0) > 0;
},
visited_count: (nodeName: unknown) => {
const name = String(nodeName ?? "");
return this.visitCounts[name] ?? 0;
},
format_invariant: (n: unknown) => {
const num = Number(n);
if (!isFinite(num)) return "0";
return new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 20 }).format(num);
},
random: () => Math.random(),
random_range: (a: unknown, b: unknown) => {
const x = Number(a), y = Number(b);
const min = Math.min(x, y);
const max = Math.max(x, y);
return min + Math.random() * (max - min);
},
dice: (sides: unknown) => {
const s = Math.max(1, Math.floor(Number(sides)) || 1);
return Math.floor(Math.random() * s) + 1;
},
min: (a: unknown, b: unknown) => Math.min(Number(a), Number(b)),
max: (a: unknown, b: unknown) => Math.max(Number(a), Number(b)),
round: (n: unknown) => Math.round(Number(n)),
round_places: (n: unknown, places: unknown) => {
const p = Math.max(0, Math.floor(Number(places)) || 0);
const factor = Math.pow(10, p);
return Math.round(Number(n) * factor) / factor;
},
floor: (n: unknown) => Math.floor(Number(n)),
ceil: (n: unknown) => Math.ceil(Number(n)),
inc: (n: unknown) => {
const v = Number(n);
return Number.isInteger(v) ? v + 1 : Math.ceil(v);
},
dec: (n: unknown) => {
const v = Number(n);
return Number.isInteger(v) ? v - 1 : Math.floor(v);
},
decimal: (n: unknown) => {
const v = Number(n);
return Math.abs(v - Math.trunc(v));
},
int: (n: unknown) => Math.trunc(Number(n)),
...(opts.functions ?? {}),
} as Record<string, (...args: unknown[]) => unknown>;
this.handleCommand = opts.handleCommand;
this.onStoryEnd = opts.onStoryEnd;
this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
this.nodeTitle = opts.startAt;
this.step();
}
/**
* Resolve a node title to an actual node (handling node groups).
*/
private resolveNode(title: string): IRNode {
const nodeOrGroup = this.program.nodes[title];
if (!nodeOrGroup) throw new Error(`Node ${title} not found`);
// If it's a single node, return it
if (!("nodes" in nodeOrGroup)) {
this.currentNodeIndex = -1;
return nodeOrGroup as IRNode;
}
// It's a node group - select the first matching node based on when conditions
const group = nodeOrGroup as IRNodeGroup;
for (let i = 0; i < group.nodes.length; i++) {
const candidate = group.nodes[i];
if (this.evaluateWhenConditions(candidate.when, title, i)) {
this.currentNodeIndex = i;
// If "once" condition, mark as seen immediately
if (candidate.when?.includes("once")) {
this.markNodeGroupOnceSeen(title, i);
}
return candidate;
}
}
// No matching node found - throw error or return first? Docs suggest error if no match
throw new Error(`No matching node found in group ${title}`);
}
/**
* Evaluate when conditions for a node in a group.
*/
private evaluateWhenConditions(conditions: string[] | undefined, nodeTitle: string, nodeIndex: number): boolean {
if (!conditions || conditions.length === 0) {
// No when condition - available by default (but should not happen in groups)
return true;
}
// All conditions must be true (AND logic)
for (const condition of conditions) {
const trimmed = condition.trim();
if (trimmed === "once") {
// Check if this node has been visited once
const onceKey = `${nodeTitle}#${nodeIndex}`;
if (this.nodeGroupOnceSeen.has(onceKey)) {
return false; // Already seen once
}
// Will mark as seen when node is entered
continue;
}
if (trimmed === "always") {
// Always available
continue;
}
// Otherwise, treat as expression (e.g., "$has_sword")
if (!this.evaluator.evaluate(trimmed)) {
return false; // Condition failed
}
}
return true; // All conditions passed
}
/**
* Mark a node group node as seen (for "once" condition).
*/
private markNodeGroupOnceSeen(nodeTitle: string, nodeIndex: number): void {
const onceKey = `${nodeTitle}#${nodeIndex}`;
this.nodeGroupOnceSeen.add(onceKey);
}
advance(optionIndex?: number) {
// If awaiting option selection, consume chosen option by pushing its block
if (this.currentResult?.type === "options") {
if (optionIndex == null) throw new Error("Option index required");
const options = this.pendingOptions;
if (!options) throw new Error("Invalid options state");
const chosen = options[optionIndex];
if (!chosen) throw new Error("Invalid option index");
// Push a block frame that we will resume across advances
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
this.pendingOptions = null;
if (this.resumeBlock()) return;
return;
}
// If we have a pending block, resume it first
if (this.resumeBlock()) return;
this.step();
}
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
const evaluateExpression = (expr: string): string => {
try {
const value = this.evaluator.evaluateExpression(expr.trim());
if (value === null || value === undefined) {
return "";
}
return String(value);
} catch {
return "";
}
};
if (!markup) {
const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
return { text: interpolated };
}
const segments = markup.segments.filter((segment) => !segment.selfClosing);
const getWrappersAt = (index: number): MarkupWrapper[] => {
for (const segment of segments) {
if (segment.start <= index && index < segment.end) {
return segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
}));
}
}
if (segments.length === 0) {
return [];
}
if (index > 0) {
return getWrappersAt(index - 1);
}
return segments[0].wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
}));
};
const resultChars: string[] = [];
const newSegments: MarkupSegment[] = [];
let currentSegment: MarkupSegment | null = null;
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const wa = a[i];
const wb = b[i];
if (wa.name !== wb.name || wa.type !== wb.type) return false;
const keysA = Object.keys(wa.properties);
const keysB = Object.keys(wb.properties);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (wa.properties[key] !== wb.properties[key]) return false;
}
}
return true;
};
const flushSegment = () => {
if (currentSegment) {
newSegments.push(currentSegment);
currentSegment = null;
}
};
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
const index = resultChars.length;
resultChars.push(char);
const wrappersCopy = wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
}));
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
currentSegment.end = index + 1;
} else {
flushSegment();
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
}
};
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
if (!value) {
flushSegment();
return;
}
for (const ch of value) {
appendCharWithWrappers(ch, wrappers);
}
};
let i = 0;
while (i < text.length) {
const char = text[i];
if (char === '{') {
const close = text.indexOf('}', i + 1);
if (close === -1) {
appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
i += 1;
continue;
}
const expr = text.slice(i + 1, close);
const evaluated = evaluateExpression(expr);
const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
appendStringWithWrappers(evaluated, wrappers);
i = close + 1;
continue;
}
appendCharWithWrappers(char, getWrappersAt(i));
i += 1;
}
flushSegment();
const interpolatedText = resultChars.join('');
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
return { text: interpolatedText, markup: normalizedMarkup };
}
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
if (!result) return undefined;
if (result.segments.length === 0) {
return undefined;
}
const hasFormatting = result.segments.some(
(segment) => segment.wrappers.length > 0 || segment.selfClosing
);
if (!hasFormatting) {
return undefined;
}
return {
text: result.text,
segments: result.segments.map((segment) => ({
start: segment.start,
end: segment.end,
wrappers: segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
selfClosing: segment.selfClosing,
})),
};
}
private resumeBlock(): boolean {
const top = this.callStack[this.callStack.length - 1];
if (!top || top.kind !== "block") return false;
// Execute from stored idx until we emit one result or finish block
while (true) {
const ins = top.block[top.idx++];
if (!ins) {
// finished block; pop and continue main step
this.callStack.pop();
this.step();
return true;
}
switch (ins.op) {
case "line": {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
return true;
}
case "command": {
try {
const parsed = parseCommand(ins.content);
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
if (this.handleCommand) this.handleCommand(ins.content, parsed);
} catch {
if (this.handleCommand) this.handleCommand(ins.content);
}
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
return true;
}
case "options": {
const available = this.filterOptions(ins.options);
if (available.length === 0) {
continue;
}
this.pendingOptions = available;
this.emit({
type: "options",
options: available.map((o) => {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
return { text: interpolatedText, tags: o.tags, markup: interpolatedMarkup };
}),
isDialogueEnd: false,
});
return true;
}
case "if": {
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
if (branch) {
// Push nested block at current top position (resume after)
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
return this.resumeBlock();
}
break;
}
case "once": {
if (!this.onceSeen.has(ins.id)) {
this.onceSeen.add(ins.id);
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
return this.resumeBlock();
}
break;
}
case "jump": {
this.nodeTitle = ins.target;
this.ip = 0;
this.step();
return true;
}
case "detour": {
this.callStack.push({ kind: "detour", title: top.title, ip: top.ip });
this.nodeTitle = ins.target;
this.ip = 0;
this.step();
return true;
}
}
}
}
private step() {
while (true) {
const resolved = this.resolveNode(this.nodeTitle);
const currentNode: IRNode = { title: this.nodeTitle, instructions: resolved.instructions };
const ins = currentNode.instructions[this.ip];
if (!ins) {
// Node ended
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
this.emit({ type: "text", text: "", nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: true });
return;
}
this.ip++;
switch (ins.op) {
case "line": {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
return;
}
case "command": {
try {
const parsed = parseCommand(ins.content);
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
if (this.handleCommand) this.handleCommand(ins.content, parsed);
} catch {
if (this.handleCommand) this.handleCommand(ins.content);
}
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
return;
}
case "jump": {
// Exiting current node due to jump
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
this.nodeTitle = ins.target;
this.ip = 0;
this.currentNodeIndex = -1; // Reset node index for new resolution
// resolveNode will handle node groups
continue;
}
case "detour": {
// Save return position, jump to target node, return when it ends
this.callStack.push({ kind: "detour", title: this.nodeTitle, ip: this.ip });
this.nodeTitle = ins.target;
this.ip = 0;
this.currentNodeIndex = -1; // Reset node index for new resolution
// resolveNode will handle node groups
continue;
}
case "return": {
const top = this.callStack.pop();
if(!top) throw new Error("No call stack to return to");
this.nodeTitle = top.title;
this.ip = top.ip;
this.currentNodeIndex = -1; // Reset node index for new resolution
continue;
}
case "options": {
const available = this.filterOptions(ins.options);
if (available.length === 0) {
continue;
}
this.pendingOptions = available;
this.emit({
type: "options",
options: available.map((o) => {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
return { text: interpolatedText, tags: o.tags, css: o.css, markup: interpolatedMarkup };
}),
nodeCss: resolved.css,
scene: resolved.scene,
isDialogueEnd: this.lookaheadIsEnd(),
});
return;
}
case "if": {
const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
if (branch) {
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
if (this.resumeBlock()) return;
}
break;
}
case "once": {
if (!this.onceSeen.has(ins.id)) {
this.onceSeen.add(ins.id);
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
if (this.resumeBlock()) return;
}
break;
}
}
}
}
private filterOptions(options: CompiledOption[]): CompiledOption[] {
const available: CompiledOption[] = [];
for (const option of options) {
if (!option.condition) {
available.push(option);
continue;
}
try {
if (this.evaluator.evaluate(option.condition)) {
available.push(option);
}
} catch {
// Treat errors as false conditions
}
}
return available;
}
private lookaheadIsEnd(): boolean {
// Check if current node has more emit-worthy instructions
const node = this.resolveNode(this.nodeTitle);
for (let k = this.ip; k < node.instructions.length; k++) {
const op = node.instructions[k]?.op;
if (!op) break;
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
if (op === "jump" || op === "detour") return false;
}
// Node is ending - mark as end (will trigger detour return if callStack exists)
return true;
}
private emit(res: RuntimeResult) {
this.currentResult = res;
this.history.push(res);
if (res.isDialogueEnd && !this.storyEnded && this.callStack.length === 0) {
this.storyEnded = true;
if (this.onStoryEnd) {
// Create a readonly copy of the variables
const variablesCopy = Object.freeze({ ...this.variables });
this.onStoryEnd({ storyEnd: true, variables: variablesCopy });
}
}
// If we ended a detour node, return to caller after emitting last result
// Position is restored here, but we wait for next advance() to continue
if (res.isDialogueEnd && this.callStack.length > 0) {
const frame = this.callStack.pop()!;
this.nodeTitle = frame.title;
this.ip = frame.ip;
}
}
/**
* Get the current variable store (read-only view).
*/
getVariables(): Readonly<Record<string, unknown>> {
return { ...this.variables };
}
/**
* Get variable value.
*/
getVariable(name: string): unknown {
return this.variables[name];
}
/**
* Set variable value.
*/
setVariable(name: string, value: unknown): void {
this.variables[name] = value;
this.evaluator.setVariable(name, value);
}
}