diff --git a/src/components/journal/declare-parser.ts b/src/components/journal/declare-parser.ts new file mode 100644 index 0000000..353983d --- /dev/null +++ b/src/components/journal/declare-parser.ts @@ -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; +} \ No newline at end of file diff --git a/src/components/journal/var-reactivity.ts b/src/components/journal/var-reactivity.ts new file mode 100644 index 0000000..9d1f723 --- /dev/null +++ b/src/components/journal/var-reactivity.ts @@ -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; + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +/** Dependency graph: $dep → Set<$declaredVar> */ +let depGraph: Map> | null = null; + +/** Reverse: $declaredVar → its expression */ +let declExprs: Map | null = null; + +/** Tag modifiers: #tag → [{target, expression}] */ +let tagModMap: Map> | null = null; + +/** Set of $vars currently being re-evaluated (cycle guard) */ +const inFlight = new Set(); + +// --------------------------------------------------------------------------- +// 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(); + 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[] { + if (!depGraph || !declExprs) return [...affected]; + + const result: string[] = []; + const visited = new Set(); + const temp = new Set(); + + 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(); + 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); + } +} \ No newline at end of file diff --git a/src/components/journal/variable-expression.ts b/src/components/journal/variable-expression.ts new file mode 100644 index 0000000..ba19f8a --- /dev/null +++ b/src/components/journal/variable-expression.ts @@ -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}`); + } +} \ No newline at end of file