712086ddca
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet. Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
4.9 KiB
TypeScript
188 lines
4.9 KiB
TypeScript
/** Minimal safe formula DSL for derived stats. */
|
|
|
|
export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none';
|
|
|
|
export type FormulaContext = Record<string, number>;
|
|
|
|
const IDENT = /^(attr|skill|resource|derived|val)\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
|
function tokenize(input: string): string[] {
|
|
const src = input.replace(/\s+/g, '');
|
|
const tokens: string[] = [];
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
const ch = src[i]!;
|
|
if ('+-*/%(),'.includes(ch)) {
|
|
tokens.push(ch);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (/[0-9.]/.test(ch)) {
|
|
let j = i + 1;
|
|
while (j < src.length && /[0-9.]/.test(src[j]!)) j += 1;
|
|
tokens.push(src.slice(i, j));
|
|
i = j;
|
|
continue;
|
|
}
|
|
if (/[A-Za-z_]/.test(ch)) {
|
|
let j = i + 1;
|
|
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j]!)) j += 1;
|
|
tokens.push(src.slice(i, j));
|
|
i = j;
|
|
continue;
|
|
}
|
|
throw new Error(`Unexpected character '${ch}' in formula`);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
type Parser = {
|
|
tokens: string[];
|
|
i: number;
|
|
ctx: FormulaContext;
|
|
};
|
|
|
|
function peek(p: Parser): string | undefined {
|
|
return p.tokens[p.i];
|
|
}
|
|
|
|
function consume(p: Parser, expected?: string): string {
|
|
const t = p.tokens[p.i];
|
|
if (t === undefined) throw new Error('Unexpected end of formula');
|
|
if (expected !== undefined && t !== expected) throw new Error(`Expected '${expected}', got '${t}'`);
|
|
p.i += 1;
|
|
return t;
|
|
}
|
|
|
|
function parseExpression(p: Parser): number {
|
|
let left = parseTerm(p);
|
|
while (peek(p) === '+' || peek(p) === '-') {
|
|
const op = consume(p);
|
|
const right = parseTerm(p);
|
|
left = op === '+' ? left + right : left - right;
|
|
}
|
|
return left;
|
|
}
|
|
|
|
function parseTerm(p: Parser): number {
|
|
let left = parseUnary(p);
|
|
while (peek(p) === '*' || peek(p) === '/' || peek(p) === '%') {
|
|
const op = consume(p);
|
|
const right = parseUnary(p);
|
|
if (op === '*') left *= right;
|
|
else if (op === '/') left /= right;
|
|
else left %= right;
|
|
}
|
|
return left;
|
|
}
|
|
|
|
function parseUnary(p: Parser): number {
|
|
if (peek(p) === '-') {
|
|
consume(p);
|
|
return -parseUnary(p);
|
|
}
|
|
if (peek(p) === '+') {
|
|
consume(p);
|
|
return parseUnary(p);
|
|
}
|
|
return parsePrimary(p);
|
|
}
|
|
|
|
function parsePrimary(p: Parser): number {
|
|
const t = peek(p);
|
|
if (t === undefined) throw new Error('Unexpected end of formula');
|
|
if (t === '(') {
|
|
consume(p, '(');
|
|
const v = parseExpression(p);
|
|
consume(p, ')');
|
|
return v;
|
|
}
|
|
if (/^[0-9]+(\.[0-9]+)?$/.test(t)) {
|
|
consume(p);
|
|
return Number(t);
|
|
}
|
|
if (IDENT.test(t)) {
|
|
consume(p);
|
|
const v = p.ctx[t];
|
|
if (typeof v !== 'number' || Number.isNaN(v)) {
|
|
throw new Error(`Unknown or non-numeric identifier '${t}'`);
|
|
}
|
|
return v;
|
|
}
|
|
// function call
|
|
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && p.tokens[p.i + 1] === '(') {
|
|
const name = consume(p);
|
|
consume(p, '(');
|
|
const args: number[] = [];
|
|
if (peek(p) !== ')') {
|
|
args.push(parseExpression(p));
|
|
while (peek(p) === ',') {
|
|
consume(p, ',');
|
|
args.push(parseExpression(p));
|
|
}
|
|
}
|
|
consume(p, ')');
|
|
return callFn(name, args);
|
|
}
|
|
throw new Error(`Unexpected token '${t}'`);
|
|
}
|
|
|
|
function callFn(name: string, args: number[]): number {
|
|
switch (name) {
|
|
case 'floor':
|
|
return Math.floor(args[0] ?? 0);
|
|
case 'ceil':
|
|
return Math.ceil(args[0] ?? 0);
|
|
case 'round':
|
|
return Math.round(args[0] ?? 0);
|
|
case 'abs':
|
|
return Math.abs(args[0] ?? 0);
|
|
case 'min':
|
|
return Math.min(...args);
|
|
case 'max':
|
|
return Math.max(...args);
|
|
case 'clamp': {
|
|
const [v = 0, lo = 0, hi = 0] = args;
|
|
return Math.min(hi, Math.max(lo, v));
|
|
}
|
|
default:
|
|
throw new Error(`Unknown function '${name}'`);
|
|
}
|
|
}
|
|
|
|
export function evaluateFormula(
|
|
formula: string,
|
|
ctx: FormulaContext,
|
|
round: FormulaRound = 'none',
|
|
): number {
|
|
const p: Parser = { tokens: tokenize(formula), i: 0, ctx };
|
|
const value = parseExpression(p);
|
|
if (p.i !== p.tokens.length) throw new Error('Trailing tokens in formula');
|
|
if (round === 'floor') return Math.floor(value);
|
|
if (round === 'ceil') return Math.ceil(value);
|
|
if (round === 'round') return Math.round(value);
|
|
return value;
|
|
}
|
|
|
|
export function buildFormulaContext(values: {
|
|
attributes?: Record<string, number | string | boolean>;
|
|
skills?: Record<string, number | string | boolean>;
|
|
resources?: Record<string, number>;
|
|
derived?: Record<string, number>;
|
|
}): FormulaContext {
|
|
const ctx: FormulaContext = {};
|
|
for (const [k, v] of Object.entries(values.attributes ?? {})) {
|
|
if (typeof v === 'number') ctx[`attr.${k}`] = v;
|
|
}
|
|
for (const [k, v] of Object.entries(values.skills ?? {})) {
|
|
if (typeof v === 'number') ctx[`skill.${k}`] = v;
|
|
}
|
|
for (const [k, v] of Object.entries(values.resources ?? {})) {
|
|
ctx[`resource.${k}`] = v;
|
|
}
|
|
for (const [k, v] of Object.entries(values.derived ?? {})) {
|
|
ctx[`derived.${k}`] = v;
|
|
}
|
|
return ctx;
|
|
}
|