feat(journal): var stuff

This commit is contained in:
2026-09-01 15:39:36 +08:00
parent 49260ae4f6
commit 7c4865ba8c
5 changed files with 315 additions and 82 deletions
+88 -26
View File
@@ -32,7 +32,7 @@ jest.mock("github-slugger", () => {
import { parseDeclareCsv } from "./declare-parser";
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
import { evaluateExpression } from "../../components/journal/variable-expression";
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import {
initReactivity,
computeCascade,
@@ -281,14 +281,12 @@ describe("resolveBlockAs", () => {
describe("evaluateExpression", () => {
test("evaluates simple arithmetic", () => {
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined });
expect(result.value).toBe(14);
expect(result.value).toEqual({ kind: "number", value: 14 });
});
test("evaluates with parentheses", () => {
const result = evaluateExpression("(2 + 3) * 4", {
lookup: () => undefined,
});
expect(result.value).toBe(20);
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 20 });
});
test("evaluates variable references", () => {
@@ -299,54 +297,105 @@ describe("evaluateExpression", () => {
return undefined;
},
});
expect(result.value).toBe(80); // 12*5 + 20
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
});
test("returns 0 for undefined variables", () => {
const result = evaluateExpression("$unknown + 5", {
lookup: () => undefined,
});
expect(result.value).toBe(5);
expect(result.value).toEqual({ kind: "number", value: 5 });
});
test("throws on tag values in arithmetic", () => {
test("throws on type mismatch (tagmap + number)", () => {
expect(() =>
evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined),
}),
).toThrow("$class is a tag");
).toThrow("Type mismatch");
});
test("resolves tagmap variable without arithmetic", () => {
const result = evaluateExpression("$class", {
lookup: (name) => (name === "class" ? "#warrior:1" : undefined),
});
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
});
test("evaluates tagmap literals", () => {
const result = evaluateExpression("#warrior:1", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
});
test("evaluates bare tag as tagmap", () => {
const result = evaluateExpression("#warrior", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
});
test("evaluates multi-entry tagmap literal", () => {
const result = evaluateExpression("#warrior:1;#druid:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } });
});
test("merges tagmaps with +", () => {
const result = evaluateExpression("#warrior:1 + #druid:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } });
});
test("adds counts for same tag with +", () => {
const result = evaluateExpression("#warrior:1 + #warrior:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } });
});
test("subtracts tagmaps with -", () => {
const result = evaluateExpression("#warrior:3;#druid:2 - #druid:2", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } });
});
test("throws on tagmap * tagmap", () => {
expect(() =>
evaluateExpression("#warrior:1 * #druid:2", { lookup: () => undefined }),
).toThrow("Type mismatch");
});
test("throws on tagmap / tagmap", () => {
expect(() =>
evaluateExpression("#warrior:1 / #druid:2", { lookup: () => undefined }),
).toThrow("Type mismatch");
});
test("evaluates floor function", () => {
const result = evaluateExpression("floor(3.7)", {
lookup: () => undefined,
});
expect(result.value).toBe(3);
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 3 });
});
test("evaluates ceil function", () => {
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined });
expect(result.value).toBe(4);
expect(result.value).toEqual({ kind: "number", value: 4 });
});
test("evaluates round function", () => {
const result = evaluateExpression("round(3.5)", {
lookup: () => undefined,
});
expect(result.value).toBe(4);
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 4 });
});
test("evaluates unary minus", () => {
const result = evaluateExpression("-5 + 10", { lookup: () => undefined });
expect(result.value).toBe(5);
expect(result.value).toEqual({ kind: "number", value: 5 });
});
test("throws on negating tagmap", () => {
expect(() =>
evaluateExpression("-#warrior", { lookup: () => undefined }),
).toThrow("Type mismatch");
});
test("evaluates dice notation", () => {
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined });
expect(typeof result.value).toBe("number");
expect(result.value.kind).toBe("number");
// 3d6 is between 3 and 18, +5 gives 8-23
expect(result.value).toBeGreaterThanOrEqual(8);
expect(result.value).toBeLessThanOrEqual(23);
expect(result.value.value).toBeGreaterThanOrEqual(8);
expect(result.value.value).toBeLessThanOrEqual(23);
});
test("throws on division by zero", () => {
@@ -369,7 +418,7 @@ describe("evaluateExpression", () => {
test("handles decimal numbers", () => {
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
expect(result.value).toBeCloseTo(6);
expect(result.value).toEqual({ kind: "number", value: 6 });
});
test("nested function calls", () => {
@@ -377,7 +426,7 @@ describe("evaluateExpression", () => {
lookup: () => undefined,
});
// ceil(3.2) = 4, floor(4) = 4
expect(result.value).toBe(4);
expect(result.value).toEqual({ kind: "number", value: 4 });
});
test("complex expression with variables and functions", () => {
@@ -388,7 +437,20 @@ describe("evaluateExpression", () => {
return undefined;
},
});
expect(result.value).toBe(10); // floor(7.5) + 3 = 7 + 3
expect(result.value).toEqual({ kind: "number", value: 10 }); // floor(7.5) + 3 = 7 + 3
});
test("exprValueToString serializes number", () => {
expect(exprValueToString({ kind: "number", value: 42 })).toBe("42");
});
test("exprValueToString serializes tagmap", () => {
expect(exprValueToString({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } }))
.toBe("#warrior:1;#druid:2");
});
test("exprValueToString returns 0 for empty tagmap", () => {
expect(exprValueToString({ kind: "tagmap", value: {} })).toBe("0");
});
});
+7 -14
View File
@@ -10,19 +10,12 @@ import { createSignal } from "solid-js";
import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression } from "./variable-expression";
import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser";
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
const TAGMAP_PATTERN =
/^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
function isTagMapExpr(expr: string): boolean {
const t = expr.trim();
return BARE_TAG_PATTERN.test(t) || TAGMAP_PATTERN.test(t);
}
function normalizeTagMap(expr: string): string {
const t = expr.trim();
@@ -140,11 +133,14 @@ export async function dispatchCommand(
const ev = evaluateExpression(arg, {
lookup: (name: string) => getCombined("$" + name, ctx.variables),
});
if (ev.value.kind !== "number") {
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
}
const payload = {
notation: arg,
label: arg,
result: {
total: ev.value,
total: ev.value.value,
detail: "",
plainDetail: "",
pools: [] as { rolls: number[]; subtotal: number }[],
@@ -205,11 +201,8 @@ function dispatchSet(
// Rolltag: pick random tag, format as tagmap entry
const idx = Math.floor(Math.random() * p.tags.length);
newValue = normalizeTagMap(p.tags[idx]);
} else if (p.expr && isTagMapExpr(p.expr)) {
// Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior")
newValue = normalizeTagMap(p.expr);
} else if (p.expr) {
// Numeric expression — evaluate using combined values
// Evaluate expression — handles both numeric and tagmap values
const result = evaluateExpression(p.expr, {
lookup: (name: string) => {
const k = "$" + name;
@@ -217,7 +210,7 @@ function dispatchSet(
return k === key ? undefined : getCombined(k, ctx.variables);
},
});
newValue = String(result.value);
newValue = exprValueToString(result.value);
} else {
return { ok: false, error: "缺少表达式" };
}
+2 -2
View File
@@ -58,8 +58,8 @@ export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag } from "./variable-expression";
export type { EvalContext, EvalResult } from "./variable-expression";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext";
+10 -4
View File
@@ -22,7 +22,7 @@
*/
import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression } from "./variable-expression";
import { evaluateExpression, exprValueToString } from "./variable-expression";
// ---------------------------------------------------------------------------
// Types
@@ -349,7 +349,7 @@ export function computeInitialValues(
},
});
const rawValue = String(result.value);
const rawValue = exprValueToString(result.value);
baseValues.set(key, rawValue);
// Check if this is a tagmap value — if so, activate matching modifiers
@@ -422,7 +422,13 @@ function applyTagMapActivations(
},
});
const value = evalResult.value;
// Modifier expressions must evaluate to a number
if (evalResult.value.kind !== "number") {
throw new Error(
`Modifier expression "${mod.expression}" must evaluate to a number`,
);
}
const value = evalResult.value.value;
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
if (targetIsTagMap) {
@@ -571,7 +577,7 @@ function reevaluateDependents(
},
});
const rawValue = String(result.value);
const rawValue = exprValueToString(result.value);
// Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback);
+207 -35
View File
@@ -4,14 +4,15 @@
*
* Supports:
* - Number literals (integer or decimal)
* - $var references (resolved via lookup, must be numeric)
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
* - $var references (resolved via lookup; auto-detects number vs tagmap)
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
* - Arithmetic: + - * /
* - Functions: floor(x), ceil(x), round(x)
* - Arithmetic: + - * / (type-checked via registry)
* - Functions: floor(x), ceil(x), round(x) (numbers only)
* - Parentheses for grouping
*
* Throws on:
* - Type mismatch (e.g. $var resolves to a tag value like "#warrior")
* - Type mismatch (e.g. number + tagmap, tagmap * number)
* - Circular variable references (detected by caller)
* - Division by zero
* - Unknown functions
@@ -24,14 +25,19 @@ import { rollFormula } from "../md-commander/hooks";
// Types
// ---------------------------------------------------------------------------
/** A value produced by the expression evaluator. */
export type ExprValue =
| { kind: "number"; value: number }
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext {
/** Resolve $var → numeric string, or a tag string like "#warrior".
/** Resolve $var → string (numeric or tagmap serialized form).
* Return undefined if the variable doesn't exist. */
lookup: (varName: string) => string | undefined;
}
export interface EvalResult {
value: number;
value: ExprValue;
}
// ---------------------------------------------------------------------------
@@ -40,8 +46,7 @@ export interface EvalResult {
/**
* Evaluate an expression string.
* Throws if any variable resolves to a non-numeric (tag) value,
* or if the expression is malformed.
* Throws if the expression is malformed or contains a type mismatch.
*/
export function evaluateExpression(
expr: string,
@@ -63,19 +68,109 @@ export function expressionIsTag(expr: string): boolean {
return trimmed.startsWith("#");
}
/** Serialize an ExprValue back to a string (for var-reactivity integration). */
export function exprValueToString(v: ExprValue): string {
if (v.kind === "number") return String(v.value);
// tagmap
const entries = Object.entries(v.value).filter(([, c]) => c > 0);
if (entries.length === 0) return "0";
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
}
// ---------------------------------------------------------------------------
// Binary operation registry
// ---------------------------------------------------------------------------
type BinaryOp = (a: ExprValue, b: ExprValue) => ExprValue;
/* Helpers that narrow ExprValue to specific kinds for use in registry callbacks. */
const num = (a: ExprValue, b: ExprValue): [number, number] =>
[a.value as number, b.value as number];
const tmap = (a: ExprValue, b: ExprValue): [Record<string, number>, Record<string, number>] =>
[a.value as Record<string, number>, b.value as Record<string, number>];
/** Registry: binaryOps[leftKind][rightKind][operator] → implementation.
* Any undefined combination throws a type-mismatch error. */
const binaryOps: Record<
string,
Record<string, Record<string, BinaryOp>>
> = {
number: {
number: {
"+": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l + r };
},
"-": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l - r };
},
"*": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l * r };
},
"/": (a, b) => {
const [l, r] = num(a, b);
if (r === 0) throw new Error("Division by zero");
return { kind: "number", value: l / r };
},
},
},
tagmap: {
tagmap: {
"+": (a, b) => {
const [leftMap, rightMap] = tmap(a, b);
const result: Record<string, number> = { ...leftMap };
for (const [tag, count] of Object.entries(rightMap)) {
result[tag] = (result[tag] ?? 0) + count;
}
return { kind: "tagmap", value: result };
},
"-": (a, b) => {
const [leftMap, rightMap] = tmap(a, b);
const result: Record<string, number> = { ...leftMap };
for (const [tag, count] of Object.entries(rightMap)) {
result[tag] = (result[tag] ?? 0) - count;
if (result[tag] <= 0) delete result[tag];
}
return { kind: "tagmap", value: result };
},
},
},
};
function getBinaryOp(
left: ExprValue,
right: ExprValue,
op: string,
): BinaryOp {
const opFn = binaryOps[left.kind]?.[right.kind]?.[op];
if (!opFn) {
throw new Error(
`Type mismatch: cannot ${op} ${left.kind} with ${right.kind}`,
);
}
return opFn;
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
interface Token {
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
value: string;
raw: string;
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
tagmap?: Record<string, number>;
}
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
/** Single tagmap entry: "#warrior" or "#warrior:1" */
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
@@ -114,6 +209,50 @@ function tokenize(input: string): Token[] {
continue;
}
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
if (ch === "#") {
let raw = "";
const map: Record<string, number> = {};
while (i < input.length && input[i] === "#") {
// Match one entry from the current position
const remaining = input.slice(i);
const m = TAGMAP_ENTRY_RE.exec(remaining);
if (!m) {
throw new Error(`Invalid tagmap entry at position ${i}: "${remaining.slice(0, 20)}..."`);
}
const matched = m[0];
raw += (raw ? ";" : "") + matched;
const tag = "#" + m[1];
const count = m[2] !== undefined ? parseInt(m[2], 10) : 1;
if (count > 0) {
map[tag] = (map[tag] ?? 0) + count;
}
i += matched.length;
// Skip whitespace after the entry
while (i < input.length && input[i] === " ") i++;
// Check for semicolon separator (continue to next entry)
if (i < input.length && input[i] === ";") {
raw += ";";
i++;
// Skip whitespace after semicolon
while (i < input.length && input[i] === " ") i++;
// If the next char is not '#', we're done with the tagmap
if (i >= input.length || input[i] !== "#") break;
} else {
break;
}
}
if (Object.keys(map).length === 0) {
throw new Error(`Empty tagmap: "${raw}"`);
}
tokens.push({ kind: "tagmap", value: raw, raw, tagmap: map });
continue;
}
// Variable reference: $var
if (ch === "$") {
let ident = "$";
@@ -186,7 +325,7 @@ function rollDice(notation: string): number {
// ---------------------------------------------------------------------------
interface ParseResult {
value: number;
value: ExprValue;
next: number; // index of next unconsumed token
}
@@ -203,11 +342,8 @@ function parseExpression(
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 };
}
const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: opFn(result.value, right.value), next: right.next };
pos = result.next;
} else {
break;
@@ -230,12 +366,8 @@ function parseTerm(
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 };
}
const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: opFn(result.value, right.value), next: right.next };
pos = result.next;
} else {
break;
@@ -245,7 +377,7 @@ function parseTerm(
return result;
}
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
/** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
function parseFactor(
tokens: Token[],
pos: number,
@@ -257,15 +389,23 @@ function parseFactor(
const tok = tokens[pos];
// Unary minus
// Unary minus (numbers only)
if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, ctx);
return { value: -inner.value, next: inner.next };
if (inner.value.kind !== "number") {
throw new Error(`Type mismatch: cannot negate ${inner.value.kind}`);
}
return { value: { kind: "number", value: -inner.value.value }, next: inner.next };
}
// Number literal (including already-rolled dice patterns)
if (tok.kind === "number") {
return { value: parseFloat(tok.value), next: pos + 1 };
return { value: { kind: "number", value: parseFloat(tok.value) }, next: pos + 1 };
}
// Tagmap literal
if (tok.kind === "tagmap") {
return { value: { kind: "tagmap", value: { ...tok.tagmap! } }, next: pos + 1 };
}
// Variable reference: $var
@@ -273,13 +413,17 @@ function parseFactor(
const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
if (resolved === undefined) {
return { value: 0, next: pos + 1 };
return { value: { kind: "number", value: 0 }, next: pos + 1 };
}
// Tag values cannot be used in arithmetic
// Auto-detect: tagmap or numeric
if (resolved.startsWith("#")) {
throw new Error(
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
);
const map = parseTagMapValue(resolved);
if (!map) {
throw new Error(
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
);
}
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
}
const num = parseFloat(resolved);
if (isNaN(num)) {
@@ -287,7 +431,7 @@ function parseFactor(
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
);
}
return { value: num, next: pos + 1 };
return { value: { kind: "number", value: num }, next: pos + 1 };
}
// Parenthesized expression
@@ -319,15 +463,43 @@ function parseFactor(
throw new Error(`Unexpected token: "${tok.raw}"`);
}
function applyFunction(name: string, arg: number): number {
function applyFunction(name: string, arg: ExprValue): ExprValue {
if (arg.kind !== "number") {
throw new Error(`Type mismatch: ${name}() requires a number, got ${arg.kind}`);
}
switch (name.toLowerCase()) {
case "floor":
return Math.floor(arg);
return { kind: "number", value: Math.floor(arg.value) };
case "ceil":
return Math.ceil(arg);
return { kind: "number", value: Math.ceil(arg.value) };
case "round":
return Math.round(arg);
return { kind: "number", value: Math.round(arg.value) };
default:
throw new Error(`Unknown function: ${name}`);
}
}
// ---------------------------------------------------------------------------
// Tagmap parsing (shared with var-reactivity, duplicated to avoid circular deps)
// ---------------------------------------------------------------------------
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
function parseTagMapValue(value: string): Record<string, number> | null {
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return null;
const parts = trimmed.split(";");
const map: Record<string, number> = {};
for (const part of parts) {
const m = TAGMAP_RE.exec(part.trim());
if (!m) return null;
const tag = "#" + m[1];
const count = parseInt(m[2], 10);
if (count <= 0) continue;
map[tag] = (map[tag] ?? 0) + count;
}
return Object.keys(map).length > 0 ? map : null;
}