Compare commits
9 Commits
ae3045f96b
...
a4cfcde613
| Author | SHA1 | Date |
|---|---|---|
|
|
a4cfcde613 | |
|
|
1c69bf394c | |
|
|
19c66640b5 | |
|
|
ef8b56e5b6 | |
|
|
45cd9a01ac | |
|
|
138c089514 | |
|
|
44e4e15f3f | |
|
|
9747409a1f | |
|
|
4faff9a391 |
|
|
@ -11,7 +11,10 @@ import {
|
||||||
scanCompletions,
|
scanCompletions,
|
||||||
type CompletionsPayload,
|
type CompletionsPayload,
|
||||||
} from "../completions/index.js";
|
} from "../completions/index.js";
|
||||||
import { extractInlineBlocks } from "../inline-blocks.js";
|
import {
|
||||||
|
processBlocks,
|
||||||
|
type ProcessedBlocks,
|
||||||
|
} from "../completions/block-processor.js";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
|
|
@ -83,8 +86,16 @@ function getBestIP(): string {
|
||||||
/**
|
/**
|
||||||
* 扫描目录内的 .md 文件,生成索引
|
* 扫描目录内的 .md 文件,生成索引
|
||||||
*/
|
*/
|
||||||
export function scanDirectory(dir: string): ContentIndex {
|
export function scanDirectory(dir: string): {
|
||||||
|
index: ContentIndex;
|
||||||
|
blocks: ProcessedBlocks;
|
||||||
|
} {
|
||||||
const index: ContentIndex = {};
|
const index: ContentIndex = {};
|
||||||
|
const blocks: ProcessedBlocks = {
|
||||||
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
|
sparkTables: [],
|
||||||
|
};
|
||||||
|
|
||||||
function scan(currentPath: string, relativePath: string) {
|
function scan(currentPath: string, relativePath: string) {
|
||||||
const entries = readdirSync(currentPath);
|
const entries = readdirSync(currentPath);
|
||||||
|
|
@ -107,12 +118,11 @@ export function scanDirectory(dir: string): ContentIndex {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(fullPath, "utf-8");
|
const content = readFileSync(fullPath, "utf-8");
|
||||||
if (entry.endsWith(".md")) {
|
if (entry.endsWith(".md")) {
|
||||||
const stripped = extractInlineBlocks(
|
const result = processBlocks(content, normalizedRelPath, index);
|
||||||
content,
|
index[normalizedRelPath] = result.stripped;
|
||||||
normalizedRelPath,
|
blocks.stats.push(...result.blocks.stats);
|
||||||
index,
|
blocks.statTemplates.push(...result.blocks.statTemplates);
|
||||||
);
|
blocks.sparkTables.push(...result.blocks.sparkTables);
|
||||||
index[normalizedRelPath] = stripped;
|
|
||||||
} else {
|
} else {
|
||||||
index[normalizedRelPath] = content;
|
index[normalizedRelPath] = content;
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +134,7 @@ export function scanDirectory(dir: string): ContentIndex {
|
||||||
}
|
}
|
||||||
|
|
||||||
scan(dir, "");
|
scan(dir, "");
|
||||||
return index;
|
return { index, blocks };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -267,23 +277,32 @@ export function createContentServer(
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
let contentIndex: ContentIndex = {};
|
||||||
|
let collectedBlocks: ProcessedBlocks = {
|
||||||
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
|
sparkTables: [],
|
||||||
|
};
|
||||||
let completionsIndex: CompletionsPayload = {
|
let completionsIndex: CompletionsPayload = {
|
||||||
dice: [],
|
dice: [],
|
||||||
links: [],
|
links: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Re-scan completions from current content index (cached) */
|
/** Re-scan completions from current content index and collected blocks */
|
||||||
function recomputeCompletions(): void {
|
function recomputeCompletions(): void {
|
||||||
completionsIndex = scanCompletions(contentIndex);
|
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
|
||||||
console.log(
|
console.log(
|
||||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`,
|
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描内容目录生成索引
|
// 扫描内容目录生成索引
|
||||||
console.log("扫描内容目录...");
|
console.log("扫描内容目录...");
|
||||||
contentIndex = scanDirectory(contentDir);
|
const scanResult = scanDirectory(contentDir);
|
||||||
|
contentIndex = scanResult.index;
|
||||||
|
collectedBlocks = scanResult.blocks;
|
||||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
|
|
||||||
|
|
@ -306,12 +325,11 @@ export function createContentServer(
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
if (relPath.endsWith(".md")) {
|
if (relPath.endsWith(".md")) {
|
||||||
const stripped = extractInlineBlocks(
|
const result = processBlocks(content, relPath, contentIndex);
|
||||||
content,
|
contentIndex[relPath] = result.stripped;
|
||||||
relPath,
|
// Re-scan to get fresh blocks (simpler than per-file merge)
|
||||||
contentIndex,
|
const rescan = scanDirectory(contentDir);
|
||||||
);
|
collectedBlocks = rescan.blocks;
|
||||||
contentIndex[relPath] = stripped;
|
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
} else {
|
} else {
|
||||||
contentIndex[relPath] = content;
|
contentIndex[relPath] = content;
|
||||||
|
|
@ -332,12 +350,10 @@ export function createContentServer(
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
if (relPath.endsWith(".md")) {
|
if (relPath.endsWith(".md")) {
|
||||||
const stripped = extractInlineBlocks(
|
const result = processBlocks(content, relPath, contentIndex);
|
||||||
content,
|
contentIndex[relPath] = result.stripped;
|
||||||
relPath,
|
const rescan = scanDirectory(contentDir);
|
||||||
contentIndex,
|
collectedBlocks = rescan.blocks;
|
||||||
);
|
|
||||||
contentIndex[relPath] = stripped;
|
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
} else {
|
} else {
|
||||||
contentIndex[relPath] = content;
|
contentIndex[relPath] = content;
|
||||||
|
|
@ -357,7 +373,11 @@ export function createContentServer(
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
delete contentIndex[relPath];
|
delete contentIndex[relPath];
|
||||||
console.log(`[删除] ${relPath}`);
|
console.log(`[删除] ${relPath}`);
|
||||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
if (relPath.endsWith(".md")) {
|
||||||
|
const rescan = scanDirectory(contentDir);
|
||||||
|
collectedBlocks = rescan.blocks;
|
||||||
|
recomputeCompletions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* CLI block processor — wraps block-scanner with Node-specific index injection
|
||||||
|
* and content stripping.
|
||||||
|
*
|
||||||
|
* Uses Node `crypto` and `path` — not safe for browser import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { posix } from "path";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import Slugger from "github-slugger";
|
||||||
|
import {
|
||||||
|
parseStatYaml,
|
||||||
|
parseStatCsv,
|
||||||
|
parseTemplateCsv,
|
||||||
|
type StatDef,
|
||||||
|
type StatTemplate,
|
||||||
|
} from "./stat-parser.js";
|
||||||
|
import {
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
parseBlockAttrs,
|
||||||
|
resolveBlockAs,
|
||||||
|
parseSparkTableCsv,
|
||||||
|
} from "./block-scanner.js";
|
||||||
|
import type { SparkTableCompletion } from "./types.js";
|
||||||
|
|
||||||
|
// Re-export shared pieces for convenience
|
||||||
|
export {
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
parseBlockAttrs,
|
||||||
|
resolveBlockAs,
|
||||||
|
parseSparkTableCsv,
|
||||||
|
type BlockAttrs,
|
||||||
|
} from "./block-scanner.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ProcessedBlocks {
|
||||||
|
stats: StatDef[];
|
||||||
|
statTemplates: StatTemplate[];
|
||||||
|
sparkTables: SparkTableCompletion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockResult {
|
||||||
|
/** Content with blocks processed (stripped or replaced with directives) */
|
||||||
|
stripped: string;
|
||||||
|
/** Parsed blocks for completions */
|
||||||
|
blocks: ProcessedBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content hash
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function contentHash(body: string): string {
|
||||||
|
return createHash("md5").update(body).digest("hex").slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main processor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all attributed fenced code blocks in a markdown file.
|
||||||
|
*
|
||||||
|
* - Strips/replaces blocks based on `as`
|
||||||
|
* - Injects `role=file` bodies into the content index
|
||||||
|
* - Collects stat/template/spark-table blocks for completions
|
||||||
|
*/
|
||||||
|
export function processBlocks(
|
||||||
|
content: string,
|
||||||
|
fileRelativePath: string,
|
||||||
|
index: Record<string, string>,
|
||||||
|
): BlockResult {
|
||||||
|
const fileDir = posix.dirname(fileRelativePath);
|
||||||
|
const slugger = new Slugger();
|
||||||
|
|
||||||
|
const blocks: ProcessedBlocks = {
|
||||||
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
|
sparkTables: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripped = content.replace(
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
(
|
||||||
|
_match: string,
|
||||||
|
lang: string,
|
||||||
|
infoString: string,
|
||||||
|
body: string,
|
||||||
|
): string => {
|
||||||
|
const attrs = parseBlockAttrs(infoString);
|
||||||
|
attrs.lang = attrs.lang || lang;
|
||||||
|
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
|
||||||
|
|
||||||
|
// ---- Dispatch by role ----
|
||||||
|
|
||||||
|
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 === "spark-table") {
|
||||||
|
const parsed = parseSparkTableCsv(body, fileRelativePath, slugger);
|
||||||
|
if (parsed) blocks.sparkTables.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.role === "file") {
|
||||||
|
const filename = attrs.id
|
||||||
|
? `${attrs.id}.${attrs.lang || "txt"}`
|
||||||
|
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
|
||||||
|
const resolvedPath = posix.join(fileDir, filename);
|
||||||
|
index[resolvedPath] = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Render by as ----
|
||||||
|
|
||||||
|
if (effectiveAs === "codeblock") {
|
||||||
|
return _match; // keep as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveAs === "none") {
|
||||||
|
return ""; // strip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directive: :md-table[./file.csv], :md-card[./file.csv], :md-dice[./file.csv]
|
||||||
|
if (effectiveAs.startsWith("md-")) {
|
||||||
|
const filename = attrs.id
|
||||||
|
? `${attrs.id}.${attrs.lang || "txt"}`
|
||||||
|
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
|
||||||
|
const resolvedPath = posix.join(fileDir, filename);
|
||||||
|
|
||||||
|
// Ensure body is in the index for directive rendering
|
||||||
|
if (!index[resolvedPath]) {
|
||||||
|
index[resolvedPath] = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect extra attrs for the directive
|
||||||
|
const extra = { ...attrs.extra };
|
||||||
|
const extraStr = Object.keys(extra).length
|
||||||
|
? `{${Object.entries(extra)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(" ")}}`
|
||||||
|
: "";
|
||||||
|
return `:${effectiveAs}[./${filename}]${extraStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown as → strip
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { stripped, blocks };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* Shared block scanning utilities — safe for both CLI and browser.
|
||||||
|
*
|
||||||
|
* Parses fenced code blocks with attributes:
|
||||||
|
* ```lang id=xxx role=xxx as=xxx
|
||||||
|
*
|
||||||
|
* - `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 (template names, file paths)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Slugger from "github-slugger";
|
||||||
|
import type { SparkTableCompletion } from "./types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BlockAttrs {
|
||||||
|
lang: string;
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
as?: string;
|
||||||
|
/** Any other attributes not in the standard set */
|
||||||
|
extra: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regex
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Matches fenced code blocks with an info string (at least one attr). */
|
||||||
|
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Attribute parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Parse key="value" and key=value pairs from an attribute string. */
|
||||||
|
export function parseBlockAttrs(info: string): BlockAttrs {
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
|
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(info)) !== null) {
|
||||||
|
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lang, id, role, as, ...extra } = attrs;
|
||||||
|
return { lang: lang || "", id, role, as, extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// as resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the effective `as` value.
|
||||||
|
*
|
||||||
|
* Defaults:
|
||||||
|
* - role is set, no explicit as → "none" (strip — it's metadata)
|
||||||
|
* - no role, no as → "codeblock" (keep as visible code block)
|
||||||
|
*/
|
||||||
|
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
|
||||||
|
if (as) return as;
|
||||||
|
if (role) return "none";
|
||||||
|
return "codeblock";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spark table parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DICE_RE = /^d\d+$/i;
|
||||||
|
|
||||||
|
export function parseSparkTableCsv(
|
||||||
|
body: string,
|
||||||
|
filePath: string,
|
||||||
|
slugger: Slugger,
|
||||||
|
): SparkTableCompletion | null {
|
||||||
|
const lines = body.trim().split(/\r?\n/);
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
|
||||||
|
const headers = lines[0].split(",").map((h) => h.trim());
|
||||||
|
if (headers.length < 2) return null;
|
||||||
|
if (!DICE_RE.test(headers[0])) return null;
|
||||||
|
|
||||||
|
const dataHeaders = headers.slice(1);
|
||||||
|
const slug = dataHeaders
|
||||||
|
.map((h) => slugger.slug(h.toLowerCase()))
|
||||||
|
.join("-");
|
||||||
|
|
||||||
|
const basePath = filePath.replace(/\.md$/, "");
|
||||||
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||||
|
const combinedSlug = `${fileName}-${slug}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${fileName} § ${slug}`,
|
||||||
|
notation: headers[0],
|
||||||
|
slug: combinedSlug,
|
||||||
|
filePath: basePath,
|
||||||
|
headers: dataHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,35 +4,34 @@
|
||||||
|
|
||||||
import { diceSource } from "./sources/dice.js";
|
import { diceSource } from "./sources/dice.js";
|
||||||
import { linksSource } from "./sources/links.js";
|
import { linksSource } from "./sources/links.js";
|
||||||
import { sparkTablesSource } from "./sources/spark-tables.js";
|
import type { ProcessedBlocks } from "./block-processor.js";
|
||||||
import type { CompletionSource, CompletionsPayload } from "./types.js";
|
import type { CompletionsPayload } from "./types.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CompletionsPayload,
|
CompletionsPayload,
|
||||||
DiceCompletion,
|
DiceCompletion,
|
||||||
LinkCompletion,
|
LinkCompletion,
|
||||||
SparkTableCompletion,
|
SparkTableCompletion,
|
||||||
|
StatDef,
|
||||||
|
StatTemplate,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/** Registered sources — open for extension */
|
|
||||||
const sources: CompletionSource[] = [
|
|
||||||
diceSource,
|
|
||||||
linksSource,
|
|
||||||
sparkTablesSource,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the full content index and return structured completion data.
|
* Build completions from the content index and pre-collected blocks.
|
||||||
* Called at server startup and on any file change.
|
* Called at server startup and on any file change.
|
||||||
*/
|
*/
|
||||||
export function scanCompletions(
|
export function scanCompletions(
|
||||||
index: Record<string, string>,
|
index: Record<string, string>,
|
||||||
|
blocks: ProcessedBlocks,
|
||||||
): CompletionsPayload {
|
): CompletionsPayload {
|
||||||
const payload: Record<string, unknown[]> = {};
|
const dice = diceSource.scan(index) as CompletionsPayload["dice"];
|
||||||
|
const links = linksSource.scan(index) as CompletionsPayload["links"];
|
||||||
|
|
||||||
for (const source of sources) {
|
return {
|
||||||
payload[source.key] = source.scan(index);
|
dice,
|
||||||
}
|
links,
|
||||||
|
sparkTables: blocks.sparkTables,
|
||||||
return payload as unknown as CompletionsPayload;
|
stats: blocks.stats,
|
||||||
|
statTemplates: blocks.statTemplates,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* Spark table completion source — extracts spark tables (markdown tables
|
|
||||||
* whose first column header is a dice formula like d6, d20, etc.) from all
|
|
||||||
* .md files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import type { CompletionSource, SparkTableCompletion } from "../types.js";
|
|
||||||
|
|
||||||
/** Regex: matches a pipe-delimited markdown table row */
|
|
||||||
function splitTableRow(line: string): string[] | null {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.includes("|")) return null;
|
|
||||||
let inner = trimmed;
|
|
||||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
|
||||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
|
||||||
return inner.split("|").map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEP_RE = /^:?-{3,}:?$/;
|
|
||||||
const DICE_RE = /^d\d+$/i;
|
|
||||||
|
|
||||||
export const sparkTablesSource: CompletionSource = {
|
|
||||||
key: "sparkTables",
|
|
||||||
|
|
||||||
scan(index) {
|
|
||||||
const items: SparkTableCompletion[] = [];
|
|
||||||
|
|
||||||
for (const [filePath, content] of Object.entries(index)) {
|
|
||||||
if (!filePath.endsWith(".md")) continue;
|
|
||||||
|
|
||||||
// Fresh slugger per file so duplicate headers across files don't
|
|
||||||
// pollute each other. (github-slugger is stateful.)
|
|
||||||
const slugger = new Slugger();
|
|
||||||
|
|
||||||
const lines = content.split(/\r?\n/);
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const headerCells = splitTableRow(lines[i]);
|
|
||||||
if (!headerCells || headerCells.length < 2) continue;
|
|
||||||
if (!DICE_RE.test(headerCells[0])) continue;
|
|
||||||
|
|
||||||
// Check separator row
|
|
||||||
if (i + 1 >= lines.length) continue;
|
|
||||||
const sepCells = splitTableRow(lines[i + 1]);
|
|
||||||
if (!sepCells || !sepCells.every((c) => SEP_RE.test(c))) continue;
|
|
||||||
|
|
||||||
// Collect body rows
|
|
||||||
let j = i + 2;
|
|
||||||
while (j < lines.length) {
|
|
||||||
const rowCells = splitTableRow(lines[j]);
|
|
||||||
if (!rowCells) break;
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
if (j <= i + 2) continue; // No body rows
|
|
||||||
|
|
||||||
// Build slug from data columns
|
|
||||||
const dataHeaders = headerCells.slice(1);
|
|
||||||
const slug = dataHeaders
|
|
||||||
.map((h: string) => slugger.slug(h.toLowerCase()))
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
||||||
|
|
||||||
// Combined key: pageName-columnSlug (what user types after /spark)
|
|
||||||
const combinedSlug = `${fileName}-${slug}`;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
label: `${fileName} § ${slug}`,
|
|
||||||
notation: headerCells[0],
|
|
||||||
slug: combinedSlug,
|
|
||||||
filePath: basePath,
|
|
||||||
headers: dataHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
i = j - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a ```csv role=stat-template file=xxx block.
|
||||||
|
*
|
||||||
|
* The first header is the dice notation (e.g. "1d10").
|
||||||
|
* The second header is "label".
|
||||||
|
* Remaining headers are modifier keys.
|
||||||
|
*/
|
||||||
|
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]; // e.g. "1d10"
|
||||||
|
const labelIdx = headers.indexOf("label");
|
||||||
|
|
||||||
|
// All columns after the notation & label are modifier keys
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +2,10 @@
|
||||||
* Completion source types — shared between CLI scanner and frontend consumer.
|
* Completion source types — shared between CLI scanner and frontend consumer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { StatDef, StatTemplate } from "./stat-parser.js";
|
||||||
|
|
||||||
|
export type { StatDef, StatTemplate };
|
||||||
|
|
||||||
/** A single dice expression found in a markdown file */
|
/** A single dice expression found in a markdown file */
|
||||||
export interface DiceCompletion {
|
export interface DiceCompletion {
|
||||||
/** Display text shown in the dropdown */
|
/** Display text shown in the dropdown */
|
||||||
|
|
@ -36,11 +40,12 @@ export interface SparkTableCompletion {
|
||||||
headers: string[];
|
headers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-level payload served at /__COMPLETIONS.json */
|
|
||||||
export interface CompletionsPayload {
|
export interface CompletionsPayload {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
|
stats: StatDef[];
|
||||||
|
statTemplates: StatTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { posix } from "path";
|
|
||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regex to match a fenced code block with attributes, e.g.:
|
|
||||||
*
|
|
||||||
* ```csv file="card-data.csv"
|
|
||||||
* name, cost, effect
|
|
||||||
* Fireball, 1, Blast em
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ```csv as="md-table" roll=true
|
|
||||||
* name, cost, effect
|
|
||||||
* Fireball, 1, Blast em
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Group 1: language identifier (e.g. "csv")
|
|
||||||
* Group 2: attribute string (e.g. `file="card-data.csv" as="md-table" roll=true`)
|
|
||||||
* Group 3: the code block body
|
|
||||||
*/
|
|
||||||
const INLINE_FILE_BLOCK_RE = /^```(\w*)\s+(.+?)\s*\n([\s\S]*?)```\s*$/gm;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse key="value" and key=value pairs from an attribute string.
|
|
||||||
*/
|
|
||||||
function parseAttrs(info: string): Record<string, string> {
|
|
||||||
const attrs: Record<string, string> = {};
|
|
||||||
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = re.exec(info)) !== null) {
|
|
||||||
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
|
||||||
}
|
|
||||||
return attrs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Short content hash for stable generated filenames.
|
|
||||||
*/
|
|
||||||
function contentHash(body: string): string {
|
|
||||||
return createHash("md5").update(body).digest("hex").slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process fenced code blocks that contain attributes.
|
|
||||||
*
|
|
||||||
* For each block like:
|
|
||||||
* ```lang file="card-data.csv" as="md-table" roll=true
|
|
||||||
* content
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* 1. Strips the block from the returned text
|
|
||||||
* 2. Injects a synthetic entry into `index` at the resolved file path
|
|
||||||
* 3. If `as` is present, replaces the block with a directive like
|
|
||||||
* `:md-table[./file.csv]{roll=true}`
|
|
||||||
*
|
|
||||||
* @param content - Raw file content
|
|
||||||
* @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md")
|
|
||||||
* @param index - The content index to inject synthetic entries into
|
|
||||||
* @returns Content with inline blocks processed
|
|
||||||
*/
|
|
||||||
export function extractInlineBlocks(
|
|
||||||
content: string,
|
|
||||||
fileRelativePath: string,
|
|
||||||
index: Record<string, string>,
|
|
||||||
): string {
|
|
||||||
const fileDir = posix.dirname(fileRelativePath);
|
|
||||||
|
|
||||||
return content.replace(
|
|
||||||
INLINE_FILE_BLOCK_RE,
|
|
||||||
(_match: string, lang: string, infoString: string, body: string) => {
|
|
||||||
const attrs = parseAttrs(infoString);
|
|
||||||
|
|
||||||
// Only process blocks that have `file` or `as`; leave others untouched
|
|
||||||
if (!attrs.file && !attrs.as) {
|
|
||||||
return _match;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine filename: explicit `file` or content-hash generated
|
|
||||||
const filename =
|
|
||||||
attrs.file || `_inline_${contentHash(body)}.${lang || "txt"}`;
|
|
||||||
const resolvedPath = posix.join(fileDir, filename);
|
|
||||||
|
|
||||||
// Inject the body as a synthetic file entry
|
|
||||||
index[resolvedPath] = body;
|
|
||||||
console.log(
|
|
||||||
`[inline] Extracted ${resolvedPath} from ${fileRelativePath}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If `as` is set, emit a directive instead of stripping
|
|
||||||
if (attrs.as) {
|
|
||||||
const extra = { ...attrs };
|
|
||||||
delete extra.file;
|
|
||||||
delete extra.as;
|
|
||||||
const extraStr = Object.keys(extra).length
|
|
||||||
? `{${Object.entries(extra)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join(" ")}}`
|
|
||||||
: "";
|
|
||||||
return `:${attrs.as}[./${filename}]${extraStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""; // has `file` but no `as` — strip the block (body is already injected into index)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -43,8 +43,9 @@ function parseEntry(raw: string): JournalDocEntry | null {
|
||||||
|
|
||||||
import journalGmRaw from "../doc-entries/journal-gm.md";
|
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||||
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||||
|
import journalStatRaw from "../doc-entries/journal-stat.md";
|
||||||
|
|
||||||
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw];
|
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw, journalStatRaw];
|
||||||
|
|
||||||
let _entries: JournalDocEntry[] | null = null;
|
let _entries: JournalDocEntry[] | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* CompletionsDropdown — autocomplete popover for journal command input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
||||||
|
import type { CompletionItem } from "./command-parser";
|
||||||
|
import type { CompletionsState } from "./completions";
|
||||||
|
|
||||||
|
export interface CompletionsDropdownProps {
|
||||||
|
show: boolean;
|
||||||
|
items: CompletionItem[];
|
||||||
|
selectedIdx: number;
|
||||||
|
onSelect: (item: CompletionItem) => void;
|
||||||
|
onHover: (idx: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
state: CompletionsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
let containerRef!: HTMLDivElement;
|
||||||
|
|
||||||
|
// Scroll selected into view
|
||||||
|
createEffect(() => {
|
||||||
|
const idx = props.selectedIdx;
|
||||||
|
if (!props.show || !containerRef) return;
|
||||||
|
const el = containerRef.querySelector(`[data-comp-idx="${idx}"]`);
|
||||||
|
if (el) el.scrollIntoView({ block: "nearest" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside
|
||||||
|
onMount(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef && !containerRef.contains(e.target as Node)) {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
onCleanup(() => document.removeEventListener("mousedown", handler));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.show}>
|
||||||
|
<Show
|
||||||
|
when={props.state.status === "loaded" || props.state.status === "empty"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.state.status === "loading"}
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
||||||
|
>
|
||||||
|
{(props.state as any).message || "无法加载补全数据"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
||||||
|
>
|
||||||
|
正在加载补全数据…
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={props.items.length > 0}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
||||||
|
>
|
||||||
|
<For each={props.items}>
|
||||||
|
{(item, idx) => (
|
||||||
|
<div
|
||||||
|
data-comp-idx={idx()}
|
||||||
|
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
||||||
|
item.kind === "no-results"
|
||||||
|
? "text-gray-400 italic cursor-default"
|
||||||
|
: idx() === props.selectedIdx
|
||||||
|
? "bg-blue-50 text-blue-700"
|
||||||
|
: "hover:bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => props.onSelect(item)}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
item.kind !== "no-results" && props.onHover(idx())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={item.kind !== "no-results"}
|
||||||
|
fallback={
|
||||||
|
<span class="text-xs text-gray-300 shrink-0">—</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={`text-xs px-1 rounded shrink-0 ${
|
||||||
|
item.kind === "command"
|
||||||
|
? "bg-purple-100 text-purple-700"
|
||||||
|
: "bg-gray-100 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.kind === "command" ? "cmd" : "val"}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="font-mono truncate flex-1">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,72 +5,27 @@
|
||||||
* - Plain text → "chat" type
|
* - Plain text → "chat" type
|
||||||
* - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM)
|
* - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM)
|
||||||
* - `/link path#section` → "link" type
|
* - `/link path#section` → "link" type
|
||||||
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
|
* - `/stat set key=value` → "stat" type
|
||||||
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
|
* - `/` alone opens completions dropdown
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
|
||||||
Component,
|
|
||||||
createSignal,
|
|
||||||
createEffect,
|
|
||||||
onMount,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
For,
|
|
||||||
Switch,
|
|
||||||
Match,
|
|
||||||
} from "solid-js";
|
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
|
import {
|
||||||
// ---- Helpers ----
|
resolveStatRoll,
|
||||||
|
resolveTemplateSet,
|
||||||
interface CompletionItem {
|
canModifyStat,
|
||||||
label: string;
|
fullKey,
|
||||||
kind: "command" | "value" | "no-results";
|
findStatDef,
|
||||||
insertText: string;
|
} from "./stat-helpers";
|
||||||
}
|
import { parseInput } from "./command-parser";
|
||||||
|
import type { CompletionItem } from "./command-parser";
|
||||||
interface ParsedInput {
|
import { buildCompletions } from "./command-completions";
|
||||||
type: "chat" | "roll" | "spark" | "link";
|
import { CompletionsDropdown } from "./CompletionsDropdown";
|
||||||
payload: Record<string, unknown>;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInput(raw: string): ParsedInput {
|
|
||||||
if (raw.startsWith("/roll ")) {
|
|
||||||
const notation = raw.slice("/roll ".length).trim();
|
|
||||||
if (!notation)
|
|
||||||
return { type: "roll", payload: {}, error: "需要骰子表达式" };
|
|
||||||
return { type: "roll", payload: { notation, label: notation } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.startsWith("/spark ")) {
|
|
||||||
const key = raw.slice("/spark ".length).trim();
|
|
||||||
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
|
|
||||||
return { type: "spark", payload: { key } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.startsWith("/link ")) {
|
|
||||||
const arg = raw.slice("/link ".length).trim();
|
|
||||||
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
|
|
||||||
const hashIdx = arg.indexOf("#");
|
|
||||||
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
|
|
||||||
const section =
|
|
||||||
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
|
|
||||||
return { type: "link", payload: { path, section } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// /roll, /spark, or /link with no space — need to complete, don't send
|
|
||||||
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
|
|
||||||
return { type: "chat", payload: {}, error: "请补全命令" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "chat", payload: { text: raw } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Component ----
|
// ---- Component ----
|
||||||
|
|
||||||
|
|
@ -89,14 +44,13 @@ export const JournalInput: Component = () => {
|
||||||
const [selectedIdx, setSelectedIdx] = createSignal(0);
|
const [selectedIdx, setSelectedIdx] = createSignal(0);
|
||||||
|
|
||||||
let textareaRef!: HTMLTextAreaElement;
|
let textareaRef!: HTMLTextAreaElement;
|
||||||
let completionsRef!: HTMLDivElement;
|
|
||||||
|
|
||||||
// Ensure completions are loading on mount
|
// Ensure completions are loading on mount
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void ensureCompletions();
|
void ensureCompletions();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.)
|
// Listen for action prefill requests from article buttons
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const action = actionPrefill();
|
const action = actionPrefill();
|
||||||
if (action) {
|
if (action) {
|
||||||
|
|
@ -106,21 +60,25 @@ export const JournalInput: Component = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Derived ----
|
||||||
|
|
||||||
|
const completions = () =>
|
||||||
|
buildCompletions(text().trim(), {
|
||||||
|
data: comp.data,
|
||||||
|
isGm: isGm(),
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Send ----
|
// ---- Send ----
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const raw = text().trim();
|
const raw = text().trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
// Players / observers: everything is plain chat
|
// Observers: everything is plain chat
|
||||||
if (isPlayer() || isObserver()) {
|
if (isObserver()) {
|
||||||
const result = sendMessage("chat", { text: raw });
|
const result = sendMessage("chat", { text: raw });
|
||||||
if (!result.success) {
|
const r = unwrap(result);
|
||||||
setError(result.error);
|
finish(r.ok, r.err);
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,171 +89,192 @@ export const JournalInput: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Players: chat + stat commands only
|
||||||
|
if (isPlayer()) {
|
||||||
|
if (parsed.type === "chat") {
|
||||||
|
const result = sendMessage("chat", { text: raw });
|
||||||
|
const r = unwrap(result);
|
||||||
|
finish(r.ok, r.err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "stat") {
|
||||||
|
handleStat(parsed.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GM: all commands
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
// GM roll: resolve the dice result locally
|
|
||||||
if (parsed.type === "roll") {
|
if (parsed.type === "roll") {
|
||||||
const p = resolveRollPayload(
|
const p = resolveRollPayload(
|
||||||
parsed.payload as { notation: string; label?: string },
|
parsed.payload as { notation: string; label?: string },
|
||||||
);
|
);
|
||||||
const result = sendMessage("roll", p);
|
const result = sendMessage("roll", p);
|
||||||
if (!result.success) {
|
const r = unwrap(result);
|
||||||
setError(result.error);
|
finish(r.ok, r.err);
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GM spark: resolve the spark table roll locally
|
|
||||||
if (parsed.type === "spark") {
|
if (parsed.type === "spark") {
|
||||||
try {
|
try {
|
||||||
const key = (parsed.payload as { key: string }).key;
|
const key = (parsed.payload as { key: string }).key;
|
||||||
// Look up filePath from completions data
|
|
||||||
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
||||||
const filePath = match?.filePath ?? "";
|
const filePath = match?.filePath ?? "";
|
||||||
const p = await resolveSparkPayload({ key, filePath });
|
const p = await resolveSparkPayload({ key, filePath });
|
||||||
const result = sendMessage("spark", p);
|
const result = sendMessage("spark", p);
|
||||||
if (!result.success) {
|
const r = unwrap(result);
|
||||||
setError(result.error);
|
finish(r.ok, r.err);
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "种子表掷骰失败");
|
setError(e instanceof Error ? e.message : "种子表掷骰失败");
|
||||||
setSending(false);
|
setSending(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "stat") {
|
||||||
|
handleStat(parsed.payload);
|
||||||
|
setSending(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = sendMessage(parsed.type, parsed.payload);
|
const result = sendMessage(parsed.type, parsed.payload);
|
||||||
if (!result.success) {
|
const r = unwrap(result);
|
||||||
setError(result.error);
|
finish(r.ok, r.err);
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handle a /stat command payload (set/del/roll) */
|
||||||
|
function handleStat(payload: Record<string, unknown>) {
|
||||||
|
const p = payload as { action?: string; key?: string; value?: string };
|
||||||
|
|
||||||
|
if (p.action === "roll" && p.key) {
|
||||||
|
const resolved = resolveStatRoll(
|
||||||
|
p.key,
|
||||||
|
comp.data.stats,
|
||||||
|
stream.stats,
|
||||||
|
stream.myName,
|
||||||
|
comp.data.statTemplates,
|
||||||
|
);
|
||||||
|
if (resolved.error) {
|
||||||
|
setError(resolved.error);
|
||||||
|
} else {
|
||||||
|
// Send the primary stat value
|
||||||
|
const result = sendMessage("stat", {
|
||||||
|
action: "set",
|
||||||
|
key: resolved.fullKey,
|
||||||
|
value: resolved.value,
|
||||||
|
});
|
||||||
|
const r = unwrap(result);
|
||||||
|
if (!r.ok) {
|
||||||
|
finish(false, r.err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send any modifier overrides from template
|
||||||
|
if (resolved.modifiers) {
|
||||||
|
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
||||||
|
sendMessage("stat", {
|
||||||
|
action: "set",
|
||||||
|
key: mk,
|
||||||
|
value: mv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p.action || !p.key) {
|
||||||
|
setError("无效的 stat 命令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve bare key to full key for set/del
|
||||||
|
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
|
||||||
|
|
||||||
|
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
|
||||||
|
setError(`无权修改属性: ${p.key}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sendMessage("stat", {
|
||||||
|
action: p.action,
|
||||||
|
key: fk,
|
||||||
|
value: p.value,
|
||||||
|
});
|
||||||
|
const r = unwrap(result);
|
||||||
|
if (!r.ok) {
|
||||||
|
finish(false, r.err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setting a template-type stat, apply its modifier overrides
|
||||||
|
if (p.action === "set" && p.value) {
|
||||||
|
const def = findStatDef(fk, comp.data.stats, stream.myName);
|
||||||
|
if (def) {
|
||||||
|
const modifiers = resolveTemplateSet(
|
||||||
|
def,
|
||||||
|
p.value,
|
||||||
|
comp.data.stats,
|
||||||
|
stream.stats,
|
||||||
|
stream.myName,
|
||||||
|
comp.data.statTemplates,
|
||||||
|
);
|
||||||
|
if (modifiers) {
|
||||||
|
for (const [mk, mv] of Object.entries(modifiers)) {
|
||||||
|
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a bare or full key to the actual runtime key. */
|
||||||
|
function resolveKey(
|
||||||
|
inputKey: string,
|
||||||
|
statDefs: typeof comp.data.stats,
|
||||||
|
playerName: string,
|
||||||
|
): string {
|
||||||
|
// Exact match
|
||||||
|
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
|
||||||
|
return inputKey;
|
||||||
|
// Bare key → full key
|
||||||
|
const def = statDefs.find((d) => d.key === inputKey);
|
||||||
|
if (def) return fullKey(def, playerName);
|
||||||
|
return inputKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear text + error on success, or set error on failure. */
|
||||||
|
function finish(success: boolean, err?: string) {
|
||||||
|
if (success) {
|
||||||
|
setText("");
|
||||||
|
} else if (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
setSending(false);
|
setSending(false);
|
||||||
textareaRef?.focus();
|
textareaRef?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Completions ----
|
/** Unwrap a sendMessage result into (success, error?) for finish(). */
|
||||||
// ---- Scroll selected completion into view ----
|
function unwrap<R>(
|
||||||
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
createEffect(() => {
|
) {
|
||||||
const idx = selectedIdx();
|
return r.success
|
||||||
if (!showCompletions() || !completionsRef) return;
|
? ({ ok: true, err: undefined } as const)
|
||||||
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`);
|
: ({ ok: false, err: r.error } as const);
|
||||||
if (el) el.scrollIntoView({ block: "nearest" });
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Completions ----
|
// ---- Completions ----
|
||||||
|
|
||||||
function buildCompletions(): CompletionItem[] {
|
|
||||||
const raw = text().trim();
|
|
||||||
// Only GM gets completions
|
|
||||||
if (!isGm()) return [];
|
|
||||||
const data = comp.data;
|
|
||||||
const commands = [
|
|
||||||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
|
||||||
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
|
||||||
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Show commands when user types / or starts typing a command name
|
|
||||||
if (raw.startsWith("/") && !raw.includes(" ")) {
|
|
||||||
const prefix = raw.toLowerCase();
|
|
||||||
const matches = commands.filter((c) =>
|
|
||||||
c.label.toLowerCase().startsWith(prefix),
|
|
||||||
);
|
|
||||||
return matches.length > 0
|
|
||||||
? matches
|
|
||||||
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// After /roll — show dice suggestions
|
|
||||||
if (raw.startsWith("/roll ")) {
|
|
||||||
const prefix = raw.slice("/roll ".length).toLowerCase();
|
|
||||||
const matches = data.dice
|
|
||||||
.filter(
|
|
||||||
(d) =>
|
|
||||||
d.notation.toLowerCase().includes(prefix) ||
|
|
||||||
d.label.toLowerCase().includes(prefix),
|
|
||||||
)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
|
|
||||||
}
|
|
||||||
return matches.map((d) => ({
|
|
||||||
label: d.notation,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: "/roll " + d.notation,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// After /spark — show spark table suggestions
|
|
||||||
if (raw.startsWith("/spark ")) {
|
|
||||||
const prefix = raw.slice("/spark ".length).toLowerCase();
|
|
||||||
const matches = data.sparkTables
|
|
||||||
.filter(
|
|
||||||
(s) =>
|
|
||||||
s.slug.toLowerCase().includes(prefix) ||
|
|
||||||
s.label.toLowerCase().includes(prefix),
|
|
||||||
)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "未找到种子表",
|
|
||||||
kind: "no-results",
|
|
||||||
insertText: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return matches.map((s) => ({
|
|
||||||
label: `${s.slug} (${s.notation})`,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: `/spark ${s.slug}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// After /link — show article and heading suggestions
|
|
||||||
if (raw.startsWith("/link ")) {
|
|
||||||
const prefix = raw.slice("/link ".length).toLowerCase();
|
|
||||||
const matches = data.links
|
|
||||||
.filter(
|
|
||||||
(l) =>
|
|
||||||
l.path.toLowerCase().includes(prefix) ||
|
|
||||||
l.label.toLowerCase().includes(prefix),
|
|
||||||
)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
|
|
||||||
}
|
|
||||||
return matches.map((l) => {
|
|
||||||
const insert = l.section
|
|
||||||
? `/link ${l.path}#${l.section}`
|
|
||||||
: `/link ${l.path}`;
|
|
||||||
return {
|
|
||||||
label: l.label,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: insert,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentCompletions(): CompletionItem[] {
|
|
||||||
return buildCompletions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptCompletion(item: CompletionItem) {
|
function acceptCompletion(item: CompletionItem) {
|
||||||
if (item.kind === "no-results") return;
|
if (item.kind === "no-results") return;
|
||||||
setText(item.insertText);
|
setText(item.insertText);
|
||||||
|
|
@ -304,7 +283,7 @@ export const JournalInput: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCompletion(dir: "up" | "down") {
|
function selectCompletion(dir: "up" | "down") {
|
||||||
const comps = currentCompletions();
|
const comps = completions();
|
||||||
if (comps.length === 0) return;
|
if (comps.length === 0) return;
|
||||||
setSelectedIdx((prev) => {
|
setSelectedIdx((prev) => {
|
||||||
if (dir === "down") return (prev + 1) % comps.length;
|
if (dir === "down") return (prev + 1) % comps.length;
|
||||||
|
|
@ -312,14 +291,17 @@ export const JournalInput: Component = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCompletions() {
|
||||||
|
setShowCompletions(true);
|
||||||
|
setSelectedIdx(0);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Keyboard ----
|
// ---- Keyboard ----
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
const comps = currentCompletions();
|
const comps = completions();
|
||||||
const open = showCompletions();
|
const open = showCompletions();
|
||||||
|
|
||||||
// When opening completions with Tab, if the text matches a command prefix
|
|
||||||
// and there are options, accept the first. Otherwise just show.
|
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
if (open) {
|
if (open) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -328,12 +310,10 @@ export const JournalInput: Component = () => {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If not open and starts with /, open completions (GM only)
|
|
||||||
const raw = text();
|
const raw = text();
|
||||||
if (isGm() && raw.startsWith("/")) {
|
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowCompletions(true);
|
openCompletions();
|
||||||
setSelectedIdx(0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -361,7 +341,6 @@ export const JournalInput: Component = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
|
|
@ -373,8 +352,7 @@ export const JournalInput: Component = () => {
|
||||||
const raw = input.value;
|
const raw = input.value;
|
||||||
setText(raw);
|
setText(raw);
|
||||||
|
|
||||||
// Completions visibility — keep open while user types any / (GM only)
|
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
||||||
if (isGm() && raw.startsWith("/")) {
|
|
||||||
setShowCompletions(true);
|
setShowCompletions(true);
|
||||||
setSelectedIdx(0);
|
setSelectedIdx(0);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -386,98 +364,20 @@ export const JournalInput: Component = () => {
|
||||||
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
|
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Click outside ----
|
// ---- Render ----
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (completionsRef && !completionsRef.contains(e.target as Node)) {
|
|
||||||
setShowCompletions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
onCleanup(() => document.removeEventListener("mousedown", handler));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Completions placeholder — shown in the dropdown area when no matches
|
|
||||||
function renderCompletionsDropdown() {
|
|
||||||
const comps = currentCompletions();
|
|
||||||
if (comps.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={completionsRef}
|
|
||||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
|
||||||
>
|
|
||||||
<For each={comps}>
|
|
||||||
{(item, idx) => (
|
|
||||||
<div
|
|
||||||
data-comp-idx={idx()}
|
|
||||||
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
|
||||||
item.kind === "no-results"
|
|
||||||
? "text-gray-400 italic cursor-default"
|
|
||||||
: idx() === selectedIdx()
|
|
||||||
? "bg-blue-50 text-blue-700"
|
|
||||||
: "hover:bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
onClick={() => acceptCompletion(item)}
|
|
||||||
onMouseEnter={() =>
|
|
||||||
item.kind !== "no-results" && setSelectedIdx(idx())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={item.kind !== "no-results"}
|
|
||||||
fallback={<span class="text-xs text-gray-300 shrink-0">—</span>}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class={`text-xs px-1 rounded shrink-0 ${
|
|
||||||
item.kind === "command"
|
|
||||||
? "bg-purple-100 text-purple-700"
|
|
||||||
: "bg-gray-100 text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.kind === "command" ? "cmd" : "val"}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="font-mono truncate flex-1">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="border-t border-gray-200 bg-white relative">
|
<div class="border-t border-gray-200 bg-white relative">
|
||||||
{/* Completions loading / error / empty teaser */}
|
<CompletionsDropdown
|
||||||
<Show when={showCompletions()}>
|
show={showCompletions()}
|
||||||
<Switch>
|
items={completions()}
|
||||||
<Match when={comp.state.status === "loading"}>
|
selectedIdx={selectedIdx()}
|
||||||
<div
|
onSelect={acceptCompletion}
|
||||||
ref={completionsRef}
|
onHover={(idx) => setSelectedIdx(idx)}
|
||||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
onClose={() => setShowCompletions(false)}
|
||||||
>
|
state={comp.state}
|
||||||
正在加载补全数据…
|
/>
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={comp.state.status === "error"}>
|
|
||||||
<div
|
|
||||||
ref={completionsRef}
|
|
||||||
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
|
||||||
>
|
|
||||||
{(comp.state as any).message || "无法加载补全数据"}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
comp.state.status === "empty" || comp.state.status === "loaded"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderCompletionsDropdown()}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Textarea + actions */}
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<Show
|
<Show
|
||||||
when={!isObserver()}
|
when={!isObserver()}
|
||||||
|
|
@ -489,13 +389,14 @@ export const JournalInput: Component = () => {
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
id="journal-input-textarea"
|
||||||
value={text()}
|
value={text()}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
isGm()
|
isGm()
|
||||||
? "输入消息,或使用 /roll、/spark、/link 命令..."
|
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
|
||||||
: "输入消息..."
|
: "输入消息,或使用 /stat 命令..."
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||||
|
|
@ -503,7 +404,6 @@ export const JournalInput: Component = () => {
|
||||||
bg-transparent min-h-[60px]"
|
bg-transparent min-h-[60px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom row: error + buttons */}
|
|
||||||
<div class="flex items-center justify-between px-2 pb-2">
|
<div class="flex items-center justify-between px-2 pb-2">
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { JournalHeader } from "./JournalHeader";
|
||||||
import { ConnectDialog } from "./ConnectDialog";
|
import { ConnectDialog } from "./ConnectDialog";
|
||||||
import { InviteDialog } from "./InviteDialog";
|
import { InviteDialog } from "./InviteDialog";
|
||||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
|
import { StatsView } from "./StatsView";
|
||||||
|
|
||||||
export interface JournalPanelProps {
|
export interface JournalPanelProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -25,6 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [showInvite, setShowInvite] = createSignal(false);
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||||
|
const [viewMode, setViewMode] = createSignal<"stream" | "stats">("stream");
|
||||||
|
|
||||||
// Player list (exclude gm and observer)
|
// Player list (exclude gm and observer)
|
||||||
const playerEntries = () =>
|
const playerEntries = () =>
|
||||||
|
|
@ -84,8 +86,33 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* View toggle */}
|
||||||
|
<div class="flex items-center border-b border-gray-200 bg-gray-50 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("stream")}
|
||||||
|
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||||
|
viewMode() === "stream"
|
||||||
|
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
消息流
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("stats")}
|
||||||
|
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||||
|
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">
|
<div class="flex-1 min-h-0">
|
||||||
|
<Show when={viewMode() === "stream"} fallback={<StatsView />}>
|
||||||
<StreamView />
|
<StreamView />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<JournalInput />
|
<JournalInput />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -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 } 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">
|
||||||
|
{def.label}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Command completions — build the dropdown items for journal input
|
||||||
|
* autocomplete based on the current raw text and completions data.
|
||||||
|
*
|
||||||
|
* Pure function with no component dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CompletionItem } from "./command-parser";
|
||||||
|
import type { JournalCompletions } from "./completions";
|
||||||
|
|
||||||
|
export interface CompletionsContext {
|
||||||
|
data: JournalCompletions;
|
||||||
|
isGm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompletions(
|
||||||
|
raw: string,
|
||||||
|
ctx: CompletionsContext,
|
||||||
|
): CompletionItem[] {
|
||||||
|
const { data, isGm } = ctx;
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
{ 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: "/stat", kind: "command" as const, insertText: "/stat " },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Show commands when user types / or starts typing a command name
|
||||||
|
if (raw.startsWith("/") && !raw.includes(" ")) {
|
||||||
|
const prefix = raw.toLowerCase();
|
||||||
|
let matches = commands.filter((c) =>
|
||||||
|
c.label.toLowerCase().startsWith(prefix),
|
||||||
|
);
|
||||||
|
if (!isGm) {
|
||||||
|
matches = matches.filter((c) => c.label === "/stat");
|
||||||
|
}
|
||||||
|
return matches.length > 0
|
||||||
|
? matches
|
||||||
|
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// After /roll — show dice suggestions
|
||||||
|
if (raw.startsWith("/roll ")) {
|
||||||
|
const prefix = raw.slice("/roll ".length).toLowerCase();
|
||||||
|
const matches = data.dice
|
||||||
|
.filter(
|
||||||
|
(d) =>
|
||||||
|
d.notation.toLowerCase().includes(prefix) ||
|
||||||
|
d.label.toLowerCase().includes(prefix),
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((d) => ({
|
||||||
|
label: d.notation,
|
||||||
|
kind: "value" as const,
|
||||||
|
insertText: "/roll " + d.notation,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// After /spark — show spark table suggestions
|
||||||
|
if (raw.startsWith("/spark ")) {
|
||||||
|
const prefix = raw.slice("/spark ".length).toLowerCase();
|
||||||
|
const matches = data.sparkTables
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.slug.toLowerCase().includes(prefix) ||
|
||||||
|
s.label.toLowerCase().includes(prefix),
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "未找到种子表", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((s) => ({
|
||||||
|
label: `${s.slug} (${s.notation})`,
|
||||||
|
kind: "value" as const,
|
||||||
|
insertText: `/spark ${s.slug}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// After /link — show article and heading suggestions
|
||||||
|
if (raw.startsWith("/link ")) {
|
||||||
|
const prefix = raw.slice("/link ".length).toLowerCase();
|
||||||
|
const matches = data.links
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
l.path.toLowerCase().includes(prefix) ||
|
||||||
|
l.label.toLowerCase().includes(prefix),
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((l) => {
|
||||||
|
const insert = l.section
|
||||||
|
? `/link ${l.path}#${l.section}`
|
||||||
|
: `/link ${l.path}`;
|
||||||
|
return { label: l.label, kind: "value" as const, insertText: insert };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After /stat — show subcommands or stat keys
|
||||||
|
if (raw.startsWith("/stat ")) {
|
||||||
|
const rest = raw.slice("/stat ".length).trim();
|
||||||
|
|
||||||
|
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: `/stat ${s} `,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* Command parser — parse a raw input string into a typed ParsedInput.
|
||||||
|
*
|
||||||
|
* Pure function with no component dependencies, so it can live in its own
|
||||||
|
* module and keep JournalInput small.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CompletionItem {
|
||||||
|
label: string;
|
||||||
|
kind: "command" | "value" | "no-results";
|
||||||
|
insertText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedInput {
|
||||||
|
type: "chat" | "roll" | "spark" | "link" | "stat";
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInput(raw: string): ParsedInput {
|
||||||
|
if (raw.startsWith("/roll ")) {
|
||||||
|
const notation = raw.slice("/roll ".length).trim();
|
||||||
|
if (!notation)
|
||||||
|
return { type: "roll", payload: {}, error: "需要骰子表达式" };
|
||||||
|
return { type: "roll", payload: { notation, label: notation } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/spark ")) {
|
||||||
|
const key = raw.slice("/spark ".length).trim();
|
||||||
|
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
|
||||||
|
return { type: "spark", payload: { key } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/link ")) {
|
||||||
|
const arg = raw.slice("/link ".length).trim();
|
||||||
|
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
|
||||||
|
const hashIdx = arg.indexOf("#");
|
||||||
|
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
|
||||||
|
const section =
|
||||||
|
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
|
||||||
|
return { type: "link", payload: { path, section } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/stat ")) {
|
||||||
|
const arg = raw.slice("/stat ".length).trim();
|
||||||
|
if (!arg)
|
||||||
|
return { type: "stat", payload: {}, error: "需要 stat 命令 (set/del/roll)" };
|
||||||
|
|
||||||
|
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 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === "/stat") {
|
||||||
|
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "chat", payload: { text: raw } };
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,19 @@ import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
} from "../../data-loader/file-index";
|
} from "../../data-loader/file-index";
|
||||||
|
import {
|
||||||
|
parseStatYaml,
|
||||||
|
parseStatCsv,
|
||||||
|
parseTemplateCsv,
|
||||||
|
} from "../../cli/completions/stat-parser";
|
||||||
|
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
|
||||||
|
import {
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
parseBlockAttrs,
|
||||||
|
parseSparkTableCsv,
|
||||||
|
} from "../../cli/completions/block-scanner";
|
||||||
|
|
||||||
|
export type { StatDef, StatTemplate };
|
||||||
|
|
||||||
// ------------------- Types (mirrors CLI) -------------------
|
// ------------------- Types (mirrors CLI) -------------------
|
||||||
|
|
||||||
|
|
@ -42,6 +55,8 @@ export interface JournalCompletions {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
|
stats: StatDef[];
|
||||||
|
statTemplates: StatTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
|
|
@ -67,6 +82,10 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
||||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||||
links: Array.isArray(data.links) ? data.links : [],
|
links: Array.isArray(data.links) ? data.links : [],
|
||||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||||
|
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||||
|
statTemplates: Array.isArray(data.statTemplates)
|
||||||
|
? data.statTemplates
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -80,17 +99,18 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
const dice: DiceCompletion[] = [];
|
const dice: DiceCompletion[] = [];
|
||||||
const links: LinkCompletion[] = [];
|
const links: LinkCompletion[] = [];
|
||||||
const sparkTables: SparkTableCompletion[] = [];
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
const stats: StatDef[] = [];
|
||||||
|
const statTemplates: StatTemplate[] = [];
|
||||||
|
const tagRegex = /:md-dice\[([^[]+)\]/gi;
|
||||||
|
|
||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
const content = await getIndexedData(filePath);
|
const content = await getIndexedData(filePath);
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
// Fresh slugger per file so duplicate headers across files don't
|
// Fresh slugger per file
|
||||||
// pollute each other.
|
|
||||||
const slugger = new Slugger();
|
const slugger = new Slugger();
|
||||||
|
|
||||||
// Dice scan
|
// ---- Dice directives ----
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
tagRegex.lastIndex = 0;
|
tagRegex.lastIndex = 0;
|
||||||
while ((match = tagRegex.exec(content)) !== null) {
|
while ((match = tagRegex.exec(content)) !== null) {
|
||||||
|
|
@ -100,7 +120,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
dice.push({ label: raw, notation: raw, source: filePath });
|
dice.push({ label: raw, notation: raw, source: filePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link scan (headings)
|
// ---- Links (headings) ----
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
const basePath = filePath.replace(/\.md$/, "");
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||||
links.push({ path: basePath, label: fileName, section: null });
|
links.push({ path: basePath, label: fileName, section: null });
|
||||||
|
|
@ -112,51 +132,35 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spark table scan
|
// ---- Unified block scanning ----
|
||||||
const sparkLines = content.split(/\r?\n/);
|
FENCED_BLOCK_RE.lastIndex = 0;
|
||||||
for (let i = 0; i < sparkLines.length; i++) {
|
let blockMatch: RegExpExecArray | null;
|
||||||
const headerCells = splitTableRow(sparkLines[i]);
|
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
||||||
if (!headerCells || headerCells.length < 2) continue;
|
const [, lang, infoString, body] = blockMatch;
|
||||||
if (!/^d\d+$/i.test(headerCells[0])) continue;
|
const attrs = parseBlockAttrs(infoString);
|
||||||
|
attrs.lang = attrs.lang || lang;
|
||||||
|
|
||||||
if (i + 1 >= sparkLines.length) continue;
|
if (attrs.role === "stat") {
|
||||||
const sepCells = splitTableRow(sparkLines[i + 1]);
|
if (attrs.lang === "yaml" || attrs.lang === "yml") {
|
||||||
if (!sepCells || !sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
|
stats.push(...parseStatYaml(body, filePath));
|
||||||
|
} else if (attrs.lang === "csv") {
|
||||||
let j = i + 2;
|
stats.push(...parseStatCsv(body, filePath));
|
||||||
while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++;
|
|
||||||
if (j <= i + 2) continue;
|
|
||||||
|
|
||||||
const dataHeaders = headerCells.slice(1);
|
|
||||||
const stSlug = dataHeaders
|
|
||||||
.map((h) => slugger.slug(h.toLowerCase()))
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
// Combined key: pageName-columnSlug
|
|
||||||
const combinedSlug = `${fileName}-${stSlug}`;
|
|
||||||
|
|
||||||
sparkTables.push({
|
|
||||||
label: `${fileName} § ${stSlug}`,
|
|
||||||
notation: headerCells[0],
|
|
||||||
slug: combinedSlug,
|
|
||||||
filePath: basePath,
|
|
||||||
headers: dataHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
i = j - 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables };
|
if (attrs.role === "stat-template") {
|
||||||
}
|
const name = attrs.id || `_tpl_${filePath}_${stats.length}`;
|
||||||
|
statTemplates.push(parseTemplateCsv(body, filePath, name));
|
||||||
|
}
|
||||||
|
|
||||||
function splitTableRow(line: string): string[] | null {
|
if (attrs.role === "spark-table") {
|
||||||
const trimmed = line.trim();
|
const parsed = parseSparkTableCsv(body, filePath, slugger);
|
||||||
if (!trimmed.includes("|")) return null;
|
if (parsed) sparkTables.push(parsed);
|
||||||
let inner = trimmed;
|
}
|
||||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
}
|
||||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
}
|
||||||
return inner.split("|").map((c) => c.trim());
|
|
||||||
|
return { dice, links, sparkTables, stats, statTemplates };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
@ -216,5 +220,14 @@ export function useJournalCompletions(): {
|
||||||
if (s.status === "loaded") {
|
if (s.status === "loaded") {
|
||||||
return { state: s, data: s.data };
|
return { state: s, data: s.data };
|
||||||
}
|
}
|
||||||
return { state: s, data: { dice: [], links: [], sparkTables: [] } };
|
return {
|
||||||
|
state: s,
|
||||||
|
data: {
|
||||||
|
dice: [],
|
||||||
|
links: [],
|
||||||
|
sparkTables: [],
|
||||||
|
stats: [],
|
||||||
|
statTemplates: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,312 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
||||||
|
const resolved: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||||
|
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||||
|
const currentVal = runtimeStats[modFullKey];
|
||||||
|
const currentNum =
|
||||||
|
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
|
||||||
|
const delta = parseFloat(mv);
|
||||||
|
if (!isNaN(currentNum) && !isNaN(delta)) {
|
||||||
|
resolved[modFullKey] = String(currentNum + delta);
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
// Modifier values like "+20" / "-10" are applied relative to current value
|
||||||
|
const resolvedModifiers: Record<string, string> = {};
|
||||||
|
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||||
|
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||||
|
|
||||||
|
// Compute absolute value: current + delta
|
||||||
|
const currentVal = runtimeStats[modFullKey];
|
||||||
|
const currentNum =
|
||||||
|
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
|
||||||
|
const delta = parseFloat(mv);
|
||||||
|
if (!isNaN(currentNum) && !isNaN(delta)) {
|
||||||
|
resolvedModifiers[modFullKey] = String(currentNum + delta);
|
||||||
|
} else {
|
||||||
|
// If we can't compute relative, fall back to raw value
|
||||||
|
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,3 +10,4 @@ import "./roll";
|
||||||
import "./spark";
|
import "./spark";
|
||||||
import "./link";
|
import "./link";
|
||||||
import "./intent";
|
import "./intent";
|
||||||
|
import "./stat";
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Built-in message type: roll
|
* Built-in message type: roll
|
||||||
*
|
*
|
||||||
* Emitters: gm
|
* Emitters: gm, player
|
||||||
* Command: /roll formula
|
* Command: /roll formula
|
||||||
* The GM's browser rolls the formula and publishes the resolved result.
|
* The sender's browser rolls the formula and publishes the resolved result.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -44,7 +44,7 @@ export function resolveRollPayload(raw: {
|
||||||
registerMessageType<RollPayload>({
|
registerMessageType<RollPayload>({
|
||||||
type: "roll",
|
type: "roll",
|
||||||
label: "掷骰",
|
label: "掷骰",
|
||||||
emitters: ["gm"],
|
emitters: ["gm", "player"],
|
||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({
|
defaultPayload: () => ({
|
||||||
notation: "1d20",
|
notation: "1d20",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Built-in message type: stat
|
||||||
|
*
|
||||||
|
* Emitters: gm, player
|
||||||
|
* Command: /stat set key=value | /stat del key | /stat roll key
|
||||||
|
*
|
||||||
|
* 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";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
|
import { produce } from "solid-js/store";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
action: z.enum(["set", "del"]),
|
||||||
|
key: z.string().min(1),
|
||||||
|
value: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StatPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
registerMessageType<StatPayload>({
|
||||||
|
type: "stat",
|
||||||
|
label: "属性",
|
||||||
|
emitters: ["gm", "player"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||||
|
render: (p) => {
|
||||||
|
if (p.action === "del") {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-lg">📊</span>
|
||||||
|
<span class="font-mono text-sm text-gray-500 line-through">
|
||||||
|
{p.key}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-lg">📊</span>
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{p.key} → <span class="font-bold">{p.value}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
reducer: (p) => {
|
||||||
|
journalSetState(
|
||||||
|
produce((s) => {
|
||||||
|
if (p.action === "del") {
|
||||||
|
delete s.stats[p.key];
|
||||||
|
} else if (p.value !== undefined) {
|
||||||
|
s.stats[p.key] = p.value;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -59,6 +59,12 @@ export interface JournalStreamState {
|
||||||
brokerUrl: string | null;
|
brokerUrl: string | null;
|
||||||
/** Active player list (keyed by player name) */
|
/** Active player list (keyed by player name) */
|
||||||
players: Record<string, { role: string }>;
|
players: Record<string, { role: string }>;
|
||||||
|
/**
|
||||||
|
* Stat values set via /stat set/del/roll commands.
|
||||||
|
* Key: stat key (e.g. "strength", "alice:hp"). Value: string.
|
||||||
|
* Populated during hydration and live receipt via the stat type's reducer.
|
||||||
|
*/
|
||||||
|
stats: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionMeta {
|
export interface SessionMeta {
|
||||||
|
|
@ -95,6 +101,7 @@ const [state, setState] = createStore<JournalStreamState>({
|
||||||
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
||||||
brokerUrl: persisted.brokerUrl,
|
brokerUrl: persisted.brokerUrl,
|
||||||
players: {},
|
players: {},
|
||||||
|
stats: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync initial URL params if they came from localStorage (not URL)
|
// Sync initial URL params if they came from localStorage (not URL)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
---
|
||||||
|
tag: journal-stat
|
||||||
|
icon: 📊
|
||||||
|
title: 属性系统
|
||||||
|
description: 在文档中定义属性,通过命令设置/删除/掷骰,在面板中查看属性表。
|
||||||
|
syntax: '```yaml role=stat'
|
||||||
|
props:
|
||||||
|
- name: 定义文件
|
||||||
|
type: —
|
||||||
|
desc: 在任意 .md 文档中使用 yaml role=stat 代码块定义属性
|
||||||
|
- name: 命令
|
||||||
|
type: —
|
||||||
|
desc: /stat set key=value | /stat del key | /stat roll key
|
||||||
|
- name: 属性类型
|
||||||
|
type: —
|
||||||
|
desc: number, string, enum, modifier, derived
|
||||||
|
- name: 权限
|
||||||
|
type: —
|
||||||
|
desc: GM 可修改所有属性,玩家只能修改自己的属性 (玩家名:xxx)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
属性系统允许你在文档中定义角色属性(如力量、生命值、技能等),
|
||||||
|
通过命令设置和掷骰,并在 Journal 面板的属性视图中查看当前值。
|
||||||
|
|
||||||
|
属性分为两层:
|
||||||
|
- **定义**(Schema):在 markdown 文档的 ` ```yaml role=stat ` 代码块中定义
|
||||||
|
- **值**(State):通过 `/stat set/del/roll` 命令在游戏过程中动态修改
|
||||||
|
|
||||||
|
## 属性类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 支持掷骰 |
|
||||||
|
|---|---|---|
|
||||||
|
| `number` | 数值,可声明 `roll` 公式 | ✅ |
|
||||||
|
| `string` | 自由文本 | ❌ |
|
||||||
|
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
||||||
|
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
||||||
|
| `derived` | 通过公式从其他属性计算 | ✅ |
|
||||||
|
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
|
||||||
|
|
||||||
|
## 属性定义语法
|
||||||
|
|
||||||
|
支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。
|
||||||
|
|
||||||
|
### YAML 格式
|
||||||
|
|
||||||
|
在 `.md` 文档中插入 ` ```yaml role=stat ` 代码块:
|
||||||
|
|
||||||
|
```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 格式
|
||||||
|
|
||||||
|
对于同质属性列表(如多个 `number` 类型的属性),CSV 更紧凑。
|
||||||
|
插入 ` ```csv role=stat ` 代码块:
|
||||||
|
|
||||||
|
```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 |
|
||||||
|
| `formula` | — | — | derived 的计算公式 |
|
||||||
|
| `options` | — | — | enum 选项,用 `\|` 分隔 |
|
||||||
|
|
||||||
|
示例 — enum 属性:
|
||||||
|
|
||||||
|
```csv role=stat
|
||||||
|
key,label,type,options
|
||||||
|
weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键语法说明
|
||||||
|
|
||||||
|
- **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀
|
||||||
|
- **`scope`**:`player` 或 `global`。`player` 表示每个玩家各自独立的值,运行时 key 为 `玩家名:strength`;`global` 表示所有玩家共享
|
||||||
|
- **`label`**:在属性视图中显示的名称
|
||||||
|
- **`type`**:属性类型(见上表)
|
||||||
|
- **`default`**:默认值,在未通过命令设置时使用
|
||||||
|
- **`roll`**:掷骰公式(仅 `number` 类型),支持引用其他属性值(使用 bare key,自动同 scope 解析)
|
||||||
|
- **`target`**:`modifier` 类型的目标属性 key(bare key,同 scope 内解析)
|
||||||
|
- **`options`**:`enum` 类型的选项列表,支持多行 `- value` 语法
|
||||||
|
- **`formula`**:`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`,属性引用使用 bare key
|
||||||
|
|
||||||
|
### 作用域说明
|
||||||
|
|
||||||
|
`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`。命令中使用 bare key:
|
||||||
|
|
||||||
|
| 命令 | 示例 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/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`**:计算公式,结果写入属性值
|
||||||
|
|
||||||
|
例如 `/stat roll attack` 会:
|
||||||
|
1. 在当前玩家 scope 下查找 `attack` 的定义
|
||||||
|
2. 解析 `roll` 公式 `1d20 + attack`,将 `attack` 替换为当前值(含修饰符)→ 如 `1d20 + 3`
|
||||||
|
3. 掷骰 → 如结果 `15`
|
||||||
|
4. 将 `15` 写入 `alice:attack`,同步到所有连接的客户端
|
||||||
|
|
||||||
|
## 属性视图
|
||||||
|
|
||||||
|
在 Journal 面板顶部点击 **属性** 标签切换视图:
|
||||||
|
|
||||||
|
- 属性按 scope 分组(全局属性 + 玩家属性)
|
||||||
|
- 显示属性名、当前值、默认值
|
||||||
|
- 修饰符自动合并显示(`strength = 16` 时,`str_mod` 自动加到 `strength` 上)
|
||||||
|
- 有掷骰属性的行显示 🎲 按钮,点击可快速掷骰
|
||||||
|
|
||||||
|
## 权限
|
||||||
|
|
||||||
|
- **GM**:可以修改所有属性
|
||||||
|
- **玩家**:只能修改自己 scope 下的属性(`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)详解
|
||||||
|
|
||||||
|
模板属性用于查表掷骰,例如年龄表、职业表等。
|
||||||
|
|
||||||
|
模板在独立的 CSV 代码块中定义,使用 `role=stat-template` 和 `id=模板名`。
|
||||||
|
**第一列是骰子表达式**(如 `1d10`),第二列是 `label`,其余列为修饰符:
|
||||||
|
|
||||||
|
```csv id=年龄 role=stat-template
|
||||||
|
1d10,label,heart,mind,strength,speed
|
||||||
|
1-3,青少年,+20,-10,,
|
||||||
|
4-7,成年,,-10,,+20
|
||||||
|
8-9,老年,,+20,-10,
|
||||||
|
10,换躯者,,,+30,-10
|
||||||
|
```
|
||||||
|
|
||||||
|
- **第一列(header)**:骰子表达式,如 `1d10`、`2d6`
|
||||||
|
- **第一列(rows)**:匹配范围,支持 `1-3`(区间)、`4`(精确)、`1-3,5`(多个)
|
||||||
|
- **`label` 列**:显示名称
|
||||||
|
- **其余列**:修饰符键名,值为变化量(正数加、负数减、空表示不修改)
|
||||||
|
|
||||||
|
然后在属性定义中引用模板:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: age
|
||||||
|
scope: player
|
||||||
|
label: 年龄
|
||||||
|
type: template
|
||||||
|
template: 年龄
|
||||||
|
```
|
||||||
|
|
||||||
|
`/stat roll age` 时:
|
||||||
|
1. 掷 `1d10`(模板头部的骰子表达式)
|
||||||
|
2. 匹配第一列获取条目
|
||||||
|
3. 发布 `set age=青少年`
|
||||||
|
4. 同时发布 `set alice:heart=52`(原始值 +20)、`set alice:mind=22`(-10)等
|
||||||
|
|
||||||
|
修饰符值中的 `+`/`-` 前缀表示相对调整。如果 modifiers 值是 `+20`,会在当前值基础上加 20;如果是 `-10`,会减 10。
|
||||||
|
|
||||||
|
## 掷骰公式中的属性引用
|
||||||
|
|
||||||
|
`roll` 字段中的标识符会自动替换为当前属性值:
|
||||||
|
|
||||||
|
```yaml role=stat
|
||||||
|
- key: attack
|
||||||
|
scope: player
|
||||||
|
type: number
|
||||||
|
default: 0
|
||||||
|
roll: "1d20 + attack + str_mod"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/stat roll alice:attack` 时,`alice:attack` 和 `alice:str_mod` 会被替换为当前值后再掷骰。
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
},
|
},
|
||||||
"include": ["src/cli/**/*"],
|
"include": ["src/cli/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue