Files
pgames-manager/frontend/src/player/nani-parser.ts
T

120 lines
7.0 KiB
TypeScript

// Runtime parser for dynamically composed expressions. This accepts the same
// bounded language as the import parser; it never evaluates JavaScript source.
import type { NaniAst } from "./naninovel";
const precedence: Record<string, number> = { or: 2, "||": 2, and: 3, "&&": 3, "|": 4, "^": 5,
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8, "<=": 8, ">=": 8,
"<<": 9, ">>": 9, "+": 10, "-": 10, "*": 11, "/": 11, "%": 11 };
export function parseNani(source: string, functions: Set<string>): NaniAst {
if (source.length > 30000) throw new Error("Naninovel: слишком длинное выражение");
source = source.trim().replace(/;$/, "").trim();
const tokens: { value: any; kind: string }[] = [];
for (let offset = 0; offset < source.length;) {
if (/\s/.test(source[offset])) { offset++; continue; }
const wrapped = source[offset] === "\\" && ["\"", "'"].includes(source[offset + 1]);
if (["\"", "'"].includes(source[offset]) || wrapped) {
const quote = source[offset + (wrapped ? 1 : 0)];
offset += wrapped ? 2 : 1;
let value = "", closed = false;
while (offset < source.length) {
let char = source[offset];
if (wrapped && char === "\\" && source[offset + 1] === quote) { offset += 2; closed = true; break; }
if (!wrapped && char === quote) { offset++; closed = true; break; }
if (char === "\\" && offset + 1 < source.length) {
char = source[++offset];
char = ({ n: "\n", r: "\r", t: "\t" } as Record<string, string>)[char] ?? char;
}
value += char; offset++;
}
if (!closed) throw new Error("Naninovel: не закрыта строка выражения");
tokens.push({ kind: "string", value }); continue;
}
const rest = source.slice(offset);
const numeric = rest.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
const name = rest.match(/^[A-Za-z_]\w*/);
const operator = rest.match(/^(?:&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^])/);
const found = numeric ?? name ?? operator;
if (!found) throw new Error(`Naninovel: неизвестный токен ${rest.slice(0, 40)}`);
tokens.push({ kind: numeric ? "number" : name ? "name" : "operator", value: numeric ? Number(found[0]) : found[0] });
offset += found[0].length;
}
let cursor = 0, depth = 0;
const peek = () => tokens[cursor]?.kind === "string" ? "\u0000string" : tokens[cursor]?.value ?? "";
const take = (expected?: string) => {
if (cursor >= tokens.length || (expected !== undefined && peek() !== expected)) throw new Error(`Naninovel: ожидалось ${expected ?? "выражение"}`);
return tokens[cursor++];
};
const expression = (minimum = 1): NaniAst => {
if (++depth > 64) throw new Error("Naninovel: слишком глубокое выражение");
const token = take(), value = token.value;
let left: NaniAst;
if (token.kind !== "string" && ["-", "+", "!", "not", "~"].includes(value)) left = ["unary", value, expression(12)];
else if (token.kind === "operator" && value === "(") { left = expression(); take(")"); }
else if (["number", "string"].includes(token.kind)) {
if (token.kind === "number" && !Number.isFinite(value)) throw new Error("Naninovel: неконечное число");
left = ["const", value];
} else if (token.kind === "name") {
if (["true", "false", "null"].includes(value.toLowerCase())) left = ["const", { true: true, false: false, null: null }[value.toLowerCase() as "true"]];
else if (peek() === "(") {
if (!functions.has(value) && value.toLowerCase() !== "if") throw new Error(`Naninovel: функция ${value} не поддерживается`);
take("(");
const args: NaniAst[] = [];
if (peek() !== ")") while (true) { args.push(expression()); if (peek() !== ",") break; take(","); }
take(")");
if (value.toLowerCase() === "if") {
if (args.length !== 3) throw new Error("Naninovel: If требует три аргумента");
left = ["if", ...args];
} else left = ["call", value, args];
} else left = ["name", value];
} else throw new Error(`Naninovel: некорректный токен ${value}`);
while (Object.hasOwn(precedence, peek()) && precedence[peek()] >= minimum) {
const op = take().value;
left = ["bin", op, left, expression(precedence[op] + 1)];
}
if (minimum <= 1 && peek() === "?") { take("?"); const yes = expression(); take(":"); left = ["if", left, yes, expression()]; }
depth--; return left;
};
const ast = expression();
if (cursor !== tokens.length) throw new Error("Naninovel: лишние токены выражения");
return ast;
}
export interface NaniCommand { op: string; args: Record<string, any>; line?: number; tid?: string }
export function inlineCommands(source: string | string[], line = -1): NaniCommand[] {
let lines: string[];
if (Array.isArray(source)) lines = source;
else {
lines = []; let start = 0, depth = 0, quote = "";
for (let index = 0; index < source.length; index++) {
const char = source[index];
if (quote) { if (char === "\\") index++; else if (char === quote) quote = ""; }
else if (["\"", "'"].includes(char)) quote = char;
else if (char === "(" || char === "{") depth++;
else if (char === ")" || char === "}") depth--;
else if (char === "," && depth === 0) { lines.push(source.slice(start, index)); start = index + 1; }
}
if (depth !== 0 || quote) throw new Error("Naninovel: незакрытая команда выбора");
lines.push(source.slice(start));
}
return lines.filter((line) => line.trim()).map((source) => {
source = source.trim();
if (!source.startsWith("@")) {
const speaker = source.match(/^([^:]{1,24}): (.*)$/s);
return { op: "PrintText", line, args: { Text: { template: speaker ? speaker[2] : source }, AuthorId: speaker?.[1] ?? "" } };
}
const [, name, body = ""] = source.match(/^(@\S+)(?:\s+(.*))?$/s)!;
const template = { template: body };
if (name === "@set") return { op: "SetCustomVariable", args: { Expression: template }, line };
if (["@goto", "@gosub"].includes(name)) return { op: name === "@goto" ? "Goto" : "Gosub", args: { Path: template }, line };
if (name === "@return") return { op: "Return", args: {}, line };
if (name === "@hideChars") return { op: "HideAllCharacters", args: { Duration: 0 }, line };
if (["@addItem", "@additem", "@removeItem"].includes(name)) return { op: name === "@removeItem" ? "RemoveItem" : "AddItem",
args: { ItemId: { template: body.split(/\s/, 1)[0] }, Amount: { template: body.match(/amount:([^ ]+)/)?.[1] ?? "1" } }, line };
if (name === "@voice") return { op: "PlayVoice", args: { VoicePath: { template: body.split(/\s/, 1)[0] } }, line };
if (name === "@hide") return { op: "HideActors", args: { ActorIds: body.split(",").filter(Boolean) }, line };
throw new Error(`Naninovel: неизвестная команда выбора ${name}`);
});
}