Compare commits
No commits in common. "a2d9605f4b453d38fd7b9b60b3421b2c08a013f9" and "86abf34c10bb4984d6d56e9aec726b349048e63a" have entirely different histories.
a2d9605f4b
...
86abf34c10
|
|
@ -48,7 +48,6 @@ export default defineConfig({
|
|||
"/content": "http://localhost:3000",
|
||||
"/.ttrpg": "http://localhost:3000",
|
||||
},
|
||||
historyApiFallback: true,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
DocDialog,
|
||||
DataSourceDialog,
|
||||
RevealManager,
|
||||
ReactiveVariableManager,
|
||||
ReactiveStatManager,
|
||||
CommandLinkManager,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
|
|
@ -158,7 +158,7 @@ const App: Component = () => {
|
|||
src={currentPath()}
|
||||
>
|
||||
<RevealManager />
|
||||
<ReactiveVariableManager />
|
||||
<ReactiveStatManager />
|
||||
<CommandLinkManager />
|
||||
</Article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -97,8 +97,8 @@ export function scanDirectory(dir: string): {
|
|||
} {
|
||||
const index: ContentIndex = {};
|
||||
const blocks: ProcessedBlocks = {
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
};
|
||||
const directiveResults: DirectiveScanResult[] = [];
|
||||
const mdFiles: { content: string; relPath: string }[] = [];
|
||||
|
|
@ -127,8 +127,8 @@ export function scanDirectory(dir: string): {
|
|||
if (entry.endsWith(".md")) {
|
||||
const result = processBlocks(content, normalizedRelPath, index);
|
||||
index[normalizedRelPath] = result.stripped;
|
||||
blocks.declarations.push(...result.blocks.declarations);
|
||||
blocks.tagModifiers.push(...result.blocks.tagModifiers);
|
||||
blocks.stats.push(...result.blocks.stats);
|
||||
blocks.statTemplates.push(...result.blocks.statTemplates);
|
||||
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
|
||||
} else {
|
||||
index[normalizedRelPath] = content;
|
||||
|
|
@ -310,23 +310,23 @@ export function createContentServer(
|
|||
): ContentServer {
|
||||
let contentIndex: ContentIndex = {};
|
||||
let collectedBlocks: ProcessedBlocks = {
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
};
|
||||
let directiveResults: DirectiveScanResult[] = [];
|
||||
let completionsIndex: CompletionsPayload = {
|
||||
dice: [],
|
||||
links: [],
|
||||
sparkTables: [],
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
};
|
||||
|
||||
/** 从当前内容索引和已收集的块重新扫描补全数据 */
|
||||
function recomputeCompletions(): void {
|
||||
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
|
||||
console.log(
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@
|
|||
|
||||
import { posix } from "path";
|
||||
import { createHash } from "crypto";
|
||||
import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js";
|
||||
import {
|
||||
parseStatYaml,
|
||||
parseStatCsv,
|
||||
parseTemplateCsv,
|
||||
parseStatModifiers,
|
||||
type StatDef,
|
||||
type StatTemplate,
|
||||
} from "./stat-parser.js";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
|
|
@ -27,8 +34,8 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessedBlocks {
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
}
|
||||
|
||||
export interface BlockResult {
|
||||
|
|
@ -55,7 +62,7 @@ function contentHash(body: string): string {
|
|||
*
|
||||
* - Strips/replaces blocks based on `as`
|
||||
* - Injects `role=file` bodies into the content index
|
||||
* - Collects declare blocks for completions
|
||||
* - Collects stat/template blocks for completions
|
||||
* - role=spark-table blocks are converted to :md-table directives
|
||||
* (spark table completions are collected later by the directive scanner)
|
||||
*/
|
||||
|
|
@ -67,8 +74,8 @@ export function processBlocks(
|
|||
const fileDir = posix.dirname(fileRelativePath);
|
||||
|
||||
const blocks: ProcessedBlocks = {
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
};
|
||||
|
||||
const stripped = content.replace(
|
||||
|
|
@ -85,16 +92,29 @@ export function processBlocks(
|
|||
|
||||
// ---- Dispatch by role ----
|
||||
|
||||
if (attrs.role === "declare") {
|
||||
try {
|
||||
const result = parseDeclareCsv(body, fileRelativePath);
|
||||
blocks.declarations.push(...result.variables);
|
||||
blocks.tagModifiers.push(...result.tagModifiers);
|
||||
} catch (e) {
|
||||
console.warn(`[block-processor] ${fileRelativePath}: ${e}`);
|
||||
if (attrs.role === "stat") {
|
||||
if (attrs.lang === "yaml" || attrs.lang === "yml") {
|
||||
blocks.stats.push(...parseStatYaml(body, fileRelativePath));
|
||||
} else if (attrs.lang === "csv") {
|
||||
blocks.stats.push(...parseStatCsv(body, fileRelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs.role === "stat-template") {
|
||||
const name = attrs.id || `_tpl_${contentHash(body)}`;
|
||||
blocks.statTemplates.push(
|
||||
parseTemplateCsv(body, fileRelativePath, name),
|
||||
);
|
||||
}
|
||||
|
||||
if (attrs.role === "stat-modifiers") {
|
||||
const id = attrs.id || `_mod_${contentHash(body)}`;
|
||||
const result = parseStatModifiers(body, fileRelativePath, id, "player", attrs.extra["label"]);
|
||||
blocks.stats.push(result.statDef);
|
||||
blocks.stats.push(...result.modifierDefs);
|
||||
blocks.statTemplates.push(result.template);
|
||||
}
|
||||
|
||||
if (attrs.role === "file") {
|
||||
const filename = attrs.id
|
||||
? `${attrs.id}.${attrs.lang || "txt"}`
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* Parses fenced code blocks with attributes:
|
||||
* ```lang id=xxx role=xxx as=xxx
|
||||
*
|
||||
* - `role` dispatches to content scanners (declare, spark-table)
|
||||
* - `role` dispatches to content scanners (stat, stat-template, spark-table)
|
||||
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice)
|
||||
* - `id` is used for cross-references (file paths)
|
||||
* - `id` is used for cross-references (template names, file paths)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
/**
|
||||
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
||||
*
|
||||
* Shared between CLI completions scanner and browser-side completions.
|
||||
*
|
||||
* Format:
|
||||
* tag,key,expr
|
||||
* ,$hp,$con*5+$mod_hp ← variable declaration
|
||||
* ,$ac,10+$dex ← variable declaration
|
||||
* #warrior,$mod_hp,20 ← tag modifier
|
||||
* #warrior,$mod_str,1 ← tag modifier
|
||||
*
|
||||
* When `tag` is empty: $key is a reactively computed variable.
|
||||
* When `tag` is present: when #tag is active, $key gets expr added to its base.
|
||||
*/
|
||||
|
||||
export interface VarDeclaration {
|
||||
key: string; // "$hp" (always starts with $)
|
||||
expression: string; // "$con*5+$mod_hp"
|
||||
}
|
||||
|
||||
export interface TagModifier {
|
||||
tag: string; // "#warrior"
|
||||
target: string; // "$mod_hp"
|
||||
expression: string; // "20"
|
||||
}
|
||||
|
||||
export interface DeclareResult {
|
||||
variables: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=declare block.
|
||||
*/
|
||||
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) 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") {
|
||||
throw new Error(
|
||||
`${source}: role=declare blocks must have headers "tag,key,expr". Got: ${headers.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
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() ?? "";
|
||||
|
||||
if (!key || !expr) {
|
||||
console.warn(`${source}:${i + 1}: skipping row with empty key or expr`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
// Tag modifier
|
||||
if (!tag.startsWith("#")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: tag must start with #, got "${tag}"`,
|
||||
);
|
||||
}
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
tagModifiers.push({ tag, target: key, expression: expr });
|
||||
} else {
|
||||
// Variable declaration
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`${source}:${i + 1}: key must start with $, got "${key}"`,
|
||||
);
|
||||
}
|
||||
variables.push({ key, expression: expr });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ export type {
|
|||
DiceCompletion,
|
||||
LinkCompletion,
|
||||
SparkTableCompletion,
|
||||
VarDeclaration,
|
||||
TagModifier,
|
||||
StatDef,
|
||||
StatTemplate,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
|
|
@ -40,7 +40,7 @@ export function scanCompletions(
|
|||
dice,
|
||||
links,
|
||||
sparkTables,
|
||||
declarations: blocks.declarations,
|
||||
tagModifiers: blocks.tagModifiers,
|
||||
stats: blocks.stats,
|
||||
statTemplates: blocks.statTemplates,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
/**
|
||||
* Shared stat block parser — used by both the CLI completions scanner and
|
||||
* the client-side completions scanner.
|
||||
*
|
||||
* Parses ```yaml role=stat and ```csv role=stat blocks from markdown content.
|
||||
*/
|
||||
|
||||
export interface StatDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "number" | "string" | "enum" | "modifier" | "derived" | "template";
|
||||
scope: "player" | "global";
|
||||
default?: string;
|
||||
target?: string;
|
||||
options?: string[];
|
||||
roll?: string;
|
||||
formula?: string;
|
||||
/** Name of a StatTemplate to use for template-type stats */
|
||||
template?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** A single row in a stat template table */
|
||||
export interface TemplateEntry {
|
||||
range: string;
|
||||
label: string;
|
||||
modifiers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** A named stat template (from ```csv role=stat-template file=xxx) */
|
||||
export interface StatTemplate {
|
||||
name: string;
|
||||
/** Dice notation from the first header, e.g. "1d10" */
|
||||
notation: string;
|
||||
entries: TemplateEntry[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseStatYaml(yaml: string, source: string): StatDef[] {
|
||||
const defs: StatDef[] = [];
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
let current: Record<string, string> | null = null;
|
||||
let collectingOptions = false;
|
||||
let options: string[] = [];
|
||||
|
||||
function flushCurrent() {
|
||||
if (!current || !current.key) return;
|
||||
const type = (current.type || "number") as StatDef["type"];
|
||||
const scope = (current.scope || "global") as StatDef["scope"];
|
||||
defs.push({
|
||||
key: current.key,
|
||||
label: current.label || current.key,
|
||||
type,
|
||||
scope,
|
||||
default: current.default,
|
||||
target: current.target,
|
||||
template: current.template,
|
||||
options:
|
||||
type === "enum" && options.length > 0
|
||||
? [...options]
|
||||
: current.options
|
||||
? current.options
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
roll: current.roll,
|
||||
formula: current.formula,
|
||||
source,
|
||||
});
|
||||
current = null;
|
||||
options = [];
|
||||
collectingOptions = false;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.startsWith("- ")) {
|
||||
flushCurrent();
|
||||
current = {};
|
||||
collectingOptions = false;
|
||||
const rest = trimmed.slice(2);
|
||||
const colonIdx = rest.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const k = rest.slice(0, colonIdx).trim();
|
||||
const v = rest.slice(colonIdx + 1).trim();
|
||||
current[k] = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current && trimmed.match(/^\w/)) {
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const k = trimmed.slice(0, colonIdx).trim();
|
||||
const v = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
if (k === "options") {
|
||||
if (v.startsWith("[") && v.endsWith("]")) {
|
||||
current[k] = v.slice(1, -1);
|
||||
} else {
|
||||
collectingOptions = true;
|
||||
options = [];
|
||||
}
|
||||
} else {
|
||||
current[k] = v;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collectingOptions && trimmed.startsWith("- ")) {
|
||||
options.push(trimmed.slice(2).trim());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
return defs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseStatCsv(csv: string, source: string): StatDef[] {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const idx = (name: string) => {
|
||||
const i = headers.indexOf(name);
|
||||
return i === -1 ? null : i;
|
||||
};
|
||||
|
||||
const defs: StatDef[] = [];
|
||||
|
||||
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 === 0) continue;
|
||||
|
||||
const get = (name: string) => {
|
||||
const colIdx = idx(name);
|
||||
if (colIdx === null || colIdx >= cols.length) return undefined;
|
||||
return cols[colIdx].trim() || undefined;
|
||||
};
|
||||
|
||||
const key = get("key");
|
||||
if (!key) continue;
|
||||
|
||||
const type = (get("type") || "number") as StatDef["type"];
|
||||
const scope = (get("scope") || "player") as StatDef["scope"];
|
||||
|
||||
let options: string[] | undefined;
|
||||
const optRaw = get("options");
|
||||
if (optRaw) {
|
||||
options = optRaw
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
defs.push({
|
||||
key,
|
||||
label: get("label") || key,
|
||||
type,
|
||||
scope,
|
||||
default: get("default"),
|
||||
roll: get("roll"),
|
||||
target: get("target"),
|
||||
template: get("template"),
|
||||
formula: get("formula"),
|
||||
options,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template CSV parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=stat-template block.
|
||||
*
|
||||
* The first header is the dice notation (e.g. "1d10").
|
||||
* The second header is "label".
|
||||
* Remaining headers are modifier keys (bare names, no prefix).
|
||||
*/
|
||||
export function parseTemplateCsv(
|
||||
csv: string,
|
||||
source: string,
|
||||
name: string,
|
||||
): StatTemplate {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) return { name, notation: "", entries: [], source };
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
if (headers.length < 2) return { name, notation: "", entries: [], source };
|
||||
|
||||
const notation = headers[0];
|
||||
const labelIdx = headers.indexOf("label");
|
||||
const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx);
|
||||
|
||||
const entries: TemplateEntry[] = [];
|
||||
|
||||
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 === 0) continue;
|
||||
|
||||
const range = cols[0]?.trim();
|
||||
if (!range) continue;
|
||||
|
||||
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
|
||||
|
||||
const modifiers: Record<string, string> = {};
|
||||
for (const mk of modKeys) {
|
||||
const mkIdx = headers.indexOf(mk);
|
||||
if (mkIdx !== -1 && mkIdx < cols.length) {
|
||||
const val = cols[mkIdx].trim();
|
||||
if (val) modifiers[mk] = val;
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ range, label, modifiers });
|
||||
}
|
||||
|
||||
return { name, notation, entries, source };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat-modifiers parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Result of parsing a stat-modifiers block. */
|
||||
export interface StatModifiersResult {
|
||||
/** The template stat def (type: template) */
|
||||
statDef: StatDef;
|
||||
/** Modifier stat defs (type: modifier, one per value column) */
|
||||
modifierDefs: StatDef[];
|
||||
/** The template table with prefixed modifier keys */
|
||||
template: StatTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=stat-modifiers block.
|
||||
*
|
||||
* Auto-generates a template stat + modifier stat defs from a single CSV.
|
||||
*
|
||||
* The first header is the dice notation (e.g. "1d10").
|
||||
* The second header is "label".
|
||||
* Remaining headers are bare target names (e.g. "mind", "heart").
|
||||
*
|
||||
* Generated modifier keys: {id}_{column} (e.g. "age_mind").
|
||||
* Template entries internally map: column → {id}_{column}.
|
||||
*/
|
||||
export function parseStatModifiers(
|
||||
csv: string,
|
||||
source: string,
|
||||
id: string,
|
||||
scope: "player" | "global" = "player",
|
||||
label?: string,
|
||||
): StatModifiersResult {
|
||||
const statLabel = label || id;
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
const emptyTemplate: StatTemplate = {
|
||||
name: id,
|
||||
notation: "",
|
||||
entries: [],
|
||||
source,
|
||||
};
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
statDef: { key: id, label: statLabel, type: "template", scope, template: id, source },
|
||||
modifierDefs: [],
|
||||
template: emptyTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
if (headers.length < 2) {
|
||||
return {
|
||||
statDef: { key: id, label: statLabel, type: "template", scope, template: id, source },
|
||||
modifierDefs: [],
|
||||
template: emptyTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
const notation = headers[0];
|
||||
const labelIdx = headers.indexOf("label");
|
||||
|
||||
// Bare column names (e.g. "mind", "heart") — these become targets
|
||||
const bareColumns = headers.filter((h, i) => i !== 0 && i !== labelIdx);
|
||||
|
||||
// Generate modifier stat defs: key = {id}_{column}, target = column
|
||||
const modifierDefs: StatDef[] = bareColumns.map((col) => ({
|
||||
key: `${id}_${col}`,
|
||||
label: col,
|
||||
type: "modifier" as const,
|
||||
scope,
|
||||
target: col,
|
||||
source,
|
||||
}));
|
||||
|
||||
// Build template entries with prefixed modifier keys
|
||||
const entries: TemplateEntry[] = [];
|
||||
|
||||
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 === 0) continue;
|
||||
|
||||
const range = cols[0]?.trim();
|
||||
if (!range) continue;
|
||||
|
||||
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
|
||||
|
||||
const modifiers: Record<string, string> = {};
|
||||
for (const col of bareColumns) {
|
||||
const colIdx = headers.indexOf(col);
|
||||
if (colIdx !== -1 && colIdx < cols.length) {
|
||||
const val = cols[colIdx].trim();
|
||||
if (val) {
|
||||
modifiers[`${id}_${col}`] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ range, label, modifiers });
|
||||
}
|
||||
|
||||
const template: StatTemplate = { name: id, notation, entries, source };
|
||||
|
||||
const statDef: StatDef = {
|
||||
key: id,
|
||||
label: statLabel,
|
||||
type: "template",
|
||||
scope,
|
||||
template: id,
|
||||
source,
|
||||
};
|
||||
|
||||
return { statDef, modifierDefs, template };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
* Completion source types — shared between CLI scanner and frontend consumer.
|
||||
*/
|
||||
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser.js";
|
||||
import type { StatDef, StatTemplate } from "./stat-parser.js";
|
||||
|
||||
export type { VarDeclaration, TagModifier };
|
||||
export type { StatDef, StatTemplate };
|
||||
|
||||
/** A single dice expression found in a markdown file */
|
||||
export interface DiceCompletion {
|
||||
|
|
@ -48,8 +48,8 @@ export interface CompletionsPayload {
|
|||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ export const CommandLinkManager: Component = () => {
|
|||
myName: stream.myName,
|
||||
command,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
variables: stream.variables,
|
||||
declarations: comp.data.declarations,
|
||||
tagModifiers: comp.data.tagModifiers,
|
||||
statValues: stream.stats,
|
||||
statDefs: comp.data.stats,
|
||||
statTemplates: comp.data.statTemplates,
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
setDispatchError(result.error);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export { Article } from "./Article";
|
|||
export type { ArticleProps } from "./Article";
|
||||
export { RevealManager } from "./RevealManager";
|
||||
export { CommandLinkManager } from "./CommandLinkManager";
|
||||
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
|
||||
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
|
||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||
export type { SidebarProps } from "./Sidebar";
|
||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
|
|
|
|||
|
|
@ -43,19 +43,9 @@ function parseEntry(raw: string): JournalDocEntry | null {
|
|||
|
||||
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||
import journalSetRaw from "../doc-entries/journal-set.md";
|
||||
import journalRollRaw from "../doc-entries/journal-roll.md";
|
||||
import journalSparkRaw from "../doc-entries/journal-spark.md";
|
||||
import journalLinkRaw from "../doc-entries/journal-link.md";
|
||||
import journalStatRaw from "../doc-entries/journal-stat.md";
|
||||
|
||||
const rawDocuments: string[] = [
|
||||
journalGmRaw,
|
||||
journalPlayerRaw,
|
||||
journalSetRaw,
|
||||
journalRollRaw,
|
||||
journalSparkRaw,
|
||||
journalLinkRaw,
|
||||
];
|
||||
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw, journalStatRaw];
|
||||
|
||||
let _entries: JournalDocEntry[] | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export const JournalInput: Component = () => {
|
|||
|
||||
setDispatchError(null);
|
||||
|
||||
// Players: chat + set commands only
|
||||
// Players: chat + stat commands only
|
||||
if (isPlayer()) {
|
||||
if (parsed.type === "chat") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
|
|
@ -94,13 +94,13 @@ export const JournalInput: Component = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (parsed.type !== "set" && parsed.type !== "rolltag") {
|
||||
setDispatchError("玩家只能发送聊天消息或使用 /set 命令");
|
||||
if (parsed.type !== "stat") {
|
||||
setDispatchError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// GM: all commands, Player: set commands
|
||||
// GM: all commands, Player: stat commands
|
||||
setSending(true);
|
||||
|
||||
await dispatchCommand({
|
||||
|
|
@ -108,9 +108,9 @@ export const JournalInput: Component = () => {
|
|||
myName: stream.myName,
|
||||
command: raw,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
variables: stream.variables,
|
||||
declarations: comp.data.declarations,
|
||||
tagModifiers: comp.data.tagModifiers,
|
||||
statValues: stream.stats,
|
||||
statDefs: comp.data.stats,
|
||||
statTemplates: comp.data.statTemplates,
|
||||
});
|
||||
|
||||
// dispatchCommand already set the shared error if it failed.
|
||||
|
|
@ -181,7 +181,7 @@ export const JournalInput: Component = () => {
|
|||
return;
|
||||
}
|
||||
const raw = text();
|
||||
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
||||
e.preventDefault();
|
||||
openCompletions();
|
||||
return;
|
||||
|
|
@ -265,8 +265,8 @@ export const JournalInput: Component = () => {
|
|||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isGm()
|
||||
? "输入消息,或使用 /roll、/spark、/link、/set 命令..."
|
||||
: "输入消息,或使用 /set 命令..."
|
||||
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
|
||||
: "输入消息,或使用 /stat 命令..."
|
||||
}
|
||||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { JournalHeader } from "./JournalHeader";
|
|||
import { ConnectDialog } from "./ConnectDialog";
|
||||
import { InviteDialog } from "./InviteDialog";
|
||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||
import { VariableView } from "./VariableView";
|
||||
import { StatsView } from "./StatsView";
|
||||
|
||||
export interface JournalPanelProps {
|
||||
open: boolean;
|
||||
|
|
@ -26,7 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
const stream = useJournalStream();
|
||||
const [showInvite, setShowInvite] = createSignal(false);
|
||||
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
||||
const [viewMode, setViewMode] = createSignal<"stream" | "stats">("stream");
|
||||
|
||||
// Player list (exclude gm and observer)
|
||||
const playerEntries = () =>
|
||||
|
|
@ -99,18 +99,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
消息流
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("variables")}
|
||||
onClick={() => setViewMode("stats")}
|
||||
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||
viewMode() === "variables"
|
||||
viewMode() === "stats"
|
||||
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
变量
|
||||
属性
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={viewMode() === "stream"} fallback={<VariableView />}>
|
||||
<Show when={viewMode() === "stream"} fallback={<StatsView />}>
|
||||
<StreamView />
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,61 @@
|
|||
/**
|
||||
* ReactiveVariableManager — mounts as a child of <Article> and reactively
|
||||
* replaces {{$key}} template patterns in the rendered markdown DOM with
|
||||
* live variable values from the journal stream.
|
||||
* ReactiveStatManager — mounts as a child of <Article> and reactively
|
||||
* replaces ${key} template patterns in the rendered markdown DOM with
|
||||
* live stat values from the journal stream.
|
||||
*
|
||||
* On mount, walks all text nodes in the article content looking for
|
||||
* {{$key}} placeholders. Each match is wrapped in a <span> with a
|
||||
* data-reactive-var attribute so that subsequent updates (driven by
|
||||
* ${key} placeholders. Each match is wrapped in a <span> with a
|
||||
* data-reactive-stat attribute so that subsequent updates (driven by
|
||||
* a Solid effect) only touch those spans.
|
||||
*/
|
||||
|
||||
import { Component, createEffect, onCleanup } from "solid-js";
|
||||
import { Component, createEffect, onCleanup, createMemo } from "solid-js";
|
||||
import { useArticleDom } from "../Article";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "../journal/completions";
|
||||
import { fullKey } from "../journal/stat-helpers";
|
||||
import type { StatDef } from "../journal/completions";
|
||||
|
||||
const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
||||
const TEMPLATE_RE = /\$\{(\w+)\}/g;
|
||||
|
||||
/** Tag name used for marker spans — must stay in sync with the scan pass. */
|
||||
const MARKER = "data-reactive-var";
|
||||
const MARKER = "data-reactive-stat";
|
||||
|
||||
export const ReactiveVariableManager: Component = () => {
|
||||
export const ReactiveStatManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const values = () => stream.variables;
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
|
||||
// ---- Initial scan: wrap {{$key}} text in marker spans ----
|
||||
/** Build a lookup: bare key -> StatDef (for scope-aware resolution) */
|
||||
const bareDefMap = createMemo(() => {
|
||||
const map = new Map<string, StatDef>();
|
||||
for (const def of statDefs()) {
|
||||
if (!map.has(def.key)) {
|
||||
map.set(def.key, def);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a bare key from markdown template to its runtime value.
|
||||
* Uses StatDef scoping when available, falls back to player-first, global-second.
|
||||
*/
|
||||
function resolveBareKey(bareKey: string): string {
|
||||
const bareDef = bareDefMap().get(bareKey);
|
||||
if (bareDef) {
|
||||
const fk = fullKey(bareDef, stream.myName);
|
||||
return values()[fk] ?? bareDef.default ?? "";
|
||||
}
|
||||
const pk = `${stream.myName}:${bareKey}`;
|
||||
if (values()[pk] !== undefined) return values()[pk];
|
||||
return values()[bareKey] ?? "";
|
||||
}
|
||||
|
||||
// ---- Initial scan: wrap ${key} text in marker spans ----
|
||||
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
|
|
@ -42,7 +73,7 @@ export const ReactiveVariableManager: Component = () => {
|
|||
if (dom) unwrapAll(dom);
|
||||
});
|
||||
|
||||
// ---- Reactive updates: whenever variable values change, update all spans ----
|
||||
// ---- Reactive updates: whenever stat values change, update all spans ----
|
||||
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
|
|
@ -57,9 +88,7 @@ export const ReactiveVariableManager: Component = () => {
|
|||
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);
|
||||
result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1]));
|
||||
}
|
||||
if (span.textContent !== result) {
|
||||
span.textContent = result;
|
||||
|
|
@ -75,8 +104,8 @@ export const ReactiveVariableManager: Component = () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk all text nodes in a subtree and wrap `{{$key}}` patterns in
|
||||
* `<span data-reactive-var="originalText">` elements.
|
||||
* Walk all text nodes in a subtree and wrap `${key}` patterns in
|
||||
* `<span data-reactive-stat="originalText">` elements.
|
||||
*/
|
||||
function scanAndWrap(root: Element): void {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
|
|
@ -90,7 +119,7 @@ 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} and wrap each key span
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
|
@ -151,4 +180,4 @@ function escapeAttr(s: string): string {
|
|||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export default ReactiveVariableManager;
|
||||
export default ReactiveStatManager;
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* StatsView — table view of all stat key-value pairs.
|
||||
*
|
||||
* Groups stats by scope (global, then per-player). Shows computed values
|
||||
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
|
||||
*/
|
||||
|
||||
import { Component, For, createMemo, Show } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { fullKey, modifierLabel } from "./stat-helpers";
|
||||
import type { StatDef } from "./completions";
|
||||
|
||||
export const StatsView: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
|
||||
/** Build a lookup: fullKey -> StatDef */
|
||||
const defMap = createMemo(() => {
|
||||
const map = new Map<string, StatDef>();
|
||||
for (const def of statDefs()) {
|
||||
const fk = fullKey(def, stream.myName);
|
||||
map.set(fk, def);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Compute the effective value of a stat (base + modifiers) */
|
||||
function computedValue(key: string): string {
|
||||
const def = defMap().get(key);
|
||||
if (!def) return values()[key] ?? "";
|
||||
|
||||
const base = values()[key] ?? def.default ?? "";
|
||||
const baseNum = parseFloat(base);
|
||||
|
||||
if (def.type === "number" || def.type === "derived") {
|
||||
// Sum modifiers that target this key (fullKey matching)
|
||||
const modKeys: string[] = [];
|
||||
for (const [fk, d] of defMap()) {
|
||||
if (
|
||||
d.type === "modifier" &&
|
||||
d.target &&
|
||||
fullKeyTarget(d.target, d, def)
|
||||
) {
|
||||
modKeys.push(fk);
|
||||
}
|
||||
}
|
||||
let total = isNaN(baseNum) ? 0 : baseNum;
|
||||
for (const mk of modKeys) {
|
||||
const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0");
|
||||
if (!isNaN(mv)) total += mv;
|
||||
}
|
||||
return String(total);
|
||||
}
|
||||
|
||||
return base || "—";
|
||||
}
|
||||
|
||||
/** Check if a modifier's target matches a given def (scoped correctly). */
|
||||
function fullKeyTarget(
|
||||
target: string,
|
||||
modDef: StatDef,
|
||||
targetDef: StatDef,
|
||||
): boolean {
|
||||
if (modDef.scope === targetDef.scope) {
|
||||
return (
|
||||
fullKey({ ...modDef, key: target }, stream.myName) ===
|
||||
fullKey(targetDef, stream.myName)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Group stats by scope */
|
||||
const groups = createMemo(() => {
|
||||
const result: {
|
||||
scope: string;
|
||||
label: string;
|
||||
entries: { fullKey: string; def: StatDef }[];
|
||||
}[] = [];
|
||||
const globalEntries: { fullKey: string; def: StatDef }[] = [];
|
||||
const playerEntries: { fullKey: string; def: StatDef }[] = [];
|
||||
|
||||
for (const def of statDefs()) {
|
||||
const fk = fullKey(def, stream.myName);
|
||||
if (def.scope === "global") {
|
||||
globalEntries.push({ fullKey: fk, def });
|
||||
} else {
|
||||
playerEntries.push({ fullKey: fk, def });
|
||||
}
|
||||
}
|
||||
|
||||
if (globalEntries.length > 0) {
|
||||
result.push({ scope: "global", label: "全局", entries: globalEntries });
|
||||
}
|
||||
|
||||
if (playerEntries.length > 0) {
|
||||
result.push({
|
||||
scope: "player",
|
||||
label: `玩家 (${stream.myName})`,
|
||||
entries: playerEntries,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="h-full overflow-y-auto bg-gray-50">
|
||||
<Show
|
||||
when={statDefs().length > 0}
|
||||
fallback={
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<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">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={group.entries}>
|
||||
{({ fullKey: fk, def }) => {
|
||||
const val = () => values()[fk];
|
||||
const comp = () => computedValue(fk);
|
||||
const hasModifiers = () => {
|
||||
for (const [mfk, md] of defMap()) {
|
||||
if (
|
||||
md.type === "modifier" &&
|
||||
md.target &&
|
||||
fullKeyTarget(md.target, md, def)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const canRoll = () =>
|
||||
def.roll ||
|
||||
def.formula ||
|
||||
def.type === "enum" ||
|
||||
def.type === "template";
|
||||
|
||||
return (
|
||||
<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 text-gray-800 truncate">
|
||||
{modifierLabel(def, statDefs())}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
||||
{def.key}
|
||||
</span>
|
||||
<Show when={def.type === "modifier"}>
|
||||
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
||||
→{def.target}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show
|
||||
when={val() !== undefined}
|
||||
fallback={
|
||||
<span class="text-sm text-gray-300">
|
||||
{def.default ?? "—"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="text-sm font-mono text-gray-700">
|
||||
{val()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
hasModifiers() &&
|
||||
comp() !== (val() ?? def.default ?? "")
|
||||
}
|
||||
>
|
||||
<span class="text-xs text-gray-400">
|
||||
= {comp()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show when={canRoll()}>
|
||||
<button
|
||||
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
||||
title="掷骰"
|
||||
onClick={() => {
|
||||
const textarea =
|
||||
document.querySelector<HTMLTextAreaElement>(
|
||||
"#journal-input-textarea",
|
||||
);
|
||||
if (textarea) {
|
||||
const nativeInputValueSetter =
|
||||
Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(
|
||||
textarea,
|
||||
`/stat roll ${def.key}`,
|
||||
);
|
||||
textarea.dispatchEvent(
|
||||
new Event("input", { bubbles: true }),
|
||||
);
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/**
|
||||
* VariableView — table view of all variable key-value pairs and active tags.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Component, For, createMemo, Show } 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";
|
||||
|
||||
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>();
|
||||
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({
|
||||
key,
|
||||
value: val,
|
||||
expr: decl.expression,
|
||||
deps: extractDependencies(decl.expression),
|
||||
});
|
||||
} else {
|
||||
plain.push({ key, value: val });
|
||||
}
|
||||
}
|
||||
|
||||
return { declared, plain, tagVars };
|
||||
});
|
||||
|
||||
const hasAnyData = () =>
|
||||
declarations().length > 0 ||
|
||||
Object.keys(values()).length > 0 ||
|
||||
activeTags().length > 0;
|
||||
|
||||
return (
|
||||
<div class="h-full overflow-y-auto bg-gray-50">
|
||||
<Show
|
||||
when={hasAnyData()}
|
||||
fallback={
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
|
||||
</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}>
|
||||
{(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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,7 +23,7 @@ export function buildCompletions(
|
|||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
||||
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
||||
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
||||
{ label: "/set", kind: "command" as const, insertText: "/set " },
|
||||
{ label: "/stat", kind: "command" as const, insertText: "/stat " },
|
||||
];
|
||||
|
||||
// Show commands when user types / or starts typing a command name
|
||||
|
|
@ -33,7 +33,7 @@ export function buildCompletions(
|
|||
c.label.toLowerCase().startsWith(prefix),
|
||||
);
|
||||
if (!isGm) {
|
||||
matches = matches.filter((c) => c.label === "/set");
|
||||
matches = matches.filter((c) => c.label === "/stat");
|
||||
}
|
||||
return matches.length > 0
|
||||
? matches
|
||||
|
|
@ -101,43 +101,50 @@ export function buildCompletions(
|
|||
});
|
||||
}
|
||||
|
||||
// After /set — show known variable and tag names
|
||||
if (raw.startsWith("/set ")) {
|
||||
const rest = raw.slice("/set ".length).trim();
|
||||
// After /stat — show subcommands or stat keys
|
||||
if (raw.startsWith("/stat ")) {
|
||||
const rest = raw.slice("/stat ".length).trim();
|
||||
|
||||
// Already has a key? Show tag suggestions
|
||||
if (rest.includes(" ")) {
|
||||
const afterSpace = rest.slice(rest.indexOf(" ") + 1).toLowerCase();
|
||||
const tags = data.tagModifiers.map((tm) => tm.tag);
|
||||
// Also gather unique tags from declarations
|
||||
const uniqueTags = [...new Set(tags)];
|
||||
const matches = uniqueTags
|
||||
.filter((t) => t.toLowerCase().startsWith(afterSpace))
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0) {
|
||||
return [{ label: "输入表达式", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
return matches.map((t) => ({
|
||||
label: t,
|
||||
if (!rest || rest.length < 3) {
|
||||
const subs = ["set", "del", "roll"];
|
||||
const matches = subs.filter((s) => s.startsWith(rest.toLowerCase()));
|
||||
if (matches.length === 0)
|
||||
return [{ label: "set/del/roll", kind: "no-results", insertText: "" }];
|
||||
return matches.map((s) => ({
|
||||
label: `/stat ${s}`,
|
||||
kind: "value" as const,
|
||||
insertText: raw + t,
|
||||
insertText: `/stat ${s} `,
|
||||
}));
|
||||
}
|
||||
|
||||
// Show variable name suggestions
|
||||
const prefix = rest.toLowerCase();
|
||||
const varNames = data.declarations.map((d) => d.key);
|
||||
const matches = varNames
|
||||
.filter((v) => v.toLowerCase().startsWith(prefix))
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0) {
|
||||
return [{ label: "$variable", kind: "no-results", insertText: "" }];
|
||||
if (rest.startsWith("set ")) {
|
||||
const prefix = rest.slice("set ".length).toLowerCase();
|
||||
const matches = data.stats
|
||||
.filter((s) => s.key.toLowerCase().includes(prefix))
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0)
|
||||
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
|
||||
return matches.map((s) => ({
|
||||
label: `${s.key} (${s.label})`,
|
||||
kind: "value" as const,
|
||||
insertText: `/stat set ${s.key}=`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (rest.startsWith("del ") || rest.startsWith("roll ")) {
|
||||
const [cmd, ...restParts] = rest.split(" ");
|
||||
const prefix = restParts.join(" ").toLowerCase();
|
||||
const matches = data.stats
|
||||
.filter((s) => s.key.toLowerCase().startsWith(prefix))
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0)
|
||||
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
|
||||
return matches.map((s) => ({
|
||||
label: `${s.key} (${s.label})`,
|
||||
kind: "value" as const,
|
||||
insertText: `/stat ${cmd} ${s.key}`,
|
||||
}));
|
||||
}
|
||||
return matches.map((v) => ({
|
||||
label: v,
|
||||
kind: "value" as const,
|
||||
insertText: `/set ${v} `,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@ import { createSignal } from "solid-js";
|
|||
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 type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
import {
|
||||
resolveStatRoll,
|
||||
resolveTemplateSet,
|
||||
canModifyStat,
|
||||
fullKey,
|
||||
findStatDef,
|
||||
} from "./stat-helpers";
|
||||
import type { StatDef, StatTemplate } from "./completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result
|
||||
|
|
@ -43,12 +48,12 @@ export interface DispatchContext {
|
|||
command: string;
|
||||
/** Spark table lookup data (from completions) */
|
||||
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
|
||||
/** Current runtime variable values */
|
||||
variables: Record<string, string>;
|
||||
/** Variable declarations (from role=declare blocks) */
|
||||
declarations: VarDeclaration[];
|
||||
/** Tag modifiers (from role=declare blocks) */
|
||||
tagModifiers: TagModifier[];
|
||||
/** Current runtime stat values */
|
||||
statValues: Record<string, string>;
|
||||
/** Stat definitions */
|
||||
statDefs: StatDef[];
|
||||
/** Stat templates */
|
||||
statTemplates: StatTemplate[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,18 +76,18 @@ export async function dispatchCommand(
|
|||
const parsed = parseInput(prefixed);
|
||||
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
||||
|
||||
// Players: chat + set commands only
|
||||
// Players: chat + stat commands only
|
||||
if (ctx.role === "player") {
|
||||
if (parsed.type === "chat") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||
if (parsed.type === "stat") {
|
||||
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
||||
}
|
||||
|
||||
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
|
||||
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" });
|
||||
}
|
||||
|
||||
// GM: all commands
|
||||
|
|
@ -111,8 +116,8 @@ export async function dispatchCommand(
|
|||
}
|
||||
}
|
||||
|
||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||
if (parsed.type === "stat") {
|
||||
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
||||
}
|
||||
|
||||
const result = sendMessage(parsed.type, parsed.payload);
|
||||
|
|
@ -126,67 +131,76 @@ function finish(r: DispatchResult): DispatchResult {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Set dispatch
|
||||
// Stat dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dispatchSet(
|
||||
function dispatchStat(
|
||||
payload: Record<string, unknown>,
|
||||
ctx: DispatchContext,
|
||||
): DispatchResult {
|
||||
const p = payload as { key?: string; expr?: string; tags?: string[] };
|
||||
const p = payload as { action?: string; key?: string; value?: string };
|
||||
|
||||
if (!p.key) {
|
||||
return { ok: false, error: "缺少变量名" };
|
||||
if (p.action === "roll" && p.key) {
|
||||
const resolved = resolveStatRoll(
|
||||
p.key,
|
||||
ctx.statDefs,
|
||||
ctx.statValues,
|
||||
ctx.myName,
|
||||
ctx.statTemplates,
|
||||
);
|
||||
if (resolved.error) return { ok: false, error: resolved.error };
|
||||
|
||||
const result = sendMessage("stat", {
|
||||
action: "set",
|
||||
key: resolved.fullKey,
|
||||
value: resolved.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
if (!r.ok) return r;
|
||||
|
||||
if (resolved.modifiers) {
|
||||
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const key = p.key;
|
||||
const oldValue = ctx.variables[key] ?? undefined;
|
||||
let newValue: string;
|
||||
|
||||
try {
|
||||
if (p.tags && p.tags.length > 0) {
|
||||
// Rolltag: pick random tag
|
||||
const idx = Math.floor(Math.random() * p.tags.length);
|
||||
newValue = p.tags[idx];
|
||||
} else if (p.expr && expressionIsTag(p.expr)) {
|
||||
// Bare tag value
|
||||
newValue = p.expr.trim();
|
||||
} else if (p.expr) {
|
||||
// Numeric expression — evaluate
|
||||
const result = evaluateExpression(p.expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return ctx.variables[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
newValue = String(result.value);
|
||||
} else {
|
||||
return { ok: false, error: "缺少表达式" };
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "表达式求值失败",
|
||||
};
|
||||
if (!p.action || !p.key) {
|
||||
return { ok: false, error: "无效的 stat 命令" };
|
||||
}
|
||||
|
||||
// Send the direct set
|
||||
const r1 = sendMessage("var", { action: "set", key, value: newValue });
|
||||
const u1 = unwrap(r1);
|
||||
if (!u1.ok) return u1;
|
||||
const fk = resolveKey(p.key, ctx.statDefs, ctx.myName);
|
||||
|
||||
// Build working variable store for cascade
|
||||
const workingVars = { ...ctx.variables, [key]: newValue };
|
||||
if (!canModifyStat(ctx.role, ctx.myName, fk, ctx.statDefs)) {
|
||||
return { ok: false, error: `无权修改属性: ${p.key}` };
|
||||
}
|
||||
|
||||
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
||||
try {
|
||||
const cascade = computeCascade(key, oldValue, workingVars);
|
||||
for (const change of cascade) {
|
||||
sendMessage("var", { action: "set", key: change.key, value: change.value });
|
||||
const result = sendMessage("stat", {
|
||||
action: p.action,
|
||||
key: fk,
|
||||
value: p.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
if (!r.ok) return r;
|
||||
|
||||
if (p.action === "set" && p.value) {
|
||||
const def = findStatDef(fk, ctx.statDefs, ctx.myName);
|
||||
if (def) {
|
||||
const modifiers = resolveTemplateSet(
|
||||
def,
|
||||
p.value,
|
||||
ctx.statDefs,
|
||||
ctx.statValues,
|
||||
ctx.myName,
|
||||
ctx.statTemplates,
|
||||
);
|
||||
if (modifiers) {
|
||||
for (const [mk, mv] of Object.entries(modifiers)) {
|
||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cascade errors are non-fatal — the direct set already succeeded
|
||||
console.warn("[dispatch] cascade error:", e);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
|
|
@ -196,6 +210,18 @@ function dispatchSet(
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve a bare or full key to the actual runtime key. */
|
||||
function resolveKey(
|
||||
inputKey: string,
|
||||
statDefs: StatDef[],
|
||||
playerName: string,
|
||||
): string {
|
||||
if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey;
|
||||
const def = statDefs.find((d) => d.key === inputKey);
|
||||
if (def) return fullKey(def, playerName);
|
||||
return inputKey;
|
||||
}
|
||||
|
||||
/** Unwrap a sendMessage result into DispatchResult. */
|
||||
function unwrap<R>(
|
||||
r: { success: true; msg: R } | { success: false; error: string },
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface CompletionItem {
|
|||
}
|
||||
|
||||
export interface ParsedInput {
|
||||
type: "chat" | "roll" | "spark" | "link" | "set" | "rolltag";
|
||||
type: "chat" | "roll" | "spark" | "link" | "stat";
|
||||
payload: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -41,35 +41,41 @@ export function parseInput(raw: string): ParsedInput {
|
|||
return { type: "link", payload: { path, section } };
|
||||
}
|
||||
|
||||
if (raw.startsWith("/set ")) {
|
||||
const rest = raw.slice("/set ".length).trim();
|
||||
if (!rest)
|
||||
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||
if (raw.startsWith("/stat ")) {
|
||||
const arg = raw.slice("/stat ".length).trim();
|
||||
if (!arg)
|
||||
return { type: "stat", payload: {}, error: "需要 stat 命令 (set/del/roll)" };
|
||||
|
||||
// Split on first space to get key and expression
|
||||
const spaceIdx = rest.indexOf(" ");
|
||||
if (spaceIdx === -1)
|
||||
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||
|
||||
const key = rest.slice(0, spaceIdx).trim();
|
||||
const expr = rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
if (!key || !expr)
|
||||
return { type: "set", payload: {}, error: "key 和 expression 不能为空" };
|
||||
|
||||
if (!key.startsWith("$"))
|
||||
return { type: "set", payload: {}, error: "key 必须以 $ 开头" };
|
||||
|
||||
// Check if expr is a rolltag (e.g. #w|#d|#s)
|
||||
if (expr.includes("|") && expr.split("|").every((t) => t.trim().startsWith("#"))) {
|
||||
const tags = expr.split("|").map((t) => t.trim());
|
||||
return { type: "rolltag", payload: { key, tags } };
|
||||
if (arg.startsWith("set ")) {
|
||||
const rest = arg.slice("set ".length).trim();
|
||||
const eqIdx = rest.indexOf("=");
|
||||
if (eqIdx === -1)
|
||||
return { type: "stat", payload: {}, error: "格式: /stat set key=value" };
|
||||
const key = rest.slice(0, eqIdx).trim();
|
||||
const value = rest.slice(eqIdx + 1).trim();
|
||||
if (!key || !value)
|
||||
return { type: "stat", payload: {}, error: "key 和 value 不能为空" };
|
||||
return { type: "stat", payload: { action: "set", key, value } };
|
||||
}
|
||||
|
||||
return { type: "set", payload: { key, expr } };
|
||||
if (arg.startsWith("del ")) {
|
||||
const key = arg.slice("del ".length).trim();
|
||||
if (!key)
|
||||
return { type: "stat", payload: {}, error: "格式: /stat del key" };
|
||||
return { type: "stat", payload: { action: "del", key } };
|
||||
}
|
||||
|
||||
if (arg.startsWith("roll ")) {
|
||||
const key = arg.slice("roll ".length).trim();
|
||||
if (!key)
|
||||
return { type: "stat", payload: {}, error: "格式: /stat roll key" };
|
||||
return { type: "stat", payload: { action: "roll", key } };
|
||||
}
|
||||
|
||||
return { type: "stat", payload: {}, error: "未知 stat 子命令: set/del/roll" };
|
||||
}
|
||||
|
||||
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/set") {
|
||||
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat") {
|
||||
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||
*
|
||||
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
||||
* back to scanning the in-memory file index for dice expressions, headings,
|
||||
* and declare blocks.
|
||||
* back to scanning the in-memory file index for dice expressions and headings.
|
||||
*
|
||||
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
||||
* from any Solid component to reactively read the state.
|
||||
|
|
@ -16,6 +15,13 @@ import {
|
|||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
} from "../../data-loader/file-index";
|
||||
import {
|
||||
parseStatYaml,
|
||||
parseStatCsv,
|
||||
parseTemplateCsv,
|
||||
parseStatModifiers,
|
||||
} from "../../cli/completions/stat-parser";
|
||||
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
|
|
@ -23,12 +29,8 @@ import {
|
|||
import {
|
||||
scanDirectives,
|
||||
} from "../../cli/completions/directive-scanner";
|
||||
import { parseDeclareCsv } from "./declare-parser";
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
||||
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
||||
|
||||
export type { VarDeclaration, TagModifier };
|
||||
export type { StatDef, StatTemplate };
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
|
||||
|
|
@ -58,8 +60,8 @@ export interface JournalCompletions {
|
|||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
}
|
||||
|
||||
export type CompletionsState =
|
||||
|
|
@ -85,11 +87,9 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
|||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||
declarations: Array.isArray(data.declarations)
|
||||
? data.declarations
|
||||
: [],
|
||||
tagModifiers: Array.isArray(data.tagModifiers)
|
||||
? data.tagModifiers
|
||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||
statTemplates: Array.isArray(data.statTemplates)
|
||||
? data.statTemplates
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -104,8 +104,8 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
const dice: DiceCompletion[] = [];
|
||||
const links: LinkCompletion[] = [];
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
const declarations: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
const stats: StatDef[] = [];
|
||||
const statTemplates: StatTemplate[] = [];
|
||||
|
||||
// Build a temporary index for resolving CSV paths
|
||||
const tempIndex: Record<string, string> = {};
|
||||
|
|
@ -135,7 +135,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
});
|
||||
}
|
||||
|
||||
// ---- Unified block scanning (declare) ----
|
||||
// ---- Unified block scanning (stats) ----
|
||||
FENCED_BLOCK_RE.lastIndex = 0;
|
||||
let blockMatch: RegExpExecArray | null;
|
||||
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
||||
|
|
@ -143,15 +143,26 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
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}`);
|
||||
if (attrs.role === "stat") {
|
||||
if (attrs.lang === "yaml" || attrs.lang === "yml") {
|
||||
stats.push(...parseStatYaml(body, filePath));
|
||||
} else if (attrs.lang === "csv") {
|
||||
stats.push(...parseStatCsv(body, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs.role === "stat-template") {
|
||||
const name = attrs.id || `_tpl_${filePath}_${stats.length}`;
|
||||
statTemplates.push(parseTemplateCsv(body, filePath, name));
|
||||
}
|
||||
|
||||
if (attrs.role === "stat-modifiers") {
|
||||
const id = attrs.id || `_mod_${filePath}_${stats.length}`;
|
||||
const result = parseStatModifiers(body, filePath, id, "player", attrs.extra["label"]);
|
||||
stats.push(result.statDef);
|
||||
stats.push(...result.modifierDefs);
|
||||
statTemplates.push(result.template);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Directive scanning (dice + spark tables) ----
|
||||
|
|
@ -161,7 +172,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
sparkTables.push(...directiveResult.sparkTables);
|
||||
}
|
||||
|
||||
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||
return { dice, links, sparkTables, stats, statTemplates };
|
||||
}
|
||||
|
||||
// ------------------- Init (runs eagerly at import time) -------------------
|
||||
|
|
@ -172,10 +183,6 @@ const _initPromise: Promise<void> = (async () => {
|
|||
const serverData = await tryServer();
|
||||
if (serverData) {
|
||||
setCompletionsState({ status: "loaded", data: serverData });
|
||||
try {
|
||||
initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers });
|
||||
seedDeclaredVariables();
|
||||
} catch (e) { console.warn("[completions] reactivity init error:", e); }
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -184,10 +191,6 @@ const _initPromise: Promise<void> = (async () => {
|
|||
const data = await scanClientSide();
|
||||
if (data.dice.length > 0 || data.links.length > 0) {
|
||||
setCompletionsState({ status: "loaded", data });
|
||||
try {
|
||||
initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers });
|
||||
seedDeclaredVariables();
|
||||
} catch (e) { console.warn("[completions] reactivity init error:", e); }
|
||||
} else {
|
||||
setCompletionsState({ status: "empty" });
|
||||
}
|
||||
|
|
@ -213,6 +216,8 @@ export function ensureCompletions(): Promise<void> {
|
|||
let _invalidated = false;
|
||||
export function invalidateCompletions(): void {
|
||||
_invalidated = true;
|
||||
// On next import (page reload), the module will re-init.
|
||||
// For a runtime invalidation, you could call init again.
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -233,19 +238,8 @@ export function useJournalCompletions(): {
|
|||
dice: [],
|
||||
links: [],
|
||||
sparkTables: [],
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed declared variables into the store on load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seedDeclaredVariables(): void {
|
||||
const initial = computeInitialValues(journalStreamState.variables);
|
||||
for (const { key, value } of initial) {
|
||||
sendMessage("var", { action: "set", key, value });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
/**
|
||||
* Declare parser re-export — delegates to the shared parser in cli/completions.
|
||||
*/
|
||||
|
||||
export {
|
||||
parseDeclareCsv,
|
||||
type VarDeclaration,
|
||||
type TagModifier,
|
||||
type DeclareResult,
|
||||
} from "../../cli/completions/declare-parser";
|
||||
|
|
@ -51,16 +51,5 @@ export { DynamicForm } from "./DynamicForm";
|
|||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||
export { dispatchCommand } from "./command-dispatcher";
|
||||
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||
export { parseInput } from "./command-parser";
|
||||
export type { ParsedInput, CompletionItem } from "./command-parser";
|
||||
export { buildCompletions } from "./command-completions";
|
||||
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 { initReactivity, computeCascade, computeActiveTags, extractDependencies, computeInitialValues } from "./var-reactivity";
|
||||
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||
export type { JournalContextValue } from "./JournalContext";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Minimal expression evaluator for derived stat formulas.
|
||||
*
|
||||
* Supports:
|
||||
* - Stat references: any identifier resolves to a number from the lookup
|
||||
* - Arithmetic: + - * /
|
||||
* - Functions: floor(x), ceil(x), round(x)
|
||||
* - Parentheses for grouping
|
||||
*/
|
||||
|
||||
export type StatLookup = (key: string) => number;
|
||||
|
||||
/** Evaluate a formula string against a stat lookup function. */
|
||||
export function evaluateFormula(formula: string, lookup: StatLookup): number {
|
||||
const tokens = tokenize(formula);
|
||||
const result = parseExpression(tokens, 0, lookup);
|
||||
if (result.next < tokens.length) {
|
||||
throw new Error(`Unexpected token at position ${result.next}: "${tokens[result.next]}"`);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Token =
|
||||
| { kind: "number"; value: number }
|
||||
| { kind: "ident"; value: string }
|
||||
| { kind: "op"; value: string }
|
||||
| { kind: "lparen" }
|
||||
| { kind: "rparen" }
|
||||
| { kind: "comma" };
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
// Whitespace
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number (integer or decimal)
|
||||
if (/[0-9]/.test(ch)) {
|
||||
let num = "";
|
||||
while (i < input.length && /[0-9.]/.test(input[i])) {
|
||||
num += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "number", value: parseFloat(num) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier or function name
|
||||
if (/[a-zA-Z_]/.test(ch)) {
|
||||
let ident = "";
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "ident", value: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators and punctuation
|
||||
switch (ch) {
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
tokens.push({ kind: "op", value: ch });
|
||||
i++;
|
||||
break;
|
||||
case "(":
|
||||
tokens.push({ kind: "lparen" });
|
||||
i++;
|
||||
break;
|
||||
case ")":
|
||||
tokens.push({ kind: "rparen" });
|
||||
i++;
|
||||
break;
|
||||
case ",":
|
||||
tokens.push({ kind: "comma" });
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected character: "${ch}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recursive descent parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ParseResult {
|
||||
value: number;
|
||||
next: number; // index of next unconsumed token
|
||||
}
|
||||
|
||||
/** expression := term (("+" | "-") term)* */
|
||||
function parseExpression(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
|
||||
let result = parseTerm(tokens, pos, lookup);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tok = tokens[pos];
|
||||
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
||||
const right = parseTerm(tokens, pos + 1, lookup);
|
||||
if (tok.value === "+") {
|
||||
result = { value: result.value + right.value, next: right.next };
|
||||
} else {
|
||||
result = { value: result.value - right.value, next: right.next };
|
||||
}
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** term := factor (("*" | "/") factor)* */
|
||||
function parseTerm(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
|
||||
let result = parseFactor(tokens, pos, lookup);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tok = tokens[pos];
|
||||
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
||||
const right = parseFactor(tokens, pos + 1, lookup);
|
||||
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 };
|
||||
}
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** factor := number | ident ["(" expression ")"] | "(" expression ")" | "-" factor */
|
||||
function parseFactor(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
|
||||
if (pos >= tokens.length) {
|
||||
throw new Error("Unexpected end of formula");
|
||||
}
|
||||
|
||||
const tok = tokens[pos];
|
||||
|
||||
// Unary minus
|
||||
if (tok.kind === "op" && tok.value === "-") {
|
||||
const inner = parseFactor(tokens, pos + 1, lookup);
|
||||
return { value: -inner.value, next: inner.next };
|
||||
}
|
||||
|
||||
// Number literal
|
||||
if (tok.kind === "number") {
|
||||
return { value: tok.value, next: pos + 1 };
|
||||
}
|
||||
|
||||
// Parenthesized expression
|
||||
if (tok.kind === "lparen") {
|
||||
const inner = parseExpression(tokens, pos + 1, lookup);
|
||||
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
|
||||
throw new Error("Missing closing parenthesis");
|
||||
}
|
||||
return { value: inner.value, next: inner.next + 1 };
|
||||
}
|
||||
|
||||
// Identifier (stat reference or function call)
|
||||
if (tok.kind === "ident") {
|
||||
const name = tok.value;
|
||||
|
||||
// Check for function call: ident "(" ...
|
||||
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
|
||||
const arg = parseExpression(tokens, pos + 2, lookup);
|
||||
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
|
||||
throw new Error(`Missing closing parenthesis after ${name}(...)`);
|
||||
}
|
||||
const value = applyFunction(name, arg.value);
|
||||
return { value, next: arg.next + 1 };
|
||||
}
|
||||
|
||||
// Plain stat reference
|
||||
const value = lookup(name);
|
||||
return { value, next: pos + 1 };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: "${JSON.stringify(tok)}"`);
|
||||
}
|
||||
|
||||
function applyFunction(name: string, arg: number): number {
|
||||
switch (name.toLowerCase()) {
|
||||
case "floor":
|
||||
return Math.floor(arg);
|
||||
case "ceil":
|
||||
return Math.ceil(arg);
|
||||
case "round":
|
||||
return Math.round(arg);
|
||||
default:
|
||||
throw new Error(`Unknown function: ${name}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* Stat command helpers — stat resolution, permission checking, and
|
||||
* dice-formula stat-reference expansion.
|
||||
*
|
||||
* Extracted from JournalInput to keep that component focused on UI.
|
||||
*/
|
||||
|
||||
import { rollFormula } from "../md-commander/hooks";
|
||||
import { evaluateFormula } from "./stat-formula";
|
||||
import type { StatDef, StatTemplate } from "./completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get the full runtime key for a stat def, given a player name. */
|
||||
export function fullKey(def: StatDef, playerName: string): string {
|
||||
return def.scope === "player" ? `${playerName}:${def.key}` : def.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display label for a modifier stat.
|
||||
*
|
||||
* If the key follows the `{parent}_{target}` convention (from stat-modifiers),
|
||||
* returns `{parent_label}/{target_label}` by looking up both defs.
|
||||
* Otherwise falls back to the def's own label.
|
||||
*/
|
||||
export function modifierLabel(
|
||||
def: StatDef,
|
||||
statDefs: StatDef[],
|
||||
): string {
|
||||
if (def.type !== "modifier") return def.label;
|
||||
|
||||
// Try to split key as parent_target
|
||||
const lastUnderscore = def.key.lastIndexOf("_");
|
||||
if (lastUnderscore === -1) return def.label;
|
||||
|
||||
const parentKey = def.key.slice(0, lastUnderscore);
|
||||
const targetKey = def.key.slice(lastUnderscore + 1);
|
||||
|
||||
const parentDef = statDefs.find((d) => d.key === parentKey);
|
||||
const targetDef = statDefs.find((d) => d.key === targetKey);
|
||||
|
||||
if (parentDef && targetDef) {
|
||||
return `${parentDef.label}/${targetDef.label}`;
|
||||
}
|
||||
|
||||
return def.label;
|
||||
}
|
||||
|
||||
/** Find a stat def by its full runtime key. */
|
||||
export function findStatDef(
|
||||
fk: string,
|
||||
statDefs: StatDef[],
|
||||
playerName: string,
|
||||
): StatDef | undefined {
|
||||
return statDefs.find((d) => fullKey(d, playerName) === fk);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula stat reference resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace stat key references in a dice formula (e.g. "1d20 + attack")
|
||||
* with their numeric values, so the result can be passed to rollFormula.
|
||||
*
|
||||
* Bare keys in formulas resolve relative to the caller's scope: if the
|
||||
* caller def is `scope: player`, then `attack` resolves to `alice:attack`.
|
||||
*/
|
||||
export function resolveStatRefs(
|
||||
formula: string,
|
||||
lookup: (key: string) => number,
|
||||
): string {
|
||||
return formula.replace(/[a-zA-Z_]\w*/g, (match) => {
|
||||
if (/^d\d/i.test(match)) return match;
|
||||
if (/^[kdh]\d/i.test(match)) return match;
|
||||
const val = lookup(match);
|
||||
return String(val);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stat lookup function that resolves bare keys to numbers.
|
||||
* Bare keys are scoped by `playerName` if the originating def is player-scoped.
|
||||
*/
|
||||
export function makeStatLookup(
|
||||
runtimeStats: Record<string, string>,
|
||||
statDefs: StatDef[],
|
||||
playerName: string,
|
||||
/** The def that references are being resolved for (for scoping bare keys). */
|
||||
callerDef?: StatDef,
|
||||
): (bareKey: string) => number {
|
||||
return (bareKey: string): number => {
|
||||
// Try as a full key first, then try scoped
|
||||
const candidates = [bareKey];
|
||||
if (callerDef && callerDef.scope === "player") {
|
||||
candidates.push(`${playerName}:${bareKey}`);
|
||||
}
|
||||
|
||||
for (const k of candidates) {
|
||||
const val = runtimeStats[k];
|
||||
if (val !== undefined) {
|
||||
const n = parseFloat(val);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
const sdef = statDefs.find((d) => fullKey(d, playerName) === k);
|
||||
if (sdef?.default !== undefined) {
|
||||
const n = parseFloat(sdef.default);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check whether a role can modify a given stat key. */
|
||||
export function canModifyStat(
|
||||
role: string,
|
||||
myName: string,
|
||||
fullKey: string,
|
||||
statDefs: StatDef[],
|
||||
): boolean {
|
||||
if (role === "gm") return true;
|
||||
if (role === "observer") return false;
|
||||
|
||||
const def = findStatDef(fullKey, statDefs, myName);
|
||||
if (!def) return false;
|
||||
if (def.scope === "player") {
|
||||
return fullKey.startsWith(myName + ":");
|
||||
}
|
||||
// Global stats: player can't modify
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roll resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StatRollResult {
|
||||
fullKey: string;
|
||||
value: string;
|
||||
error?: string;
|
||||
/** For template rolls: additional modifier keys → values to apply */
|
||||
modifiers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a template-type stat is set explicitly (not rolled), look up the
|
||||
* template entry by label and return the modifier overrides to apply.
|
||||
* Returns null if the stat is not a template type or the label doesn't match.
|
||||
*/
|
||||
export function resolveTemplateSet(
|
||||
def: StatDef,
|
||||
value: string,
|
||||
statDefs: StatDef[],
|
||||
runtimeStats: Record<string, string>,
|
||||
playerName: string,
|
||||
templates?: StatTemplate[],
|
||||
): Record<string, string> | null {
|
||||
if (def.type !== "template" || !def.template) return null;
|
||||
|
||||
const tpl = templates?.find((t) => t.name === def.template);
|
||||
if (!tpl) return null;
|
||||
|
||||
const entry = tpl.entries.find((e) => e.label === value);
|
||||
if (!entry || Object.keys(entry.modifiers).length === 0) return null;
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
|
||||
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||
resolved[modFullKey] = mv;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a /stat roll command: look up the stat definition (by bare or full
|
||||
* key), evaluate the appropriate resolution strategy, and return the string
|
||||
* value to publish.
|
||||
*/
|
||||
export function resolveStatRoll(
|
||||
inputKey: string,
|
||||
statDefs: StatDef[],
|
||||
runtimeStats: Record<string, string>,
|
||||
playerName: string,
|
||||
templates?: StatTemplate[],
|
||||
): StatRollResult {
|
||||
// Try exact match first, then resolve bare key → full key
|
||||
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
|
||||
if (!def) {
|
||||
// Try bare key: find a player-scoped def whose fullKey would match
|
||||
def = statDefs.find(
|
||||
(d) => d.scope === "player" && fullKey(d, playerName) === inputKey,
|
||||
);
|
||||
}
|
||||
if (!def) {
|
||||
// Try finding a bare key match (for global or player)
|
||||
def = statDefs.find((d) => d.key === inputKey);
|
||||
}
|
||||
|
||||
if (!def) {
|
||||
return { fullKey: inputKey, value: "", error: `未知属性: ${inputKey}` };
|
||||
}
|
||||
|
||||
const fk = fullKey(def, playerName);
|
||||
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
||||
|
||||
if (def.type === "template" && def.template) {
|
||||
const tpl = templates?.find((t) => t.name === def.template);
|
||||
if (!tpl || tpl.entries.length === 0) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: `未找到模板: ${def.template}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Roll the dice to pick an entry (use template's notation)
|
||||
const formula = tpl.notation || "1d" + String(tpl.entries.length);
|
||||
const resolvedFormula = resolveStatRefs(formula, lookup);
|
||||
const roll = rollFormula(resolvedFormula);
|
||||
const rolled = roll.result.total;
|
||||
|
||||
// Find matching entry by range
|
||||
const entry = matchTemplateRange(rolled, tpl.entries);
|
||||
if (!entry) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: `模板 "${def.template}" 中未找到匹配 ${rolled} 的条目`,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve modifier keys to full keys (same scope as def)
|
||||
// Template values are absolute — set the modifier stat directly.
|
||||
// The modifier stat (type: modifier) adds to its target in StatsView.
|
||||
const resolvedModifiers: Record<string, string> = {};
|
||||
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||
resolvedModifiers[modFullKey] = mv;
|
||||
}
|
||||
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: entry.label,
|
||||
modifiers: resolvedModifiers,
|
||||
};
|
||||
}
|
||||
|
||||
if (def.type === "enum" && def.options && def.options.length > 0) {
|
||||
const idx = Math.floor(Math.random() * def.options.length);
|
||||
return { fullKey: fk, value: def.options[idx] };
|
||||
}
|
||||
|
||||
if (def.type === "derived" && def.formula) {
|
||||
try {
|
||||
const result = evaluateFormula(def.formula, lookup);
|
||||
return { fullKey: fk, value: String(result) };
|
||||
} catch (e) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: e instanceof Error ? e.message : "公式计算失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (def.roll) {
|
||||
const resolvedFormula = resolveStatRefs(def.roll, lookup);
|
||||
const roll = rollFormula(resolvedFormula);
|
||||
return { fullKey: fk, value: String(roll.result.total) };
|
||||
}
|
||||
|
||||
return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template range matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Match a rolled number against template entries with range strings.
|
||||
*
|
||||
* Range formats:
|
||||
* "1-3" → inclusive range
|
||||
* "4" → exact match
|
||||
* "1-3,5" → multiple ranges
|
||||
*
|
||||
* Returns the first matching entry, or undefined.
|
||||
*/
|
||||
function matchTemplateRange(
|
||||
rolled: number,
|
||||
entries: {
|
||||
range: string;
|
||||
label: string;
|
||||
modifiers: Record<string, string>;
|
||||
}[],
|
||||
): (typeof entries)[number] | undefined {
|
||||
for (const entry of entries) {
|
||||
const parts = entry.range.split(",").map((s) => s.trim());
|
||||
for (const part of parts) {
|
||||
if (part.includes("-")) {
|
||||
const [lo, hi] = part.split("-").map(Number);
|
||||
if (!isNaN(lo) && !isNaN(hi) && rolled >= lo && rolled <= hi) {
|
||||
return entry;
|
||||
}
|
||||
} else {
|
||||
const n = Number(part);
|
||||
if (!isNaN(n) && rolled === n) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -10,4 +10,4 @@ import "./roll";
|
|||
import "./spark";
|
||||
import "./link";
|
||||
import "./intent";
|
||||
import "./var";
|
||||
import "./stat";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Built-in message type: var
|
||||
* Built-in message type: stat
|
||||
*
|
||||
* Emitters: gm, player
|
||||
* Command: /set $key expression
|
||||
* Command: /stat set key=value | /stat del key | /stat roll key
|
||||
*
|
||||
* Variable declarations and tag modifiers are authored in ```csv role=declare
|
||||
* code blocks in markdown documents. The stream carries set/del mutations
|
||||
* that build up the runtime variable store additively.
|
||||
* Stat definitions are discovered from ```stat YAML blocks in markdown
|
||||
* documents. The stream carries set/del mutations that build up the
|
||||
* runtime stat store additively.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
|
@ -20,11 +20,11 @@ const schema = z.object({
|
|||
value: z.string().optional(),
|
||||
});
|
||||
|
||||
export type VarPayload = z.infer<typeof schema>;
|
||||
export type StatPayload = z.infer<typeof schema>;
|
||||
|
||||
registerMessageType<VarPayload>({
|
||||
type: "var",
|
||||
label: "变量",
|
||||
registerMessageType<StatPayload>({
|
||||
type: "stat",
|
||||
label: "属性",
|
||||
emitters: ["gm", "player"],
|
||||
schema,
|
||||
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||
|
|
@ -32,7 +32,7 @@ registerMessageType<VarPayload>({
|
|||
if (p.action === "del") {
|
||||
return (
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🔢</span>
|
||||
<span class="text-lg">📊</span>
|
||||
<span class="font-mono text-sm text-gray-500 line-through">
|
||||
{p.key}
|
||||
</span>
|
||||
|
|
@ -41,9 +41,9 @@ registerMessageType<VarPayload>({
|
|||
}
|
||||
return (
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🔢</span>
|
||||
<span class="text-lg">📊</span>
|
||||
<span class="font-mono text-sm">
|
||||
{p.key} = <span class="font-bold">{p.value}</span>
|
||||
{p.key} → <span class="font-bold">{p.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -52,9 +52,9 @@ registerMessageType<VarPayload>({
|
|||
journalSetState(
|
||||
produce((s) => {
|
||||
if (p.action === "del") {
|
||||
delete s.variables[p.key];
|
||||
delete s.stats[p.key];
|
||||
} else if (p.value !== undefined) {
|
||||
s.variables[p.key] = p.value;
|
||||
s.stats[p.key] = p.value;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,519 +0,0 @@
|
|||
/**
|
||||
* Variable reactivity engine — tracks variable declarations and tag
|
||||
* 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
|
||||
*
|
||||
* Circular dependency detection happens at registration time
|
||||
* (topological sort). At runtime, we guard against re-entrant
|
||||
* evaluation with an in-flight set.
|
||||
*/
|
||||
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
import { evaluateExpression } from "./variable-expression";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dependency graph: $dep → Set<$declaredVar> */
|
||||
let depGraph: Map<string, Set<string>> | null = null;
|
||||
|
||||
/** Reverse: $declaredVar → its expression */
|
||||
let declExprs: Map<string, string> | null = null;
|
||||
|
||||
/** Tag modifiers: #tag → [{target, expression}] */
|
||||
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
||||
|
||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialize (or re-initialize) the reactivity engine from declarations
|
||||
* and tag modifiers parsed from role=declare blocks.
|
||||
*
|
||||
* Throws if a circular dependency is detected.
|
||||
*/
|
||||
export function initReactivity(state: VarReactivityState): void {
|
||||
depGraph = new Map();
|
||||
declExprs = new Map();
|
||||
tagModMap = new Map();
|
||||
|
||||
// Index tag modifiers
|
||||
for (const tm of state.tagModifiers) {
|
||||
let list = tagModMap.get(tm.tag);
|
||||
if (!list) {
|
||||
list = [];
|
||||
tagModMap.set(tm.tag, list);
|
||||
}
|
||||
list.push({ target: tm.target, expression: tm.expression });
|
||||
}
|
||||
|
||||
// Index declarations and build dependency graph
|
||||
for (const decl of state.declarations) {
|
||||
declExprs.set(decl.key, decl.expression);
|
||||
const deps = extractDependencies(decl.expression);
|
||||
for (const dep of deps) {
|
||||
let dependents = depGraph.get(dep);
|
||||
if (!dependents) {
|
||||
dependents = new Set();
|
||||
depGraph.set(dep, dependents);
|
||||
}
|
||||
dependents.add(decl.key);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const name = "$" + m[1];
|
||||
if (!vars.includes(name)) vars.push(name);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which declared variables need re-evaluation after
|
||||
* a set of base variables changed, and what tag modifier effects
|
||||
* to apply.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function computeCascade(
|
||||
changedVar: string,
|
||||
oldValue: string | undefined,
|
||||
currentVars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs || !tagModMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
|
||||
// ---- Tag activation/deactivation ----
|
||||
const newTag = isTagValue(currentVars[changedVar]);
|
||||
const oldTag = isTagValue(oldValue);
|
||||
|
||||
if (newTag !== oldTag) {
|
||||
// Deactivate old tag
|
||||
if (oldTag) {
|
||||
const removed = computeTagDeactivation(
|
||||
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,
|
||||
);
|
||||
for (const r of added) {
|
||||
results.push(r);
|
||||
currentVars = { ...currentVars, [r.key]: r.value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Declaration re-evaluation ----
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!declExprs) return [];
|
||||
|
||||
const allKeys = [...declExprs.keys()];
|
||||
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);
|
||||
if (!expr) continue;
|
||||
|
||||
try {
|
||||
const result = evaluateExpression(expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return localVars[k] ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const newValue = String(result.value);
|
||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
||||
|
||||
if (finalValue !== (localVars[key] ?? "")) {
|
||||
localVars[key] = finalValue;
|
||||
results.push({ key, value: finalValue });
|
||||
}
|
||||
} catch {
|
||||
// skip failed evaluations at init time
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isTagValue(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("#") ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the dependency graph from `changedVar` and re-evaluate all
|
||||
* affected declarations. Uses topological order.
|
||||
*/
|
||||
function reevaluateDependents(
|
||||
changedVar: string,
|
||||
vars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs) return [];
|
||||
|
||||
// Collect all dependents reachable from changedVar (BFS)
|
||||
const affected = new Set<string>();
|
||||
const queue = [changedVar];
|
||||
while (queue.length > 0) {
|
||||
const dep = queue.shift()!;
|
||||
const dependents = depGraph.get(dep);
|
||||
if (!dependents) continue;
|
||||
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...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
inFlight.add(key);
|
||||
|
||||
try {
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
const newValue = String(result.value);
|
||||
|
||||
// Apply any active tag modifiers to this key
|
||||
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
||||
|
||||
if (finalValue !== localVars[key]) {
|
||||
localVars[key] = finalValue;
|
||||
results.push({ key, value: finalValue });
|
||||
}
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const temp = new Set<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);
|
||||
for (const dep of deps) {
|
||||
if (affected.has(dep) || declExprs!.has(dep)) {
|
||||
visit(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
temp.delete(key);
|
||||
visited.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
|
||||
for (const key of affected) {
|
||||
visit(key);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const allKeys = [...declExprs.keys()];
|
||||
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||||
for (const k of allKeys) state.set(k, "unvisited");
|
||||
|
||||
const path: string[] = [];
|
||||
|
||||
function dfs(key: string): void {
|
||||
const s = state.get(key);
|
||||
if (s === "visited") return;
|
||||
if (s === "visiting") {
|
||||
const cycleStart = path.indexOf(key);
|
||||
const cycle = path.slice(cycleStart).concat(key);
|
||||
throw new Error(
|
||||
`Circular dependency detected: ${cycle.join(" → ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
state.set(key, "visiting");
|
||||
path.push(key);
|
||||
|
||||
const expr = declExprs!.get(key);
|
||||
if (expr) {
|
||||
const deps = extractDependencies(expr);
|
||||
for (const dep of deps) {
|
||||
if (declExprs!.has(dep)) {
|
||||
dfs(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
state.set(key, "visited");
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
dfs(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
/**
|
||||
* Variable expression evaluator — parses and evaluates expressions used
|
||||
* in `/set` commands and `role=declare` code blocks.
|
||||
*
|
||||
* Supports:
|
||||
* - Number literals (integer or decimal)
|
||||
* - $var references (resolved via lookup, must be numeric)
|
||||
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
||||
* - Arithmetic: + - * /
|
||||
* - Functions: floor(x), ceil(x), round(x)
|
||||
* - Parentheses for grouping
|
||||
*
|
||||
* Throws on:
|
||||
* - Type mismatch (e.g. $var resolves to a tag value like "#warrior")
|
||||
* - Circular variable references (detected by caller)
|
||||
* - Division by zero
|
||||
* - Unknown functions
|
||||
* - Malformed expressions
|
||||
*/
|
||||
|
||||
import { rollFormula } from "../md-commander/hooks";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EvalContext {
|
||||
/** Resolve $var → numeric string, or a tag string like "#warrior".
|
||||
* Return undefined if the variable doesn't exist. */
|
||||
lookup: (varName: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
value: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate an expression string.
|
||||
* Throws if any variable resolves to a non-numeric (tag) value,
|
||||
* or if the expression is malformed.
|
||||
*/
|
||||
export function evaluateExpression(
|
||||
expr: string,
|
||||
ctx: EvalContext,
|
||||
): EvalResult {
|
||||
const tokens = tokenize(expr);
|
||||
const result = parseExpression(tokens, 0, ctx);
|
||||
if (result.next < tokens.length) {
|
||||
throw new Error(
|
||||
`Unexpected token at position ${result.next}: "${tokens[result.next].raw}"`,
|
||||
);
|
||||
}
|
||||
return { value: result.value };
|
||||
}
|
||||
|
||||
/** Quick check: does this expression produce a tag value? */
|
||||
export function expressionIsTag(expr: string): boolean {
|
||||
const trimmed = expr.trim();
|
||||
return trimmed.startsWith("#");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Token {
|
||||
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
||||
value: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
// Whitespace
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number (integer or decimal, but NOT followed by 'd' which makes it a dice pattern)
|
||||
if (/[0-9]/.test(ch)) {
|
||||
let num = "";
|
||||
while (i < input.length && /[0-9.]/.test(input[i])) {
|
||||
num += input[i];
|
||||
i++;
|
||||
}
|
||||
// Peek ahead: if next char is 'd' (case-insensitive), this is a dice pattern
|
||||
if (i < input.length && /[dD]/.test(input[i])) {
|
||||
// Dice pattern
|
||||
let dice = num;
|
||||
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
|
||||
dice += input[i];
|
||||
i++;
|
||||
}
|
||||
if (!DICE_RE.test(dice)) {
|
||||
throw new Error(`Invalid dice notation: "${dice}"`);
|
||||
}
|
||||
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: "number", value: num, raw: num });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Variable reference: $var
|
||||
if (ch === "$") {
|
||||
let ident = "$";
|
||||
i++;
|
||||
if (i >= input.length || !/[a-zA-Z_]/.test(input[i])) {
|
||||
throw new Error(`Invalid variable reference at position ${i - 1}: expected identifier after $`);
|
||||
}
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "var", value: ident, raw: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier or function name
|
||||
if (/[a-zA-Z_]/.test(ch)) {
|
||||
let ident = "";
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "ident", value: ident, raw: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators and punctuation
|
||||
switch (ch) {
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
tokens.push({ kind: "op", value: ch, raw: ch });
|
||||
i++;
|
||||
break;
|
||||
case "(":
|
||||
tokens.push({ kind: "lparen", value: "(", raw: "(" });
|
||||
i++;
|
||||
break;
|
||||
case ")":
|
||||
tokens.push({ kind: "rparen", value: ")", raw: ")" });
|
||||
i++;
|
||||
break;
|
||||
case ",":
|
||||
tokens.push({ kind: "comma", value: ",", raw: "," });
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected character: "${ch}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rollDice(notation: string): number {
|
||||
const result = rollFormula(notation);
|
||||
if (!result.success) {
|
||||
throw new Error(`Dice roll failed: ${result.error ?? notation}`);
|
||||
}
|
||||
return result.result.total;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recursive descent parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ParseResult {
|
||||
value: number;
|
||||
next: number; // index of next unconsumed token
|
||||
}
|
||||
|
||||
/** expression := term (("+" | "-") term)* */
|
||||
function parseExpression(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
let result = parseTerm(tokens, pos, ctx);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
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 };
|
||||
}
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** term := factor (("*" | "/") factor)* */
|
||||
function parseTerm(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
let result = parseFactor(tokens, pos, ctx);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
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 };
|
||||
}
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
|
||||
function parseFactor(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
if (pos >= tokens.length) {
|
||||
throw new Error("Unexpected end of expression");
|
||||
}
|
||||
|
||||
const tok = tokens[pos];
|
||||
|
||||
// Unary minus
|
||||
if (tok.kind === "op" && tok.value === "-") {
|
||||
const inner = parseFactor(tokens, pos + 1, ctx);
|
||||
return { value: -inner.value, next: inner.next };
|
||||
}
|
||||
|
||||
// Number literal (including already-rolled dice patterns)
|
||||
if (tok.kind === "number") {
|
||||
return { value: parseFloat(tok.value), next: pos + 1 };
|
||||
}
|
||||
|
||||
// Variable reference: $var
|
||||
if (tok.kind === "var") {
|
||||
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 };
|
||||
}
|
||||
// Tag values cannot be used in arithmetic
|
||||
if (resolved.startsWith("#")) {
|
||||
throw new Error(
|
||||
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
|
||||
);
|
||||
}
|
||||
const num = parseFloat(resolved);
|
||||
if (isNaN(num)) {
|
||||
throw new Error(
|
||||
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
|
||||
);
|
||||
}
|
||||
return { value: num, next: pos + 1 };
|
||||
}
|
||||
|
||||
// Parenthesized expression
|
||||
if (tok.kind === "lparen") {
|
||||
const inner = parseExpression(tokens, pos + 1, ctx);
|
||||
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
|
||||
throw new Error("Missing closing parenthesis");
|
||||
}
|
||||
return { value: inner.value, next: inner.next + 1 };
|
||||
}
|
||||
|
||||
// Function call: ident "(" expression ")"
|
||||
if (tok.kind === "ident") {
|
||||
const name = tok.value;
|
||||
|
||||
// Check for function call: ident "(" ...
|
||||
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
|
||||
const arg = parseExpression(tokens, pos + 2, ctx);
|
||||
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
|
||||
throw new Error(`Missing closing parenthesis after ${name}(...)`);
|
||||
}
|
||||
const value = applyFunction(name, arg.value);
|
||||
return { value, next: arg.next + 1 };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown identifier: "${name}" (use $ for variables)`);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: "${tok.raw}"`);
|
||||
}
|
||||
|
||||
function applyFunction(name: string, arg: number): number {
|
||||
switch (name.toLowerCase()) {
|
||||
case "floor":
|
||||
return Math.floor(arg);
|
||||
case "ceil":
|
||||
return Math.ceil(arg);
|
||||
case "round":
|
||||
return Math.round(arg);
|
||||
default:
|
||||
throw new Error(`Unknown function: ${name}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ export interface JournalStreamState {
|
|||
myRole: "gm" | "player" | "observer";
|
||||
brokerUrl: string | null;
|
||||
players: Record<string, { role: string }>;
|
||||
variables: Record<string, string>;
|
||||
stats: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SessionMeta {
|
||||
|
|
@ -60,7 +60,7 @@ export type { SessionManifest } from "../../workers/journal.worker";
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _workerAPI: Comlink.Remote<JournalWorkerAPI> | null = null;
|
||||
|
||||
let _unsubscribe: (() => void) | null = null;
|
||||
|
||||
function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
|
||||
if (!_workerAPI) {
|
||||
|
|
@ -96,7 +96,7 @@ const [state, setState] = createStore<JournalStreamState>({
|
|||
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
||||
brokerUrl: persisted.brokerUrl,
|
||||
players: {},
|
||||
variables: {},
|
||||
stats: {},
|
||||
});
|
||||
|
||||
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
||||
|
|
@ -126,7 +126,7 @@ function runReducer(msg: StreamMessage): void {
|
|||
function replayReducers(): void {
|
||||
// Reset derived state
|
||||
setState("revealedPaths", {});
|
||||
setState("variables", {});
|
||||
setState("stats", {});
|
||||
for (const msg of state.messages) {
|
||||
runReducer(msg);
|
||||
}
|
||||
|
|
@ -160,7 +160,7 @@ function handlePatch(patch: WorkerPatch): void {
|
|||
syncUrlParam("player", patch.state.myName);
|
||||
}
|
||||
saveRole(patch.state.myRole);
|
||||
// Rebuild derived state (revealedPaths, variables) by replaying reducers
|
||||
// Rebuild derived state (revealedPaths, stats) by replaying reducers
|
||||
replayReducers();
|
||||
break;
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ async function ensureSubscribed(): Promise<void> {
|
|||
_subscribed = true;
|
||||
|
||||
const api = getWorkerAPI();
|
||||
await api.subscribe(
|
||||
_unsubscribe = await api.subscribe(
|
||||
Comlink.proxy((patch: WorkerPatch) => {
|
||||
handlePatch(patch);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
tag: journal-link
|
||||
icon: 🔗
|
||||
title: 链接命令
|
||||
description: 在 Journal 中发送可点击的文档链接,可指向特定章节。
|
||||
syntax: '/link 路径#章节'
|
||||
props:
|
||||
- name: 路径
|
||||
type: string
|
||||
desc: 文档路径(不含 .md 扩展名)
|
||||
- name: 章节
|
||||
type: string
|
||||
desc: 可选,文档中的章节标题
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`/link` 命令用于在 Journal 流中发送文档链接,点击后可在文章区域打开对应文档。
|
||||
|
||||
## 语法
|
||||
|
||||
```
|
||||
/link 路径
|
||||
/link 路径#章节
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/link rules/combat
|
||||
/link rules/combat#伤害计算
|
||||
/link npc/商人
|
||||
```
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可使用
|
||||
- **玩家**:不可使用
|
||||
- **观察者**:不可使用
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
tag: journal-roll
|
||||
icon: 🎲
|
||||
title: 掷骰命令
|
||||
description: 在 Journal 中发送掷骰结果,支持标准骰子表达式。
|
||||
syntax: '/roll 骰子表达式'
|
||||
props:
|
||||
- name: 骰子表达式
|
||||
type: string
|
||||
desc: 标准骰子表达式,如 3d6、2d8kh1、d20+5
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`/roll` 命令用于在 Journal 流中掷骰并发布结果。结果会同步到所有连接的客户端。
|
||||
|
||||
## 语法
|
||||
|
||||
```
|
||||
/roll 骰子表达式
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/roll d20
|
||||
/roll 3d6
|
||||
/roll 2d8kh1
|
||||
/roll 1d20+5
|
||||
/roll 4d6k3
|
||||
```
|
||||
|
||||
## 支持的骰子表达式
|
||||
|
||||
| 表达式 | 说明 |
|
||||
|---|---|
|
||||
| `d20` | 单个 20 面骰 |
|
||||
| `3d6` | 3 个 6 面骰求和 |
|
||||
| `2d8kh1` | 2 个 8 面骰保留最高 1 个 |
|
||||
| `1d20+5` | 1 个 20 面骰加 5 |
|
||||
| `4d6k3` | 4 个 6 面骰保留最高 3 个 |
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可使用
|
||||
- **玩家**:不可使用
|
||||
- **观察者**:不可使用
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
---
|
||||
tag: journal-set
|
||||
icon: 🔢
|
||||
title: 变量系统
|
||||
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
|
||||
syntax: '```csv role=declare'
|
||||
props:
|
||||
- name: 定义文件
|
||||
type: —
|
||||
desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签
|
||||
- name: 命令
|
||||
type: —
|
||||
desc: /set $key expression | /set $key #tag1|#tag2|#tag3
|
||||
- name: 权限
|
||||
type: —
|
||||
desc: GM 和玩家均可设置变量
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
变量系统允许你定义数值变量和标签,通过命令设置值,系统自动计算派生变量和标签效果。
|
||||
|
||||
核心概念:
|
||||
- **变量**(`$name`):存储数值或标签,通过 `/set` 命令修改
|
||||
- **标签**(`#tag`):当某个变量的值为 `#tag` 时,该标签被激活,触发相应的修饰效果
|
||||
- **声明**(`declare`):在 `role=declare` 代码块中定义派生变量和标签修饰符
|
||||
|
||||
## 代码块语法
|
||||
|
||||
```csv role=declare
|
||||
tag,key,expr
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex
|
||||
#warrior,$mod_hp,20
|
||||
#warrior,$mod_str,1
|
||||
```
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| `tag` | 标签名(空表示普通变量声明) |
|
||||
| `key` | 变量名,以 `$` 开头 |
|
||||
| `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 |
|
||||
|
||||
### 声明类型
|
||||
|
||||
**普通声明**(tag 为空):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
|
||||
|
||||
```csv role=declare
|
||||
tag,key,expr
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex
|
||||
```
|
||||
|
||||
**标签修饰符**(tag 非空):当 `#tag` 被激活时,`$key` 的当前值加上表达式的计算结果。
|
||||
|
||||
```csv role=declare
|
||||
tag,key,expr
|
||||
#warrior,$mod_hp,20
|
||||
#warrior,$mod_str,1
|
||||
```
|
||||
|
||||
## 表达式语法
|
||||
|
||||
支持以下表达式元素:
|
||||
|
||||
| 元素 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| 数字 | `10`, `3.5` | 整数或小数 |
|
||||
| 变量引用 | `$str`, `$mod_hp` | 引用其他变量的值 |
|
||||
| 骰子 | `3d6`, `2d8kh1` | 每次求值时重新掷骰 |
|
||||
| 算术 | `+ - * /` | 四则运算 |
|
||||
| 函数 | `floor(x)`, `ceil(x)`, `round(x)` | 取整函数 |
|
||||
| 括号 | `(2+3)*4` | 分组 |
|
||||
|
||||
变量引用必须解析为数值,不能是标签值(`#warrior`)。如果引用的变量是标签类型,求值会报错。
|
||||
|
||||
## 命令
|
||||
|
||||
所有命令在 Journal 输入框中输入:
|
||||
|
||||
| 命令 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| `/set $key expr` | `/set $str 16` | 设置变量为数值 |
|
||||
| `/set $key #tag` | `/set $class #warrior` | 设置变量为标签 |
|
||||
| `/set $key #a\|#b\|#c` | `/set $class #w\|#d\|#s` | 随机选择一个标签 |
|
||||
|
||||
### 设置数值
|
||||
|
||||
```
|
||||
/set $con 14
|
||||
/set $dex 12
|
||||
```
|
||||
|
||||
设置后,依赖 `$con` 和 `$dex` 的派生变量(如 `$hp`、`$ac`)会自动重新计算。
|
||||
|
||||
### 设置标签
|
||||
|
||||
```
|
||||
/set $class #warrior
|
||||
```
|
||||
|
||||
当 `$class` 设置为 `#warrior` 时:
|
||||
1. `#warrior` 标签被激活
|
||||
2. 所有 `#warrior` 的修饰符生效:`$mod_hp += 20`,`$mod_str += 1`
|
||||
3. 依赖这些变量的派生变量自动重新计算
|
||||
|
||||
### 随机标签
|
||||
|
||||
```
|
||||
/set $class #warrior|#druid|#sorcerer|#wizard
|
||||
```
|
||||
|
||||
从给定标签中随机选择一个设置为变量值。
|
||||
|
||||
## 标签激活
|
||||
|
||||
标签的激活状态由变量的值决定:
|
||||
|
||||
- 如果**任何**变量的值为 `#tag`,该标签被激活
|
||||
- 如果**没有**变量的值为 `#tag`,该标签被停用
|
||||
- 标签激活时,其修饰符**加到**目标变量上
|
||||
- 标签停用时,修饰符**从**目标变量中减去
|
||||
|
||||
一个变量只能存储一个值类型:数值或标签,不能同时存储两者。
|
||||
|
||||
## 变量视图
|
||||
|
||||
在 Journal 面板顶部点击 **变量** 标签切换视图:
|
||||
|
||||
- **激活标签**:显示当前激活的标签、激活来源和修饰效果
|
||||
- **声明变量**:显示由表达式定义的派生变量及其当前值
|
||||
- **直接设置**:显示通过 `/set` 直接赋值的变量
|
||||
- **标签变量**:显示当前值为标签的变量
|
||||
|
||||
## 文档模板
|
||||
|
||||
在 markdown 文章中使用 `{{$key}}` 语法显示变量的实时值:
|
||||
|
||||
```
|
||||
当前生命值:{{$hp}}
|
||||
护甲等级:{{$ac}}
|
||||
```
|
||||
|
||||
变量值会随着 Journal 流中的 `/set` 命令实时更新。
|
||||
|
||||
## 完整示例
|
||||
|
||||
在文档中定义:
|
||||
|
||||
```csv role=declare
|
||||
tag,key,expr
|
||||
,$con,10
|
||||
,$dex,12
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex
|
||||
#warrior,$mod_hp,20
|
||||
#warrior,$mod_str,1
|
||||
```
|
||||
|
||||
在 Journal 中输入:
|
||||
|
||||
```
|
||||
/set $con 14
|
||||
/set $class #warrior
|
||||
```
|
||||
|
||||
结果:
|
||||
- `$con = 14`
|
||||
- `$dex = 12`
|
||||
- `$hp = 14*5 + 20 = 90`
|
||||
- `$ac = 10 + 12 = 22`
|
||||
- `$mod_hp = 20`(来自 `#warrior` 修饰符)
|
||||
- `$mod_str = 1`(来自 `#warrior` 修饰符)
|
||||
|
||||
## 循环依赖
|
||||
|
||||
系统在加载声明时检测循环依赖。如果声明之间存在循环引用(如 `$a = $b + 1`, `$b = $a + 1`),会抛出错误提示。
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可设置所有变量
|
||||
- **玩家**:可设置变量
|
||||
- **观察者**:不能设置变量
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
tag: journal-spark
|
||||
icon: 🎰
|
||||
title: 种子表命令
|
||||
description: 在 Journal 中随机生成种子表内容,用于生成随机遭遇、NPC、物品等。
|
||||
syntax: '/spark 种子表键名'
|
||||
props:
|
||||
- name: 种子表键名
|
||||
type: string
|
||||
desc: 在文档中通过 :spark[CSV路径] 指令定义的种子表 slug
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`/spark` 命令用于从种子表中随机抽取一行并发布结果。种子表在 markdown 文档中通过 `:spark[CSV路径]` 指令定义。
|
||||
|
||||
## 语法
|
||||
|
||||
```
|
||||
/spark 种子表键名
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/spark npc
|
||||
/spark encounter
|
||||
/spark loot
|
||||
```
|
||||
|
||||
## 种子表定义
|
||||
|
||||
种子表在文档中定义:
|
||||
|
||||
```
|
||||
:spark[npc]
|
||||
:spark[encounter]
|
||||
:spark[loot]
|
||||
```
|
||||
|
||||
CSV 文件的每一行对应种子表的一个条目,随机选取一行发布。
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可使用
|
||||
- **玩家**:不可使用
|
||||
- **观察者**:不可使用
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
---
|
||||
tag: journal-stat
|
||||
icon: 📊
|
||||
title: 属性系统
|
||||
description: 在文档中定义属性,通过命令设置/删除/掷骰,在面板中查看属性表。
|
||||
syntax: '```yaml role=stat'
|
||||
props:
|
||||
- name: 定义文件
|
||||
type: —
|
||||
desc: 在任意 .md 文档中使用 ```yaml role=stat 或 ```csv role=stat 代码块定义属性
|
||||
- name: 命令
|
||||
type: —
|
||||
desc: /stat set key=value | /stat del key | /stat roll key
|
||||
- name: 属性类型
|
||||
type: —
|
||||
desc: number, string, enum, modifier, derived, template
|
||||
- name: 权限
|
||||
type: —
|
||||
desc: GM 可修改所有属性,玩家只能修改自己的属性
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
属性系统允许你在文档中定义角色属性,通过命令设置和掷骰,
|
||||
并在 Journal 面板的属性视图中查看当前值。
|
||||
|
||||
属性分为两层:
|
||||
- **定义**(Schema):在 markdown 文档的 ` ```yaml role=stat ` 或 ` ```csv role=stat ` 代码块中定义
|
||||
- **值**(State):通过 `/stat set/del/roll` 命令在游戏过程中动态修改
|
||||
|
||||
## 代码块语法
|
||||
|
||||
所有属性相关代码块使用统一的属性语法:
|
||||
|
||||
```lang role=xxx [id=xxx]
|
||||
|
||||
| 属性 | 说明 | 示例 |
|
||||
|---|---|---|
|
||||
| `lang` | 代码语言 | `yaml`, `csv` |
|
||||
| `role` | 块用途 | `stat`(属性定义), `stat-template`(模板表) |
|
||||
| `id` | 引用标识 | 模板名称,如 `id=年龄` |
|
||||
|
||||
代码块默认会被**剥离**(不渲染到页面),仅用于数据提取。
|
||||
如需保留为可见代码块,添加 `as=codeblock`。
|
||||
|
||||
## 属性定义
|
||||
|
||||
支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。
|
||||
|
||||
### YAML 格式
|
||||
|
||||
```yaml role=stat
|
||||
- key: strength
|
||||
scope: player
|
||||
label: "力量"
|
||||
type: number
|
||||
default: 10
|
||||
|
||||
- key: str_mod
|
||||
scope: player
|
||||
label: "力量调整"
|
||||
type: modifier
|
||||
target: strength
|
||||
|
||||
- key: attack
|
||||
scope: player
|
||||
label: "近战攻击"
|
||||
type: number
|
||||
default: 0
|
||||
roll: "1d20 + attack"
|
||||
|
||||
- key: loot
|
||||
scope: player
|
||||
label: "战利品"
|
||||
type: enum
|
||||
options:
|
||||
- 金币 x10
|
||||
- 魔法药水
|
||||
- 破旧长剑
|
||||
|
||||
- key: hp_max
|
||||
scope: player
|
||||
label: "最大生命值"
|
||||
type: derived
|
||||
formula: "strength * 2 + 10"
|
||||
|
||||
- key: notes
|
||||
scope: player
|
||||
label: "备注"
|
||||
type: string
|
||||
|
||||
- key: weather
|
||||
scope: global
|
||||
label: "天气"
|
||||
type: enum
|
||||
options:
|
||||
- 晴天
|
||||
- 阴天
|
||||
- 雨天
|
||||
- 暴风雨
|
||||
```
|
||||
|
||||
### CSV 格式
|
||||
|
||||
```csv role=stat
|
||||
key,label,type,roll
|
||||
mind,心智,number,2d10+20
|
||||
heart,心灵,number,2d10+20
|
||||
strength,力量,number,2d10+20
|
||||
speed,速度,number,2d10+20
|
||||
```
|
||||
|
||||
CSV 列说明:
|
||||
|
||||
| 列 | 必填 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `key` | ✅ | — | 属性标识符 |
|
||||
| `label` | — | key 的值 | 显示名称 |
|
||||
| `type` | — | `number` | 属性类型 |
|
||||
| `scope` | — | `player` | `player` 或 `global` |
|
||||
| `default` | — | — | 默认值 |
|
||||
| `roll` | — | — | 掷骰公式 |
|
||||
| `target` | — | — | modifier 的目标 key |
|
||||
| `template` | — | — | template 类型引用的模板 id |
|
||||
| `formula` | — | — | derived 的计算公式 |
|
||||
| `options` | — | — | enum 选项,用 `\|` 分隔 |
|
||||
|
||||
### 属性类型
|
||||
|
||||
| 类型 | 说明 | 支持掷骰 |
|
||||
|---|---|---|
|
||||
| `number` | 数值,可声明 `roll` 公式 | ✅ |
|
||||
| `string` | 自由文本 | ❌ |
|
||||
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
||||
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
||||
| `derived` | 通过公式从其他属性计算 | ✅ |
|
||||
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
|
||||
|
||||
### 关键字段说明
|
||||
|
||||
- **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀
|
||||
- **`scope`**:`player` 或 `global`。`player` 表示每个玩家各自独立的值,运行时 key 为 `玩家名:strength`;`global` 表示所有玩家共享
|
||||
- **`label`**:在属性视图中显示的名称
|
||||
- **`default`**:默认值,在未通过命令设置时使用
|
||||
- **`roll`**:掷骰公式(`number` 类型),支持引用其他属性值(bare key,自动同 scope 解析)
|
||||
- **`target`**:`modifier` 类型的目标属性 key
|
||||
- **`options`**:`enum` 类型的选项列表,YAML 支持多行 `- value` 语法,CSV 用 `|` 分隔
|
||||
- **`formula`**:`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`
|
||||
- **`template`**:`template` 类型引用的模板 id(对应 `id=xxx` 的 stat-template 块)
|
||||
|
||||
### 作用域
|
||||
|
||||
`scope: player` 的属性在运行时自动加上玩家名前缀。Alice 连接时,`strength` 的实际 key 是 `alice:strength`。
|
||||
|
||||
在公式(`roll`、`formula`)和 `target` 中,使用 bare key 即可,系统自动在相同 scope 内查找:
|
||||
|
||||
```yaml role=stat
|
||||
- key: attack
|
||||
scope: player
|
||||
roll: "1d20 + attack" # attack 自动解析为 alice:attack
|
||||
|
||||
- key: str_mod
|
||||
scope: player
|
||||
target: strength # 自动解析为 alice:strength
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
所有命令在 Journal 输入框中输入,前缀为 `/stat`:
|
||||
|
||||
| 命令 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| `/stat set key=value` | `/stat set strength=16` | 设置属性值 |
|
||||
| `/stat del key` | `/stat del strength` | 删除属性值,恢复默认 |
|
||||
| `/stat roll key` | `/stat roll attack` | 掷骰并发布结果 |
|
||||
|
||||
### 掷骰行为
|
||||
|
||||
- **`number` + `roll`**:解析公式中的属性引用,掷骰,结果写入属性值
|
||||
- **`enum`**:从选项列表中随机选择一项
|
||||
- **`derived` + `formula`**:计算公式,结果写入属性值
|
||||
- **`template`**:掷模板头部骰子,匹配范围,应用修饰符
|
||||
|
||||
例如 `/stat roll attack`:
|
||||
1. 在当前玩家 scope 下查找 `attack` 的定义
|
||||
2. 解析 `roll` 公式 `1d20 + attack`,将 `attack` 替换为当前值(含修饰符)→ `1d20 + 3`
|
||||
3. 掷骰 → `15`
|
||||
4. 将 `15` 写入 `alice:attack`,同步到所有客户端
|
||||
|
||||
## 属性视图
|
||||
|
||||
在 Journal 面板顶部点击 **属性** 标签切换视图:
|
||||
|
||||
- 属性按 scope 分组(全局 + 玩家)
|
||||
- 显示属性名、当前值、默认值
|
||||
- 修饰符自动合并显示
|
||||
- 可掷骰的行显示 🎲 按钮
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可修改所有属性
|
||||
- **玩家**:只能修改 `scope: player` 的属性
|
||||
- **观察者**:不能修改任何属性
|
||||
|
||||
## 修饰符(modifier)
|
||||
|
||||
修饰符自动加到 `target` 属性上:
|
||||
|
||||
```yaml role=stat
|
||||
- key: strength
|
||||
scope: player
|
||||
type: number
|
||||
default: 10
|
||||
|
||||
- key: str_mod
|
||||
scope: player
|
||||
type: modifier
|
||||
target: strength
|
||||
```
|
||||
|
||||
`/stat set str_mod=3` 后,`strength` 的计算值为 `10 + 3 = 13`。
|
||||
多个修饰符指向同一目标时累加。
|
||||
|
||||
## 派生属性(derived)
|
||||
|
||||
通过公式从其他属性计算:
|
||||
|
||||
```yaml role=stat
|
||||
- key: hp_max
|
||||
scope: player
|
||||
type: derived
|
||||
formula: "strength * 2 + 10"
|
||||
```
|
||||
|
||||
公式引用其他属性时自动使用计算值(含修饰符)。
|
||||
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
||||
|
||||
## 模板属性(template)
|
||||
|
||||
模板属性用于查表掷骰(年龄表、职业表等)。
|
||||
|
||||
### 方式一:stat-modifiers(推荐)
|
||||
|
||||
一个代码块同时生成模板 stat 和修饰符 stat defs:
|
||||
|
||||
```csv id=年龄 role=stat-modifiers
|
||||
1d10,label,mind,heart,strength,speed
|
||||
1-3,青少年,-10,+20,,
|
||||
4-7,成年,,-10,,+20
|
||||
8-9,老年,,+20,-10,
|
||||
10,换躯者,,,+30,-10
|
||||
```
|
||||
|
||||
这会自动生成:
|
||||
- `age`(type: template, template: 年龄)
|
||||
- `age_mind`(type: modifier, target: mind)
|
||||
- `age_heart`(type: modifier, target: heart)
|
||||
- `age_strength`(type: modifier, target: strength)
|
||||
- `age_speed`(type: modifier, target: speed)
|
||||
|
||||
修饰符键名规则:`{id}_{列名}`,目标为列名本身。
|
||||
|
||||
### 方式二:手动定义
|
||||
|
||||
分别定义模板表和修饰符 stat:
|
||||
|
||||
```csv id=年龄 role=stat-template
|
||||
1d10,label,age_mind,age_heart,age_strength,age_speed
|
||||
1-3,青少年,-10,+20,,
|
||||
4-7,成年,,-10,,+20
|
||||
8-9,老年,,+20,-10,
|
||||
10,换躯者,,,+30,-10
|
||||
```
|
||||
|
||||
```yaml role=stat
|
||||
- key: age_mind
|
||||
type: modifier
|
||||
target: mind
|
||||
- key: age_heart
|
||||
type: modifier
|
||||
target: heart
|
||||
- key: age
|
||||
type: template
|
||||
template: 年龄
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
### 使用
|
||||
|
||||
- `/stat roll age` — 掷 `1d10`,匹配范围,应用修饰符
|
||||
- `/stat set age=换躯者` — 直接设置,同样应用修饰符
|
||||
|
||||
`/stat roll age` 或 `/stat set age=青少年` 时:
|
||||
1. 发布 `set age=青少年`
|
||||
2. 同时发布 `set alice:age_mind=-10`、`set alice:age_heart=+20` 等
|
||||
|
||||
修饰符值中的 `+`/`-` 前缀表示相对调整,基于当前值计算。
|
||||
|
||||
### 模板表语法
|
||||
|
||||
- **第一列(header)**:骰子表达式,如 `1d10`
|
||||
- **第一列(rows)**:匹配范围,`1-3`(区间)、`4`(精确)、`1-3,5`(多个)
|
||||
- **`label`**:显示名称
|
||||
- **其余列**:修饰符键名,值为变化量(`+`加、`-`减、空不修改)
|
||||
|
||||
## 掷骰公式中的属性引用
|
||||
|
||||
`roll` 字段中的标识符自动替换为当前属性值:
|
||||
|
||||
```yaml role=stat
|
||||
- key: attack
|
||||
scope: player
|
||||
type: number
|
||||
default: 0
|
||||
roll: "1d20 + attack + str_mod"
|
||||
```
|
||||
|
|
@ -496,7 +496,7 @@ const api = {
|
|||
},
|
||||
|
||||
/** Register a callback for state patches. Sends full state immediately. */
|
||||
subscribe(callback: Comlink.ProxyOrClone<PatchCallback>): void {
|
||||
subscribe(callback: Comlink.ProxyOrClone<PatchCallback>): () => void {
|
||||
const cb = callback as PatchCallback;
|
||||
subscribers.add(cb);
|
||||
// Send initial full state
|
||||
|
|
@ -513,6 +513,9 @@ const api = {
|
|||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue