Compare commits
10 Commits
a2d9605f4b
...
ffa023b92a
| Author | SHA1 | Date |
|---|---|---|
|
|
ffa023b92a | |
|
|
c106b6f79c | |
|
|
2c762b0411 | |
|
|
f172da378a | |
|
|
3137970b97 | |
|
|
6f01a29723 | |
|
|
4c2eb65470 | |
|
|
c524dd5867 | |
|
|
411f4d79ff | |
|
|
ae44191b28 |
|
|
@ -4,6 +4,10 @@ export default {
|
|||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
moduleNameMapper: {
|
||||
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
||||
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Jest mock for csv-parse/browser/esm/sync
|
||||
*
|
||||
* The real import path is csv-parse/browser/esm/sync which only works in
|
||||
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
|
||||
*/
|
||||
export { parse } from "csv-parse/sync";
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Shared between CLI completions scanner and browser-side completions.
|
||||
*
|
||||
* Format:
|
||||
* Format (columns can be in any order; `tag` is optional):
|
||||
* tag,key,expr
|
||||
* ,$hp,$con*5+$mod_hp ← variable declaration
|
||||
* ,$ac,10+$dex ← variable declaration
|
||||
|
|
@ -14,6 +14,40 @@
|
|||
* When `tag` is present: when #tag is active, $key gets expr added to its base.
|
||||
*/
|
||||
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
import { FENCED_BLOCK_RE, parseBlockAttrs } from "./block-scanner.js";
|
||||
|
||||
/**
|
||||
* Scan markdown content for ```csv role=declare blocks and return
|
||||
* parsed declarations and tag modifiers. Shared between CLI and client.
|
||||
*/
|
||||
export function scanDeclareBlocks(content: string, filePath: string): DeclareResult {
|
||||
const variables: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
FENCED_BLOCK_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = FENCED_BLOCK_RE.exec(content)) !== null) {
|
||||
const [, , infoString, body] = m;
|
||||
const attrs = parseBlockAttrs(infoString);
|
||||
if (attrs.role !== "declare") continue;
|
||||
|
||||
try {
|
||||
const result = parseDeclareCsv(body, filePath);
|
||||
variables.push(...result.variables);
|
||||
tagModifiers.push(...result.tagModifiers);
|
||||
} catch (e) {
|
||||
console.warn(`[declare-parser] ${filePath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { variables, tagModifiers };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VarDeclaration {
|
||||
key: string; // "$hp" (always starts with $)
|
||||
expression: string; // "$con*5+$mod_hp"
|
||||
|
|
@ -31,36 +65,37 @@ export interface DeclareResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=declare block.
|
||||
* Parse a single ```csv role=declare block body.
|
||||
*/
|
||||
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) return { variables: [], tagModifiers: [] };
|
||||
const trimmed = csv.trim();
|
||||
if (!trimmed) return { variables: [], tagModifiers: [] };
|
||||
|
||||
// First line is header; validate it
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
if (headers[0] !== "tag" || headers[1] !== "key" || headers[2] !== "expr") {
|
||||
// Validate that required columns exist (order-agnostic, tag is optional)
|
||||
const firstLine = trimmed.split(/\r?\n/)[0];
|
||||
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
||||
if (!headers.includes("key") || !headers.includes("expr")) {
|
||||
throw new Error(
|
||||
`${source}: role=declare blocks must have headers "tag,key,expr". Got: ${headers.join(",")}`,
|
||||
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const records = parse(trimmed, {
|
||||
columns: true,
|
||||
trim: true,
|
||||
skipEmptyLines: true,
|
||||
}) as Array<{ tag?: string; key: string; expr: string }>;
|
||||
|
||||
const variables: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].trim();
|
||||
if (!row || row.startsWith("#")) continue;
|
||||
|
||||
const cols = splitCsvRow(row);
|
||||
if (cols.length < 3) continue;
|
||||
|
||||
const tag = cols[0]?.trim() ?? "";
|
||||
const key = cols[1]?.trim() ?? "";
|
||||
const expr = cols[2]?.trim() ?? "";
|
||||
for (const row of records) {
|
||||
const tag = row.tag ?? "";
|
||||
const key = row.key ?? "";
|
||||
const expr = row.expr ?? "";
|
||||
|
||||
if (!key || !expr) {
|
||||
console.warn(`${source}:${i + 1}: skipping row with empty key or expr`);
|
||||
console.warn(`${source}: skipping row with empty key or expr`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -68,12 +103,12 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|||
// Tag modifier
|
||||
if (!tag.startsWith("#")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: tag must start with #, got "${tag}"`,
|
||||
`${source}: tag must start with #, got "${tag}"`,
|
||||
);
|
||||
}
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: key must start with $, got "${key}"`,
|
||||
`${source}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
tagModifiers.push({ tag, target: key, expression: expr });
|
||||
|
|
@ -81,7 +116,7 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|||
// Variable declaration
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: key must start with $, got "${key}"`,
|
||||
`${source}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
variables.push({ key, expression: expr });
|
||||
|
|
@ -90,27 +125,3 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|||
|
||||
return { variables, tagModifiers };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV row splitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function splitCsvRow(row: string): string[] {
|
||||
const cols: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const ch = row[i];
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (ch === "," && !inQuote) {
|
||||
cols.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cols.push(current);
|
||||
return cols;
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/**
|
||||
* Dice completion source — extracts `<md-dice>` text content from markdown files.
|
||||
*/
|
||||
|
||||
import type { CompletionSource, DiceCompletion } from "../types.js";
|
||||
|
||||
function looksLikeDice(raw: string): boolean {
|
||||
if (raw.length > 80) return false;
|
||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||
}
|
||||
|
||||
export const diceSource: CompletionSource = {
|
||||
key: "dice",
|
||||
|
||||
scan(index) {
|
||||
const items: DiceCompletion[] = [];
|
||||
const tagRegex = /:md-dice\[([^[]+)\]/gi;
|
||||
|
||||
for (const [path, content] of Object.entries(index)) {
|
||||
if (!path.endsWith(".md")) continue;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
tagRegex.lastIndex = 0;
|
||||
while ((match = tagRegex.exec(content)) !== null) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || !looksLikeDice(raw)) continue;
|
||||
|
||||
items.push({
|
||||
label: raw,
|
||||
notation: raw,
|
||||
source: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* Spark table scanner — CLI-side markdown table detection and conversion.
|
||||
*
|
||||
* Parses markdown tables, detects spark tables (first column header is a dice
|
||||
* formula), and converts them to CSV for the directive scanner.
|
||||
*
|
||||
* Not imported at runtime — the frontend only needs CSV parsing + rolling.
|
||||
*/
|
||||
|
||||
import Slugger from "github-slugger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MarkdownTable {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown table parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse all markdown tables from a markdown string.
|
||||
* Handles both leading/trailing `|` styles and bare styles.
|
||||
*/
|
||||
export function parseMarkdownTables(markdown: string): MarkdownTable[] {
|
||||
const tables: MarkdownTable[] = [];
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const headerCells = splitTableRow(lines[i]);
|
||||
if (!headerCells || headerCells.length < 2) continue;
|
||||
|
||||
// Peek at the next line — must be a separator row
|
||||
if (i + 1 >= lines.length) continue;
|
||||
const sepCells = splitTableRow(lines[i + 1]);
|
||||
if (!sepCells || sepCells.length < headerCells.length) continue;
|
||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
|
||||
|
||||
// Valid table header + separator — collect body rows
|
||||
const rows: string[][] = [];
|
||||
let j = i + 2;
|
||||
while (j < lines.length) {
|
||||
const rowCells = splitTableRow(lines[j]);
|
||||
if (!rowCells) break;
|
||||
// Allow rows with fewer cells (unfilled trailing columns)
|
||||
rows.push(rowCells);
|
||||
j++;
|
||||
}
|
||||
|
||||
// Only include tables with at least one data row
|
||||
if (rows.length > 0) {
|
||||
tables.push({ headers: headerCells, rows });
|
||||
}
|
||||
i = j - 1;
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */
|
||||
function splitTableRow(line: string): string[] | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.includes("|")) return null;
|
||||
|
||||
// Strip optional leading and trailing `|`
|
||||
let inner = trimmed;
|
||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
||||
|
||||
return inner.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spark table detection & metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DICE_HEADER_RE = /^d\d+$/i;
|
||||
|
||||
/** Check whether a table is a spark table (first header is a dice formula) */
|
||||
export function isSparkTable(table: MarkdownTable): boolean {
|
||||
if (table.headers.length < 2) return false;
|
||||
return DICE_HEADER_RE.test(table.headers[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the spark table slug by concatenating slugs of all data column
|
||||
* headers (excluding the dice column).
|
||||
*/
|
||||
export function sparkTableSlug(table: MarkdownTable): string {
|
||||
const slugger = new Slugger();
|
||||
return table.headers
|
||||
.slice(1)
|
||||
.map((h) => slugger.slug(h.toLowerCase()))
|
||||
.join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all spark tables in a markdown file and return their metadata
|
||||
* (without rows — suitable for listing available tables).
|
||||
*/
|
||||
export function scanSparkTables(
|
||||
markdown: string,
|
||||
): { notation: string; slug: string; dataHeaders: string[] }[] {
|
||||
const tables = parseMarkdownTables(markdown);
|
||||
return tables.filter(isSparkTable).map((table) => ({
|
||||
notation: table.headers[0],
|
||||
slug: sparkTableSlug(table),
|
||||
dataHeaders: table.headers.slice(1),
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,868 @@
|
|||
/**
|
||||
* Tests for the variable/tag system:
|
||||
* - declare-parser (CSV parsing)
|
||||
* - block-scanner (attribute parsing)
|
||||
* - variable-expression (expression evaluation)
|
||||
* - var-reactivity (dependency graph, cascade, tag activation)
|
||||
* - command-parser (input parsing)
|
||||
* - directive-scanner (spark table detection)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock("csv-parse/browser/esm/sync", () => {
|
||||
// Redirect browser-specific import to Node-compatible sync parser
|
||||
const actual = jest.requireActual("csv-parse/sync");
|
||||
return { parse: actual.parse };
|
||||
});
|
||||
|
||||
jest.mock("github-slugger", () => {
|
||||
// Simple slugger mock for testing
|
||||
const MockSlugger = jest.fn(function (this: any) {
|
||||
this.slug = (s: string) => s.toLowerCase().replace(/\s+/g, "-");
|
||||
});
|
||||
return { __esModule: true, default: MockSlugger };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { parseDeclareCsv } from "./declare-parser";
|
||||
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
|
||||
import { evaluateExpression } from "../../components/journal/variable-expression";
|
||||
import {
|
||||
initReactivity,
|
||||
computeCascade,
|
||||
computeInitialValues,
|
||||
getCombined,
|
||||
setBase,
|
||||
getMods,
|
||||
getDeclExpr,
|
||||
extractDependencies,
|
||||
} from "../../components/journal/var-reactivity";
|
||||
import { parseInput } from "../../components/journal/command-parser";
|
||||
import {
|
||||
inspectSparkTableCsv,
|
||||
buildSparkTableCompletion,
|
||||
} from "./directive-scanner";
|
||||
import Slugger from "github-slugger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a minimal VariableStore for testing */
|
||||
function store(entries: Record<string, string> = {}): Record<string, string> {
|
||||
return { ...entries };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// declare-parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseDeclareCsv", () => {
|
||||
test("parses variable declarations", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(2);
|
||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" });
|
||||
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("parses tag modifiers", () => {
|
||||
const csv = `tag,key,expr
|
||||
#warrior,$mod_hp,20
|
||||
#mage,$mod_mp,15`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(0);
|
||||
expect(result.tagModifiers).toHaveLength(2);
|
||||
expect(result.tagModifiers[0]).toEqual({
|
||||
tag: "#warrior",
|
||||
target: "$mod_hp",
|
||||
expression: "20",
|
||||
});
|
||||
expect(result.tagModifiers[1]).toEqual({
|
||||
tag: "#mage",
|
||||
target: "$mod_mp",
|
||||
expression: "15",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses mixed declarations and tag modifiers", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5
|
||||
#warrior,$mod_hp,20
|
||||
,$ac,10+$dex`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(2);
|
||||
expect(result.tagModifiers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rejects missing key column", () => {
|
||||
const csv = `tag,expr
|
||||
,$con*5`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'role=declare blocks must have "key" and "expr" columns',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects missing expr column", () => {
|
||||
const csv = `tag,key
|
||||
,$hp`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'role=declare blocks must have "key" and "expr" columns',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects tag without # prefix", () => {
|
||||
const csv = `tag,key,expr
|
||||
warrior,$mod_hp,20`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'tag must start with #',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects key without $ prefix in declaration", () => {
|
||||
const csv = `tag,key,expr
|
||||
,hp,$con*5`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'key must start with $',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects key without $ prefix in tag modifier", () => {
|
||||
const csv = `tag,key,expr
|
||||
#warrior,mod_hp,20`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'key must start with $',
|
||||
);
|
||||
});
|
||||
|
||||
test("skips rows with empty key or expr (warns, doesn't throw)", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5
|
||||
,,`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("handles empty CSV", () => {
|
||||
const result = parseDeclareCsv("", "test.md");
|
||||
expect(result.variables).toHaveLength(0);
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles whitespace-only CSV", () => {
|
||||
const result = parseDeclareCsv(" \n ", "test.md");
|
||||
expect(result.variables).toHaveLength(0);
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("columns can be in any order", () => {
|
||||
const csv = `expr,key,tag
|
||||
$con*5,$hp,
|
||||
20,$mod_hp,#warrior`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(1);
|
||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5" });
|
||||
expect(result.tagModifiers).toHaveLength(1);
|
||||
expect(result.tagModifiers[0]).toEqual({
|
||||
tag: "#warrior",
|
||||
target: "$mod_hp",
|
||||
expression: "20",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// block-scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseBlockAttrs", () => {
|
||||
test("parses standard attributes", () => {
|
||||
// parseBlockAttrs receives the info string AFTER the lang.
|
||||
// The lang is extracted from the fenced block regex capture group
|
||||
// and applied separately in block-processor.
|
||||
const attrs = parseBlockAttrs('id=stats role=declare as=none');
|
||||
expect(attrs.id).toBe("stats");
|
||||
expect(attrs.role).toBe("declare");
|
||||
expect(attrs.as).toBe("none");
|
||||
});
|
||||
|
||||
test("parses quoted values", () => {
|
||||
const attrs = parseBlockAttrs('csv id="my stats" role=declare');
|
||||
expect(attrs.id).toBe("my stats");
|
||||
expect(attrs.role).toBe("declare");
|
||||
});
|
||||
|
||||
test("collects unknown attributes in extra", () => {
|
||||
const attrs = parseBlockAttrs('csv role=declare foo=bar baz=42');
|
||||
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
||||
});
|
||||
|
||||
test("handles empty info string", () => {
|
||||
const attrs = parseBlockAttrs("");
|
||||
expect(attrs.lang).toBe("");
|
||||
expect(attrs.id).toBeUndefined();
|
||||
expect(attrs.role).toBeUndefined();
|
||||
expect(attrs.as).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles empty info string (lang extracted separately)", () => {
|
||||
const attrs = parseBlockAttrs("");
|
||||
expect(attrs.lang).toBe("");
|
||||
expect(attrs.id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlockAs", () => {
|
||||
test("returns explicit as value", () => {
|
||||
expect(resolveBlockAs("declare", "codeblock")).toBe("codeblock");
|
||||
});
|
||||
|
||||
test("defaults to none when role is set", () => {
|
||||
expect(resolveBlockAs("declare", undefined)).toBe("none");
|
||||
expect(resolveBlockAs("file", undefined)).toBe("none");
|
||||
});
|
||||
|
||||
test("defaults to md-table for spark-table role", () => {
|
||||
expect(resolveBlockAs("spark-table", undefined)).toBe("md-table");
|
||||
});
|
||||
|
||||
test("defaults to codeblock when no role and no as", () => {
|
||||
expect(resolveBlockAs(undefined, undefined)).toBe("codeblock");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// variable-expression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("evaluateExpression", () => {
|
||||
test("evaluates simple arithmetic", () => {
|
||||
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined });
|
||||
expect(result.value).toBe(14);
|
||||
});
|
||||
|
||||
test("evaluates with parentheses", () => {
|
||||
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
|
||||
expect(result.value).toBe(20);
|
||||
});
|
||||
|
||||
test("evaluates variable references", () => {
|
||||
const result = evaluateExpression("$con * 5 + $mod_hp", {
|
||||
lookup: (name) => {
|
||||
if (name === "con") return "12";
|
||||
if (name === "mod_hp") return "20";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
expect(result.value).toBe(80); // 12*5 + 20
|
||||
});
|
||||
|
||||
test("returns 0 for undefined variables", () => {
|
||||
const result = evaluateExpression("$unknown + 5", {
|
||||
lookup: () => undefined,
|
||||
});
|
||||
expect(result.value).toBe(5);
|
||||
});
|
||||
|
||||
test("throws on tag values in arithmetic", () => {
|
||||
expect(() =>
|
||||
evaluateExpression("$class + 5", {
|
||||
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
||||
}),
|
||||
).toThrow('$class is a tag');
|
||||
});
|
||||
|
||||
test("evaluates floor function", () => {
|
||||
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
|
||||
expect(result.value).toBe(3);
|
||||
});
|
||||
|
||||
test("evaluates ceil function", () => {
|
||||
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined });
|
||||
expect(result.value).toBe(4);
|
||||
});
|
||||
|
||||
test("evaluates round function", () => {
|
||||
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
|
||||
expect(result.value).toBe(4);
|
||||
});
|
||||
|
||||
test("evaluates unary minus", () => {
|
||||
const result = evaluateExpression("-5 + 10", { lookup: () => undefined });
|
||||
expect(result.value).toBe(5);
|
||||
});
|
||||
|
||||
test("evaluates dice notation", () => {
|
||||
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined });
|
||||
expect(typeof result.value).toBe("number");
|
||||
// 3d6 is between 3 and 18, +5 gives 8-23
|
||||
expect(result.value).toBeGreaterThanOrEqual(8);
|
||||
expect(result.value).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
test("throws on division by zero", () => {
|
||||
expect(() =>
|
||||
evaluateExpression("10 / 0", { lookup: () => undefined }),
|
||||
).toThrow("Division by zero");
|
||||
});
|
||||
|
||||
test("throws on unknown function", () => {
|
||||
expect(() =>
|
||||
evaluateExpression("foo(5)", { lookup: () => undefined }),
|
||||
).toThrow("Unknown function");
|
||||
});
|
||||
|
||||
test("throws on malformed expression", () => {
|
||||
expect(() =>
|
||||
evaluateExpression("5 +", { lookup: () => undefined }),
|
||||
).toThrow("Unexpected end");
|
||||
});
|
||||
|
||||
test("handles decimal numbers", () => {
|
||||
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
||||
expect(result.value).toBeCloseTo(6);
|
||||
});
|
||||
|
||||
test("nested function calls", () => {
|
||||
const result = evaluateExpression("floor(ceil(3.2))", {
|
||||
lookup: () => undefined,
|
||||
});
|
||||
// ceil(3.2) = 4, floor(4) = 4
|
||||
expect(result.value).toBe(4);
|
||||
});
|
||||
|
||||
test("complex expression with variables and functions", () => {
|
||||
const result = evaluateExpression("floor($con / 2) + $mod", {
|
||||
lookup: (name) => {
|
||||
if (name === "con") return "15";
|
||||
if (name === "mod") return "3";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
expect(result.value).toBe(10); // floor(7.5) + 3 = 7 + 3
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// var-reactivity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("var-reactivity", () => {
|
||||
// Reset module state before each test
|
||||
beforeEach(() => {
|
||||
// Re-initialize with empty state to clear module-level maps
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
});
|
||||
|
||||
describe("initReactivity", () => {
|
||||
test("builds dependency graph from declarations", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$hp", expression: "$con * 5 + $mod_hp" },
|
||||
{ key: "$ac", expression: "10 + $dex" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
});
|
||||
|
||||
const deps = extractDependencies("$con * 5 + $mod_hp");
|
||||
expect(deps).toContain("$con");
|
||||
expect(deps).toContain("$mod_hp");
|
||||
});
|
||||
|
||||
test("detects circular dependencies", () => {
|
||||
expect(() =>
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$a", expression: "$b + 1" },
|
||||
{ key: "$b", expression: "$a + 1" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
}),
|
||||
).toThrow("Circular dependency");
|
||||
});
|
||||
|
||||
test("detects self-referencing circular dependency", () => {
|
||||
expect(() =>
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$a", expression: "$a + 1" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
}),
|
||||
).toThrow("Circular dependency");
|
||||
});
|
||||
|
||||
test("detects three-way circular dependency", () => {
|
||||
expect(() =>
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$a", expression: "$b + 1" },
|
||||
{ key: "$b", expression: "$c + 1" },
|
||||
{ key: "$c", expression: "$a + 1" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
}),
|
||||
).toThrow("Circular dependency");
|
||||
});
|
||||
|
||||
test("allows diamond dependency (non-circular)", () => {
|
||||
// $d depends on $b and $c, both depend on $a — no cycle
|
||||
expect(() =>
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$b", expression: "$a + 1" },
|
||||
{ key: "$c", expression: "$a + 2" },
|
||||
{ key: "$d", expression: "$b + $c" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeInitialValues", () => {
|
||||
test("computes values for all declared variables", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$hp", expression: "10 + 5" },
|
||||
{ key: "$ac", expression: "15" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
});
|
||||
|
||||
const results = computeInitialValues(store());
|
||||
const hpResult = results.find((r) => r.key === "$hp");
|
||||
const acResult = results.find((r) => r.key === "$ac");
|
||||
expect(hpResult?.value).toBe("15");
|
||||
expect(acResult?.value).toBe("15");
|
||||
});
|
||||
|
||||
test("evaluates in dependency order", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$hp", expression: "$con * 5" },
|
||||
{ key: "$con", expression: "12" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
});
|
||||
|
||||
const results = computeInitialValues(store());
|
||||
const hpResult = results.find((r) => r.key === "$hp");
|
||||
expect(hpResult?.value).toBe("60"); // 12 * 5
|
||||
});
|
||||
|
||||
test("returns empty array when no declarations", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
const results = computeInitialValues(store());
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCombined / setBase", () => {
|
||||
test("returns base value when no mods", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
setBase("$hp", "42");
|
||||
expect(getCombined("$hp")).toBe("42");
|
||||
});
|
||||
|
||||
test("returns 0 for unknown keys", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
expect(getCombined("$unknown")).toBe("0");
|
||||
});
|
||||
|
||||
test("falls back to store for unknown keys", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
expect(getCombined("$hp", { $hp: "99" })).toBe("99");
|
||||
});
|
||||
|
||||
test("returns tag value without numeric mods", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
setBase("$class", "#warrior");
|
||||
expect(getCombined("$class")).toBe("#warrior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCascade — tag activation/deactivation", () => {
|
||||
test("activates tag modifiers when a tag value is set", () => {
|
||||
initReactivity({
|
||||
declarations: [],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
{ tag: "#warrior", target: "$mod_str", expression: "5" },
|
||||
],
|
||||
});
|
||||
|
||||
// Set $class to #warrior — should activate both modifiers
|
||||
setBase("$class", "#warrior");
|
||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
// Should produce combined values for both targets
|
||||
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
||||
const modStr = cascade.find((r) => r.key === "$mod_str");
|
||||
expect(modHp?.value).toBe("20");
|
||||
expect(modStr?.value).toBe("5");
|
||||
});
|
||||
|
||||
test("deactivates tag modifiers when tag value changes", () => {
|
||||
initReactivity({
|
||||
declarations: [],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
],
|
||||
});
|
||||
|
||||
// First activate
|
||||
setBase("$class", "#warrior");
|
||||
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
// Now change to a different tag
|
||||
setBase("$class", "#mage");
|
||||
const cascade = computeCascade(
|
||||
"$class",
|
||||
"#warrior",
|
||||
store({ $class: "#mage" }),
|
||||
);
|
||||
|
||||
// Should deactivate #warrior mods (mod_hp goes back to 0)
|
||||
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
||||
expect(modHp?.value).toBe("0");
|
||||
});
|
||||
|
||||
test("switches between two tags with different modifiers", () => {
|
||||
initReactivity({
|
||||
declarations: [],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
{ tag: "#mage", target: "$mod_hp", expression: "10" },
|
||||
],
|
||||
});
|
||||
|
||||
// Activate #warrior
|
||||
setBase("$class", "#warrior");
|
||||
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
// Switch to #mage
|
||||
setBase("$class", "#mage");
|
||||
const cascade = computeCascade(
|
||||
"$class",
|
||||
"#warrior",
|
||||
store({ $class: "#mage" }),
|
||||
);
|
||||
|
||||
// Cascade returns both deactivation ("0") and activation ("10") entries.
|
||||
// The last entry for a key is the authoritative one.
|
||||
const modHpEntries = cascade.filter((r) => r.key === "$mod_hp");
|
||||
const lastModHp = modHpEntries[modHpEntries.length - 1];
|
||||
expect(lastModHp?.value).toBe("10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCascade — declaration re-evaluation", () => {
|
||||
test("re-evaluates dependents when a dependency changes", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$hp", expression: "$con * 5" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
});
|
||||
|
||||
// Set initial base for $con
|
||||
setBase("$con", "10");
|
||||
// Manually set $hp base so it exists
|
||||
setBase("$hp", "50");
|
||||
|
||||
const cascade = computeCascade("$con", "10", store({ $con: "10" }));
|
||||
const hpResult = cascade.find((r) => r.key === "$hp");
|
||||
expect(hpResult?.value).toBe("50"); // 10 * 5
|
||||
});
|
||||
|
||||
test("cascades through multiple levels of dependents", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$b", expression: "$a * 2" },
|
||||
{ key: "$c", expression: "$b + 10" },
|
||||
],
|
||||
tagModifiers: [],
|
||||
});
|
||||
|
||||
setBase("$a", "5");
|
||||
setBase("$b", "10");
|
||||
setBase("$c", "20");
|
||||
|
||||
const cascade = computeCascade("$a", "5", store({ $a: "5" }));
|
||||
const bResult = cascade.find((r) => r.key === "$b");
|
||||
const cResult = cascade.find((r) => r.key === "$c");
|
||||
expect(bResult?.value).toBe("10"); // 5 * 2
|
||||
expect(cResult?.value).toBe("20"); // 10 + 10
|
||||
});
|
||||
|
||||
test("handles tag transition during re-evaluation", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$class", expression: "$level > 5 ? '#warrior' : '#novice'" },
|
||||
],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
{ tag: "#novice", target: "$mod_hp", expression: "5" },
|
||||
],
|
||||
// Note: the expression above uses a ternary which isn't supported
|
||||
// by the expression evaluator. We'll test with direct tag values.
|
||||
});
|
||||
|
||||
// Set $class directly to a tag
|
||||
setBase("$class", "#novice");
|
||||
computeCascade("$class", undefined, store({ $class: "#novice" }));
|
||||
|
||||
// Now change to #warrior
|
||||
setBase("$class", "#warrior");
|
||||
const cascade = computeCascade(
|
||||
"$class",
|
||||
"#novice",
|
||||
store({ $class: "#warrior" }),
|
||||
);
|
||||
|
||||
// Cascade returns both deactivation and activation entries.
|
||||
// The last entry for a key is authoritative.
|
||||
const modHpEntries = cascade.filter((r) => r.key === "$mod_hp");
|
||||
const lastModHp = modHpEntries[modHpEntries.length - 1];
|
||||
expect(lastModHp?.value).toBe("20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMods / getDeclExpr", () => {
|
||||
test("getDeclExpr returns expression for declared variable", () => {
|
||||
initReactivity({
|
||||
declarations: [{ key: "$hp", expression: "$con * 5" }],
|
||||
tagModifiers: [],
|
||||
});
|
||||
expect(getDeclExpr("$hp")).toBe("$con * 5");
|
||||
expect(getDeclExpr("$unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getMods returns active modifiers after tag activation", () => {
|
||||
initReactivity({
|
||||
declarations: [],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
],
|
||||
});
|
||||
|
||||
setBase("$class", "#warrior");
|
||||
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
const mods = getMods("$mod_hp");
|
||||
expect(mods).toHaveLength(1);
|
||||
expect(mods[0].tag).toBe("#warrior");
|
||||
expect(mods[0].value).toBe(20);
|
||||
expect(mods[0].source).toBe("$class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDependencies", () => {
|
||||
test("extracts $var references from expression", () => {
|
||||
const deps = extractDependencies("$con * 5 + $mod_hp");
|
||||
expect(deps).toEqual(["$con", "$mod_hp"]);
|
||||
});
|
||||
|
||||
test("returns empty for expression without variables", () => {
|
||||
const deps = extractDependencies("10 + 5");
|
||||
expect(deps).toEqual([]);
|
||||
});
|
||||
|
||||
test("deduplicates repeated variables", () => {
|
||||
const deps = extractDependencies("$a + $a + $b");
|
||||
expect(deps).toEqual(["$a", "$b"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// command-parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseInput", () => {
|
||||
test("parses /roll command", () => {
|
||||
const result = parseInput("/roll 3d6+5");
|
||||
expect(result.type).toBe("roll");
|
||||
expect(result.payload).toEqual({ notation: "3d6+5", label: "3d6+5" });
|
||||
});
|
||||
|
||||
test("parses /spark command", () => {
|
||||
const result = parseInput("/spark my-table");
|
||||
expect(result.type).toBe("spark");
|
||||
expect(result.payload).toEqual({ key: "my-table" });
|
||||
});
|
||||
|
||||
test("parses /link command", () => {
|
||||
const result = parseInput("/link /rules/combat#initiative");
|
||||
expect(result.type).toBe("link");
|
||||
expect(result.payload).toEqual({
|
||||
path: "/rules/combat",
|
||||
section: "initiative",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses /link without section", () => {
|
||||
const result = parseInput("/link /rules/combat");
|
||||
expect(result.type).toBe("link");
|
||||
expect(result.payload).toEqual({
|
||||
path: "/rules/combat",
|
||||
section: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses /set command", () => {
|
||||
const result = parseInput("/set $hp $con*5+10");
|
||||
expect(result.type).toBe("set");
|
||||
expect(result.payload).toEqual({ key: "$hp", expr: "$con*5+10" });
|
||||
});
|
||||
|
||||
test("parses /set with rolltag (pipe-separated tags)", () => {
|
||||
const result = parseInput("/set $class #w|#d|#s");
|
||||
expect(result.type).toBe("rolltag");
|
||||
expect(result.payload).toEqual({
|
||||
key: "$class",
|
||||
tags: ["#w", "#d", "#s"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses chat messages", () => {
|
||||
const result = parseInput("Hello world");
|
||||
expect(result.type).toBe("chat");
|
||||
expect(result.payload).toEqual({ text: "Hello world" });
|
||||
});
|
||||
|
||||
test("returns error for empty /roll", () => {
|
||||
const result = parseInput("/roll ");
|
||||
expect(result.error).toBe("需要骰子表达式");
|
||||
});
|
||||
|
||||
test("returns error for empty /set", () => {
|
||||
const result = parseInput("/set ");
|
||||
expect(result.error).toBe("格式: /set $key expression");
|
||||
});
|
||||
|
||||
test("returns error for /set without expression", () => {
|
||||
const result = parseInput("/set $hp");
|
||||
expect(result.error).toBe("格式: /set $key expression");
|
||||
});
|
||||
|
||||
test("returns error for /set without $ prefix", () => {
|
||||
const result = parseInput("/set hp 10");
|
||||
expect(result.error).toBe("key 必须以 $ 开头");
|
||||
});
|
||||
|
||||
test("handles incomplete commands", () => {
|
||||
const result = parseInput("/roll");
|
||||
expect(result.type).toBe("chat");
|
||||
expect(result.error).toBe("请补全命令");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// directive-scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("inspectSparkTableCsv", () => {
|
||||
test("detects spark table from CSV headers", () => {
|
||||
const csv = `d6,Name,Description
|
||||
1,Alice,The brave
|
||||
2,Bob,The wise`;
|
||||
const headers = inspectSparkTableCsv(csv);
|
||||
expect(headers).toEqual(["Name", "Description"]);
|
||||
});
|
||||
|
||||
test("detects d20 spark table", () => {
|
||||
const csv = `d20,Result
|
||||
1,Critical failure
|
||||
20,Critical success`;
|
||||
const headers = inspectSparkTableCsv(csv);
|
||||
expect(headers).toEqual(["Result"]);
|
||||
});
|
||||
|
||||
test("returns null for non-dice first column", () => {
|
||||
const csv = `Name,Value
|
||||
Alice,10
|
||||
Bob,20`;
|
||||
const headers = inspectSparkTableCsv(csv);
|
||||
expect(headers).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for single-column CSV", () => {
|
||||
const csv = `d6`;
|
||||
const headers = inspectSparkTableCsv(csv);
|
||||
expect(headers).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty CSV", () => {
|
||||
const csv = "";
|
||||
const headers = inspectSparkTableCsv(csv);
|
||||
expect(headers).toBeNull();
|
||||
});
|
||||
|
||||
test("handles 2d6 notation", () => {
|
||||
const csv = `2d6,Outcome
|
||||
2,Terrible
|
||||
7,Average
|
||||
12,Amazing`;
|
||||
const headers = inspectSparkTableCsv(csv);
|
||||
expect(headers).toEqual(["Outcome"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSparkTableCompletion", () => {
|
||||
test("builds completion from CSV data", () => {
|
||||
const csv = `d6,Name,Description
|
||||
1,Alice,The brave`;
|
||||
const slugger = new Slugger();
|
||||
const result = buildSparkTableCompletion(
|
||||
csv,
|
||||
"/rules/combat.md",
|
||||
"/rules/combat/test.csv",
|
||||
false,
|
||||
slugger,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.notation).toBe("d6");
|
||||
expect(result!.headers).toEqual(["Name", "Description"]);
|
||||
expect(result!.filePath).toBe("/rules/combat");
|
||||
expect(result!.remix).toBe(false);
|
||||
});
|
||||
|
||||
test("returns null for non-spark CSV", () => {
|
||||
const csv = `Name,Value
|
||||
Alice,10`;
|
||||
const slugger = new Slugger();
|
||||
const result = buildSparkTableCompletion(
|
||||
csv,
|
||||
"/test.md",
|
||||
"/test.csv",
|
||||
false,
|
||||
slugger,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("sets remix flag", () => {
|
||||
const csv = `d6,Result
|
||||
1,Yes`;
|
||||
const slugger = new Slugger();
|
||||
const result = buildSparkTableCompletion(
|
||||
csv,
|
||||
"/test.md",
|
||||
"/test.csv",
|
||||
true,
|
||||
slugger,
|
||||
);
|
||||
expect(result!.remix).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -222,7 +222,7 @@ export const JournalInput: Component = () => {
|
|||
const raw = input.value;
|
||||
setText(raw);
|
||||
|
||||
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
||||
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||
setShowCompletions(true);
|
||||
setSelectedIdx(0);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ import { useJournalStream } from "../stores/journalStream";
|
|||
|
||||
const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
||||
|
||||
/** Tag name used for marker spans — must stay in sync with the scan pass. */
|
||||
const MARKER = "data-reactive-var";
|
||||
/** Attribute storing the original template text on marker spans. */
|
||||
const ATTR_TEMPLATE = "data-reactive-var";
|
||||
|
||||
/** Attribute storing pre-computed key list (comma-separated) for fast updates. */
|
||||
const ATTR_KEYS = "data-reactive-keys";
|
||||
|
||||
export const ReactiveVariableManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
|
|
@ -32,7 +35,7 @@ export const ReactiveVariableManager: Component = () => {
|
|||
|
||||
// Only scan once — after the first scan, the spans exist and we
|
||||
// switch to the reactive update effect below.
|
||||
if (dom.querySelector(`[${MARKER}]`)) return;
|
||||
if (dom.querySelector(`[${ATTR_TEMPLATE}]`)) return;
|
||||
|
||||
scanAndWrap(dom);
|
||||
});
|
||||
|
|
@ -49,17 +52,14 @@ export const ReactiveVariableManager: Component = () => {
|
|||
if (!dom) return;
|
||||
|
||||
const v = values();
|
||||
const spans = dom.querySelectorAll<HTMLElement>(`[${MARKER}]`);
|
||||
const spans = dom.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
|
||||
|
||||
for (const span of spans) {
|
||||
const template = span.getAttribute(MARKER) ?? "";
|
||||
const template = span.getAttribute(ATTR_TEMPLATE) ?? "";
|
||||
const keys = span.getAttribute(ATTR_KEYS)?.split(",") ?? [];
|
||||
let result = template;
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_RE.exec(template)) !== null) {
|
||||
const key = m[1]; // includes $ prefix
|
||||
const resolved = v[key] ?? "";
|
||||
result = result.replace(`{{${key}}}`, resolved);
|
||||
for (const key of keys) {
|
||||
result = result.replaceAll(`{{${key}}}`, v[key] ?? "");
|
||||
}
|
||||
if (span.textContent !== result) {
|
||||
span.textContent = result;
|
||||
|
|
@ -90,7 +90,8 @@ function scanAndWrap(root: Element): void {
|
|||
TEMPLATE_RE.lastIndex = 0;
|
||||
if (!TEMPLATE_RE.test(text)) continue;
|
||||
|
||||
// Build replacement HTML: split on {{$key}} and wrap each key span
|
||||
// Build replacement HTML: split on {{$key}}, wrap each match in a span
|
||||
// with pre-computed key for fast updates.
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
|
@ -99,8 +100,9 @@ function scanAndWrap(root: Element): void {
|
|||
if (m.index > lastIndex) {
|
||||
parts.push(escapeHtml(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
const key = m[1]; // includes $ prefix
|
||||
parts.push(
|
||||
`<span ${MARKER}="${escapeAttr(m[0])}">${escapeHtml(m[0])}</span>`,
|
||||
`<span ${ATTR_TEMPLATE}="${escapeAttr(m[0])}" ${ATTR_KEYS}="${escapeAttr(key)}">${escapeHtml(m[0])}</span>`,
|
||||
);
|
||||
lastIndex = TEMPLATE_RE.lastIndex;
|
||||
}
|
||||
|
|
@ -123,13 +125,13 @@ function scanAndWrap(root: Element): void {
|
|||
* Called on cleanup so subsequent remounts get a clean slate.
|
||||
*/
|
||||
function unwrapAll(root: Element): void {
|
||||
const spans = root.querySelectorAll<HTMLElement>(`[${MARKER}]`);
|
||||
const spans = root.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
|
||||
// Process in reverse to avoid DOM mutation issues
|
||||
for (let i = spans.length - 1; i >= 0; i--) {
|
||||
const span = spans[i];
|
||||
const parent = span.parentNode;
|
||||
if (!parent) continue;
|
||||
const text = span.getAttribute(MARKER) ?? span.textContent ?? "";
|
||||
const text = span.getAttribute(ATTR_TEMPLATE) ?? span.textContent ?? "";
|
||||
span.replaceWith(document.createTextNode(text));
|
||||
}
|
||||
// Normalize to merge adjacent text nodes
|
||||
|
|
|
|||
|
|
@ -1,96 +1,34 @@
|
|||
/**
|
||||
* VariableView — table view of all variable key-value pairs and active tags.
|
||||
* VariableView — flat list of all variable key-value pairs.
|
||||
*
|
||||
* Shows variables grouped by type (declared, plain, tag-typed) and an
|
||||
* active tags section showing which variable activates each tag and
|
||||
* what modifier effects are in play.
|
||||
* Hovering a value shows a popup with:
|
||||
* - Declaration expression (if any), styled distinctly
|
||||
* - Active tag modifiers: #tag +N (from $source)
|
||||
*/
|
||||
|
||||
import { Component, For, createMemo, Show } from "solid-js";
|
||||
import { Component, For, createMemo, Show, createSignal } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { computeActiveTags } from "./var-reactivity";
|
||||
import { extractDependencies } from "./var-reactivity";
|
||||
import type { VarDeclaration } from "./declare-parser";
|
||||
import { getMods, getDeclExpr } from "./var-reactivity";
|
||||
|
||||
export const VariableView: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const declarations = createMemo(() => comp.data.declarations);
|
||||
const tagModifiers = createMemo(() => comp.data.tagModifiers);
|
||||
const values = createMemo(() => stream.variables);
|
||||
|
||||
/** Build a map: $key → VarDeclaration */
|
||||
const declMap = createMemo(() => {
|
||||
const map = new Map<string, VarDeclaration>();
|
||||
for (const d of declarations()) {
|
||||
map.set(d.key, d);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Active tags */
|
||||
const activeTags = createMemo(() => computeActiveTags(values()));
|
||||
|
||||
/** Tag modifier index: #tag → [{target, expression}] */
|
||||
const tagModMap = createMemo(() => {
|
||||
const map = new Map<string, Array<{ target: string; expression: string }>>();
|
||||
for (const tm of tagModifiers()) {
|
||||
let list = map.get(tm.tag);
|
||||
if (!list) {
|
||||
list = [];
|
||||
map.set(tm.tag, list);
|
||||
}
|
||||
list.push({ target: tm.target, expression: tm.expression });
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Which variable activates each tag */
|
||||
const tagActivators = createMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
/** Flat list of all variables */
|
||||
const items = createMemo(() => {
|
||||
const vars = values();
|
||||
for (const [key, val] of Object.entries(vars)) {
|
||||
if (val.startsWith("#")) {
|
||||
if (!map.has(val)) map.set(val, key);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Group variables: declared, plain (set directly), tag-typed */
|
||||
const groups = createMemo(() => {
|
||||
const vars = values();
|
||||
const declKeys = new Set(declarations().map((d) => d.key));
|
||||
|
||||
const declared: Array<{ key: string; value: string; expr: string; deps: string[] }> = [];
|
||||
const plain: Array<{ key: string; value: string }> = [];
|
||||
const tagVars: Array<{ key: string; value: string }> = [];
|
||||
|
||||
for (const [key, val] of Object.entries(vars)) {
|
||||
if (val.startsWith("#")) {
|
||||
tagVars.push({ key, value: val });
|
||||
} else if (declKeys.has(key)) {
|
||||
const decl = declMap().get(key)!;
|
||||
declared.push({
|
||||
return Object.entries(vars).map(([key, value]) => ({
|
||||
key,
|
||||
value: val,
|
||||
expr: decl.expression,
|
||||
deps: extractDependencies(decl.expression),
|
||||
});
|
||||
} else {
|
||||
plain.push({ key, value: val });
|
||||
}
|
||||
}
|
||||
|
||||
return { declared, plain, tagVars };
|
||||
value,
|
||||
isTag: value.startsWith("#"),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasAnyData = () =>
|
||||
declarations().length > 0 ||
|
||||
Object.keys(values()).length > 0 ||
|
||||
activeTags().length > 0;
|
||||
comp.data.declarations.length > 0 || Object.keys(values()).length > 0;
|
||||
|
||||
return (
|
||||
<div class="h-full overflow-y-auto bg-gray-50">
|
||||
|
|
@ -102,142 +40,85 @@ export const VariableView: Component = () => {
|
|||
</p>
|
||||
}
|
||||
>
|
||||
{/* Active Tags */}
|
||||
<Show when={activeTags().length > 0}>
|
||||
<div class="mb-3">
|
||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-600">激活标签</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={activeTags()}>
|
||||
{(tag) => {
|
||||
const activator = () => tagActivators().get(tag);
|
||||
const mods = () => tagModMap().get(tag) ?? [];
|
||||
return (
|
||||
<div class="px-3 py-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-mono text-purple-700 font-semibold">
|
||||
{tag}
|
||||
</span>
|
||||
<Show when={activator()}>
|
||||
<span class="text-[10px] text-gray-400">
|
||||
← {activator()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={mods().length > 0}>
|
||||
<div class="mt-1 space-y-0.5">
|
||||
<For each={mods()}>
|
||||
{(mod) => {
|
||||
const currentVal = () => values()[mod.target];
|
||||
return (
|
||||
<div class="text-[10px] text-gray-500 ml-2 flex items-center gap-1">
|
||||
<span class="font-mono">{mod.target}</span>
|
||||
<span>+{mod.expression}</span>
|
||||
<Show when={currentVal() !== undefined}>
|
||||
<span class="text-gray-400">
|
||||
= {currentVal()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Declared Variables */}
|
||||
<Show when={groups().declared.length > 0}>
|
||||
<div class="mb-3">
|
||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-600">
|
||||
声明变量
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={groups().declared}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span class="text-sm font-mono text-gray-800">
|
||||
{item.key}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
||||
{item.expr}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-sm font-mono text-gray-700 font-semibold">
|
||||
{item.value}
|
||||
</span>
|
||||
<Show when={item.deps.length > 0}>
|
||||
<span class="text-[10px] text-gray-400">
|
||||
← {item.deps.join(", ")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<VariableRow
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
isTag={item.isTag}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Plain Variables */}
|
||||
<Show when={groups().plain.length > 0}>
|
||||
<div class="mb-3">
|
||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-600">
|
||||
直接设置
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={groups().plain}>
|
||||
{(item) => (
|
||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||
<span class="flex-1 text-sm font-mono text-gray-800">
|
||||
{item.key}
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-700 font-semibold">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Tag Variables */}
|
||||
<Show when={groups().tagVars.length > 0}>
|
||||
<div class="mb-3">
|
||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-600">标签变量</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={groups().tagVars}>
|
||||
{(item) => (
|
||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||
<span class="flex-1 text-sm font-mono text-gray-800">
|
||||
{item.key}
|
||||
</span>
|
||||
<span class="text-sm font-mono text-purple-700 font-semibold">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VariableRow — single row with hover popup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VariableRow: Component<{
|
||||
key: string;
|
||||
value: string;
|
||||
isTag: boolean;
|
||||
}> = (props) => {
|
||||
const [hovered, setHovered] = createSignal(false);
|
||||
|
||||
const declExpr = createMemo(() => getDeclExpr(props.key));
|
||||
const mods = createMemo(() => getMods(props.key));
|
||||
|
||||
const hasPopup = () => !!declExpr() || mods().length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<span class="flex-1 text-sm font-mono text-gray-800">{props.key}</span>
|
||||
<span
|
||||
class={props.isTag
|
||||
? "text-sm font-mono text-purple-700 font-semibold"
|
||||
: "text-sm font-mono text-gray-700 font-semibold"
|
||||
}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
|
||||
{/* Hover popup */}
|
||||
<Show when={hasPopup() && hovered()}>
|
||||
<div class="absolute right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-md shadow-lg p-2 min-w-45 text-xs">
|
||||
{/* Declaration expression */}
|
||||
<Show when={declExpr()}>
|
||||
<div class="mb-1">
|
||||
<span class="text-gray-400">expr: </span>
|
||||
<span class="font-mono text-gray-500 bg-gray-50 px-1 rounded">
|
||||
{declExpr()}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Tag modifiers */}
|
||||
<Show when={mods().length > 0}>
|
||||
<div class={declExpr() ? "border-t border-gray-100 pt-1 mt-1" : ""}>
|
||||
<For each={mods()}>
|
||||
{(mod) => (
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<span class="font-mono text-purple-600">{mod.tag}</span>
|
||||
<span>+{mod.value}</span>
|
||||
<span class="text-gray-400">(from {mod.source})</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableView;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { parseInput } from "./command-parser";
|
|||
import { resolveRollPayload } from "./types/roll";
|
||||
import { resolveSparkPayload } from "./types/spark";
|
||||
import { evaluateExpression, expressionIsTag } from "./variable-expression";
|
||||
import { computeCascade } from "./var-reactivity";
|
||||
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -140,7 +140,9 @@ function dispatchSet(
|
|||
}
|
||||
|
||||
const key = p.key;
|
||||
const oldValue = ctx.variables[key] ?? undefined;
|
||||
// Use getCombined with stream fallback for old value so tag transitions
|
||||
// are detected correctly even when the variable has active mods.
|
||||
const oldValue = getCombined(key, ctx.variables);
|
||||
let newValue: string;
|
||||
|
||||
try {
|
||||
|
|
@ -152,11 +154,12 @@ function dispatchSet(
|
|||
// Bare tag value
|
||||
newValue = p.expr.trim();
|
||||
} else if (p.expr) {
|
||||
// Numeric expression — evaluate
|
||||
// Numeric expression — evaluate using combined values
|
||||
const result = evaluateExpression(p.expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return ctx.variables[k] ?? undefined;
|
||||
// Use getCombined with stream fallback for accurate values
|
||||
return k === key ? undefined : getCombined(k, ctx.variables);
|
||||
},
|
||||
});
|
||||
newValue = String(result.value);
|
||||
|
|
@ -170,12 +173,11 @@ function dispatchSet(
|
|||
};
|
||||
}
|
||||
|
||||
// Send the direct set
|
||||
const r1 = sendMessage("var", { action: "set", key, value: newValue });
|
||||
const u1 = unwrap(r1);
|
||||
if (!u1.ok) return u1;
|
||||
// Update base value so getCombined returns the correct new value
|
||||
// during cascade computation
|
||||
setBase(key, newValue);
|
||||
|
||||
// Build working variable store for cascade
|
||||
// Build working variable store for cascade (includes the new value)
|
||||
const workingVars = { ...ctx.variables, [key]: newValue };
|
||||
|
||||
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
||||
|
|
@ -189,6 +191,14 @@ function dispatchSet(
|
|||
console.warn("[dispatch] cascade error:", e);
|
||||
}
|
||||
|
||||
// Send the direct set last so cascade messages arrive first.
|
||||
// Use getCombined with stream fallback so the stream store receives the
|
||||
// correct value (base + active mods) rather than just the raw base.
|
||||
const combinedValue = getCombined(key, ctx.variables);
|
||||
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
|
||||
const u1 = unwrap(r1);
|
||||
if (!u1.ok) return u1;
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,57 +10,33 @@
|
|||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import Slugger from "github-slugger";
|
||||
import { extractHeadings } from "../../data-loader/toc";
|
||||
import {
|
||||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
} from "../../data-loader/file-index";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
} from "../../cli/completions/block-scanner";
|
||||
import {
|
||||
scanDirectives,
|
||||
} from "../../cli/completions/directive-scanner";
|
||||
import { parseDeclareCsv } from "./declare-parser";
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
import { scanDeclareBlocks } from "../../cli/completions/declare-parser";
|
||||
import type {
|
||||
CompletionsPayload,
|
||||
DiceCompletion,
|
||||
LinkCompletion,
|
||||
SparkTableCompletion,
|
||||
VarDeclaration,
|
||||
TagModifier,
|
||||
} from "../../cli/completions/types";
|
||||
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
||||
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
||||
|
||||
export type { VarDeclaration, TagModifier };
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
// Re-export CLI types so consumers don't need to know about the CLI path
|
||||
export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
|
||||
|
||||
export interface DiceCompletion {
|
||||
label: string;
|
||||
notation: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface LinkCompletion {
|
||||
path: string;
|
||||
label: string;
|
||||
section: string | null;
|
||||
}
|
||||
|
||||
export interface SparkTableCompletion {
|
||||
label: string;
|
||||
notation: string;
|
||||
slug: string;
|
||||
filePath: string;
|
||||
csvPath: string;
|
||||
headers: string[];
|
||||
remix: boolean;
|
||||
}
|
||||
|
||||
export interface JournalCompletions {
|
||||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
|
||||
export type JournalCompletions = CompletionsPayload;
|
||||
|
||||
export type CompletionsState =
|
||||
| { status: "loading" }
|
||||
|
|
@ -120,9 +96,6 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
const content = tempIndex[filePath];
|
||||
if (!content) continue;
|
||||
|
||||
// Fresh slugger per file
|
||||
const slugger = new Slugger();
|
||||
|
||||
// ---- Links (headings) - from original content ----
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||
|
|
@ -135,24 +108,10 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
});
|
||||
}
|
||||
|
||||
// ---- Unified block scanning (declare) ----
|
||||
FENCED_BLOCK_RE.lastIndex = 0;
|
||||
let blockMatch: RegExpExecArray | null;
|
||||
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
||||
const [, lang, infoString, body] = blockMatch;
|
||||
const attrs = parseBlockAttrs(infoString);
|
||||
attrs.lang = attrs.lang || lang;
|
||||
|
||||
if (attrs.role === "declare") {
|
||||
try {
|
||||
const result = parseDeclareCsv(body, filePath);
|
||||
declarations.push(...result.variables);
|
||||
tagModifiers.push(...result.tagModifiers);
|
||||
} catch (e) {
|
||||
console.warn(`[completions] ${filePath}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---- Declare block scanning (shared with CLI) ----
|
||||
const declareResult = scanDeclareBlocks(content, filePath);
|
||||
declarations.push(...declareResult.variables);
|
||||
tagModifiers.push(...declareResult.tagModifiers);
|
||||
|
||||
// ---- Directive scanning (dice + spark tables) ----
|
||||
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
||||
|
|
@ -209,12 +168,6 @@ export function ensureCompletions(): Promise<void> {
|
|||
return _initPromise;
|
||||
}
|
||||
|
||||
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
|
||||
let _invalidated = false;
|
||||
export function invalidateCompletions(): void {
|
||||
_invalidated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive hooks for the journal input.
|
||||
* Returns the current completions state + a convenience `data` extractor.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export { StreamMessageCard } from "./StreamMessage";
|
|||
export { ComposePanel } from "./ComposePanel";
|
||||
export { JournalInput } from "./JournalInput";
|
||||
export { DynamicForm } from "./DynamicForm";
|
||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||
export { useJournalCompletions } from "./completions";
|
||||
export { dispatchCommand } from "./command-dispatcher";
|
||||
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||
export { parseInput } from "./command-parser";
|
||||
|
|
@ -60,7 +60,7 @@ 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 { initReactivity, computeCascade, computeActiveTags, 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 { JournalContext, useJournalContext } from "./JournalContext";
|
||||
export type { JournalContextValue } from "./JournalContext";
|
||||
|
|
|
|||
|
|
@ -3,16 +3,17 @@
|
|||
* modifiers, then cascades changes when variables are set.
|
||||
*
|
||||
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||||
* When a variable changes, it:
|
||||
* 1. Sends the base var message
|
||||
* 2. Detects tag activation/deactivation diff
|
||||
* 3. Re-evaluates declared variables whose dependencies changed
|
||||
* 4. Applies/removes tag modifiers on affected targets
|
||||
* 5. Sends var messages for every derived change
|
||||
* Uses a base/mod separation:
|
||||
* - baseValues: what /set writes (or declaration evaluation produces)
|
||||
* - activeMods: per-target list of {tag, value, source} from tag activations
|
||||
* - sourceActivations: per-source list of {tag, target, value} for deactivation
|
||||
*
|
||||
* Circular dependency detection happens at registration time
|
||||
* (topological sort). At runtime, we guard against re-entrant
|
||||
* evaluation with an in-flight set.
|
||||
* Combined value = base + sum(activeMods). Tag values (starting with #)
|
||||
* are not numeric and don't receive mods.
|
||||
*
|
||||
* All functions that resolve variable values accept an explicit `fallback`
|
||||
* (the stream VariableStore) rather than relying on mutable module state.
|
||||
* This ensures correctness regardless of call order.
|
||||
*/
|
||||
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
|
|
@ -23,15 +24,19 @@ import { evaluateExpression } from "./variable-expression";
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VarReactivityState {
|
||||
/** All variable declarations (from role=declare blocks) */
|
||||
declarations: VarDeclaration[];
|
||||
/** All tag modifiers (from role=declare blocks) */
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/** The runtime variable store (read from stream state). */
|
||||
export type VariableStore = Record<string, string>;
|
||||
|
||||
/** A single modifier applied to a target variable. */
|
||||
export interface ActiveMod {
|
||||
tag: string; // "#warrior"
|
||||
value: number; // evaluated modifier expression result
|
||||
source: string; // "$class" — which variable activated this tag
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -42,9 +47,18 @@ let depGraph: Map<string, Set<string>> | null = null;
|
|||
/** Reverse: $declaredVar → its expression */
|
||||
let declExprs: Map<string, string> | null = null;
|
||||
|
||||
/** Tag modifiers: #tag → [{target, expression}] */
|
||||
/** Tag modifiers from declare blocks: #tag → [{target, expression}] */
|
||||
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
||||
|
||||
/** Base values set by /set or declaration evaluation */
|
||||
const baseValues = new Map<string, string>();
|
||||
|
||||
/** Active mods per target: $target → [{tag, value, source}] */
|
||||
const activeMods = new Map<string, ActiveMod[]>();
|
||||
|
||||
/** Activations per source: $source → [{tag, target, value}] */
|
||||
const sourceActivations = new Map<string, Array<{ tag: string; target: string; value: number }>>();
|
||||
|
||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
|
|
@ -62,6 +76,9 @@ export function initReactivity(state: VarReactivityState): void {
|
|||
depGraph = new Map();
|
||||
declExprs = new Map();
|
||||
tagModMap = new Map();
|
||||
baseValues.clear();
|
||||
activeMods.clear();
|
||||
sourceActivations.clear();
|
||||
|
||||
// Index tag modifiers
|
||||
for (const tm of state.tagModifiers) {
|
||||
|
|
@ -87,14 +104,12 @@ export function initReactivity(state: VarReactivityState): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Circular dependency check
|
||||
checkCircular();
|
||||
}
|
||||
|
||||
/** Extract $var names from an expression string. */
|
||||
export function extractDependencies(expr: string): string[] {
|
||||
const vars: string[] = [];
|
||||
// Match $identifier patterns (not inside function names, just bare $var)
|
||||
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(expr)) !== null) {
|
||||
|
|
@ -105,16 +120,81 @@ export function extractDependencies(expr: string): string[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Compute which declared variables need re-evaluation after
|
||||
* a set of base variables changed, and what tag modifier effects
|
||||
* to apply.
|
||||
* Get the combined value of a variable (base + sum of active mods).
|
||||
* Falls back to the provided stream store for variables not tracked locally.
|
||||
*/
|
||||
export function getCombined(key: string, fallback?: VariableStore): string {
|
||||
const base = baseValues.get(key);
|
||||
if (base !== undefined && isTagValue(base)) {
|
||||
return base; // tag values don't receive numeric mods
|
||||
}
|
||||
|
||||
const baseNum = base !== undefined ? parseFloat(base) : NaN;
|
||||
const mods = activeMods.get(key) ?? [];
|
||||
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
|
||||
|
||||
if (!isNaN(baseNum)) {
|
||||
return String(baseNum + modSum);
|
||||
}
|
||||
|
||||
// Fall back to stream store. The stream value is authoritative for
|
||||
// variables not tracked locally — it already includes any mods from
|
||||
// the sender's engine, so we return it as-is without adding local mods.
|
||||
const fb = fallback?.[key];
|
||||
if (fb !== undefined) return fb;
|
||||
|
||||
// No base, but has mods
|
||||
if (mods.length > 0) return String(modSum);
|
||||
|
||||
return "0";
|
||||
}
|
||||
|
||||
/** Get the active mods for a variable (for UI hover display). */
|
||||
export function getMods(key: string): ActiveMod[] {
|
||||
return activeMods.get(key) ?? [];
|
||||
}
|
||||
|
||||
/** Get the declaration expression for a variable, if any. */
|
||||
export function getDeclExpr(key: string): string | undefined {
|
||||
return declExprs?.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the base value for a variable. Called by dispatchSet before
|
||||
* computing the cascade, so getCombined returns the correct new value.
|
||||
*/
|
||||
export function setBase(key: string, value: string): void {
|
||||
baseValues.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild local reactivity state (baseValues, activeMods, sourceActivations)
|
||||
* from the hydrated stream variable store. Must be called after replayReducers
|
||||
* so that tag activations are correctly tracked for subsequent cascade
|
||||
* computations.
|
||||
*/
|
||||
export function rebuildReactivityFromStore(variables: VariableStore): void {
|
||||
if (!tagModMap) return;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (!baseValues.has(key)) {
|
||||
baseValues.set(key, value);
|
||||
}
|
||||
|
||||
const tag = isTagValue(value);
|
||||
if (tag && !sourceActivations.has(key)) {
|
||||
activateTagFromSource(key, tag, variables);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cascade effects after a variable change.
|
||||
*
|
||||
* Returns the list of { key, value } pairs to publish as var messages.
|
||||
* The caller is responsible for sending these via sendMessage.
|
||||
*
|
||||
* `changedVar` is the variable that was just set (e.g. "$con").
|
||||
* `oldValue` is its previous value (for detecting tag transitions).
|
||||
* `currentVars` is the full variable store.
|
||||
* @param changedVar - the variable that was just set (e.g. "$con")
|
||||
* @param oldValue - its previous combined value (for tag transition detection)
|
||||
* @param currentVars - the full stream variable store (as fallback)
|
||||
* @returns list of {key, value} pairs (combined values) to publish as var messages
|
||||
*/
|
||||
export function computeCascade(
|
||||
changedVar: string,
|
||||
|
|
@ -128,46 +208,33 @@ export function computeCascade(
|
|||
const results: Array<{ key: string; value: string }> = [];
|
||||
|
||||
// ---- Tag activation/deactivation ----
|
||||
const newTag = isTagValue(currentVars[changedVar]);
|
||||
const newValue = getCombined(changedVar, currentVars);
|
||||
const newTag = isTagValue(newValue);
|
||||
const oldTag = isTagValue(oldValue);
|
||||
|
||||
if (newTag !== oldTag) {
|
||||
// Deactivate old tag
|
||||
if (oldTag) {
|
||||
const removed = computeTagDeactivation(
|
||||
oldTag,
|
||||
currentVars,
|
||||
);
|
||||
const removed = deactivateTagFromSource(changedVar, oldTag, currentVars);
|
||||
for (const r of removed) {
|
||||
results.push(r);
|
||||
// Update currentVars in-place so subsequent steps see new values
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
|
||||
// Activate new tag
|
||||
if (newTag) {
|
||||
const added = computeTagActivation(
|
||||
newTag,
|
||||
currentVars,
|
||||
);
|
||||
const added = activateTagFromSource(changedVar, newTag, currentVars);
|
||||
for (const r of added) {
|
||||
results.push(r);
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Declaration re-evaluation ----
|
||||
const reevaluated = reevaluateDependents(
|
||||
changedVar,
|
||||
currentVars,
|
||||
);
|
||||
const reevaluated = reevaluateDependents(changedVar, currentVars);
|
||||
for (const r of reevaluated) {
|
||||
// Avoid re-sending the same key if it was already in tag results
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +244,6 @@ export function computeCascade(
|
|||
/**
|
||||
* Compute initial values for all declared variables.
|
||||
* Called once after initReactivity() to seed the store.
|
||||
* Unset dependencies default to 0.
|
||||
*/
|
||||
export function computeInitialValues(
|
||||
currentVars: VariableStore,
|
||||
|
|
@ -188,7 +254,6 @@ export function computeInitialValues(
|
|||
const sorted = topoSortAffected(new Set(allKeys));
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...currentVars };
|
||||
|
||||
for (const key of sorted) {
|
||||
const expr = declExprs.get(key);
|
||||
|
|
@ -198,16 +263,28 @@ export function computeInitialValues(
|
|||
const result = evaluateExpression(expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
return getCombined(k, currentVars);
|
||||
},
|
||||
});
|
||||
|
||||
const newValue = String(result.value);
|
||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
||||
const rawValue = String(result.value);
|
||||
baseValues.set(key, rawValue);
|
||||
|
||||
if (finalValue !== (localVars[key] ?? "")) {
|
||||
localVars[key] = finalValue;
|
||||
results.push({ key, value: finalValue });
|
||||
// Check if this is a tag value — if so, activate it
|
||||
const tag = isTagValue(rawValue);
|
||||
if (tag) {
|
||||
const added = activateTagFromSource(key, tag, currentVars);
|
||||
for (const r of added) {
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always emit the combined value for this key
|
||||
const combined = getCombined(key, currentVars);
|
||||
if (!results.some((x) => x.key === key)) {
|
||||
results.push({ key, value: combined });
|
||||
}
|
||||
} catch {
|
||||
// skip failed evaluations at init time
|
||||
|
|
@ -217,22 +294,8 @@ export function computeInitialValues(
|
|||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which tags are active given the current variable store.
|
||||
* A tag is active if any variable has that tag as its value.
|
||||
*/
|
||||
export function computeActiveTags(vars: VariableStore): string[] {
|
||||
const active: string[] = [];
|
||||
for (const value of Object.values(vars)) {
|
||||
if (isTagValue(value) && !active.includes(value)) {
|
||||
active.push(value);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// Tag activation / deactivation (source-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isTagValue(value: string | undefined): string | null {
|
||||
|
|
@ -241,13 +304,115 @@ function isTagValue(value: string | undefined): string | null {
|
|||
return trimmed.startsWith("#") ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the dependency graph from `changedVar` and re-evaluate all
|
||||
* affected declarations. Uses topological order.
|
||||
*/
|
||||
/** Activate a tag from a source variable. Evaluates modifiers once. */
|
||||
function activateTagFromSource(
|
||||
source: string,
|
||||
tag: string,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const sourceEntries: Array<{ tag: string; target: string; value: number }> = [];
|
||||
|
||||
for (const mod of mods) {
|
||||
try {
|
||||
// Snapshot the target's current combined value as its base before
|
||||
// adding the mod, so deactivation can restore it correctly.
|
||||
if (!baseValues.has(mod.target)) {
|
||||
baseValues.set(mod.target, getCombined(mod.target, fallback));
|
||||
}
|
||||
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return getCombined(k, fallback);
|
||||
},
|
||||
});
|
||||
|
||||
const entry: ActiveMod = { tag, value: result.value, source };
|
||||
|
||||
let targetMods = activeMods.get(mod.target);
|
||||
if (!targetMods) {
|
||||
targetMods = [];
|
||||
activeMods.set(mod.target, targetMods);
|
||||
}
|
||||
targetMods.push(entry);
|
||||
|
||||
sourceEntries.push({ tag, target: mod.target, value: result.value });
|
||||
|
||||
// Emit new combined value for the target
|
||||
const combined = getCombined(mod.target, fallback);
|
||||
const existing = results.findIndex((r) => r.key === mod.target);
|
||||
if (existing >= 0) {
|
||||
results[existing] = { key: mod.target, value: combined };
|
||||
} else {
|
||||
results.push({ key: mod.target, value: combined });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
}
|
||||
}
|
||||
|
||||
sourceActivations.set(source, sourceEntries);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Deactivate a tag from a source variable. Removes exactly the mods it created. */
|
||||
function deactivateTagFromSource(
|
||||
source: string,
|
||||
_tag: string,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
const entries = sourceActivations.get(source);
|
||||
if (!entries) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const affectedTargets = new Set<string>();
|
||||
|
||||
// Remove mods from activeMods
|
||||
for (const entry of entries) {
|
||||
const targetMods = activeMods.get(entry.target);
|
||||
if (!targetMods) continue;
|
||||
|
||||
const idx = targetMods.findIndex(
|
||||
(m) => m.tag === entry.tag && m.source === source,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
targetMods.splice(idx, 1);
|
||||
affectedTargets.add(entry.target);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty arrays
|
||||
for (const target of affectedTargets) {
|
||||
const mods = activeMods.get(target);
|
||||
if (mods && mods.length === 0) {
|
||||
activeMods.delete(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove source activations
|
||||
sourceActivations.delete(source);
|
||||
|
||||
// Emit new combined values for affected targets
|
||||
for (const target of affectedTargets) {
|
||||
results.push({ key: target, value: getCombined(target, fallback) });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Declaration re-evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function reevaluateDependents(
|
||||
changedVar: string,
|
||||
vars: VariableStore,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs) return [];
|
||||
|
||||
|
|
@ -261,21 +426,19 @@ function reevaluateDependents(
|
|||
for (const d of dependents) {
|
||||
if (!affected.has(d)) {
|
||||
affected.add(d);
|
||||
queue.push(d); // transitive: if C depends on B which depends on A...
|
||||
queue.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort: declarations that depend only on base vars go first
|
||||
const sorted = topoSortAffected(affected);
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...vars }; // working copy
|
||||
|
||||
for (const key of sorted) {
|
||||
if (inFlight.has(key)) {
|
||||
// Shouldn't happen (caught at init), but guard anyway
|
||||
throw new Error(`Circular dependency detected during evaluation of ${key}`);
|
||||
throw new Error(
|
||||
`Circular dependency detected during evaluation of ${key}`,
|
||||
);
|
||||
}
|
||||
inFlight.add(key);
|
||||
|
||||
|
|
@ -283,23 +446,49 @@ function reevaluateDependents(
|
|||
const expr = declExprs.get(key);
|
||||
if (!expr) continue;
|
||||
|
||||
// Evaluate with current localVars (which includes previously
|
||||
// re-evaluated dependents)
|
||||
const result = evaluateExpression(expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
return getCombined(k, fallback);
|
||||
},
|
||||
});
|
||||
|
||||
const newValue = String(result.value);
|
||||
const rawValue = String(result.value);
|
||||
|
||||
// Apply any active tag modifiers to this key
|
||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
||||
// Check for tag transition on this declared variable
|
||||
const oldCombined = getCombined(key, fallback);
|
||||
const oldTag = isTagValue(oldCombined);
|
||||
const newTag = isTagValue(rawValue);
|
||||
|
||||
if (finalValue !== localVars[key]) {
|
||||
localVars[key] = finalValue;
|
||||
results.push({ key, value: finalValue });
|
||||
if (oldTag !== newTag) {
|
||||
// Deactivate old tag
|
||||
if (oldTag) {
|
||||
const removed = deactivateTagFromSource(key, oldTag, fallback);
|
||||
for (const r of removed) {
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Activate new tag
|
||||
if (newTag) {
|
||||
baseValues.set(key, rawValue);
|
||||
const added = activateTagFromSource(key, newTag, fallback);
|
||||
for (const r of added) {
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update base value
|
||||
baseValues.set(key, rawValue);
|
||||
|
||||
// Emit combined value
|
||||
const combined = getCombined(key, fallback);
|
||||
if (!results.some((x) => x.key === key)) {
|
||||
results.push({ key, value: combined });
|
||||
}
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
|
|
@ -309,131 +498,10 @@ function reevaluateDependents(
|
|||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all active tag modifiers that target `key`.
|
||||
* Returns the modified value (base + sum of active modifiers).
|
||||
*/
|
||||
function applyTagModifiersTo(
|
||||
key: string,
|
||||
baseValue: string,
|
||||
vars: VariableStore,
|
||||
): string {
|
||||
if (!tagModMap) return baseValue;
|
||||
|
||||
const activeTags = computeActiveTags(vars);
|
||||
const baseNum = parseFloat(baseValue);
|
||||
if (isNaN(baseNum)) return baseValue; // non-numeric, can't apply modifiers
|
||||
|
||||
let modifierSum = 0;
|
||||
for (const tag of activeTags) {
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) continue;
|
||||
for (const mod of mods) {
|
||||
if (mod.target === key) {
|
||||
try {
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return vars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
modifierSum += result.value;
|
||||
} catch {
|
||||
// If modifier expression fails, skip it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(baseNum + modifierSum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effects of activating a tag: for each modifier,
|
||||
* evaluate and add to the target variable's current value.
|
||||
*/
|
||||
function computeTagActivation(
|
||||
tag: string,
|
||||
vars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...vars };
|
||||
|
||||
for (const mod of mods) {
|
||||
try {
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const currentBase = parseFloat(localVars[mod.target] ?? "0");
|
||||
if (!isNaN(currentBase)) {
|
||||
const newValue = String(currentBase + result.value);
|
||||
localVars[mod.target] = newValue;
|
||||
results.push({ key: mod.target, value: newValue });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effects of deactivating a tag: for each modifier,
|
||||
* subtract from the target variable's current value.
|
||||
*/
|
||||
function computeTagDeactivation(
|
||||
tag: string,
|
||||
vars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const localVars = { ...vars };
|
||||
|
||||
for (const mod of mods) {
|
||||
try {
|
||||
const result = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const currentValue = parseFloat(localVars[mod.target] ?? "0");
|
||||
if (!isNaN(currentValue)) {
|
||||
const newValue = String(currentValue - result.value);
|
||||
localVars[mod.target] = newValue;
|
||||
results.push({ key: mod.target, value: newValue });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort & circular check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Topologically sort a set of affected declaration keys so that
|
||||
* dependents are evaluated after their dependencies.
|
||||
*/
|
||||
function topoSortAffected(affected: Set<string>): string[] {
|
||||
if (!depGraph || !declExprs) return [...affected];
|
||||
|
||||
|
|
@ -444,12 +512,10 @@ function topoSortAffected(affected: Set<string>): string[] {
|
|||
function visit(key: string): void {
|
||||
if (visited.has(key)) return;
|
||||
if (temp.has(key)) {
|
||||
// This shouldn't happen if checkCircular passed, but guard
|
||||
throw new Error(`Circular dependency involving ${key}`);
|
||||
}
|
||||
temp.add(key);
|
||||
|
||||
// Visit dependencies first
|
||||
const expr = declExprs!.get(key);
|
||||
if (expr) {
|
||||
const deps = extractDependencies(expr);
|
||||
|
|
@ -472,10 +538,6 @@ function topoSortAffected(affected: Set<string>): string[] {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for circular dependencies in the full declaration graph.
|
||||
* Throws with a descriptive message if a cycle is found.
|
||||
*/
|
||||
function checkCircular(): void {
|
||||
if (!declExprs) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createSignal } from "solid-js";
|
|||
import * as Comlink from "comlink";
|
||||
import type { StreamMessage } from "../journal/registry";
|
||||
import { getMessageType, validatePayload } from "../journal/registry";
|
||||
import { rebuildReactivityFromStore } from "../journal/var-reactivity";
|
||||
import type { WorkerState, WorkerPatch, SessionManifest } from "../../workers/journal.worker";
|
||||
import type { JournalWorkerAPI } from "../../workers/journal.worker";
|
||||
import {
|
||||
|
|
@ -162,6 +163,9 @@ function handlePatch(patch: WorkerPatch): void {
|
|||
saveRole(patch.state.myRole);
|
||||
// Rebuild derived state (revealedPaths, variables) by replaying reducers
|
||||
replayReducers();
|
||||
// Rebuild local reactivity engine state so tag activations from
|
||||
// hydrated variables are tracked for subsequent cascade computations.
|
||||
rebuildReactivityFromStore(state.variables);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue