fix(journal): surface variable eval errors

Collect and report failed declarations, failed tag modifier expressions, and
circular dependencies instead of swallowing them. Diagnostics are deduped,
logged with key/expression context, cleared on re-init, and exposed through
useJournalCompletions and the VariableView warning banner. A bad declaration
no longer aborts the rest of a cascade.

Reject malformed numeric literals in the tokenizer so inputs like "1.2.3"
throw instead of being truncated by parseFloat.
This commit is contained in:
hyper
2026-09-12 22:22:18 +08:00
parent a26d53cbeb
commit 9f372fa2b0
7 changed files with 399 additions and 9 deletions
+111 -5
View File
@@ -56,6 +56,26 @@ export interface ActiveMod {
kind: "numeric" | "tagmap";
}
/**
* A problem detected while evaluating declarations or tag modifiers at
* runtime. These are benign to the engine (the offending entry is skipped) but
* signal an authoring mistake that would otherwise silently produce a wrong
* value, so they are collected for surfacing in the UI.
*/
export interface ReactivityDiagnostic {
kind: "init" | "declaration" | "modifier";
/** Human-readable description, already including the offending context. */
message: string;
/** The variable the diagnostic concerns (e.g. "$hp"). */
key?: string;
/** The expression that failed to evaluate. */
expression?: string;
/** For modifiers: the tag whose activation triggered the modifier ("#warrior"). */
tag?: string;
/** For modifiers: the variable whose tagmap activated the tag ("$class"). */
source?: string;
}
// ---------------------------------------------------------------------------
// Tagmap helpers
// ---------------------------------------------------------------------------
@@ -133,6 +153,61 @@ const sourceActivations = new Map<string, Array<{
/** Set of $vars currently being re-evaluated (cycle guard) */
const inFlight = new Set<string>();
/** Diagnostics collected since the last init, keyed by a stable id to dedupe. */
const diagnostics = new Map<string, ReactivityDiagnostic>();
/** Listeners notified whenever the diagnostic set changes. */
const diagnosticListeners = new Set<(diagnostics: ReactivityDiagnostic[]) => void>();
function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function diagnosticId(d: ReactivityDiagnostic): string {
return [d.kind, d.key ?? "", d.expression ?? "", d.source ?? "", d.tag ?? "", d.message].join("\u0000");
}
function notifyDiagnostics(): void {
const list = [...diagnostics.values()];
for (const listener of diagnosticListeners) listener(list);
}
/** Record a diagnostic, dedupe by identity, and notify listeners. */
function reportDiagnostic(d: ReactivityDiagnostic): void {
const id = diagnosticId(d);
if (diagnostics.has(id)) return;
diagnostics.set(id, d);
notifyDiagnostics();
console.warn(`[var-reactivity] ${d.message}`);
}
/**
* Diagnostics collected by the engine (bad declarations, modifier expressions,
* circular dependencies). Cleared by `initReactivity` so a re-init reports a
* fresh set. Repeated failures of the same expression are reported once.
*/
export function getReactivityDiagnostics(): ReactivityDiagnostic[] {
return [...diagnostics.values()];
}
/** Remove all collected diagnostics. */
export function clearReactivityDiagnostics(): void {
if (diagnostics.size === 0) return;
diagnostics.clear();
notifyDiagnostics();
}
/**
* Subscribe to diagnostic changes. The listener receives the current list on
* every change. Returns an unsubscribe function.
*/
export function onReactivityDiagnostics(
listener: (diagnostics: ReactivityDiagnostic[]) => void,
): () => void {
diagnosticListeners.add(listener);
return () => diagnosticListeners.delete(listener);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -151,6 +226,7 @@ export function initReactivity(state: VarReactivityState): void {
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
clearReactivityDiagnostics();
// Index tag modifiers
for (const tm of state.tagModifiers) {
@@ -176,7 +252,15 @@ export function initReactivity(state: VarReactivityState): void {
}
}
checkCircular();
try {
checkCircular();
} catch (e) {
reportDiagnostic({
kind: "init",
message: `Declarations contain an unresolvable dependency: ${errorText(e)}`,
});
throw e;
}
}
/** Extract $var names from an expression string. */
@@ -400,8 +484,13 @@ export function computeInitialValues(
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined, base: rawValue });
}
} catch {
// skip failed evaluations at init time
} catch (e) {
reportDiagnostic({
kind: "declaration",
key,
expression: expr,
message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`,
});
}
}
@@ -506,8 +595,17 @@ function applyTagMapActivations(
} else {
results.push({ key: mod.target, value: combined, base });
}
} catch {
// skip failed modifier
} catch (e) {
reportDiagnostic({
kind: "modifier",
key: mod.target,
expression: mod.expression,
tag,
source,
message:
`Modifier ${tag} → ${mod.target} failed to evaluate ` +
`"${mod.expression}" (activated by ${source}): ${errorText(e)}`,
});
}
} else if (wasActive && !isActive) {
// Deactivate this modifier
@@ -635,6 +733,14 @@ function reevaluateDependents(
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined, base: rawValue });
}
} catch (e) {
// Report the bad declaration but keep evaluating unrelated dependents.
reportDiagnostic({
kind: "declaration",
key,
expression: declExprs.get(key),
message: `Declaration ${key} failed to re-evaluate "${declExprs.get(key) ?? ""}": ${errorText(e)}`,
});
} finally {
inFlight.delete(key);
}