feat: implement variable reactivity and expression engine
Add support for `role=declare` blocks in markdown to allow for dynamic variable declarations and tag-based modifiers. - Implement `declare-parser` to parse CSV-formatted declaration blocks. - Implement `var-reactivity` to manage dependency graphs, topological sorting, and cascading updates when variables change. - Implement `variable-expression` to evaluate arithmetic, dice rolls, and math functions within expressions.
This commit is contained in:
parent
86abf34c10
commit
2983aa4440
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
||||
*
|
||||
* Format:
|
||||
* tag,key,expr
|
||||
* ,$hp,$con*5+$mod_hp ← variable declaration
|
||||
* ,$ac,10+$dex ← variable declaration
|
||||
* #warrior,$mod_hp,20 ← tag modifier
|
||||
* #warrior,$mod_str,1 ← tag modifier
|
||||
*
|
||||
* When `tag` is empty: $key is a reactively computed variable.
|
||||
* When `tag` is present: when #tag is active, $key gets expr added to its base.
|
||||
*/
|
||||
|
||||
export interface VarDeclaration {
|
||||
key: string; // "$hp" (always starts with $)
|
||||
expression: string; // "$con*5+$mod_hp"
|
||||
}
|
||||
|
||||
export interface TagModifier {
|
||||
tag: string; // "#warrior"
|
||||
target: string; // "$mod_hp"
|
||||
expression: string; // "20"
|
||||
}
|
||||
|
||||
export interface DeclareResult {
|
||||
variables: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=declare block.
|
||||
*/
|
||||
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) return { variables: [], tagModifiers: [] };
|
||||
|
||||
// First line is header; validate it
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
if (headers[0] !== "tag" || headers[1] !== "key" || headers[2] !== "expr") {
|
||||
throw new Error(
|
||||
`${source}: role=declare blocks must have headers "tag,key,expr". Got: ${headers.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const variables: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].trim();
|
||||
if (!row || row.startsWith("#")) continue;
|
||||
|
||||
const cols = splitCsvRow(row);
|
||||
if (cols.length < 3) continue;
|
||||
|
||||
const tag = cols[0]?.trim() ?? "";
|
||||
const key = cols[1]?.trim() ?? "";
|
||||
const expr = cols[2]?.trim() ?? "";
|
||||
|
||||
if (!key || !expr) {
|
||||
console.warn(`${source}:${i + 1}: skipping row with empty key or expr`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
// Tag modifier
|
||||
if (!tag.startsWith("#")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: tag must start with #, got "${tag}"`,
|
||||
);
|
||||
}
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
tagModifiers.push({ tag, target: key, expression: expr });
|
||||
} else {
|
||||
// Variable declaration
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
variables.push({ key, expression: expr });
|
||||
}
|
||||
}
|
||||
|
||||
return { variables, tagModifiers };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV row splitter (shared with stat-parser)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function splitCsvRow(row: string): string[] {
|
||||
const cols: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const ch = row[i];
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (ch === "," && !inQuote) {
|
||||
cols.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cols.push(current);
|
||||
return cols;
|
||||
}
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
/**
|
||||
* Variable reactivity engine — tracks variable declarations and tag
|
||||
* modifiers, then cascades changes when variables are set.
|
||||
*
|
||||
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||||
* When a variable changes, it:
|
||||
* 1. Sends the base var message
|
||||
* 2. Detects tag activation/deactivation diff
|
||||
* 3. Re-evaluates declared variables whose dependencies changed
|
||||
* 4. Applies/removes tag modifiers on affected targets
|
||||
* 5. Sends var messages for every derived change
|
||||
*
|
||||
* Circular dependency detection happens at registration time
|
||||
* (topological sort). At runtime, we guard against re-entrant
|
||||
* evaluation with an in-flight set.
|
||||
*/
|
||||
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
import { evaluateExpression } from "./variable-expression";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VarReactivityState {
|
||||
/** All variable declarations (from role=declare blocks) */
|
||||
declarations: VarDeclaration[];
|
||||
/** All tag modifiers (from role=declare blocks) */
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/** The runtime variable store (read from stream state). */
|
||||
export type VariableStore = Record<string, string>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dependency graph: $dep → Set<$declaredVar> */
|
||||
let depGraph: Map<string, Set<string>> | null = null;
|
||||
|
||||
/** Reverse: $declaredVar → its expression */
|
||||
let declExprs: Map<string, string> | null = null;
|
||||
|
||||
/** Tag modifiers: #tag → [{target, expression}] */
|
||||
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
||||
|
||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialize (or re-initialize) the reactivity engine from declarations
|
||||
* and tag modifiers parsed from role=declare blocks.
|
||||
*
|
||||
* Throws if a circular dependency is detected.
|
||||
*/
|
||||
export function initReactivity(state: VarReactivityState): void {
|
||||
depGraph = new Map();
|
||||
declExprs = new Map();
|
||||
tagModMap = new Map();
|
||||
|
||||
// Index tag modifiers
|
||||
for (const tm of state.tagModifiers) {
|
||||
let list = tagModMap.get(tm.tag);
|
||||
if (!list) {
|
||||
list = [];
|
||||
tagModMap.set(tm.tag, list);
|
||||
}
|
||||
list.push({ target: tm.target, expression: tm.expression });
|
||||
}
|
||||
|
||||
// Index declarations and build dependency graph
|
||||
for (const decl of state.declarations) {
|
||||
declExprs.set(decl.key, decl.expression);
|
||||
const deps = extractDependencies(decl.expression);
|
||||
for (const dep of deps) {
|
||||
let dependents = depGraph.get(dep);
|
||||
if (!dependents) {
|
||||
dependents = new Set();
|
||||
depGraph.set(dep, dependents);
|
||||
}
|
||||
dependents.add(decl.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Circular dependency check
|
||||
checkCircular();
|
||||
}
|
||||
|
||||
/** Extract $var names from an expression string. */
|
||||
export function extractDependencies(expr: string): string[] {
|
||||
const vars: string[] = [];
|
||||
// Match $identifier patterns (not inside function names, just bare $var)
|
||||
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(expr)) !== null) {
|
||||
const name = "$" + m[1];
|
||||
if (!vars.includes(name)) vars.push(name);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which declared variables need re-evaluation after
|
||||
* a set of base variables changed, and what tag modifier effects
|
||||
* to apply.
|
||||
*
|
||||
* Returns the list of { key, value } pairs to publish as var messages.
|
||||
* The caller is responsible for sending these via sendMessage.
|
||||
*
|
||||
* `changedVar` is the variable that was just set (e.g. "$con").
|
||||
* `oldValue` is its previous value (for detecting tag transitions).
|
||||
* `currentVars` is the full variable store.
|
||||
*/
|
||||
export function computeCascade(
|
||||
changedVar: string,
|
||||
oldValue: string | undefined,
|
||||
currentVars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs || !tagModMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
|
||||
// ---- Tag activation/deactivation ----
|
||||
const newTag = isTagValue(currentVars[changedVar]);
|
||||
const oldTag = isTagValue(oldValue);
|
||||
|
||||
if (newTag !== oldTag) {
|
||||
// Deactivate old tag
|
||||
if (oldTag) {
|
||||
const removed = computeTagDeactivation(
|
||||
oldTag,
|
||||
currentVars,
|
||||
);
|
||||
for (const r of removed) {
|
||||
results.push(r);
|
||||
// Update currentVars in-place so subsequent steps see new values
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
|
||||
// Activate new tag
|
||||
if (newTag) {
|
||||
const added = computeTagActivation(
|
||||
newTag,
|
||||
currentVars,
|
||||
);
|
||||
for (const r of added) {
|
||||
results.push(r);
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Declaration re-evaluation ----
|
||||
const reevaluated = reevaluateDependents(
|
||||
changedVar,
|
||||
currentVars,
|
||||
);
|
||||
for (const r of reevaluated) {
|
||||
// Avoid re-sending the same key if it was already in tag results
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which tags are active given the current variable store.
|
||||
* A tag is active if any variable has that tag as its value.
|
||||
*/
|
||||
export function computeActiveTags(vars: VariableStore): string[] {
|
||||
const active: string[] = [];
|
||||
for (const value of Object.values(vars)) {
|
||||
if (isTagValue(value) && !active.includes(value)) {
|
||||
active.push(value);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isTagValue(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("#") ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the dependency graph from `changedVar` and re-evaluate all
|
||||
* affected declarations. Uses topological order.
|
||||
*/
|
||||
function reevaluateDependents(
|
||||
changedVar: string,
|
||||
vars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs) return [];
|
||||
|
||||
// Collect all dependents reachable from changedVar (BFS)
|
||||
const affected = new Set<string>();
|
||||
const queue = [changedVar];
|
||||
while (queue.length > 0) {
|
||||
const dep = queue.shift()!;
|
||||
const dependents = depGraph.get(dep);
|
||||
if (!dependents) continue;
|
||||
for (const d of dependents) {
|
||||
if (!affected.has(d)) {
|
||||
affected.add(d);
|
||||
queue.push(d); // transitive: if C depends on B which depends on A...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort: declarations that depend only on base vars go first
|
||||
const sorted = topoSortAffected(affected);
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...vars }; // working copy
|
||||
|
||||
for (const key of sorted) {
|
||||
if (inFlight.has(key)) {
|
||||
// Shouldn't happen (caught at init), but guard anyway
|
||||
throw new Error(`Circular dependency detected during evaluation of ${key}`);
|
||||
}
|
||||
inFlight.add(key);
|
||||
|
||||
try {
|
||||
const expr = declExprs.get(key);
|
||||
if (!expr) continue;
|
||||
|
||||
// Evaluate with current localVars (which includes previously
|
||||
// re-evaluated dependents)
|
||||
const result = evaluateExpression(expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const newValue = String(result.value);
|
||||
|
||||
// Apply any active tag modifiers to this key
|
||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
||||
|
||||
if (finalValue !== localVars[key]) {
|
||||
localVars[key] = finalValue;
|
||||
results.push({ key, value: finalValue });
|
||||
}
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all active tag modifiers that target `key`.
|
||||
* Returns the modified value (base + sum of active modifiers).
|
||||
*/
|
||||
function applyTagModifiersTo(
|
||||
key: string,
|
||||
baseValue: string,
|
||||
vars: VariableStore,
|
||||
): string {
|
||||
if (!tagModMap) return baseValue;
|
||||
|
||||
const activeTags = computeActiveTags(vars);
|
||||
const baseNum = parseFloat(baseValue);
|
||||
if (isNaN(baseNum)) return baseValue; // non-numeric, can't apply modifiers
|
||||
|
||||
let modifierSum = 0;
|
||||
for (const tag of activeTags) {
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) continue;
|
||||
for (const mod of mods) {
|
||||
if (mod.target === key) {
|
||||
try {
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return vars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
modifierSum += result.value;
|
||||
} catch {
|
||||
// If modifier expression fails, skip it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(baseNum + modifierSum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effects of activating a tag: for each modifier,
|
||||
* evaluate and add to the target variable's current value.
|
||||
*/
|
||||
function computeTagActivation(
|
||||
tag: string,
|
||||
vars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...vars };
|
||||
|
||||
for (const mod of mods) {
|
||||
try {
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const currentBase = parseFloat(localVars[mod.target] ?? "0");
|
||||
if (!isNaN(currentBase)) {
|
||||
const newValue = String(currentBase + result.value);
|
||||
localVars[mod.target] = newValue;
|
||||
results.push({ key: mod.target, value: newValue });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effects of deactivating a tag: for each modifier,
|
||||
* subtract from the target variable's current value.
|
||||
*/
|
||||
function computeTagDeactivation(
|
||||
tag: string,
|
||||
vars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...vars };
|
||||
|
||||
for (const mod of mods) {
|
||||
try {
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const currentValue = parseFloat(localVars[mod.target] ?? "0");
|
||||
if (!isNaN(currentValue)) {
|
||||
const newValue = String(currentValue - result.value);
|
||||
localVars[mod.target] = newValue;
|
||||
results.push({ key: mod.target, value: newValue });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort & circular check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Topologically sort a set of affected declaration keys so that
|
||||
* dependents are evaluated after their dependencies.
|
||||
*/
|
||||
function topoSortAffected(affected: Set<string>): string[] {
|
||||
if (!depGraph || !declExprs) return [...affected];
|
||||
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const temp = new Set<string>();
|
||||
|
||||
function visit(key: string): void {
|
||||
if (visited.has(key)) return;
|
||||
if (temp.has(key)) {
|
||||
// This shouldn't happen if checkCircular passed, but guard
|
||||
throw new Error(`Circular dependency involving ${key}`);
|
||||
}
|
||||
temp.add(key);
|
||||
|
||||
// Visit dependencies first
|
||||
const expr = declExprs!.get(key);
|
||||
if (expr) {
|
||||
const deps = extractDependencies(expr);
|
||||
for (const dep of deps) {
|
||||
if (affected.has(dep) || declExprs!.has(dep)) {
|
||||
visit(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
temp.delete(key);
|
||||
visited.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
|
||||
for (const key of affected) {
|
||||
visit(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for circular dependencies in the full declaration graph.
|
||||
* Throws with a descriptive message if a cycle is found.
|
||||
*/
|
||||
function checkCircular(): void {
|
||||
if (!declExprs) return;
|
||||
|
||||
const allKeys = [...declExprs.keys()];
|
||||
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||||
for (const k of allKeys) state.set(k, "unvisited");
|
||||
|
||||
const path: string[] = [];
|
||||
|
||||
function dfs(key: string): void {
|
||||
const s = state.get(key);
|
||||
if (s === "visited") return;
|
||||
if (s === "visiting") {
|
||||
const cycleStart = path.indexOf(key);
|
||||
const cycle = path.slice(cycleStart).concat(key);
|
||||
throw new Error(
|
||||
`Circular dependency detected: ${cycle.join(" → ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
state.set(key, "visiting");
|
||||
path.push(key);
|
||||
|
||||
const expr = declExprs!.get(key);
|
||||
if (expr) {
|
||||
const deps = extractDependencies(expr);
|
||||
for (const dep of deps) {
|
||||
if (declExprs!.has(dep)) {
|
||||
dfs(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
state.set(key, "visited");
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
dfs(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* Variable expression evaluator — parses and evaluates expressions used
|
||||
* in `/set` commands and `role=declare` code blocks.
|
||||
*
|
||||
* Supports:
|
||||
* - Number literals (integer or decimal)
|
||||
* - $var references (resolved via lookup, must be numeric)
|
||||
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
||||
* - Arithmetic: + - * /
|
||||
* - Functions: floor(x), ceil(x), round(x)
|
||||
* - Parentheses for grouping
|
||||
*
|
||||
* Throws on:
|
||||
* - Type mismatch (e.g. $var resolves to a tag value like "#warrior")
|
||||
* - Circular variable references (detected by caller)
|
||||
* - Division by zero
|
||||
* - Unknown functions
|
||||
* - Malformed expressions
|
||||
*/
|
||||
|
||||
import { rollFormula } from "../md-commander/hooks";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EvalContext {
|
||||
/** Resolve $var → numeric string, or a tag string like "#warrior".
|
||||
* Return undefined if the variable doesn't exist. */
|
||||
lookup: (varName: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
value: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate an expression string.
|
||||
* Throws if any variable resolves to a non-numeric (tag) value,
|
||||
* or if the expression is malformed.
|
||||
*/
|
||||
export function evaluateExpression(
|
||||
expr: string,
|
||||
ctx: EvalContext,
|
||||
): EvalResult {
|
||||
const tokens = tokenize(expr);
|
||||
const result = parseExpression(tokens, 0, ctx);
|
||||
if (result.next < tokens.length) {
|
||||
throw new Error(
|
||||
`Unexpected token at position ${result.next}: "${tokens[result.next].raw}"`,
|
||||
);
|
||||
}
|
||||
return { value: result.value };
|
||||
}
|
||||
|
||||
/** Quick check: does this expression produce a tag value? */
|
||||
export function expressionIsTag(expr: string): boolean {
|
||||
const trimmed = expr.trim();
|
||||
return trimmed.startsWith("#");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Token {
|
||||
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
||||
value: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
// Whitespace
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number (integer or decimal, but NOT followed by 'd' which makes it a dice pattern)
|
||||
if (/[0-9]/.test(ch)) {
|
||||
let num = "";
|
||||
while (i < input.length && /[0-9.]/.test(input[i])) {
|
||||
num += input[i];
|
||||
i++;
|
||||
}
|
||||
// Peek ahead: if next char is 'd' (case-insensitive), this is a dice pattern
|
||||
if (i < input.length && /[dD]/.test(input[i])) {
|
||||
// Dice pattern
|
||||
let dice = num;
|
||||
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
|
||||
dice += input[i];
|
||||
i++;
|
||||
}
|
||||
if (!DICE_RE.test(dice)) {
|
||||
throw new Error(`Invalid dice notation: "${dice}"`);
|
||||
}
|
||||
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: "number", value: num, raw: num });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Variable reference: $var
|
||||
if (ch === "$") {
|
||||
let ident = "$";
|
||||
i++;
|
||||
if (i >= input.length || !/[a-zA-Z_]/.test(input[i])) {
|
||||
throw new Error(`Invalid variable reference at position ${i - 1}: expected identifier after $`);
|
||||
}
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "var", value: ident, raw: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier or function name
|
||||
if (/[a-zA-Z_]/.test(ch)) {
|
||||
let ident = "";
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "ident", value: ident, raw: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators and punctuation
|
||||
switch (ch) {
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
tokens.push({ kind: "op", value: ch, raw: ch });
|
||||
i++;
|
||||
break;
|
||||
case "(":
|
||||
tokens.push({ kind: "lparen", value: "(", raw: "(" });
|
||||
i++;
|
||||
break;
|
||||
case ")":
|
||||
tokens.push({ kind: "rparen", value: ")", raw: ")" });
|
||||
i++;
|
||||
break;
|
||||
case ",":
|
||||
tokens.push({ kind: "comma", value: ",", raw: "," });
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected character: "${ch}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rollDice(notation: string): number {
|
||||
const result = rollFormula(notation);
|
||||
if (!result.success) {
|
||||
throw new Error(`Dice roll failed: ${result.error ?? notation}`);
|
||||
}
|
||||
return result.result.total;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recursive descent parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ParseResult {
|
||||
value: number;
|
||||
next: number; // index of next unconsumed token
|
||||
}
|
||||
|
||||
/** expression := term (("+" | "-") term)* */
|
||||
function parseExpression(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
let result = parseTerm(tokens, pos, ctx);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tok = tokens[pos];
|
||||
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
||||
const right = parseTerm(tokens, pos + 1, ctx);
|
||||
if (tok.value === "+") {
|
||||
result = { value: result.value + right.value, next: right.next };
|
||||
} else {
|
||||
result = { value: result.value - right.value, next: right.next };
|
||||
}
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** term := factor (("*" | "/") factor)* */
|
||||
function parseTerm(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
let result = parseFactor(tokens, pos, ctx);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tok = tokens[pos];
|
||||
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
||||
const right = parseFactor(tokens, pos + 1, ctx);
|
||||
if (tok.value === "*") {
|
||||
result = { value: result.value * right.value, next: right.next };
|
||||
} else {
|
||||
if (right.value === 0) throw new Error("Division by zero");
|
||||
result = { value: result.value / right.value, next: right.next };
|
||||
}
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
|
||||
function parseFactor(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
if (pos >= tokens.length) {
|
||||
throw new Error("Unexpected end of expression");
|
||||
}
|
||||
|
||||
const tok = tokens[pos];
|
||||
|
||||
// Unary minus
|
||||
if (tok.kind === "op" && tok.value === "-") {
|
||||
const inner = parseFactor(tokens, pos + 1, ctx);
|
||||
return { value: -inner.value, next: inner.next };
|
||||
}
|
||||
|
||||
// Number literal (including already-rolled dice patterns)
|
||||
if (tok.kind === "number") {
|
||||
return { value: parseFloat(tok.value), next: pos + 1 };
|
||||
}
|
||||
|
||||
// Variable reference: $var
|
||||
if (tok.kind === "var") {
|
||||
const varName = tok.value; // includes $ prefix
|
||||
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
||||
if (resolved === undefined) {
|
||||
throw new Error(`Unknown variable: ${varName}`);
|
||||
}
|
||||
// Tag values cannot be used in arithmetic
|
||||
if (resolved.startsWith("#")) {
|
||||
throw new Error(
|
||||
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
|
||||
);
|
||||
}
|
||||
const num = parseFloat(resolved);
|
||||
if (isNaN(num)) {
|
||||
throw new Error(
|
||||
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
|
||||
);
|
||||
}
|
||||
return { value: num, next: pos + 1 };
|
||||
}
|
||||
|
||||
// Parenthesized expression
|
||||
if (tok.kind === "lparen") {
|
||||
const inner = parseExpression(tokens, pos + 1, ctx);
|
||||
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
|
||||
throw new Error("Missing closing parenthesis");
|
||||
}
|
||||
return { value: inner.value, next: inner.next + 1 };
|
||||
}
|
||||
|
||||
// Function call: ident "(" expression ")"
|
||||
if (tok.kind === "ident") {
|
||||
const name = tok.value;
|
||||
|
||||
// Check for function call: ident "(" ...
|
||||
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
|
||||
const arg = parseExpression(tokens, pos + 2, ctx);
|
||||
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
|
||||
throw new Error(`Missing closing parenthesis after ${name}(...)`);
|
||||
}
|
||||
const value = applyFunction(name, arg.value);
|
||||
return { value, next: arg.next + 1 };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown identifier: "${name}" (use $ for variables)`);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: "${tok.raw}"`);
|
||||
}
|
||||
|
||||
function applyFunction(name: string, arg: number): number {
|
||||
switch (name.toLowerCase()) {
|
||||
case "floor":
|
||||
return Math.floor(arg);
|
||||
case "ceil":
|
||||
return Math.ceil(arg);
|
||||
case "round":
|
||||
return Math.round(arg);
|
||||
default:
|
||||
throw new Error(`Unknown function: ${name}`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue