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 { parseDeclareCsv } from "./declare-parser";
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner"; import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
import { evaluateExpression } from "../../components/journal/variable-expression"; import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import { import {
initReactivity, initReactivity,
computeCascade, computeCascade,
@@ -281,14 +281,12 @@ describe("resolveBlockAs", () => {
describe("evaluateExpression", () => { describe("evaluateExpression", () => {
test("evaluates simple arithmetic", () => { test("evaluates simple arithmetic", () => {
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined }); 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", () => { test("evaluates with parentheses", () => {
const result = evaluateExpression("(2 + 3) * 4", { const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
lookup: () => undefined, expect(result.value).toEqual({ kind: "number", value: 20 });
});
expect(result.value).toBe(20);
}); });
test("evaluates variable references", () => { test("evaluates variable references", () => {
@@ -299,54 +297,105 @@ describe("evaluateExpression", () => {
return undefined; 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", () => { test("returns 0 for undefined variables", () => {
const result = evaluateExpression("$unknown + 5", { const result = evaluateExpression("$unknown + 5", {
lookup: () => undefined, 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(() => expect(() =>
evaluateExpression("$class + 5", { evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined), 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", () => { test("evaluates floor function", () => {
const result = evaluateExpression("floor(3.7)", { const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
lookup: () => undefined, expect(result.value).toEqual({ kind: "number", value: 3 });
});
expect(result.value).toBe(3);
}); });
test("evaluates ceil function", () => { test("evaluates ceil function", () => {
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined }); 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", () => { test("evaluates round function", () => {
const result = evaluateExpression("round(3.5)", { const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
lookup: () => undefined, expect(result.value).toEqual({ kind: "number", value: 4 });
});
expect(result.value).toBe(4);
}); });
test("evaluates unary minus", () => { test("evaluates unary minus", () => {
const result = evaluateExpression("-5 + 10", { lookup: () => undefined }); 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", () => { test("evaluates dice notation", () => {
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined }); 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 // 3d6 is between 3 and 18, +5 gives 8-23
expect(result.value).toBeGreaterThanOrEqual(8); expect(result.value.value).toBeGreaterThanOrEqual(8);
expect(result.value).toBeLessThanOrEqual(23); expect(result.value.value).toBeLessThanOrEqual(23);
}); });
test("throws on division by zero", () => { test("throws on division by zero", () => {
@@ -369,7 +418,7 @@ describe("evaluateExpression", () => {
test("handles decimal numbers", () => { test("handles decimal numbers", () => {
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined }); 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", () => { test("nested function calls", () => {
@@ -377,7 +426,7 @@ describe("evaluateExpression", () => {
lookup: () => undefined, lookup: () => undefined,
}); });
// ceil(3.2) = 4, floor(4) = 4 // 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", () => { test("complex expression with variables and functions", () => {
@@ -388,7 +437,20 @@ describe("evaluateExpression", () => {
return undefined; 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 { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll"; import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark"; import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression } from "./variable-expression"; import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity"; import { computeCascade, getCombined, setBase } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser"; import type { VarDeclaration, TagModifier } from "./declare-parser";
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand. // Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/; 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 { function normalizeTagMap(expr: string): string {
const t = expr.trim(); const t = expr.trim();
@@ -140,11 +133,14 @@ export async function dispatchCommand(
const ev = evaluateExpression(arg, { const ev = evaluateExpression(arg, {
lookup: (name: string) => getCombined("$" + name, ctx.variables), 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 = { const payload = {
notation: arg, notation: arg,
label: arg, label: arg,
result: { result: {
total: ev.value, total: ev.value.value,
detail: "", detail: "",
plainDetail: "", plainDetail: "",
pools: [] as { rolls: number[]; subtotal: number }[], pools: [] as { rolls: number[]; subtotal: number }[],
@@ -205,11 +201,8 @@ function dispatchSet(
// Rolltag: pick random tag, format as tagmap entry // Rolltag: pick random tag, format as tagmap entry
const idx = Math.floor(Math.random() * p.tags.length); const idx = Math.floor(Math.random() * p.tags.length);
newValue = normalizeTagMap(p.tags[idx]); 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) { } else if (p.expr) {
// Numeric expression — evaluate using combined values // Evaluate expression — handles both numeric and tagmap values
const result = evaluateExpression(p.expr, { const result = evaluateExpression(p.expr, {
lookup: (name: string) => { lookup: (name: string) => {
const k = "$" + name; const k = "$" + name;
@@ -217,7 +210,7 @@ function dispatchSet(
return k === key ? undefined : getCombined(k, ctx.variables); return k === key ? undefined : getCombined(k, ctx.variables);
}, },
}); });
newValue = String(result.value); newValue = exprValueToString(result.value);
} else { } else {
return { ok: false, error: "缺少表达式" }; return { ok: false, error: "缺少表达式" };
} }
+2 -2
View File
@@ -58,8 +58,8 @@ export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView"; export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser"; export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser"; export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag } from "./variable-expression"; export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult } from "./variable-expression"; export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity"; export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity"; export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext"; export { JournalContext, useJournalContext } from "./JournalContext";
+10 -4
View File
@@ -22,7 +22,7 @@
*/ */
import type { VarDeclaration, TagModifier } from "./declare-parser"; import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression } from "./variable-expression"; import { evaluateExpression, exprValueToString } from "./variable-expression";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -349,7 +349,7 @@ export function computeInitialValues(
}, },
}); });
const rawValue = String(result.value); const rawValue = exprValueToString(result.value);
baseValues.set(key, rawValue); baseValues.set(key, rawValue);
// Check if this is a tagmap value — if so, activate matching modifiers // 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)); const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
if (targetIsTagMap) { 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 // Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback); const oldCombined = getCombined(key, fallback);
+205 -33
View File
@@ -4,14 +4,15 @@
* *
* Supports: * Supports:
* - Number literals (integer or decimal) * - 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) * - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
* - Arithmetic: + - * / * - Arithmetic: + - * / (type-checked via registry)
* - Functions: floor(x), ceil(x), round(x) * - Functions: floor(x), ceil(x), round(x) (numbers only)
* - Parentheses for grouping * - Parentheses for grouping
* *
* Throws on: * 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) * - Circular variable references (detected by caller)
* - Division by zero * - Division by zero
* - Unknown functions * - Unknown functions
@@ -24,14 +25,19 @@ import { rollFormula } from "../md-commander/hooks";
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** A value produced by the expression evaluator. */
export type ExprValue =
| { kind: "number"; value: number }
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext { 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. */ * Return undefined if the variable doesn't exist. */
lookup: (varName: string) => string | undefined; lookup: (varName: string) => string | undefined;
} }
export interface EvalResult { export interface EvalResult {
value: number; value: ExprValue;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,8 +46,7 @@ export interface EvalResult {
/** /**
* Evaluate an expression string. * Evaluate an expression string.
* Throws if any variable resolves to a non-numeric (tag) value, * Throws if the expression is malformed or contains a type mismatch.
* or if the expression is malformed.
*/ */
export function evaluateExpression( export function evaluateExpression(
expr: string, expr: string,
@@ -63,19 +68,109 @@ export function expressionIsTag(expr: string): boolean {
return trimmed.startsWith("#"); 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 // Tokenizer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface Token { interface Token {
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma"; kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
value: string; value: string;
raw: string; raw: string;
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
tagmap?: Record<string, number>;
} }
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */ /** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i; 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[] { function tokenize(input: string): Token[] {
const tokens: Token[] = []; const tokens: Token[] = [];
let i = 0; let i = 0;
@@ -114,6 +209,50 @@ function tokenize(input: string): Token[] {
continue; 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 // Variable reference: $var
if (ch === "$") { if (ch === "$") {
let ident = "$"; let ident = "$";
@@ -186,7 +325,7 @@ function rollDice(notation: string): number {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface ParseResult { interface ParseResult {
value: number; value: ExprValue;
next: number; // index of next unconsumed token next: number; // index of next unconsumed token
} }
@@ -203,11 +342,8 @@ function parseExpression(
const tok = tokens[pos]; const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) { if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
const right = parseTerm(tokens, pos + 1, ctx); const right = parseTerm(tokens, pos + 1, ctx);
if (tok.value === "+") { const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: result.value + right.value, next: right.next }; result = { value: opFn(result.value, right.value), next: right.next };
} else {
result = { value: result.value - right.value, next: right.next };
}
pos = result.next; pos = result.next;
} else { } else {
break; break;
@@ -230,12 +366,8 @@ function parseTerm(
const tok = tokens[pos]; const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) { if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
const right = parseFactor(tokens, pos + 1, ctx); const right = parseFactor(tokens, pos + 1, ctx);
if (tok.value === "*") { const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: result.value * right.value, next: right.next }; result = { value: opFn(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; pos = result.next;
} else { } else {
break; break;
@@ -245,7 +377,7 @@ function parseTerm(
return result; return result;
} }
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */ /** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
function parseFactor( function parseFactor(
tokens: Token[], tokens: Token[],
pos: number, pos: number,
@@ -257,15 +389,23 @@ function parseFactor(
const tok = tokens[pos]; const tok = tokens[pos];
// Unary minus // Unary minus (numbers only)
if (tok.kind === "op" && tok.value === "-") { if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, ctx); 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) // Number literal (including already-rolled dice patterns)
if (tok.kind === "number") { 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 // Variable reference: $var
@@ -273,21 +413,25 @@ function parseFactor(
const varName = tok.value; // includes $ prefix const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
if (resolved === undefined) { 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("#")) { if (resolved.startsWith("#")) {
const map = parseTagMapValue(resolved);
if (!map) {
throw new Error( throw new Error(
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`, `Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
); );
} }
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
}
const num = parseFloat(resolved); const num = parseFloat(resolved);
if (isNaN(num)) { if (isNaN(num)) {
throw new Error( throw new Error(
`Type mismatch: ${varName} is not numeric ("${resolved}")`, `Type mismatch: ${varName} is not numeric ("${resolved}")`,
); );
} }
return { value: num, next: pos + 1 }; return { value: { kind: "number", value: num }, next: pos + 1 };
} }
// Parenthesized expression // Parenthesized expression
@@ -319,15 +463,43 @@ function parseFactor(
throw new Error(`Unexpected token: "${tok.raw}"`); 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()) { switch (name.toLowerCase()) {
case "floor": case "floor":
return Math.floor(arg); return { kind: "number", value: Math.floor(arg.value) };
case "ceil": case "ceil":
return Math.ceil(arg); return { kind: "number", value: Math.ceil(arg.value) };
case "round": case "round":
return Math.round(arg); return { kind: "number", value: Math.round(arg.value) };
default: default:
throw new Error(`Unknown function: ${name}`); 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;
}