feat(journal): var stuff
This commit is contained in:
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: "缺少表达式" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user