// Evaluate the server-parsed Naninovel AST. Game text is never evaluated as JS. export type NaniAst = any[]; export function naniTruth(value: any): boolean { if (typeof value === "string") return value !== "" && !["false", "0", "null"].includes(value.toLowerCase()); return Boolean(value); } function number(value: any): number { const n = Number(value); if (!Number.isFinite(n)) throw new Error(`Naninovel: не число ${String(value)}`); return n; } function equal(a: any, b: any): boolean { if (a === null || b === null) return a === null && b === null; if ((typeof a === "string") !== (typeof b === "string")) { const text = typeof a === "string" ? a : b; return text.trim() !== "" && Number.isFinite(Number(text)) && Number(a) === Number(b); } if (["number", "boolean"].includes(typeof a) && ["number", "boolean"].includes(typeof b)) return Number(a) === Number(b); return typeof a === typeof b && a === b; } export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random, functions: Record any> = {}): any { const evaluate = (node: NaniAst): any => { switch (node[0]) { case "const": return node[1]; case "name": return lookup(node[1]) ?? 0; case "unary": { const value = evaluate(node[2]); if (node[1] === "!" || node[1] === "not") return !naniTruth(value); if (node[1] === "-") return -number(value); if (node[1] === "+") return number(value); if (node[1] === "~") return ~number(value); throw new Error(`Naninovel: оператор ${node[1]}`); } case "if": return evaluate(naniTruth(evaluate(node[1])) ? node[2] : node[3]); case "bin": { const a = evaluate(node[2]); if (["and", "&&"].includes(node[1])) return naniTruth(a) && naniTruth(evaluate(node[3])); if (["or", "||"].includes(node[1])) return naniTruth(a) || naniTruth(evaluate(node[3])); const b = evaluate(node[3]); switch (node[1]) { case "+": return typeof a === "string" || typeof b === "string" ? String(a) + String(b) : number(a) + number(b); case "-": return number(a) - number(b); case "*": return number(a) * number(b); case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b); case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b); case "=": case "==": return equal(a, b); case "!=": case "<>": return !equal(a, b); case "<": return typeof a === "string" && typeof b === "string" ? a < b : number(a) < number(b); case ">": return typeof a === "string" && typeof b === "string" ? a > b : number(a) > number(b); case "<=": return typeof a === "string" && typeof b === "string" ? a <= b : number(a) <= number(b); case ">=": return typeof a === "string" && typeof b === "string" ? a >= b : number(a) >= number(b); case "&": return number(a) & number(b); case "|": return number(a) | number(b); case "^": return number(a) ^ number(b); case "<<": return number(a) << number(b); case ">>": return number(a) >> number(b); default: throw new Error(`Naninovel: оператор ${node[1]}`); } } case "call": { const values = node[2].map((value: NaniAst) => evaluate(value)); if (Object.hasOwn(functions, node[1])) return functions[node[1]](...values); const args = values.map(number); switch (node[1]) { case "Random": case "random": case "Rand": { const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0]; return min + Math.floor(random() * (max - min)); } case "GetRandValue": return Math.floor(random() * args[0]); case "Min": return Math.min(...args); case "Max": return Math.max(...args); case "Abs": return Math.abs(args[0]); case "Pow": return Math.pow(args[0], args[1]); case "Round": { const power = 10 ** (args[1] ?? 0); return Math.round(args[0] * power) / power; } default: throw new Error(`Naninovel: функция ${node[1]}`); } } default: throw new Error(`Naninovel: узел выражения ${ast[0]}`); } }; return evaluate(ast); } export function naniText(source: string, expressions: Record, lookup: (name: string) => any): string { // Expressions are substituted after the locale is selected. Brackets in their // results are escaped before passing to the shared rich-text renderer. const escaped = source.replace(/\{([^{}]+)\}/g, (_match, expression: string) => { const ast = expressions[expression]; if (!ast) throw new Error(`Naninovel: неизвестное выражение в тексте ${expression}`); return String(evaluateNani(ast, lookup)).replaceAll("{", "{{").replaceAll("[", "[["); }); return escaped.replace(/<(\/?)(b|i|u|s)>/gi, "{$1$2}") .replace(/<(color|size)=([^<>]+)>/gi, "{$1=$2}") .replace(/<\/(color|size)>/gi, "{/$1}") .replace(//gi, "\n"); }