102 lines
5.2 KiB
TypeScript
102 lines
5.2 KiB
TypeScript
// 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<string, (...args: any[]) => 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<string, NaniAst>, 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(/<br\s*\/?>/gi, "\n");
|
|
}
|