fix(journal): unify $var lookup and error

EvalContext.lookup now receives the full $var name, matching getCombined,
setBase, and getMods, and parseFactor throws "Unknown variable" when the
lookup returns undefined. /set and /roll use the new resolveVariable() so a
typo errors instead of silently evaluating to 0.

Declarations and tag modifiers stay lenient (unknown -> 0): the documented
$hp = $con*5+$mod_hp pattern depends on external/undeclared variables
defaulting to 0.
This commit is contained in:
hyper
2026-09-12 22:34:12 +08:00
parent 02442a1100
commit 16f2038fd8
6 changed files with 83 additions and 41 deletions
+5 -7
View File
@@ -11,7 +11,7 @@ import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity";
import { computeCascade, getCombined, resolveVariable, setBase } from "./var-reactivity";
import { ensureReactivity } from "./completions";
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
@@ -131,7 +131,8 @@ export async function dispatchCommand(
if (arg.includes("$")) {
try {
const ev = evaluateExpression(arg, {
lookup: (name: string) => getCombined("$" + name, ctx.variables),
// Strict: referencing an undefined variable is an error, not 0.
lookup: (name) => resolveVariable(name, ctx.variables),
});
if (ev.value.kind !== "number") {
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
@@ -204,11 +205,8 @@ function dispatchSet(
} else if (p.expr) {
// Evaluate expression — handles both numeric and tagmap values
const result = evaluateExpression(p.expr, {
lookup: (name: string) => {
const k = "$" + name;
// Use getCombined with stream fallback for accurate values
return k === key ? undefined : getCombined(k, ctx.variables);
},
// Strict: referencing an undefined variable is an error, not 0.
lookup: (name) => resolveVariable(name, ctx.variables),
});
newValue = exprValueToString(result.value);
} else {
+1 -1
View File
@@ -60,7 +60,7 @@ export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics } from "./var-reactivity";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics, resolveVariable } from "./var-reactivity";
export type { VarReactivityState, VariableStore, ReactivityDiagnostic } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext";
+25 -12
View File
@@ -331,6 +331,24 @@ export function getCombined(key: string, fallback?: VariableStore): string {
return "0";
}
/**
* Resolve a `$var` to its combined value, distinguishing an unknown variable
* (undefined) from a real value of "0". Strict contexts such as `/set` and
* `/roll` use this so a typo'd or undeclared reference errors instead of
* silently evaluating to 0.
*/
export function resolveVariable(
key: string,
fallback?: VariableStore,
): string | undefined {
const known =
baseValues.has(key) ||
numericMods.has(key) ||
tagMapMods.has(key) ||
(fallback !== undefined && fallback[key] !== undefined);
return known ? getCombined(key, fallback) : undefined;
}
/**
* Get the effective tagmap for a variable (base + tagmap mods).
* Returns null if the variable is not a tagmap variable.
@@ -472,10 +490,9 @@ export function computeInitialValues(
try {
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, currentVars);
},
// Declarations may reference runtime/external variables; getCombined
// defaults unknown keys to "0" rather than erroring.
lookup: (name) => getCombined(name, currentVars),
});
const rawValue = exprValueToString(result.value);
@@ -550,10 +567,8 @@ function applyTagMapActivations(
}
const evalResult = evaluateExpression(mod.expression, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
// Modifier expressions tolerate undefined references (→ "0").
lookup: (name) => getCombined(name, fallback),
});
// Modifier expressions must evaluate to a number
@@ -716,10 +731,8 @@ function reevaluateDependents(
if (!expr) continue;
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
// Declaration re-evaluation stays lenient for undeclared references.
lookup: (name) => getCombined(name, fallback),
});
const rawValue = exprValueToString(result.value);
+10 -4
View File
@@ -16,6 +16,7 @@
* - Circular variable references (detected by caller)
* - Division by zero
* - Unknown functions
* - Unknown variables (lookup returned undefined)
* - Malformed expressions
* - Malformed numeric literals (e.g. "1.2.3")
*/
@@ -32,8 +33,13 @@ export type ExprValue =
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext {
/** Resolve $var → string (numeric or tagmap serialized form).
* Return undefined if the variable doesn't exist. */
/** Resolve a variable reference — the full name including the leading `$`
* (e.g. `"$hp"`) — to its serialized value: numeric ("12") or tagmap
* ("#warrior:1;#druid:2").
*
* Return undefined for an unknown variable; the evaluator then throws
* `Unknown variable`. Contexts that want a default (e.g. undeclared
* variables in declarations resolving to 0) should return a value instead. */
lookup: (varName: string) => string | undefined;
}
@@ -421,9 +427,9 @@ function parseFactor(
// Variable reference: $var
if (tok.kind === "var") {
const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
const resolved = ctx.lookup(varName);
if (resolved === undefined) {
return { value: { kind: "number", value: 0 }, next: pos + 1 };
throw new Error(`Unknown variable: ${varName}`);
}
// Auto-detect: tagmap or numeric
if (resolved.startsWith("#")) {