/** Minimal safe formula DSL for derived stats. */ export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none'; export type FormulaContext = Record; 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; skills?: Record; resources?: Record; derived?: Record; }): 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; }