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:
hypercross 2026-07-12 16:52:04 +08:00
parent 86abf34c10
commit 2983aa4440
3 changed files with 923 additions and 0 deletions

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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}`);
}
}