Compare commits

..

No commits in common. "f1aa536c4d56128b4258a95353bfbea81b5eea31" and "a226a9516cdef636d5d287d8defa40b85c9025c7" have entirely different histories.

8 changed files with 99 additions and 341 deletions

View File

@ -21,8 +21,7 @@ title: 节点名称
``` ```
对于`随机`遭遇而言,使用多个`title`相同的节点。 对于`随机`遭遇而言,使用多个`title`相同的节点。
使用`when: `来为节点触发添加条件。 可以使用`when: `来为节点触发添加条件。
如果没有条件,则使用`when: always`。
若需要节点只触发一次,可以结合`变量`。 若需要节点只触发一次,可以结合`变量`。
```yarn-spinner ```yarn-spinner
@ -44,29 +43,30 @@ when: $villager_encountered == false
随机遭遇可以使用`<<return>>`来返回主线。 随机遭遇可以使用`<<return>>`来返回主线。
在玩家可以反复尝试的遭遇中,在结尾添加`<<jump 节点名称>>```,跳转到自身的开头。 ## 休息与睡觉
在合适的地方可以休息。如果时间在晚0点到早6点之间可以睡觉。
## 命令和函数 ## 命令和函数
使用命令读写时间。不要创建变量。 使用命令读写时间。不要创建变量。
- `<<add_hour>>``<<add_minute>>``<<add_day>>`:触发时间流逝 - ```<<time_pass>>```:时间流逝,若休息则使用`<<time_pass true>>`。若在夜间休息则休息至第二天6点否则休息1小时
- `$time_day``$time_hour``$time_minute`读取24小时制当前时间 - ```<<if time_of_day() <= 6>>```读取24小时制当前时间。午夜为0点
- `$hour_total`:总共流逝的时间。 - ```<<if time_of_game() >= 72>>```:总共流逝的时间。
使用以下命令操作角色属性: 使用以下命令操作角色属性:
- ```<<damage str 4>>```:造成角色属性损伤。 - ```<<damage str 4>>```:造成角色属性损伤。若可耐受,则使用```<<damage str 4 true>>```。
- ```<<heal str 4>>```:解除属性创伤,并恢复角色属性损伤。 - ```<<heal str 4>>```:解除属性创伤,并恢复角色属性损伤。
- ```<<buff str 4 4>>```:施加属性修改值,持续一定小时数。
- ```<<set $result to check("nodeId:checkId", "wis")>> ```:检定角色属性。成功返回正值代表成功进度,失败返回负值代表失败进度。 - ```<<set $result to check("nodeId:checkId", "wis")>> ```:检定角色属性。成功返回正值代表成功进度,失败返回负值代表失败进度。
- ```<<if $result >= 3>>```:处理检定成功进度。 - ```<<if $result >= 3>>```:处理检定成功进度。
- ```<<if $result <= -2>>```:处理检定失败进度。 - ```<<if $result <= -2>>```:处理检定失败进度。
- ```<<if $result > 0>>```:如果既没有达成成功进度也没有达成失败进度,暗示玩家应该继续尝试。 - ```<<if $result > 0>>```:如果既没有成功也没有失败,暗示玩家应该继续尝试。
- 检定后总是在文本中描述玩家当前的进度(但不要描述难度和机会)
直接使用变量来记录玩家物品。没有背包限制。 使用以下命令操作角色物品:
- ```<<set $item_light_armor to $item_light_armor + 1>>```:添加物品。 - ```<<add_item light_armor 1>>```:添加物品。
- ```<<if $item_gold >= 100)>>```:检查物品。 - ```<<if consume_item("gold", 100)>>```:消耗物品。
- ```<<if has_item("gold", 100)>>```:检查物品。
直接使用变量来记录势力计划、地点、事件的进度。
## 文件分割 ## 文件分割

View File

@ -2,8 +2,9 @@ import { customElement, noShadowDOM } from 'solid-element';
import { For, Show, createEffect } from 'solid-js'; import { For, Show, createEffect } from 'solid-js';
import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results'; import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results';
import { createYarnStore } from './stores/yarnStore'; import { createYarnStore } from './stores/yarnStore';
import {RunnerOptions} from "../yarn-spinner/runtime/runner";
customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { element }) => { customElement<RunnerOptions>('md-yarn-spinner', {startAt: 'start'}, (props, { element }) => {
noShadowDOM(); noShadowDOM();
let historyContainer: HTMLDivElement | undefined; let historyContainer: HTMLDivElement | undefined;

View File

@ -1,111 +0,0 @@
type IRunner = {
getVariable(key: string): unknown;
setVariable(key: string, value: unknown): void;
setSmartVariable(name: string, expression: string): void;
}
export function getTtrpgFunctions(runner: IRunner){
/* time related */
function ensure_time(){
if(!runner.getVariable("time_day")){
runner.setVariable("time_day", 1);
runner.setVariable("time_hour", 0);
runner.setVariable("time_minute", 0);
runner.setSmartVariable("hour_total", "$time_day * 24 + $time_hour");
}
}
// midnight - 6am is night time
function is_night(){
ensure_time();
const hour = runner.getVariable("time_hour") as number;
return hour < 6;
}
function add_minute(delta: number){
ensure_time();
const min = (runner.getVariable("time_minute") as number) + delta;
runner.setVariable("time_minute", min % 60);
if(min >= 60) add_hour(Math.floor(min / 60));
}
function add_hour(delta: number){
ensure_time();
const hour = (runner.getVariable("time_hour") as number) + delta;
runner.setVariable("time_hour", hour % 24);
if(hour >= 24) add_day(Math.floor(hour / 24));
}
function add_day(delta: number){
ensure_time();
const day = (runner.getVariable("time_day") as number) + delta;
runner.setVariable("time_day", day);
}
/* stat related */
function ensure_stat(stat: string){
if(runner.getVariable(stat) === undefined){
const statBase = `${stat}_base`;
const statMod = `${stat}_mod`;
const statDamage = `${stat}_damage`;
const statWound = `${stat}_wound`;
runner.setVariable(statBase, 10);
runner.setVariable(statMod, 0);
runner.setVariable(statDamage, 0);
runner.setVariable(statWound, false);
runner.setSmartVariable(stat, `$${statBase} - $${statDamage} + $${statMod}`);
}
}
function get_stat(stat: string){
ensure_stat(stat);
return runner.getVariable(stat) as number || 0;
}
function damage(stat: string, amount: number){
const current = get_stat(stat);
if(amount * 2 >= current)
runner.setVariable(`${stat}_wound`, true);
const damage = get_stat(`${stat}_damage`);
runner.setVariable(`${stat}_damage`, damage + amount);
}
function heal(stat: string, amount = 0){
ensure_stat(stat);
runner.setVariable(`${stat}_wound`, false);
const damage = get_stat(`${stat}_damage`);
runner.setVariable(`${stat}_damage`, Math.max(0, damage - amount));
}
function check(id: string, stat: string): number {
const statVal = get_stat(stat);
const pass = Math.ceil(Math.random() * 20) <= statVal;
const key = `${id}_${pass ? 'pass' : 'fail'}`;
const progress = runner.getVariable(key) as number || 0;
const newProgress = progress + Math.ceil(Math.random() * 4);
runner.setVariable(key, newProgress);
console.log(`check ${stat}(${statVal}) ${pass ? 'pass' : 'fail'}, now ${newProgress}`);
return pass ? newProgress : -newProgress;
}
function rollStat(){
const index = Math.floor(Math.random() * 6);
return ['str', 'dex', 'con', 'int', 'wis', 'cha'][index];
}
function fatigue(){
damage(rollStat(), 1);
}
function regen(){
let stat = rollStat();
if(runner.getVariable(`${stat}_wound`) || get_stat(`${stat}_damage`) <= 0) {
stat = rollStat();
}
if(runner.getVariable(`${stat}_wound`) || get_stat(`${stat}_damage`) <= 0) {
return;
}
heal(stat, 1);
}
return {
commands: {
add_minute,
add_hour,
add_day,
damage,
heal,
fatigue,
regen
} as Record<string, (...args: any[]) =>any>,
functions: {
check
} as Record<string, (...args: any[]) =>any>
}
}

View File

@ -4,7 +4,6 @@ import {compile, parseYarn, YarnRunner} from "../../yarn-spinner";
import {loadElementSrc, resolvePath} from "../utils/path"; import {loadElementSrc, resolvePath} from "../utils/path";
import {createStore} from "solid-js/store"; import {createStore} from "solid-js/store";
import {RunnerOptions} from "../../yarn-spinner/runtime/runner"; import {RunnerOptions} from "../../yarn-spinner/runtime/runner";
import {getTtrpgFunctions} from "./ttrpgRunner";
type YarnSpinnerStore = { type YarnSpinnerStore = {
dialogueHistory: (RuntimeResult | OptionsResult['options'][0])[], dialogueHistory: (RuntimeResult | OptionsResult['options'][0])[],
@ -13,7 +12,7 @@ type YarnSpinnerStore = {
runnerInstance: YarnRunner | null, runnerInstance: YarnRunner | null,
} }
export function createYarnStore(element: HTMLElement, props: {start: string}){ export function createYarnStore(element: HTMLElement, props: RunnerOptions){
const [store, setStore] = createStore<YarnSpinnerStore>({ const [store, setStore] = createStore<YarnSpinnerStore>({
dialogueHistory: [], dialogueHistory: [],
currentOptions: null, currentOptions: null,
@ -39,13 +38,7 @@ export function createYarnStore(element: HTMLElement, props: {start: string}){
const ast = parseYarn(content); const ast = parseYarn(content);
const program = compile(ast); const program = compile(ast);
const runner = new YarnRunner(program, { return new YarnRunner(program, props);
startAt: props.start,
});
const {commands, functions} = getTtrpgFunctions(runner);
runner.registerFunctions(functions);
runner.registerCommands(commands);
return runner;
} catch (error) { } catch (error) {
console.error('Failed to initialize YarnRunner:', error); console.error('Failed to initialize YarnRunner:', error);
return null; return null;

View File

@ -13,7 +13,6 @@ export interface ParsedCommand {
/** /**
* Parse a command string like "command_name arg1 arg2" or "set variable value" * Parse a command string like "command_name arg1 arg2" or "set variable value"
* Supports quoted strings with spaces, nested parentheses in expressions.
*/ */
export function parseCommand(content: string): ParsedCommand { export function parseCommand(content: string): ParsedCommand {
const trimmed = content.trim(); const trimmed = content.trim();
@ -25,14 +24,15 @@ export function parseCommand(content: string): ParsedCommand {
let current = ""; let current = "";
let inQuotes = false; let inQuotes = false;
let quoteChar = ""; let quoteChar = "";
let parenDepth = 0;
for (let i = 0; i < trimmed.length; i++) { for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i]; const char = trimmed[i];
// Handle quote toggling (only when not inside parentheses) if ((char === '"' || char === "'") && !inQuotes) {
if ((char === '"' || char === "'") && !inQuotes && parenDepth === 0) { // If we have accumulated non-quoted content (e.g. a function name and "(")
// Push accumulated non-quoted content as a part // 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()) { if (current.trim()) {
parts.push(current.trim()); parts.push(current.trim());
current = ""; current = "";
@ -43,23 +43,17 @@ export function parseCommand(content: string): ParsedCommand {
} }
if (char === quoteChar && inQuotes) { if (char === quoteChar && inQuotes) {
// End of quoted string - preserve quotes in the output 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); parts.push(quoteChar + current + quoteChar);
quoteChar = ""; quoteChar = "";
current = ""; current = "";
inQuotes = false;
continue; continue;
} }
// Track parenthesis depth to avoid splitting inside expressions if (char === " " && !inQuotes) {
if (char === "(" && !inQuotes) {
parenDepth++;
} else if (char === ")" && !inQuotes) {
parenDepth = Math.max(0, parenDepth - 1);
}
// Split on spaces only when not in quotes and not in parentheses
if (char === " " && !inQuotes && parenDepth === 0) {
if (current.trim()) { if (current.trim()) {
parts.push(current.trim()); parts.push(current.trim());
current = ""; current = "";
@ -70,7 +64,6 @@ export function parseCommand(content: string): ParsedCommand {
current += char; current += char;
} }
// Push any remaining content
if (current.trim()) { if (current.trim()) {
parts.push(current.trim()); parts.push(current.trim());
} }

View File

@ -320,72 +320,47 @@ export class ExpressionEvaluator {
let depth = 0; let depth = 0;
let current = ""; let current = "";
let lastOp: "&&" | "||" | null = null; let lastOp: "&&" | "||" | null = null;
let i = 0;
while (i < expr.length) { for (const char of expr) {
const char = expr[i]; if (char === "(") depth++;
else if (char === ")") depth--;
if (char === "(") { else if (depth === 0 && expr.includes(char === "&" ? "&&" : char === "|" ? "||" : "")) {
depth++; // Check for && or ||
current += char; const remaining = expr.slice(expr.indexOf(char));
i++; if (remaining.startsWith("&&")) {
continue;
}
if (char === ")") {
depth--;
current += char;
i++;
continue;
}
// Check for && or || at current position (only at depth 0)
if (depth === 0) {
if (expr.slice(i, i + 2) === "&&") {
if (current.trim()) { if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp }); parts.push({ expr: current.trim(), op: lastOp });
current = ""; current = "";
} }
lastOp = "&&"; lastOp = "&&";
i += 2; // skip &&
continue; continue;
} } else if (remaining.startsWith("||")) {
if (expr.slice(i, i + 2) === "||") {
if (current.trim()) { if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp }); parts.push({ expr: current.trim(), op: lastOp });
current = ""; current = "";
} }
lastOp = "||"; lastOp = "||";
i += 2; // skip ||
continue; continue;
} }
} }
current += char; current += char;
i++;
}
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
} }
if (current.trim()) parts.push({ expr: current.trim(), op: lastOp });
// Simple case: single expression // Simple case: single expression
if (parts.length <= 1) { if (parts.length === 0) return !!this.evaluateExpression(expr);
return !!this.evaluateExpression(expr);
}
// Evaluate parts with short-circuit logic // Evaluate parts (supports &&, ||, ^ as xor)
let result = this.evaluateExpression(parts[0].expr); let result = this.evaluateExpression(parts[0].expr);
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
const val = this.evaluateExpression(part.expr);
if (part.op === "&&") { if (part.op === "&&") {
// Short-circuit: if result is false, no need to evaluate further result = result && val;
if (!result) return false;
result = result && this.evaluateExpression(part.expr);
} else if (part.op === "||") { } else if (part.op === "||") {
// Short-circuit: if result is true, no need to evaluate further result = result || val;
if (result) return true;
result = result || this.evaluateExpression(part.expr);
} }
} }
@ -483,33 +458,9 @@ export class ExpressionEvaluator {
if (a === b) return true; if (a === b) return true;
if (a == null || b == null) return a === b; if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false; if (typeof a !== typeof b) return false;
if (typeof a === "object") { if (typeof a === "object") {
// Handle arrays return JSON.stringify(a) === JSON.stringify(b);
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!this.deepEquals(a[i], b[i])) return false;
}
return true;
}
// Handle plain objects
if (!Array.isArray(a) && !Array.isArray(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!this.deepEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false;
}
}
return true;
}
// Mixed types (array vs object)
return false;
} }
return false; return false;
} }
@ -548,11 +499,4 @@ export class ExpressionEvaluator {
getVariable(name: string): unknown { getVariable(name: string): unknown {
return this.variables[name]; return this.variables[name];
} }
registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
this.functions = {
...this.functions,
...functions
}
}
} }

View File

@ -1,8 +1,4 @@
import type { MarkupParseResult } from "../markup/types"; import type { MarkupParseResult } from "../markup/types";
/**
* Result emitted when the dialogue produces text/dialogue.
*/
export type TextResult = { export type TextResult = {
type: "text"; type: "text";
text: string; text: string;
@ -14,9 +10,6 @@ export type TextResult = {
isDialogueEnd: boolean; isDialogueEnd: boolean;
}; };
/**
* Result emitted when the dialogue presents options to the user.
*/
export type OptionsResult = { export type OptionsResult = {
type: "options"; type: "options";
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[]; options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
@ -25,17 +18,11 @@ export type OptionsResult = {
isDialogueEnd: boolean; isDialogueEnd: boolean;
}; };
/**
* Result emitted when the dialogue executes a command.
*/
export type CommandResult = { export type CommandResult = {
type: "command"; type: "command";
command: string; command: string;
isDialogueEnd: boolean; isDialogueEnd: boolean;
}; };
/**
* Union type of all possible runtime results emitted by the YarnRunner.
*/
export type RuntimeResult = TextResult | OptionsResult | CommandResult; export type RuntimeResult = TextResult | OptionsResult | CommandResult;

View File

@ -11,14 +11,8 @@ export interface RunnerOptions {
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void; handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
commandHandler?: CommandHandler; commandHandler?: CommandHandler;
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void; onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
/**
* If true, each runner instance maintains its own once-seen state.
* If false (default), all runners share global once-seen state.
*/
isolated?: boolean;
} }
// Global shared state for once-seen tracking (default behavior)
const globalOnceSeen = new Set<string>(); const globalOnceSeen = new Set<string>();
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index" const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
@ -31,27 +25,27 @@ type CompiledOption = {
block: IRInstruction[]; block: IRInstruction[];
}; };
type CallStackFrame =
| { kind: "detour"; title: string; ip: number }
| { kind: "block"; title: string; ip: number; block: IRInstruction[]; idx: number };
export class YarnRunner { export class YarnRunner {
private readonly program: IRProgram; private readonly program: IRProgram;
private readonly variables: Record<string, unknown>; 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 handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
private readonly commandHandler: CommandHandler; private readonly commandHandler: CommandHandler;
private readonly evaluator: ExpressionEvaluator; private readonly evaluator: ExpressionEvaluator;
private readonly onceSeen: Set<string>; private readonly onceSeen = globalOnceSeen;
private readonly nodeGroupOnceSeen: Set<string>;
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"]; private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
private storyEnded = false; private storyEnded = false;
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
private readonly visitCounts: Record<string, number> = {}; private readonly visitCounts: Record<string, number> = {};
private pendingOptions: CompiledOption[] | null = null; private pendingOptions: CompiledOption[] | null = null;
private nodeTitle: string; private nodeTitle: string;
private ip = 0; // instruction pointer within node private ip = 0; // instruction pointer within node
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node) private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
private callStack: CallStackFrame[] = []; private callStack: Array<
| ({ title: string; ip: number } & { kind: "detour" })
| ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" })
> = [];
currentResult: RuntimeResult | null = null; currentResult: RuntimeResult | null = null;
history: RuntimeResult[] = []; history: RuntimeResult[] = [];
@ -65,15 +59,7 @@ export class YarnRunner {
this.variables[normalizedKey] = value; this.variables[normalizedKey] = value;
} }
} }
// Use isolated state if requested, otherwise share global state this.functions = {
if (opts.isolated) {
this.onceSeen = new Set<string>();
this.nodeGroupOnceSeen = new Set<string>();
} else {
this.onceSeen = globalOnceSeen;
this.nodeGroupOnceSeen = globalNodeGroupOnceSeen;
}
let functions = {
// Default conversion helpers // Default conversion helpers
string: (v: unknown) => String(v ?? ""), string: (v: unknown) => String(v ?? ""),
number: (v: unknown) => Number(v), number: (v: unknown) => Number(v),
@ -129,26 +115,13 @@ export class YarnRunner {
} as Record<string, (...args: unknown[]) => unknown>; } as Record<string, (...args: unknown[]) => unknown>;
this.handleCommand = opts.handleCommand; this.handleCommand = opts.handleCommand;
this.onStoryEnd = opts.onStoryEnd; this.onStoryEnd = opts.onStoryEnd;
this.evaluator = new ExpressionEvaluator(this.variables, functions, this.program.enums); this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables); this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
this.nodeTitle = opts.startAt; this.nodeTitle = opts.startAt;
this.step(); this.step();
} }
public registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
this.evaluator.registerFunctions(functions);
}
public registerCommands(commands: Record<string, (args: unknown[], evaluator?: ExpressionEvaluator) => void | Promise<void>>) {
for(const key in commands){
this.commandHandler.register(key, (args, evaluator) => {
if(!evaluator) return;
commands[key].call(this, args.map(arg => evaluator.evaluateExpression(arg)));
});
}
};
/** /**
* Resolve a node title to an actual node (handling node groups). * Resolve a node title to an actual node (handling node groups).
*/ */
@ -244,9 +217,6 @@ export class YarnRunner {
this.step(); this.step();
} }
/**
* Interpolate variables in text and update markup segments accordingly.
*/
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } { private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
const evaluateExpression = (expr: string): string => { const evaluateExpression = (expr: string): string => {
try { try {
@ -265,17 +235,6 @@ export class YarnRunner {
return { text: interpolated }; return { text: interpolated };
} }
return this.interpolateWithMarkup(text, markup, evaluateExpression);
}
/**
* Interpolate text while preserving and updating markup segments.
*/
private interpolateWithMarkup(
text: string,
markup: MarkupParseResult,
evaluateExpression: (expr: string) => string
): { text: string; markup?: MarkupParseResult } {
const segments = markup.segments.filter((segment) => !segment.selfClosing); const segments = markup.segments.filter((segment) => !segment.selfClosing);
const getWrappersAt = (index: number): MarkupWrapper[] => { const getWrappersAt = (index: number): MarkupWrapper[] => {
for (const segment of segments) { for (const segment of segments) {
@ -304,6 +263,29 @@ export class YarnRunner {
const newSegments: MarkupSegment[] = []; const newSegments: MarkupSegment[] = [];
let currentSegment: MarkupSegment | null = null; 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 appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
const index = resultChars.length; const index = resultChars.length;
resultChars.push(char); resultChars.push(char);
@ -312,18 +294,17 @@ export class YarnRunner {
type: wrapper.type, type: wrapper.type,
properties: { ...wrapper.properties }, properties: { ...wrapper.properties },
})); }));
if (currentSegment && this.wrappersEqual(currentSegment.wrappers, wrappersCopy)) { if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
currentSegment.end = index + 1; currentSegment.end = index + 1;
} else { } else {
this.flushSegment(currentSegment, newSegments); flushSegment();
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy }; currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
} }
}; };
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => { const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
if (!value) { if (!value) {
this.flushSegment(currentSegment, newSegments); flushSegment();
currentSegment = null;
return; return;
} }
for (const ch of value) { for (const ch of value) {
@ -352,34 +333,12 @@ export class YarnRunner {
i += 1; i += 1;
} }
this.flushSegment(currentSegment, newSegments); flushSegment();
const interpolatedText = resultChars.join(''); const interpolatedText = resultChars.join('');
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments }); const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
return { text: interpolatedText, markup: normalizedMarkup }; return { text: interpolatedText, markup: normalizedMarkup };
} }
private wrappersEqual(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
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;
}
private flushSegment(segment: MarkupSegment | null, segments: MarkupSegment[]): void {
if (segment) {
segments.push(segment);
}
}
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined { private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
if (!result) return undefined; if (!result) return undefined;
if (result.segments.length === 0) { if (result.segments.length === 0) {
@ -425,12 +384,13 @@ export class YarnRunner {
return true; return true;
} }
case "command": { case "command": {
const parsed = parseCommand(ins.content); try {
// Execute command handler (errors are caught internally) const parsed = parseCommand(ins.content);
this.commandHandler.execute(parsed, this.evaluator).catch((err) => { this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
console.warn(`Command execution error: ${err}`); if (this.handleCommand) this.handleCommand(ins.content, parsed);
}); } catch {
if (this.handleCommand) this.handleCommand(ins.content, parsed); if (this.handleCommand) this.handleCommand(ins.content);
}
this.emit({ type: "command", command: ins.content, isDialogueEnd: false }); this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
return true; return true;
} }
@ -503,12 +463,13 @@ export class YarnRunner {
return; return;
} }
case "command": { case "command": {
const parsed = parseCommand(ins.content); try {
// Execute command handler (errors are caught internally) const parsed = parseCommand(ins.content);
this.commandHandler.execute(parsed, this.evaluator).catch((err) => { this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
console.warn(`Command execution error: ${err}`); if (this.handleCommand) this.handleCommand(ins.content, parsed);
}); } catch {
if (this.handleCommand) this.handleCommand(ins.content, parsed); if (this.handleCommand) this.handleCommand(ins.content);
}
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() }); this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
return; return;
} }
@ -532,10 +493,7 @@ export class YarnRunner {
} }
case "return": { case "return": {
const top = this.callStack.pop(); const top = this.callStack.pop();
if (!top) { if(!top) throw new Error("No call stack to return to");
console.warn("Return called with empty call stack");
continue;
}
this.nodeTitle = top.title; this.nodeTitle = top.title;
this.ip = top.ip; this.ip = top.ip;
this.currentNodeIndex = -1; // Reset node index for new resolution this.currentNodeIndex = -1; // Reset node index for new resolution
@ -590,8 +548,7 @@ export class YarnRunner {
if (this.evaluator.evaluate(option.condition)) { if (this.evaluator.evaluate(option.condition)) {
available.push(option); available.push(option);
} }
} catch (err) { } catch {
console.warn(`Option condition evaluation error: ${err}`);
// Treat errors as false conditions // Treat errors as false conditions
} }
} }
@ -599,15 +556,15 @@ export class YarnRunner {
} }
private lookaheadIsEnd(): boolean { private lookaheadIsEnd(): boolean {
// Check if current node has more emit-worthy instructions ahead // Check if current node has more emit-worthy instructions
const node = this.resolveNode(this.nodeTitle); const node = this.resolveNode(this.nodeTitle);
for (let k = this.ip; k < node.instructions.length; k++) { for (let k = this.ip; k < node.instructions.length; k++) {
const op = node.instructions[k]?.op; const op = node.instructions[k]?.op;
if (!op) break; if (!op) break;
// These instructions produce output or control flow changes
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false; if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
if (op === "jump" || op === "detour") return false; if (op === "jump" || op === "detour") return false;
} }
// Node is ending - mark as end (will trigger detour return if callStack exists)
return true; return true;
} }
@ -642,8 +599,6 @@ export class YarnRunner {
* Get variable value. * Get variable value.
*/ */
getVariable(name: string): unknown { getVariable(name: string): unknown {
if(this.evaluator.isSmartVariable(name))
return this.evaluator.evaluateExpression(`$${name}`);
return this.variables[name]; return this.variables[name];
} }
@ -654,9 +609,5 @@ export class YarnRunner {
this.variables[name] = value; this.variables[name] = value;
this.evaluator.setVariable(name, value); this.evaluator.setVariable(name, value);
} }
setSmartVariable(name: string, expression: string): void {
this.evaluator.setSmartVariable(name, expression);
}
} }