From 7c4865ba8c3ec586b61a7f49baa1ab39cc18aad3 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 1 Sep 2026 15:38:44 +0800 Subject: [PATCH] feat(journal): var stuff --- src/cli/completions/variable-system.test.ts | 114 ++++++-- src/components/journal/command-dispatcher.ts | 21 +- src/components/journal/index.ts | 4 +- src/components/journal/var-reactivity.ts | 14 +- src/components/journal/variable-expression.ts | 244 +++++++++++++++--- 5 files changed, 315 insertions(+), 82 deletions(-) diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts index 839dbba..a11f5cb 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -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"); }); }); diff --git a/src/components/journal/command-dispatcher.ts b/src/components/journal/command-dispatcher.ts index a5cf68a..a7079d4 100644 --- a/src/components/journal/command-dispatcher.ts +++ b/src/components/journal/command-dispatcher.ts @@ -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: "缺少表达式" }; } diff --git a/src/components/journal/index.ts b/src/components/journal/index.ts index b23def8..86c83e2 100644 --- a/src/components/journal/index.ts +++ b/src/components/journal/index.ts @@ -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"; diff --git a/src/components/journal/var-reactivity.ts b/src/components/journal/var-reactivity.ts index 3b25fca..1eea7da 100644 --- a/src/components/journal/var-reactivity.ts +++ b/src/components/journal/var-reactivity.ts @@ -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); diff --git a/src/components/journal/variable-expression.ts b/src/components/journal/variable-expression.ts index 7713afd..be78fb9 100644 --- a/src/components/journal/variable-expression.ts +++ b/src/components/journal/variable-expression.ts @@ -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 }; + 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, Record] => + [a.value as Record, b.value as Record]; + +/** Registry: binaryOps[leftKind][rightKind][operator] → implementation. + * Any undefined combination throws a type-mismatch error. */ +const binaryOps: Record< + string, + Record> +> = { + 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 = { ...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 = { ...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; } /** 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 = {}; + + 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}`); } -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// 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 | null { + const trimmed = value.trim(); + if (!trimmed.startsWith("#")) return null; + + const parts = trimmed.split(";"); + const map: Record = {}; + + 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; +}