feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
@@ -0,0 +1,88 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random): 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 String(a) === String(b);
|
||||
case "!=": case "<>": return String(a) !== String(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);
|
||||
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 args = node[2].map((value: NaniAst) => number(evaluate(value)));
|
||||
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");
|
||||
}
|
||||
Reference in new issue
Block a user