Compare commits
59
Commits
2068ecad10
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b83123b6e | ||
|
|
b7a804f1cf | ||
|
|
256c685f6c | ||
|
|
5061c4d19d | ||
|
|
638e8f6526 | ||
|
|
80e2b5b209 | ||
|
|
5fc2bc5262 | ||
|
|
d75df5280f | ||
|
|
f1ddc8fb8e | ||
|
|
16dfc8a88c | ||
|
|
023d3a0cb9 | ||
|
|
7c4865ba8c | ||
|
|
49260ae4f6 | ||
|
|
750af4a50c | ||
|
|
c03528a293 | ||
|
|
0a68122efd | ||
|
|
e8aa7165cb | ||
|
|
9e081891df | ||
|
|
e3daac3080 | ||
|
|
a69a41875d | ||
|
|
6bcdca3a77 | ||
|
|
18cad20d2c | ||
|
|
8c9506c106 | ||
|
|
a934901cce | ||
|
|
d78d15d8ec | ||
|
|
f877a58a31 | ||
|
|
99dd49b077 | ||
|
|
7cb28e631e | ||
|
|
d74ba69fdf | ||
|
|
c4038a5213 | ||
|
|
ffa023b92a | ||
|
|
c106b6f79c | ||
|
|
2c762b0411 | ||
|
|
f172da378a | ||
|
|
3137970b97 | ||
|
|
6f01a29723 | ||
|
|
4c2eb65470 | ||
|
|
c524dd5867 | ||
|
|
411f4d79ff | ||
|
|
ae44191b28 | ||
|
|
a2d9605f4b | ||
|
|
c9c977bee1 | ||
|
|
960e310208 | ||
|
|
981c829d0f | ||
|
|
dbe2f9d9d8 | ||
|
|
894f1735e5 | ||
|
|
2983aa4440 | ||
|
|
86abf34c10 | ||
|
|
ffbfa65716 | ||
|
|
d690d5922e | ||
|
|
42e8971ff7 | ||
|
|
182d7ff28d | ||
|
|
736bcf4bb2 | ||
|
|
722a8110e6 | ||
|
|
40f2190307 | ||
|
|
241c8609f1 | ||
|
|
2187b7ed82 | ||
|
|
fc6e37a13d | ||
|
|
e46cc879ae |
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* CJS mock of `github-slugger` (v2 is ESM-only, which jest's CJS runtime
|
||||
* cannot require). Auto-applied to all test files via the root `__mocks__`
|
||||
* directory — no `jest.mock()` call needed.
|
||||
*/
|
||||
class Slugger {
|
||||
constructor() {
|
||||
this.seen = new Map();
|
||||
}
|
||||
|
||||
slug(value, maintainCase) {
|
||||
let slug = String(value).trim();
|
||||
if (!maintainCase) slug = slug.toLowerCase();
|
||||
slug = slug
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// github-slugger dedups repeated slugs with a -1, -2, ... suffix
|
||||
const count = this.seen.get(slug) || 0;
|
||||
this.seen.set(slug, count + 1);
|
||||
if (count > 0) return `${slug}-${count}`;
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Slugger;
|
||||
+24
-4
@@ -1,13 +1,12 @@
|
||||
# yaml/tag 代码块格式测试
|
||||
# yaml role=tag 代码块格式测试
|
||||
|
||||
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
|
||||
|
||||
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
|
||||
|
||||
## 使用 yaml/tag 语法创建 md-deck
|
||||
## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
|
||||
|
||||
```yaml/tag
|
||||
tag: md-deck
|
||||
```yaml role=tag tag=md-deck
|
||||
body: ./names.csv
|
||||
size: 54x86
|
||||
grid: 5x8
|
||||
@@ -15,3 +14,24 @@ bleed: 1
|
||||
padding: 2
|
||||
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
|
||||
```
|
||||
|
||||
```yaml role=tag
|
||||
tag: md-deck
|
||||
body: ./names.csv
|
||||
data-config:
|
||||
size: 54x86
|
||||
grid: 5x8
|
||||
layers:
|
||||
- prop: name1
|
||||
pos: 1,1-2,2
|
||||
font: 12
|
||||
- prop: num2
|
||||
pos: 4,5-5,6
|
||||
font: 12
|
||||
orientation: s
|
||||
- template: |
|
||||
**{{name1}}** / {{num1}}
|
||||
pos: 1,3-5,4
|
||||
font: 6
|
||||
align: l
|
||||
```
|
||||
|
||||
+55
-16
@@ -373,26 +373,30 @@ label,name,description
|
||||
:md-table[./quests.csv]{roll=true remix=true}
|
||||
```
|
||||
|
||||
**自动表格转换:**
|
||||
**内联表格(显式声明):**
|
||||
|
||||
标准 Markdown 表格会自动转换为 `md-table` 组件,当表头包含 `label` 或 `md-table-label` 列时:
|
||||
Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`):
|
||||
|
||||
```markdown
|
||||
| label | name | description |
|
||||
|-------|------|-------------|
|
||||
| 1 | 战士 | 近战专家 |
|
||||
| 2 | 法师 | 奥术施法者 |
|
||||
````markdown
|
||||
```markdown role=spark-table
|
||||
| d6 | 结果 |
|
||||
|----|------|
|
||||
| 1 | 遭遇强盗 |
|
||||
| 2 | 平安无事 |
|
||||
```
|
||||
````
|
||||
|
||||
自动转换为 `:md-table` 组件。
|
||||
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
|
||||
|
||||
**特殊表头标识:**
|
||||
````markdown
|
||||
```csv role=spark-table
|
||||
d6,结果
|
||||
1,遭遇强盗
|
||||
2,平安无事
|
||||
```
|
||||
````
|
||||
|
||||
| 表头 | 效果 |
|
||||
|------|------|
|
||||
| `label` 或 `md-table-label` | 转换为 md-table |
|
||||
| `md-roll-label` 或骰子格式(如 `1d6`) | 添加 `roll=true` |
|
||||
| `md-remix-label` | 添加 `roll=true remix=true` |
|
||||
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|
||||
|
||||
### 🃏 卡牌组件 (md-deck)
|
||||
|
||||
@@ -423,6 +427,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
||||
|
||||
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
||||
|
||||
**结构化配置(yaml 代码块):**
|
||||
|
||||
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
|
||||
|
||||
````markdown
|
||||
```yaml role=tag
|
||||
tag: md-deck
|
||||
body: ./cards.csv
|
||||
data-config:
|
||||
size: 63x88
|
||||
grid: 5x5
|
||||
layers:
|
||||
- prop: title
|
||||
pos: 1,1-5,1
|
||||
font: 12
|
||||
- template: |
|
||||
**{{name}}** — {{type}}
|
||||
{{description}}
|
||||
pos: 1,3-5,8
|
||||
font: 3
|
||||
align: l
|
||||
```
|
||||
````
|
||||
|
||||
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
|
||||
|
||||
### 🧶 叙事线组件 (md-yarn-spinner)
|
||||
|
||||
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
||||
@@ -463,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
||||
|
||||
## YAML 标签
|
||||
|
||||
使用 ```yaml/tag 代码块创建自定义标签:
|
||||
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
||||
|
||||
````markdown
|
||||
```yaml/tag
|
||||
```yaml role=tag
|
||||
tag: tag-name
|
||||
class: custom-class
|
||||
id: my-id
|
||||
@@ -474,6 +504,15 @@ body: 标签内容
|
||||
```
|
||||
````
|
||||
|
||||
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
|
||||
|
||||
````markdown
|
||||
```yaml role=tag tag=tag-name id=my-id
|
||||
class: custom-class
|
||||
body: 标签内容
|
||||
```
|
||||
````
|
||||
|
||||
渲染为:
|
||||
|
||||
```html
|
||||
|
||||
@@ -4,6 +4,14 @@ export default {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
moduleNameMapper: {
|
||||
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
||||
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
||||
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
|
||||
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
|
||||
// Same for the browser ESM build of csv-parse — map to the CJS build.
|
||||
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
|
||||
Generated
+7
@@ -14,6 +14,7 @@
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"comlink": "^4.4.2",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
@@ -4332,6 +4333,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comlink": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
|
||||
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"comlink": "^4.4.2",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
|
||||
@@ -48,6 +48,7 @@ export default defineConfig({
|
||||
"/content": "http://localhost:3000",
|
||||
"/.ttrpg": "http://localhost:3000",
|
||||
},
|
||||
historyApiFallback: true,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
|
||||
@@ -18,9 +18,12 @@ import {
|
||||
DocDialog,
|
||||
DataSourceDialog,
|
||||
RevealManager,
|
||||
ReactiveVariableManager,
|
||||
CommandLinkManager,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
import { JournalPanel } from "./components/journal";
|
||||
import { useHeadingFlash } from "./components/useHeadingFlash";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll container context – lets child components (FileTree, RevealManager)
|
||||
@@ -68,6 +71,8 @@ const App: Component = () => {
|
||||
void loadToc();
|
||||
});
|
||||
|
||||
useHeadingFlash();
|
||||
|
||||
const handleSourceChanged = () => {
|
||||
setTocKey((k) => k + 1);
|
||||
};
|
||||
@@ -156,6 +161,8 @@ const App: Component = () => {
|
||||
src={currentPath()}
|
||||
>
|
||||
<RevealManager />
|
||||
<ReactiveVariableManager />
|
||||
<CommandLinkManager />
|
||||
</Article>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+42
-159
@@ -2,24 +2,18 @@ import type { ServeCommandHandler } from "../types.js";
|
||||
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
||||
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
||||
import { createReadStream } from "fs";
|
||||
import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||
import { join, resolve, extname, relative, dirname } from "path";
|
||||
import { watch } from "chokidar";
|
||||
import { networkInterfaces } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createJournalServer } from "../journal.js";
|
||||
import {
|
||||
scanCompletions,
|
||||
type CompletionsPayload,
|
||||
} from "../completions/index.js";
|
||||
import {
|
||||
processBlocks,
|
||||
type ProcessedBlocks,
|
||||
} from "../completions/block-processor.js";
|
||||
import {
|
||||
scanDirectives,
|
||||
type DirectiveScanResult,
|
||||
} from "../completions/directive-scanner.js";
|
||||
import type { StatSheet } from "../completions/types.js";
|
||||
buildRegistryFromIndex,
|
||||
normalizePathKey,
|
||||
deriveCompletions,
|
||||
type ContentRegistry,
|
||||
} from "../content-registry.js";
|
||||
import type { CompletionsPayload } from "../completions/types.js";
|
||||
|
||||
interface ContentIndex {
|
||||
[path: string]: string;
|
||||
@@ -89,37 +83,10 @@ function getBestIP(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SVG 字符串中提取 <title> 内容,无则返回 null
|
||||
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
|
||||
*/
|
||||
function extractSvgTitle(svg: string): string | null {
|
||||
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名生成可读标签
|
||||
* 如 "character-sheet" -> "Character Sheet"
|
||||
*/
|
||||
function labelFromId(id: string): string {
|
||||
return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录内的 .md 等文件,生成内容索引与块数据
|
||||
*/
|
||||
export function scanDirectory(dir: string): {
|
||||
index: ContentIndex;
|
||||
blocks: ProcessedBlocks;
|
||||
directiveResults: DirectiveScanResult[];
|
||||
} {
|
||||
const index: ContentIndex = {};
|
||||
const blocks: ProcessedBlocks = {
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
const directiveResults: DirectiveScanResult[] = [];
|
||||
const mdFiles: { content: string; relPath: string }[] = [];
|
||||
export function buildRegistry(dir: string): ContentRegistry {
|
||||
const index: Record<string, string> = {};
|
||||
|
||||
function scan(currentPath: string, relativePath: string) {
|
||||
const entries = readdirSync(currentPath);
|
||||
@@ -129,7 +96,7 @@ export function scanDirectory(dir: string): {
|
||||
|
||||
const fullPath = join(currentPath, entry);
|
||||
const relPath = relativePath ? join(relativePath, entry) : entry;
|
||||
const normalizedRelPath = "/" + relPath.split(sep).join("/");
|
||||
const normalizedRelPath = normalizePathKey(relPath);
|
||||
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
@@ -141,27 +108,7 @@ export function scanDirectory(dir: string): {
|
||||
entry.endsWith(".svg")
|
||||
) {
|
||||
try {
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
if (entry.endsWith(".md")) {
|
||||
const result = processBlocks(content, normalizedRelPath, index);
|
||||
index[normalizedRelPath] = result.stripped;
|
||||
blocks.stats.push(...result.blocks.stats);
|
||||
blocks.statTemplates.push(...result.blocks.statTemplates);
|
||||
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
|
||||
} else if (entry.endsWith(".sheet.svg")) {
|
||||
index[normalizedRelPath] = content;
|
||||
const id = entry.replace(/\.sheet\.svg$/i, "");
|
||||
const title = extractSvgTitle(content);
|
||||
const sheet: StatSheet = {
|
||||
id,
|
||||
label: title || labelFromId(id),
|
||||
svg: content,
|
||||
source: normalizedRelPath,
|
||||
};
|
||||
blocks.statSheets.push(sheet);
|
||||
} else {
|
||||
index[normalizedRelPath] = content;
|
||||
}
|
||||
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
|
||||
} catch (e) {
|
||||
console.error(`读取文件失败:${fullPath}`, e);
|
||||
}
|
||||
@@ -171,31 +118,7 @@ export function scanDirectory(dir: string): {
|
||||
|
||||
scan(dir, "");
|
||||
|
||||
// ---- Directive scanning pass (after all blocks processed) ----
|
||||
const posixDir = dir.split(sep).join("/");
|
||||
for (const { content, relPath } of mdFiles) {
|
||||
const fileDir = posixRelDir(relPath);
|
||||
const result = scanDirectives(content, relPath, index, fileDir);
|
||||
|
||||
// Apply rewritten content back to index
|
||||
index[relPath] = result.rewritten;
|
||||
|
||||
// Inject new index entries (inline CSV bodies)
|
||||
for (const [key, value] of Object.entries(result.newIndexEntries)) {
|
||||
index[key] = value;
|
||||
}
|
||||
|
||||
directiveResults.push(result);
|
||||
}
|
||||
|
||||
return { index, blocks, directiveResults };
|
||||
}
|
||||
|
||||
/** Get the POSIX directory of a file path */
|
||||
function posixRelDir(filePath: string): string {
|
||||
const parts = filePath.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/") || ".";
|
||||
return buildRegistryFromIndex(index);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,6 +183,7 @@ function createRequestHandler(
|
||||
distDir: string,
|
||||
getIndex: () => ContentIndex,
|
||||
getCompletions: () => CompletionsPayload,
|
||||
getRegistry: () => ContentRegistry,
|
||||
) {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url || "/";
|
||||
@@ -277,6 +201,12 @@ function createRequestHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
|
||||
if (filePath === "/__CONTENT_REGISTRY.json") {
|
||||
sendJson(res, getRegistry());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理 /static/ 目录(从 dist/web)
|
||||
if (filePath.startsWith("/static/")) {
|
||||
if (tryServeStatic(res, filePath, distDir)) {
|
||||
@@ -337,37 +267,27 @@ export function createContentServer(
|
||||
distPath: string = distDir,
|
||||
host: string = "0.0.0.0",
|
||||
): ContentServer {
|
||||
let contentIndex: ContentIndex = {};
|
||||
let collectedBlocks: ProcessedBlocks = {
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
let directiveResults: DirectiveScanResult[] = [];
|
||||
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
let completionsIndex: CompletionsPayload = {
|
||||
dice: [],
|
||||
links: [],
|
||||
sparkTables: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
};
|
||||
|
||||
/** 从当前内容索引和已收集的块重新扫描补全数据 */
|
||||
/** 从当前注册表重新派生补全数据 */
|
||||
function recomputeCompletions(): void {
|
||||
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
|
||||
completionsIndex = deriveCompletions(registry);
|
||||
console.log(
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`,
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 扫描内容目录生成索引
|
||||
// 扫描内容目录生成注册表
|
||||
console.log("正在扫描内容目录...");
|
||||
const scanResult = scanDirectory(contentDir);
|
||||
contentIndex = scanResult.index;
|
||||
collectedBlocks = scanResult.blocks;
|
||||
directiveResults = scanResult.directiveResults;
|
||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
||||
registry = buildRegistry(contentDir);
|
||||
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
|
||||
recomputeCompletions();
|
||||
|
||||
// 监听文件变化
|
||||
@@ -387,26 +307,10 @@ export function createContentServer(
|
||||
path.endsWith(".svg")
|
||||
) {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
if (relPath.endsWith(".md")) {
|
||||
const result = processBlocks(content, relPath, contentIndex);
|
||||
contentIndex[relPath] = result.stripped;
|
||||
// Re-scan to get fresh blocks (simpler than per-file merge)
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
} else {
|
||||
contentIndex[relPath] = content;
|
||||
if (relPath.endsWith(".sheet.svg")) {
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
}
|
||||
}
|
||||
console.log(`[新增] ${relPath}`);
|
||||
// 全量重建注册表以刷新跨文件派生(spark 注入)
|
||||
registry = buildRegistry(contentDir);
|
||||
recomputeCompletions();
|
||||
console.log(`[新增] ${path}`);
|
||||
} catch (e) {
|
||||
console.error(`读取新增文件失败:${path}`, e);
|
||||
}
|
||||
@@ -420,25 +324,9 @@ export function createContentServer(
|
||||
path.endsWith(".svg")
|
||||
) {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
if (relPath.endsWith(".md")) {
|
||||
const result = processBlocks(content, relPath, contentIndex);
|
||||
contentIndex[relPath] = result.stripped;
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
} else {
|
||||
contentIndex[relPath] = content;
|
||||
if (relPath.endsWith(".sheet.svg")) {
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
}
|
||||
}
|
||||
console.log(`[更新] ${relPath}`);
|
||||
registry = buildRegistry(contentDir);
|
||||
recomputeCompletions();
|
||||
console.log(`[更新] ${path}`);
|
||||
} catch (e) {
|
||||
console.error(`读取更新文件失败:${path}`, e);
|
||||
}
|
||||
@@ -451,15 +339,9 @@ export function createContentServer(
|
||||
path.endsWith(".yarn") ||
|
||||
path.endsWith(".svg")
|
||||
) {
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
delete contentIndex[relPath];
|
||||
console.log(`[删除] ${relPath}`);
|
||||
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
}
|
||||
registry = buildRegistry(contentDir);
|
||||
recomputeCompletions();
|
||||
console.log(`[删除] ${path}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -470,8 +352,9 @@ export function createContentServer(
|
||||
const handleRequest = createRequestHandler(
|
||||
contentDir,
|
||||
distPath,
|
||||
() => contentIndex,
|
||||
() => registry.pathIndex,
|
||||
() => completionsIndex,
|
||||
() => registry,
|
||||
);
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
@@ -495,7 +378,7 @@ export function createContentServer(
|
||||
return {
|
||||
server,
|
||||
watcher,
|
||||
index: contentIndex,
|
||||
index: registry.pathIndex,
|
||||
completions: completionsIndex,
|
||||
close() {
|
||||
console.log("正在关闭内容服务器...");
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Jest mock for csv-parse/browser/esm/sync
|
||||
*
|
||||
* The real import path is csv-parse/browser/esm/sync which only works in
|
||||
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
|
||||
*/
|
||||
export { parse } from "csv-parse/sync";
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
parseStatYaml,
|
||||
parseStatCsv,
|
||||
parseTemplateCsv,
|
||||
parseStatModifiers,
|
||||
type StatDef,
|
||||
type StatTemplate,
|
||||
} from "./stat-parser.js";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
resolveBlockAs,
|
||||
} from "./block-scanner.js";
|
||||
import type { StatSheet } from "./types.js";
|
||||
|
||||
// Re-export shared pieces for convenience
|
||||
export {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
resolveBlockAs,
|
||||
type BlockAttrs,
|
||||
} from "./block-scanner.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessedBlocks {
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
statSheets: StatSheet[];
|
||||
}
|
||||
|
||||
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 blocks for completions
|
||||
* - role=spark-table blocks are converted to :md-table directives
|
||||
* (spark table completions are collected later by the directive scanner)
|
||||
*/
|
||||
export function processBlocks(
|
||||
content: string,
|
||||
fileRelativePath: string,
|
||||
index: Record<string, string>,
|
||||
): BlockResult {
|
||||
const fileDir = posix.dirname(fileRelativePath);
|
||||
|
||||
const blocks: ProcessedBlocks = {
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
|
||||
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 === "stat-modifiers") {
|
||||
const id = attrs.id || `_mod_${contentHash(body)}`;
|
||||
const result = parseStatModifiers(body, fileRelativePath, id, "player", attrs.extra["label"]);
|
||||
blocks.stats.push(result.statDef);
|
||||
blocks.stats.push(...result.modifierDefs);
|
||||
blocks.statTemplates.push(result.template);
|
||||
}
|
||||
|
||||
if (attrs.role === "file") {
|
||||
const filename = attrs.id
|
||||
? `${attrs.id}.${attrs.lang || "txt"}`
|
||||
: `_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 };
|
||||
}
|
||||
@@ -2,25 +2,21 @@
|
||||
* Shared block scanning utilities — safe for both CLI and browser.
|
||||
*
|
||||
* Parses fenced code blocks with attributes:
|
||||
* ```lang id=xxx role=xxx as=xxx
|
||||
* ```lang id=xxx role=xxx key=value
|
||||
*
|
||||
* - `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)
|
||||
* The `role` fully determines block behavior (see `scanDoc` in
|
||||
* `content-registry.ts`):
|
||||
* - spark-table → stored as CSV, rendered as an `:md-table` directive
|
||||
* - declare / file → stored, block stripped
|
||||
* - tag → block kept intact for the yaml-tag render extension
|
||||
* - unknown role → warned about and stripped
|
||||
* - no role → kept as a visible code block
|
||||
*
|
||||
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
|
||||
* extensions can share the same syntax.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BlockAttrs {
|
||||
lang: string;
|
||||
id?: string;
|
||||
role?: string;
|
||||
as?: string;
|
||||
/** Any other attributes not in the standard set */
|
||||
extra: Record<string, string>;
|
||||
}
|
||||
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex
|
||||
@@ -28,40 +24,3 @@ export interface BlockAttrs {
|
||||
|
||||
/** 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)
|
||||
* - role=spark-table → "md-table" (render as md-table directive)
|
||||
* - no role, no as → "codeblock" (keep as visible code block)
|
||||
*/
|
||||
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
|
||||
if (as) return as;
|
||||
// spark-table blocks render as md-table by default
|
||||
if (role === "spark-table") return "md-table";
|
||||
if (role) return "none";
|
||||
return "codeblock";
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
||||
*
|
||||
* Shared between CLI completions scanner and browser-side completions.
|
||||
*
|
||||
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
||||
* tag,threshold,key,expr
|
||||
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
||||
* ,,$ac,10+$dex ← variable declaration
|
||||
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
||||
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
||||
*
|
||||
* When `tag` is empty: $key is a reactively computed variable.
|
||||
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
||||
* $key gets expr added to its base.
|
||||
*/
|
||||
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VarDeclaration {
|
||||
key: string; // "$hp" (always starts with $)
|
||||
expression: string; // "$con*5+$mod_hp"
|
||||
}
|
||||
|
||||
export interface TagModifier {
|
||||
tag: string; // "#warrior"
|
||||
target: string; // "$mod_hp"
|
||||
expression: string; // "20"
|
||||
threshold: number; // minimum tagmap count to activate (default 1)
|
||||
}
|
||||
|
||||
export interface DeclareResult {
|
||||
variables: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single ```csv role=declare block body.
|
||||
*/
|
||||
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||
const trimmed = csv.trim();
|
||||
if (!trimmed) return { variables: [], tagModifiers: [] };
|
||||
|
||||
// Validate that required columns exist (order-agnostic, tag is optional)
|
||||
const firstLine = trimmed.split(/\r?\n/)[0];
|
||||
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
||||
if (!headers.includes("key") || !headers.includes("expr")) {
|
||||
throw new Error(
|
||||
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const records = parse(trimmed, {
|
||||
columns: true,
|
||||
trim: true,
|
||||
skipEmptyLines: true,
|
||||
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
||||
|
||||
const variables: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (const row of records) {
|
||||
const tag = row.tag ?? "";
|
||||
const key = row.key ?? "";
|
||||
const expr = row.expr ?? "";
|
||||
|
||||
if (!key || !expr) {
|
||||
console.warn(`${source}: skipping row with empty key or expr`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
// Tag modifier
|
||||
if (!tag.startsWith("#")) {
|
||||
throw new Error(`${source}: tag must start with #, got "${tag}"`);
|
||||
}
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||
}
|
||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
||||
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
||||
if (isNaN(threshold) || threshold < 1) {
|
||||
throw new Error(
|
||||
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
|
||||
);
|
||||
}
|
||||
tagModifiers.push({ tag, target: key, expression: expr, threshold });
|
||||
} else {
|
||||
// Variable declaration
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||
}
|
||||
variables.push({ key, expression: expr });
|
||||
}
|
||||
}
|
||||
|
||||
return { variables, tagModifiers };
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
/**
|
||||
* Unified directive scanner — shared between CLI and browser.
|
||||
*
|
||||
* One pass over stripped markdown content that:
|
||||
* 1. Detects markdown tables that look like spark tables → coerces to
|
||||
* :md-table[./_inline_{hash}.csv] directives
|
||||
* 2. Scans :md-dice[...] directives → collects DiceCompletion
|
||||
* 3. Scans :md-table[...] directives → resolves CSV, checks if spark table
|
||||
* → collects SparkTableCompletion
|
||||
* 4. Scans :md-card[...] directives → same as md-table
|
||||
*
|
||||
* Safe for both Node and browser. No Node-specific imports.
|
||||
*/
|
||||
|
||||
import Slugger from "github-slugger";
|
||||
import type { DiceCompletion, SparkTableCompletion } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DirectiveScanResult {
|
||||
/** Rewritten content with markdown tables coerced to directives */
|
||||
rewritten: string;
|
||||
/** Dice completions discovered */
|
||||
dice: DiceCompletion[];
|
||||
/** Spark table completions discovered */
|
||||
sparkTables: SparkTableCompletion[];
|
||||
/** New index entries for inline CSV bodies (key → CSV content) */
|
||||
newIndexEntries: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DICE_HEADER_RE = /^\d*d\d+$/i;
|
||||
|
||||
function contentHash(body: string): string {
|
||||
// Simple hash suitable for both Node and browser
|
||||
let hash = 0;
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash + ch) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(16).slice(0, 8);
|
||||
}
|
||||
|
||||
function looksLikeDice(raw: string): boolean {
|
||||
if (raw.length > 80) return false;
|
||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||
}
|
||||
|
||||
/** Parse key=value pairs from directive extra attrs string */
|
||||
function parseDirectiveAttrs(extraStr: string | undefined): Record<string, string> {
|
||||
if (!extraStr) return {};
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(extraStr)) !== null) {
|
||||
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown table → CSV conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Split a markdown table row into cells.
|
||||
* Handles leading/trailing pipes and trims whitespace.
|
||||
*/
|
||||
function splitTableRow(row: string): string[] {
|
||||
return row
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a cell value for CSV output.
|
||||
*/
|
||||
function escapeCsvCell(cell: string): string {
|
||||
if (
|
||||
cell.includes(",") ||
|
||||
cell.includes("\n") ||
|
||||
cell.includes('"') ||
|
||||
cell.includes("#")
|
||||
) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown table (header + separator + rows) to a CSV string.
|
||||
*/
|
||||
function markdownTableToCsv(
|
||||
headerRow: string,
|
||||
separatorRow: string,
|
||||
bodyRows: string[],
|
||||
): string | null {
|
||||
const headers = splitTableRow(headerRow);
|
||||
if (headers.length === 0) return null;
|
||||
|
||||
// Validate separator row (must contain dashes)
|
||||
const sepCells = splitTableRow(separatorRow);
|
||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
||||
if (sepCells.length !== headers.length) return null;
|
||||
|
||||
const csvHeader = headers.map(escapeCsvCell).join(",");
|
||||
|
||||
const csvRows = bodyRows.map((row) => {
|
||||
const cells = splitTableRow(row);
|
||||
// Pad to match header length
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
||||
});
|
||||
|
||||
return [csvHeader, ...csvRows].join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spark table CSV inspection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if a CSV body represents a spark table.
|
||||
* Returns the data column headers (excluding the dice column) if so, or null.
|
||||
*/
|
||||
export function inspectSparkTableCsv(csv: string): string[] | null {
|
||||
const lines = csv.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_HEADER_RE.test(headers[0])) return null;
|
||||
|
||||
return headers.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SparkTableCompletion from CSV data and file path.
|
||||
*/
|
||||
export function buildSparkTableCompletion(
|
||||
csv: string,
|
||||
filePath: string,
|
||||
csvPath: string,
|
||||
remix: boolean,
|
||||
slugger: Slugger,
|
||||
): SparkTableCompletion | null {
|
||||
const dataHeaders = inspectSparkTableCsv(csv);
|
||||
if (!dataHeaders) return null;
|
||||
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
const headers = lines[0].split(",").map((h) => h.trim());
|
||||
|
||||
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,
|
||||
csvPath,
|
||||
headers: dataHeaders,
|
||||
remix,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan a single markdown file's stripped content for directives and
|
||||
* spark-shaped markdown tables.
|
||||
*
|
||||
* @param content - Stripped markdown content (after block processing)
|
||||
* @param filePath - The file's path (e.g. "/rules/combat.md")
|
||||
* @param index - The content index for resolving CSV paths
|
||||
* @param fileDir - Directory of the file (for resolving relative paths)
|
||||
*/
|
||||
export function scanDirectives(
|
||||
content: string,
|
||||
filePath: string,
|
||||
index: Record<string, string>,
|
||||
fileDir: string,
|
||||
): DirectiveScanResult {
|
||||
const slugger = new Slugger();
|
||||
const dice: DiceCompletion[] = [];
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
const newIndexEntries: Record<string, string> = {};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 1: Coerce spark-shaped markdown tables to :md-table directives
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const mdTableRegex =
|
||||
/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
|
||||
|
||||
let rewritten = content;
|
||||
let mdMatch: RegExpExecArray | null;
|
||||
|
||||
// Collect matches first (rewriting while iterating is tricky with regex)
|
||||
interface TableMatch {
|
||||
fullMatch: string;
|
||||
headerRow: string;
|
||||
separatorRow: string;
|
||||
bodyRowsText: string;
|
||||
index: number;
|
||||
}
|
||||
const tableMatches: TableMatch[] = [];
|
||||
|
||||
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
||||
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
||||
const headers = splitTableRow(headerRow);
|
||||
|
||||
// Check if this looks like a spark table: first column is a dice formula
|
||||
const isSpark = DICE_HEADER_RE.test(headers[0]);
|
||||
|
||||
if (!isSpark) continue;
|
||||
|
||||
const bodyRows = bodyRowsText
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.filter((r) => r.trim().startsWith("|"));
|
||||
|
||||
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
|
||||
if (!csv) continue;
|
||||
|
||||
tableMatches.push({
|
||||
fullMatch: mdMatch[0],
|
||||
headerRow,
|
||||
separatorRow,
|
||||
bodyRowsText,
|
||||
index: mdMatch.index,
|
||||
});
|
||||
}
|
||||
|
||||
// Replace matches from end to start to preserve indices
|
||||
for (let i = tableMatches.length - 1; i >= 0; i--) {
|
||||
const m = tableMatches[i];
|
||||
const bodyRows = m.bodyRowsText
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.filter((r) => r.trim().startsWith("|"));
|
||||
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
|
||||
|
||||
const hash = contentHash(csv);
|
||||
const filename = `_spark_md_${hash}.csv`;
|
||||
const resolvedPath = `${fileDir}/${filename}`;
|
||||
|
||||
newIndexEntries[resolvedPath] = csv;
|
||||
|
||||
// Collect spark table completion
|
||||
const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger);
|
||||
if (st) {
|
||||
sparkTables.push(st);
|
||||
}
|
||||
|
||||
// Replace markdown table with :md-table directive
|
||||
const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`;
|
||||
rewritten =
|
||||
rewritten.slice(0, m.index) +
|
||||
directive +
|
||||
rewritten.slice(m.index + m.fullMatch.length);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 2: Scan :md-dice[...] directives
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const diceRegex = /:md-dice\[([^[\]]+)\]/gi;
|
||||
let diceMatch: RegExpExecArray | null;
|
||||
while ((diceMatch = diceRegex.exec(rewritten)) !== null) {
|
||||
const raw = diceMatch[1].trim();
|
||||
if (!raw || !looksLikeDice(raw)) continue;
|
||||
dice.push({ label: raw, notation: raw, source: filePath });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 3: Scan :md-table[...] and :md-card[...] directives
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||
let tableMatch: RegExpExecArray | null;
|
||||
while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) {
|
||||
const [, /* type */ , path, extraStr] = tableMatch;
|
||||
|
||||
// Resolve the CSV path
|
||||
const csvPath = path.startsWith("./")
|
||||
? `${fileDir}/${path.slice(2)}`
|
||||
: path;
|
||||
|
||||
let csv = index[csvPath] ?? newIndexEntries[csvPath];
|
||||
if (!csv) continue;
|
||||
|
||||
// Parse extra attrs for remix flag
|
||||
const attrs = parseDirectiveAttrs(extraStr);
|
||||
const isRemix = attrs["remix"] === "true";
|
||||
|
||||
const st = buildSparkTableCompletion(csv, filePath, csvPath, isRemix, slugger);
|
||||
if (!st) continue;
|
||||
|
||||
// Check if data-spark is already set in extra attrs
|
||||
if (!extraStr || !extraStr.includes("data-spark=")) {
|
||||
// Inject data-spark attribute into the directive
|
||||
const fullMatch = tableMatch[0];
|
||||
const insertPos = fullMatch.indexOf("]") + 1;
|
||||
const before = fullMatch.slice(0, insertPos);
|
||||
const after = fullMatch.slice(insertPos);
|
||||
|
||||
const sparkAttr = `{data-spark="${st.slug}"}`;
|
||||
let replacement: string;
|
||||
if (after.startsWith("{")) {
|
||||
// Merge into existing attrs
|
||||
replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `);
|
||||
} else {
|
||||
replacement = before + sparkAttr + after;
|
||||
}
|
||||
|
||||
rewritten =
|
||||
rewritten.slice(0, tableMatch.index) +
|
||||
replacement +
|
||||
rewritten.slice(tableMatch.index + fullMatch.length);
|
||||
}
|
||||
|
||||
sparkTables.push(st);
|
||||
}
|
||||
|
||||
return { rewritten, dice, sparkTables, newIndexEntries };
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Completion index — orchestrates all registered completion sources.
|
||||
*/
|
||||
|
||||
import { linksSource } from "./sources/links.js";
|
||||
import type { ProcessedBlocks } from "./block-processor.js";
|
||||
import type { CompletionsPayload } from "./types.js";
|
||||
import type { DirectiveScanResult } from "./directive-scanner.js";
|
||||
|
||||
export type {
|
||||
CompletionsPayload,
|
||||
DiceCompletion,
|
||||
LinkCompletion,
|
||||
SparkTableCompletion,
|
||||
StatDef,
|
||||
StatTemplate,
|
||||
StatSheet,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Build completions from the content index, pre-collected blocks,
|
||||
* and directive scan results.
|
||||
* Called at server startup and on any file change.
|
||||
*/
|
||||
export function scanCompletions(
|
||||
index: Record<string, string>,
|
||||
blocks: ProcessedBlocks,
|
||||
directiveResults: DirectiveScanResult[],
|
||||
): CompletionsPayload {
|
||||
const links = linksSource.scan(index) as CompletionsPayload["links"];
|
||||
|
||||
// Merge all directive scan results
|
||||
const dice: CompletionsPayload["dice"] = [];
|
||||
const sparkTables: CompletionsPayload["sparkTables"] = [];
|
||||
for (const dr of directiveResults) {
|
||||
dice.push(...dr.dice);
|
||||
sparkTables.push(...dr.sparkTables);
|
||||
}
|
||||
|
||||
return {
|
||||
dice,
|
||||
links,
|
||||
sparkTables,
|
||||
stats: blocks.stats,
|
||||
statTemplates: blocks.statTemplates,
|
||||
statSheets: blocks.statSheets,
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Dice completion source — extracts `<md-dice>` text content from markdown files.
|
||||
*/
|
||||
|
||||
import type { CompletionSource, DiceCompletion } from "../types.js";
|
||||
|
||||
function looksLikeDice(raw: string): boolean {
|
||||
if (raw.length > 80) return false;
|
||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||
}
|
||||
|
||||
export const diceSource: CompletionSource = {
|
||||
key: "dice",
|
||||
|
||||
scan(index) {
|
||||
const items: DiceCompletion[] = [];
|
||||
const tagRegex = /:md-dice\[([^[]+)\]/gi;
|
||||
|
||||
for (const [path, content] of Object.entries(index)) {
|
||||
if (!path.endsWith(".md")) continue;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
tagRegex.lastIndex = 0;
|
||||
while ((match = tagRegex.exec(content)) !== null) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || !looksLikeDice(raw)) continue;
|
||||
|
||||
items.push({
|
||||
label: raw,
|
||||
notation: raw,
|
||||
source: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Link completion source — extracts markdown headings from all .md files.
|
||||
*
|
||||
* Produces two entries per heading section, plus one for the file itself.
|
||||
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
|
||||
*/
|
||||
|
||||
import Slugger from "github-slugger";
|
||||
import type { CompletionSource, LinkCompletion } from "../types.js";
|
||||
|
||||
export const linksSource: CompletionSource = {
|
||||
key: "links",
|
||||
|
||||
scan(index) {
|
||||
const items: LinkCompletion[] = [];
|
||||
|
||||
for (const [filePath, content] of Object.entries(index)) {
|
||||
if (!filePath.endsWith(".md")) continue;
|
||||
|
||||
// Strip .md extension for the router-friendly path
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = fileNameFromPath(basePath);
|
||||
const slugger = new Slugger();
|
||||
|
||||
// Add the file itself as a link (whole article)
|
||||
items.push({
|
||||
path: basePath,
|
||||
label: fileName,
|
||||
section: null,
|
||||
});
|
||||
|
||||
// Parse headings for section-scoped links
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const title = match[2].trim();
|
||||
const id = slugger.slug(title.toLowerCase());
|
||||
|
||||
items.push({
|
||||
path: basePath,
|
||||
label: `${fileName} § ${title}`,
|
||||
section: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Spark table scanner — CLI-side markdown table detection and conversion.
|
||||
*
|
||||
* Parses markdown tables, detects spark tables (first column header is a dice
|
||||
* formula), and converts them to CSV for the directive scanner.
|
||||
*
|
||||
* Not imported at runtime — the frontend only needs CSV parsing + rolling.
|
||||
*/
|
||||
|
||||
import Slugger from "github-slugger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MarkdownTable {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown table parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse all markdown tables from a markdown string.
|
||||
* Handles both leading/trailing `|` styles and bare styles.
|
||||
*/
|
||||
export function parseMarkdownTables(markdown: string): MarkdownTable[] {
|
||||
const tables: MarkdownTable[] = [];
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const headerCells = splitTableRow(lines[i]);
|
||||
if (!headerCells || headerCells.length < 2) continue;
|
||||
|
||||
// Peek at the next line — must be a separator row
|
||||
if (i + 1 >= lines.length) continue;
|
||||
const sepCells = splitTableRow(lines[i + 1]);
|
||||
if (!sepCells || sepCells.length < headerCells.length) continue;
|
||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
|
||||
|
||||
// Valid table header + separator — collect body rows
|
||||
const rows: string[][] = [];
|
||||
let j = i + 2;
|
||||
while (j < lines.length) {
|
||||
const rowCells = splitTableRow(lines[j]);
|
||||
if (!rowCells) break;
|
||||
// Allow rows with fewer cells (unfilled trailing columns)
|
||||
rows.push(rowCells);
|
||||
j++;
|
||||
}
|
||||
|
||||
// Only include tables with at least one data row
|
||||
if (rows.length > 0) {
|
||||
tables.push({ headers: headerCells, rows });
|
||||
}
|
||||
i = j - 1;
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */
|
||||
function splitTableRow(line: string): string[] | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.includes("|")) return null;
|
||||
|
||||
// Strip optional leading and trailing `|`
|
||||
let inner = trimmed;
|
||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
||||
|
||||
return inner.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spark table detection & metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DICE_HEADER_RE = /^d\d+$/i;
|
||||
|
||||
/** Check whether a table is a spark table (first header is a dice formula) */
|
||||
export function isSparkTable(table: MarkdownTable): boolean {
|
||||
if (table.headers.length < 2) return false;
|
||||
return DICE_HEADER_RE.test(table.headers[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the spark table slug by concatenating slugs of all data column
|
||||
* headers (excluding the dice column).
|
||||
*/
|
||||
export function sparkTableSlug(table: MarkdownTable): string {
|
||||
const slugger = new Slugger();
|
||||
return table.headers
|
||||
.slice(1)
|
||||
.map((h) => slugger.slug(h.toLowerCase()))
|
||||
.join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all spark tables in a markdown file and return their metadata
|
||||
* (without rows — suitable for listing available tables).
|
||||
*/
|
||||
export function scanSparkTables(
|
||||
markdown: string,
|
||||
): { notation: string; slug: string; dataHeaders: string[] }[] {
|
||||
const tables = parseMarkdownTables(markdown);
|
||||
return tables.filter(isSparkTable).map((table) => ({
|
||||
notation: table.headers[0],
|
||||
slug: sparkTableSlug(table),
|
||||
dataHeaders: table.headers.slice(1),
|
||||
}));
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Shared stat block parser — used by both the CLI completions scanner and
|
||||
* the client-side completions scanner.
|
||||
*
|
||||
* Parses ```yaml role=stat and ```csv role=stat blocks from markdown content.
|
||||
*/
|
||||
|
||||
export interface StatDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "number" | "string" | "enum" | "modifier" | "derived" | "template";
|
||||
scope: "player" | "global";
|
||||
default?: string;
|
||||
target?: string;
|
||||
options?: string[];
|
||||
roll?: string;
|
||||
formula?: string;
|
||||
/** Name of a StatTemplate to use for template-type stats */
|
||||
template?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** A single row in a stat template table */
|
||||
export interface TemplateEntry {
|
||||
range: string;
|
||||
label: string;
|
||||
modifiers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** A named stat template (from ```csv role=stat-template file=xxx) */
|
||||
export interface StatTemplate {
|
||||
name: string;
|
||||
/** Dice notation from the first header, e.g. "1d10" */
|
||||
notation: string;
|
||||
entries: TemplateEntry[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseStatYaml(yaml: string, source: string): StatDef[] {
|
||||
const defs: StatDef[] = [];
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
let current: Record<string, string> | null = null;
|
||||
let collectingOptions = false;
|
||||
let options: string[] = [];
|
||||
|
||||
function flushCurrent() {
|
||||
if (!current || !current.key) return;
|
||||
const type = (current.type || "number") as StatDef["type"];
|
||||
const scope = (current.scope || "global") as StatDef["scope"];
|
||||
defs.push({
|
||||
key: current.key,
|
||||
label: current.label || current.key,
|
||||
type,
|
||||
scope,
|
||||
default: current.default,
|
||||
target: current.target,
|
||||
template: current.template,
|
||||
options:
|
||||
type === "enum" && options.length > 0
|
||||
? [...options]
|
||||
: current.options
|
||||
? current.options
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
roll: current.roll,
|
||||
formula: current.formula,
|
||||
source,
|
||||
});
|
||||
current = null;
|
||||
options = [];
|
||||
collectingOptions = false;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.startsWith("- ")) {
|
||||
flushCurrent();
|
||||
current = {};
|
||||
collectingOptions = false;
|
||||
const rest = trimmed.slice(2);
|
||||
const colonIdx = rest.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const k = rest.slice(0, colonIdx).trim();
|
||||
const v = rest.slice(colonIdx + 1).trim();
|
||||
current[k] = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current && trimmed.match(/^\w/)) {
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const k = trimmed.slice(0, colonIdx).trim();
|
||||
const v = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
if (k === "options") {
|
||||
if (v.startsWith("[") && v.endsWith("]")) {
|
||||
current[k] = v.slice(1, -1);
|
||||
} else {
|
||||
collectingOptions = true;
|
||||
options = [];
|
||||
}
|
||||
} else {
|
||||
current[k] = v;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collectingOptions && trimmed.startsWith("- ")) {
|
||||
options.push(trimmed.slice(2).trim());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
return defs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseStatCsv(csv: string, source: string): StatDef[] {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const idx = (name: string) => {
|
||||
const i = headers.indexOf(name);
|
||||
return i === -1 ? null : i;
|
||||
};
|
||||
|
||||
const defs: StatDef[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].trim();
|
||||
if (!row || row.startsWith("#")) continue;
|
||||
const cols = splitCsvRow(row);
|
||||
if (cols.length === 0) continue;
|
||||
|
||||
const get = (name: string) => {
|
||||
const colIdx = idx(name);
|
||||
if (colIdx === null || colIdx >= cols.length) return undefined;
|
||||
return cols[colIdx].trim() || undefined;
|
||||
};
|
||||
|
||||
const key = get("key");
|
||||
if (!key) continue;
|
||||
|
||||
const type = (get("type") || "number") as StatDef["type"];
|
||||
const scope = (get("scope") || "player") as StatDef["scope"];
|
||||
|
||||
let options: string[] | undefined;
|
||||
const optRaw = get("options");
|
||||
if (optRaw) {
|
||||
options = optRaw
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
defs.push({
|
||||
key,
|
||||
label: get("label") || key,
|
||||
type,
|
||||
scope,
|
||||
default: get("default"),
|
||||
roll: get("roll"),
|
||||
target: get("target"),
|
||||
template: get("template"),
|
||||
formula: get("formula"),
|
||||
options,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template CSV parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=stat-template block.
|
||||
*
|
||||
* The first header is the dice notation (e.g. "1d10").
|
||||
* The second header is "label".
|
||||
* Remaining headers are modifier keys (bare names, no prefix).
|
||||
*/
|
||||
export function parseTemplateCsv(
|
||||
csv: string,
|
||||
source: string,
|
||||
name: string,
|
||||
): StatTemplate {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length === 0) return { name, notation: "", entries: [], source };
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
if (headers.length < 2) return { name, notation: "", entries: [], source };
|
||||
|
||||
const notation = headers[0];
|
||||
const labelIdx = headers.indexOf("label");
|
||||
const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx);
|
||||
|
||||
const entries: TemplateEntry[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].trim();
|
||||
if (!row || row.startsWith("#")) continue;
|
||||
const cols = splitCsvRow(row);
|
||||
if (cols.length === 0) continue;
|
||||
|
||||
const range = cols[0]?.trim();
|
||||
if (!range) continue;
|
||||
|
||||
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
|
||||
|
||||
const modifiers: Record<string, string> = {};
|
||||
for (const mk of modKeys) {
|
||||
const mkIdx = headers.indexOf(mk);
|
||||
if (mkIdx !== -1 && mkIdx < cols.length) {
|
||||
const val = cols[mkIdx].trim();
|
||||
if (val) modifiers[mk] = val;
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ range, label, modifiers });
|
||||
}
|
||||
|
||||
return { name, notation, entries, source };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat-modifiers parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Result of parsing a stat-modifiers block. */
|
||||
export interface StatModifiersResult {
|
||||
/** The template stat def (type: template) */
|
||||
statDef: StatDef;
|
||||
/** Modifier stat defs (type: modifier, one per value column) */
|
||||
modifierDefs: StatDef[];
|
||||
/** The template table with prefixed modifier keys */
|
||||
template: StatTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ```csv role=stat-modifiers block.
|
||||
*
|
||||
* Auto-generates a template stat + modifier stat defs from a single CSV.
|
||||
*
|
||||
* The first header is the dice notation (e.g. "1d10").
|
||||
* The second header is "label".
|
||||
* Remaining headers are bare target names (e.g. "mind", "heart").
|
||||
*
|
||||
* Generated modifier keys: {id}_{column} (e.g. "age_mind").
|
||||
* Template entries internally map: column → {id}_{column}.
|
||||
*/
|
||||
export function parseStatModifiers(
|
||||
csv: string,
|
||||
source: string,
|
||||
id: string,
|
||||
scope: "player" | "global" = "player",
|
||||
label?: string,
|
||||
): StatModifiersResult {
|
||||
const statLabel = label || id;
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
const emptyTemplate: StatTemplate = {
|
||||
name: id,
|
||||
notation: "",
|
||||
entries: [],
|
||||
source,
|
||||
};
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
statDef: { key: id, label: statLabel, type: "template", scope, template: id, source },
|
||||
modifierDefs: [],
|
||||
template: emptyTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
if (headers.length < 2) {
|
||||
return {
|
||||
statDef: { key: id, label: statLabel, type: "template", scope, template: id, source },
|
||||
modifierDefs: [],
|
||||
template: emptyTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
const notation = headers[0];
|
||||
const labelIdx = headers.indexOf("label");
|
||||
|
||||
// Bare column names (e.g. "mind", "heart") — these become targets
|
||||
const bareColumns = headers.filter((h, i) => i !== 0 && i !== labelIdx);
|
||||
|
||||
// Generate modifier stat defs: key = {id}_{column}, target = column
|
||||
const modifierDefs: StatDef[] = bareColumns.map((col) => ({
|
||||
key: `${id}_${col}`,
|
||||
label: col,
|
||||
type: "modifier" as const,
|
||||
scope,
|
||||
target: col,
|
||||
source,
|
||||
}));
|
||||
|
||||
// Build template entries with prefixed modifier keys
|
||||
const entries: TemplateEntry[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].trim();
|
||||
if (!row || row.startsWith("#")) continue;
|
||||
const cols = splitCsvRow(row);
|
||||
if (cols.length === 0) continue;
|
||||
|
||||
const range = cols[0]?.trim();
|
||||
if (!range) continue;
|
||||
|
||||
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
|
||||
|
||||
const modifiers: Record<string, string> = {};
|
||||
for (const col of bareColumns) {
|
||||
const colIdx = headers.indexOf(col);
|
||||
if (colIdx !== -1 && colIdx < cols.length) {
|
||||
const val = cols[colIdx].trim();
|
||||
if (val) {
|
||||
modifiers[`${id}_${col}`] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ range, label, modifiers });
|
||||
}
|
||||
|
||||
const template: StatTemplate = { name: id, notation, entries, source };
|
||||
|
||||
const statDef: StatDef = {
|
||||
key: id,
|
||||
label: statLabel,
|
||||
type: "template",
|
||||
scope,
|
||||
template: id,
|
||||
source,
|
||||
};
|
||||
|
||||
return { statDef, modifierDefs, template };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function splitCsvRow(row: string): string[] {
|
||||
const cols: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const ch = row[i];
|
||||
if (ch === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (ch === "," && !inQuote) {
|
||||
cols.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cols.push(current);
|
||||
return cols;
|
||||
}
|
||||
@@ -2,21 +2,9 @@
|
||||
* Completion source types — shared between CLI scanner and frontend consumer.
|
||||
*/
|
||||
|
||||
import type { StatDef, StatTemplate } from "./stat-parser.js";
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser.js";
|
||||
|
||||
export type { StatDef, StatTemplate };
|
||||
|
||||
/** A stat sheet discovered from a *.sheet.svg file */
|
||||
export interface StatSheet {
|
||||
/** Unique id — filename sans .sheet.svg */
|
||||
id: string;
|
||||
/** Display label — from <title> or derived from filename */
|
||||
label: string;
|
||||
/** Raw SVG content */
|
||||
svg: string;
|
||||
/** Source file path for attribution */
|
||||
source: string;
|
||||
}
|
||||
export type { VarDeclaration, TagModifier };
|
||||
|
||||
/** A single dice expression found in a markdown file */
|
||||
export interface DiceCompletion {
|
||||
@@ -48,6 +36,8 @@ export interface SparkTableCompletion {
|
||||
slug: string;
|
||||
/** File path of the containing .md file (without extension) */
|
||||
filePath: string;
|
||||
/** Path of the containing .md file (with extension) — for inline lookup */
|
||||
docPath: string;
|
||||
/** Resolved path to the .csv file backing this spark table */
|
||||
csvPath: string;
|
||||
/** Data column headers for display */
|
||||
@@ -60,9 +50,8 @@ export interface CompletionsPayload {
|
||||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
statSheets: StatSheet[];
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
scanDoc,
|
||||
resolveContent,
|
||||
resolveContentEntry,
|
||||
buildRegistryFromIndex,
|
||||
deriveCompletions,
|
||||
type ContentRegistry,
|
||||
} from "./content-registry";
|
||||
|
||||
function emptyRegistry(): ContentRegistry {
|
||||
return { pathIndex: {}, docContent: {} };
|
||||
}
|
||||
|
||||
describe("scanDoc", () => {
|
||||
test("csv role=spark-table block becomes an :md-table directive", () => {
|
||||
const md = [
|
||||
"```csv role=spark-table",
|
||||
"d6,Name",
|
||||
"1,Alice",
|
||||
"2,Bob",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.kind).toBe("csv");
|
||||
expect(entry.body).toContain("d6,Name");
|
||||
expect(entry.role).toBe("spark-table");
|
||||
});
|
||||
|
||||
test("markdown role=spark-table body is converted to CSV", () => {
|
||||
const md = [
|
||||
"```markdown role=spark-table",
|
||||
"| d6 | Name |",
|
||||
"|----|------|",
|
||||
"| 1 | Alice |",
|
||||
"| 2 | Bob |",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.body).toBe("d6,Name\n1,Alice\n2,Bob");
|
||||
});
|
||||
|
||||
test("markdown role=spark-table with non-dice first column warns but still stores", () => {
|
||||
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const md = [
|
||||
"```markdown role=spark-table",
|
||||
"| Name | Value |",
|
||||
"|------|-------|",
|
||||
"| Alice | 10 |",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("is not a dice formula"),
|
||||
);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.body).toBe("Name,Value\nAlice,10");
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test("plain markdown tables are left untouched", () => {
|
||||
const md = [
|
||||
"| d6 | Name |",
|
||||
"|----|------|",
|
||||
"| 1 | Alice |",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("plain markdown tables with label headers are left untouched", () => {
|
||||
const md = [
|
||||
"| md-table-label | body |",
|
||||
"|----------------|------|",
|
||||
"| 1 | text |",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tag blocks are kept intact", () => {
|
||||
const md = "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\n```";
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("blocks without role are kept as code blocks", () => {
|
||||
const md = "```csv\nlabel,body\n1,text\n```";
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("unknown roles warn and strip", () => {
|
||||
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const md = "```csv role=frobnicate\nlabel,body\n```";
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe("");
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown role "frobnicate"'),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test("legacy as= attribute is ignored", () => {
|
||||
const md = [
|
||||
"```csv role=spark-table as=codeblock",
|
||||
"d6,Name",
|
||||
"1,Alice",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { stripped } = scanDoc(md, "test.md");
|
||||
|
||||
// Role wins — the block still becomes a directive.
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContent", () => {
|
||||
test("resolves inline content ids", () => {
|
||||
const registry = emptyRegistry();
|
||||
registry.docContent["test.md"] = {
|
||||
csv_abc: {
|
||||
id: "csv_abc",
|
||||
kind: "csv",
|
||||
body: "d6,Name\n1,Alice",
|
||||
role: "spark-table",
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveContent(registry, "test.md", "./csv_abc")).toBe(
|
||||
"d6,Name\n1,Alice",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not sniff refs as inline CSV", () => {
|
||||
const registry = emptyRegistry();
|
||||
// A CSV-looking ref is treated as a relative path, not content.
|
||||
expect(
|
||||
resolveContent(registry, "test.md", "d6,Name\n1,Alice"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("resolveContentEntry reports inline vs file resolution", () => {
|
||||
const registry = emptyRegistry();
|
||||
registry.pathIndex["/test.md"] = "# Doc";
|
||||
registry.pathIndex["/data/table.csv"] = "d6,Name\n1,Alice";
|
||||
registry.docContent["/test.md"] = {
|
||||
csv_abc: {
|
||||
id: "csv_abc",
|
||||
kind: "csv",
|
||||
body: "d6,Name\n1,Bob",
|
||||
role: "spark-table",
|
||||
},
|
||||
};
|
||||
|
||||
const inline = resolveContentEntry(registry, "/test.md", "./csv_abc");
|
||||
expect(inline).toMatchObject({ path: "csv_abc", inline: true });
|
||||
|
||||
const file = resolveContentEntry(registry, "/test.md", "./data/table.csv");
|
||||
expect(file).toMatchObject({ path: "/data/table.csv", inline: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveCompletions", () => {
|
||||
test("headings inside fenced code blocks do not become link completions", () => {
|
||||
const md = [
|
||||
"# Real Heading",
|
||||
"",
|
||||
"```markdown",
|
||||
"# Fake Heading In Code",
|
||||
"```",
|
||||
].join("\n");
|
||||
const registry = buildRegistryFromIndex({ "test.md": md });
|
||||
const { links } = deriveCompletions(registry);
|
||||
|
||||
const testLinks = links.filter((l) => l.path === "/test");
|
||||
expect(testLinks.map((l) => l.label)).toEqual([
|
||||
"test",
|
||||
"test § Real Heading",
|
||||
]);
|
||||
expect(testLinks.some((l) => l.label.includes("Fake"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* Content registry — the single source of truth for all content in a
|
||||
* TTRPG Tools project.
|
||||
*
|
||||
* Two stores:
|
||||
* - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`)
|
||||
* - `docContent`: inline content *defined inside* a markdown doc, keyed by
|
||||
* a stable id and owned by that doc.
|
||||
*
|
||||
* Everything structured (completions, declarations, tag modifiers) is a
|
||||
* *derived* view over this registry — see `deriveCompletions`.
|
||||
*
|
||||
* This module is browser-safe (no Node-only imports) so the CLI and the
|
||||
* frontend share one implementation. The filesystem walk lives in the CLI
|
||||
* (`buildRegistry` in `commands/serve.ts`).
|
||||
*/
|
||||
|
||||
import Slugger from "github-slugger";
|
||||
import {
|
||||
parseDeclareCsv,
|
||||
type VarDeclaration,
|
||||
type TagModifier,
|
||||
} from "./completions/declare-parser.js";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
} from "./completions/block-scanner.js";
|
||||
import type {
|
||||
CompletionsPayload,
|
||||
DiceCompletion,
|
||||
LinkCompletion,
|
||||
SparkTableCompletion,
|
||||
} from "./completions/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ContentKind = "csv" | "text" | "declare";
|
||||
|
||||
/** A single piece of inline content defined inside a doc. */
|
||||
export interface DocContent {
|
||||
/** Stable id — author-supplied or derived from the body. */
|
||||
id: string;
|
||||
kind: ContentKind;
|
||||
body: string;
|
||||
/** Origin role that produced this content, for debugging. */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ContentRegistry {
|
||||
/** Real files on disk, keyed by path. */
|
||||
pathIndex: Record<string, string>;
|
||||
/** Inline content defined inside each doc, keyed by id. */
|
||||
docContent: Record<string, Record<string, DocContent>>;
|
||||
}
|
||||
|
||||
export const EMPTY_REGISTRY: ContentRegistry = {
|
||||
pathIndex: {},
|
||||
docContent: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a file path into a stable registry key: forward slashes and a
|
||||
* single leading `/`. Both the CLI filesystem walk and the browser folder
|
||||
* scan funnel through this so identical files always share a key.
|
||||
*/
|
||||
export function normalizePathKey(path: string): string {
|
||||
const normalized = path.split("\\").join("/");
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Id / hash derivation (single source of truth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Browser-safe content hash — stable across CLI and frontend. */
|
||||
export function contentHash(body: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash + ch) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(16).slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a stable content id.
|
||||
* Author-supplied `id` wins; otherwise `{kind}_{hash}`.
|
||||
*/
|
||||
export function deriveContentId(
|
||||
kind: ContentKind,
|
||||
body: string,
|
||||
id?: string,
|
||||
): string {
|
||||
if (id) return id;
|
||||
return `${kind}_${contentHash(body)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-doc scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DocScanResult {
|
||||
/** Content with blocks processed (stripped or replaced with directives). */
|
||||
stripped: string;
|
||||
/** Inline content defined in this doc, keyed by id. */
|
||||
content: Record<string, DocContent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single markdown doc:
|
||||
* - dispatches attributed fenced code blocks by `role`
|
||||
* - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
|
||||
* - collects inline content (spark-table, declare, file) into the doc's
|
||||
* content store
|
||||
*
|
||||
* Does NOT touch the path index — the caller assembles the registry.
|
||||
*/
|
||||
export function scanDoc(content: string, docPath: string): DocScanResult {
|
||||
const contentStore: Record<string, DocContent> = {};
|
||||
|
||||
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;
|
||||
|
||||
switch (attrs.role) {
|
||||
case "declare": {
|
||||
const id = deriveContentId("declare", body, attrs.id);
|
||||
contentStore[id] = { id, kind: "declare", body, role: attrs.role };
|
||||
return "";
|
||||
}
|
||||
|
||||
case "file": {
|
||||
const id = deriveContentId("text", body, attrs.id);
|
||||
contentStore[id] = { id, kind: "text", body, role: attrs.role };
|
||||
return "";
|
||||
}
|
||||
|
||||
case "spark-table": {
|
||||
let blockBody = body;
|
||||
if (isMarkdownTableLang(attrs.lang)) {
|
||||
blockBody = markdownTableBodyToCsv(body, docPath);
|
||||
}
|
||||
const id = deriveContentId("csv", blockBody, attrs.id);
|
||||
contentStore[id] = {
|
||||
id,
|
||||
kind: "csv",
|
||||
body: blockBody,
|
||||
role: attrs.role,
|
||||
};
|
||||
|
||||
const extraStr = Object.entries(attrs.extra)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" ");
|
||||
return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`;
|
||||
}
|
||||
|
||||
case "tag":
|
||||
// Kept intact so the code-block-yaml-tag render extension sees it.
|
||||
return match;
|
||||
|
||||
case undefined:
|
||||
// No role declared — plain visible code block.
|
||||
return match;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
`[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { stripped, content: contentStore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ContentRegistry` from a raw `{ path: content }` index.
|
||||
*
|
||||
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
||||
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
||||
* both modes produce identical `pathIndex`/`docContent` — including the
|
||||
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
||||
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
||||
* both modes produce identical `pathIndex`/`docContent`.
|
||||
*
|
||||
* Keys are normalized via `normalizePathKey`; `.md` files are run through
|
||||
* `scanDoc`, other extensions are stored raw.
|
||||
*/
|
||||
export function buildRegistryFromIndex(
|
||||
index: Record<string, string>,
|
||||
): ContentRegistry {
|
||||
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
|
||||
for (const [rawPath, content] of Object.entries(index)) {
|
||||
const path = normalizePathKey(rawPath);
|
||||
if (path.endsWith(".md")) {
|
||||
const result = scanDoc(content, path);
|
||||
registry.pathIndex[path] = result.stripped;
|
||||
registry.docContent[path] = result.content;
|
||||
} else {
|
||||
registry.pathIndex[path] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spark table coercion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether a table header cell is a dice formula (a "spark table" first
|
||||
* column). Used to validate `role=spark-table` blocks (CSV or markdown
|
||||
* pipe-table bodies).
|
||||
*/
|
||||
export function isSparkTableHeader(header: string): boolean {
|
||||
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
|
||||
}
|
||||
|
||||
/** Split a markdown table row into cells. */
|
||||
function splitTableRow(row: string): string[] {
|
||||
return row
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
/** Escape a cell value for CSV output. */
|
||||
function escapeCsvCell(cell: string): string {
|
||||
if (
|
||||
cell.includes(",") ||
|
||||
cell.includes("\n") ||
|
||||
cell.includes('"') ||
|
||||
cell.includes("#")
|
||||
) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** Convert a markdown table (header + separator + rows) to a CSV string. */
|
||||
function markdownTableToCsv(
|
||||
headerRow: string,
|
||||
separatorRow: string,
|
||||
bodyRows: string[],
|
||||
): string | null {
|
||||
const headers = splitTableRow(headerRow);
|
||||
if (headers.length === 0) return null;
|
||||
|
||||
const sepCells = splitTableRow(separatorRow);
|
||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
||||
if (sepCells.length !== headers.length) return null;
|
||||
|
||||
const csvHeader = headers.map(escapeCsvCell).join(",");
|
||||
const csvRows = bodyRows.map((row) => {
|
||||
const cells = splitTableRow(row);
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
||||
});
|
||||
|
||||
return [csvHeader, ...csvRows].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Languages whose fenced-block bodies are markdown pipe tables. Used with
|
||||
* `role=spark-table` to convert the table to CSV at scan time — explicitly
|
||||
* authorized by the role, never by content shape.
|
||||
*/
|
||||
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
||||
|
||||
function isMarkdownTableLang(lang: string): boolean {
|
||||
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
|
||||
* blocks. Validates the dice-formula first column (warning only — the role
|
||||
* already declared intent) and falls back to storing the body as-is when it
|
||||
* is not a recognizable pipe table.
|
||||
*/
|
||||
function markdownTableBodyToCsv(body: string, docPath: string): string {
|
||||
const lines = body
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l.trim().startsWith("|"));
|
||||
if (lines.length < 2) {
|
||||
console.warn(
|
||||
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
const [headerRow, separatorRow, ...rows] = lines;
|
||||
const headers = splitTableRow(headerRow);
|
||||
if (!isSparkTableHeader(headers[0] || "")) {
|
||||
console.warn(
|
||||
`[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`,
|
||||
);
|
||||
}
|
||||
|
||||
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A resolved content reference: the body plus how it was addressed.
|
||||
*/
|
||||
export interface ResolvedContent {
|
||||
body: string;
|
||||
/** Content id for inline content, path-index key for real files. */
|
||||
path: string;
|
||||
/** True when resolved from the doc's inline content store. */
|
||||
inline: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a content reference from within a doc.
|
||||
*
|
||||
* - `ref` is an absolute path → path index.
|
||||
* - `ref` is a relative path → resolved against the doc directory; checks
|
||||
* the doc's inline content store first, then the path index.
|
||||
*
|
||||
* Refs are always ids or paths — inline content must be defined in a fenced
|
||||
* block and referenced by its `./{id}`.
|
||||
*
|
||||
* Returns `null` when nothing matches.
|
||||
*/
|
||||
export function resolveContentEntry(
|
||||
registry: ContentRegistry,
|
||||
docPath: string,
|
||||
ref: string,
|
||||
): ResolvedContent | null {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
const body = registry.pathIndex[trimmed];
|
||||
return body == null ? null : { body, path: trimmed, inline: false };
|
||||
}
|
||||
|
||||
// Inline content in the same doc (e.g. ./{id}).
|
||||
const docStore = registry.docContent[docPath];
|
||||
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
|
||||
const entry = docStore?.[id];
|
||||
if (entry) return { body: entry.body, path: id, inline: true };
|
||||
|
||||
// Relative path resolved against the doc directory (`./` already stripped).
|
||||
const resolved = posixJoin(posixDir(docPath), id);
|
||||
const body = registry.pathIndex[resolved];
|
||||
return body != null ? { body, path: resolved, inline: false } : null;
|
||||
}
|
||||
|
||||
/** Resolve a content reference to its body only. */
|
||||
export function resolveContent(
|
||||
registry: ContentRegistry,
|
||||
docPath: string,
|
||||
ref: string,
|
||||
): string | null {
|
||||
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content
|
||||
* by searching every doc's content store for a matching id. Used by the
|
||||
* frontend when a directive ref has already been resolved to a path.
|
||||
*
|
||||
* Returns `null` when no inline content matches.
|
||||
*/
|
||||
export function resolveInlineByPath(
|
||||
registry: ContentRegistry,
|
||||
resolvedPath: string,
|
||||
): string | null {
|
||||
const id = resolvedPath.split("/").filter(Boolean).pop() || "";
|
||||
if (!id) return null;
|
||||
for (const store of Object.values(registry.docContent)) {
|
||||
const entry = store[id];
|
||||
if (entry) return entry.body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived completions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derive the completions payload from the registry.
|
||||
* Pure function — recompute on any file change.
|
||||
*
|
||||
* Dice + spark completions come from scanning each doc's stripped content
|
||||
* for directives (resolving CSV refs through the registry), so both inline
|
||||
* and real-file spark tables are covered. Declarations come from the doc
|
||||
* content store.
|
||||
*/
|
||||
export function deriveCompletions(
|
||||
registry: ContentRegistry,
|
||||
): CompletionsPayload {
|
||||
const links = deriveLinks(registry.pathIndex);
|
||||
const dice: DiceCompletion[] = [];
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
const declarations: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (const [docPath, content] of Object.entries(registry.pathIndex)) {
|
||||
if (!docPath.endsWith(".md")) continue;
|
||||
const found = scanDocDirectives(content, docPath, registry);
|
||||
dice.push(...found.dice);
|
||||
sparkTables.push(...found.sparkTables);
|
||||
}
|
||||
|
||||
const blocks = deriveBlocks(registry);
|
||||
declarations.push(...blocks.declarations);
|
||||
tagModifiers.push(...blocks.tagModifiers);
|
||||
|
||||
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card`
|
||||
* directives, resolving CSV refs through the registry.
|
||||
*/
|
||||
function scanDocDirectives(
|
||||
content: string,
|
||||
docPath: string,
|
||||
registry: ContentRegistry,
|
||||
): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } {
|
||||
const dice = scanDice(content, docPath);
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
|
||||
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
||||
const [, , ref, extraStr] = m;
|
||||
const resolved = resolveContentEntry(registry, docPath, ref);
|
||||
if (!resolved) continue;
|
||||
|
||||
const attrs = parseBlockAttrs(extraStr || "").extra;
|
||||
const st = buildSparkTableCompletion(
|
||||
resolved.body,
|
||||
docPath,
|
||||
resolved.path,
|
||||
attrs["remix"] === "true",
|
||||
);
|
||||
if (st) sparkTables.push(st);
|
||||
}
|
||||
|
||||
return { dice, sparkTables };
|
||||
}
|
||||
|
||||
/** Derive variable declarations + tag modifiers from the registry. */
|
||||
export function deriveBlocks(registry: ContentRegistry): {
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
} {
|
||||
const declarations: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (const [docPath, store] of Object.entries(registry.docContent)) {
|
||||
for (const entry of Object.values(store)) {
|
||||
if (entry.kind !== "declare") continue;
|
||||
try {
|
||||
const result = parseDeclareCsv(entry.body, docPath);
|
||||
declarations.push(...result.variables);
|
||||
tagModifiers.push(...result.tagModifiers);
|
||||
} catch (e) {
|
||||
console.warn(`[content-registry] ${docPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { declarations, tagModifiers };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derivation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remove fenced code blocks (backtick or tilde) from content, so text
|
||||
* scanners (headings, dice directives) don't match example code.
|
||||
*/
|
||||
function stripFencedBlocks(content: string): string {
|
||||
const out: string[] = [];
|
||||
let fence: string | null = null;
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const fenceMatch = /^(`{3,}|~{3,})/.exec(line);
|
||||
if (fence) {
|
||||
if (line.startsWith(fence)) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (fenceMatch) {
|
||||
fence = fenceMatch[1];
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Extract headings from all `.md` files as link completions. */
|
||||
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
||||
const items: LinkCompletion[] = [];
|
||||
for (const [filePath, rawContent] of Object.entries(pathIndex)) {
|
||||
if (!filePath.endsWith(".md")) continue;
|
||||
|
||||
// Headings inside fenced code blocks (e.g. markdown examples) are not
|
||||
// real headings — exclude them from link completions.
|
||||
const content = stripFencedBlocks(rawContent);
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = fileNameFromPath(basePath);
|
||||
const slugger = new Slugger();
|
||||
|
||||
items.push({ path: basePath, label: fileName, section: null });
|
||||
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const title = match[2].trim();
|
||||
const id = slugger.slug(title.toLowerCase());
|
||||
items.push({
|
||||
path: basePath,
|
||||
label: `${fileName} § ${title}`,
|
||||
section: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi;
|
||||
|
||||
function looksLikeDice(raw: string): boolean {
|
||||
if (raw.length > 80) return false;
|
||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||
}
|
||||
|
||||
/** Scan a text body for `:md-dice[...]` directives. */
|
||||
function scanDice(body: string, source: string): DiceCompletion[] {
|
||||
const dice: DiceCompletion[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw || !looksLikeDice(raw)) continue;
|
||||
dice.push({ label: raw, notation: raw, source });
|
||||
}
|
||||
return dice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a CSV body and return its data-column headers if it's a spark
|
||||
* table (first column header is a dice formula), or null otherwise.
|
||||
*/
|
||||
export function inspectSparkTableCsv(csv: string): string[] | null {
|
||||
const lines = csv.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 (!isSparkTableHeader(headers[0])) return null;
|
||||
|
||||
return headers.slice(1);
|
||||
}
|
||||
|
||||
/** Build a SparkTableCompletion from a CSV body. */
|
||||
export function buildSparkTableCompletion(
|
||||
csv: string,
|
||||
docPath: string,
|
||||
contentId: string,
|
||||
remix: boolean,
|
||||
): SparkTableCompletion | null {
|
||||
const dataHeaders = inspectSparkTableCsv(csv);
|
||||
if (!dataHeaders) return null;
|
||||
|
||||
const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim();
|
||||
const slugger = new Slugger();
|
||||
const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
|
||||
|
||||
const basePath = docPath.replace(/\.md$/, "");
|
||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||
const combinedSlug = `${fileName}-${slug}`;
|
||||
|
||||
return {
|
||||
label: `${fileName} § ${slug}`,
|
||||
notation,
|
||||
slug: combinedSlug,
|
||||
filePath: basePath,
|
||||
docPath,
|
||||
csvPath: contentId,
|
||||
headers: dataHeaders,
|
||||
remix,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path helpers (browser-safe posix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function posixDir(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
return idx >= 0 ? path.slice(0, idx) : ".";
|
||||
}
|
||||
|
||||
function posixJoin(dir: string, rel: string): string {
|
||||
if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`;
|
||||
const base = dir.replace(/\/+$/, "");
|
||||
const r = rel.replace(/^\/+/, "");
|
||||
return `${base}/${r}`;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
@@ -108,7 +108,15 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||
if (href.startsWith("#")) return;
|
||||
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
// Resolve relative hrefs against the current page path so that
|
||||
// e.g. "blah.md" on /content/page.md becomes /content/blah
|
||||
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
||||
let pathname = resolved.pathname;
|
||||
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
||||
if (pathname.endsWith(".md")) {
|
||||
pathname = pathname.slice(0, -3);
|
||||
}
|
||||
navigate(pathname + resolved.hash);
|
||||
};
|
||||
|
||||
dom.addEventListener("click", onClick);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||
* clicks on :cmd[] directive spans to dispatch them via the shared
|
||||
* command dispatcher. Errors are surfaced through the shared
|
||||
* dispatchError signal, shown by JournalInput above the textarea.
|
||||
*/
|
||||
|
||||
import { Component, onCleanup } from "solid-js";
|
||||
import { useArticleDom } from "./Article";
|
||||
import { useJournalStream } from "./stores/journalStream";
|
||||
import { useJournalCompletions } from "./journal/completions";
|
||||
import { dispatchCommand, setDispatchError } from "./journal/command-dispatcher";
|
||||
|
||||
export const CommandLinkManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
||||
if (!cmdSpan) return;
|
||||
|
||||
const command = cmdSpan.getAttribute("data-cmd");
|
||||
if (!command) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
dispatchCommand({
|
||||
role: stream.myRole as "gm" | "player" | "observer",
|
||||
myName: stream.myName,
|
||||
command,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
variables: stream.variables,
|
||||
declarations: comp.data.declarations,
|
||||
tagModifiers: comp.data.tagModifiers,
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
setDispatchError(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const dom = contentDom();
|
||||
if (dom) {
|
||||
dom.addEventListener("click", onClick, true);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
const d = contentDom();
|
||||
if (d) d.removeEventListener("click", onClick, true);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CommandLinkManager;
|
||||
@@ -162,73 +162,12 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="max-w-none">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
||||
{e.icon} {e.title}
|
||||
<code class="ml-3 text-base font-mono bg-gray-100 px-2 py-0.5 rounded text-blue-700">
|
||||
:{e.tag}
|
||||
</code>
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-600 mb-6">{e.description}</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
|
||||
基本语法
|
||||
</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg text-sm overflow-x-auto">
|
||||
<code>{e.syntax}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Show when={e.props.length > 0}>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
|
||||
属性
|
||||
</h3>
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-2 pr-4 font-medium text-gray-600">
|
||||
属性
|
||||
</th>
|
||||
<th class="text-left py-2 pr-4 font-medium text-gray-600">
|
||||
类型
|
||||
</th>
|
||||
<th class="text-left py-2 pr-4 font-medium text-gray-600">
|
||||
默认值
|
||||
</th>
|
||||
<th class="text-left py-2 font-medium text-gray-600">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={e.props}>
|
||||
{(prop) => (
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-2 pr-4">
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs text-pink-700">
|
||||
{prop.name}
|
||||
</code>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<code class="text-xs text-gray-600">{prop.type}</code>
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-xs text-gray-400">
|
||||
{prop.default ?? "—"}
|
||||
</td>
|
||||
<td class="py-2 text-sm text-gray-600">{prop.desc}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="prose-sm max-w-none">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">示例</h3>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm leading-relaxed whitespace-pre-wrap font-mono text-gray-800">
|
||||
{e.body}
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
|
||||
{e.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { type FileNode, type TocNode } from "../data-loader";
|
||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||
import { useScrollContainer } from "../App";
|
||||
@@ -24,6 +25,7 @@ export const FileTreeNode: Component<{
|
||||
isHidden?: (node: FileNode) => boolean;
|
||||
}> = (props) => {
|
||||
const navigate = useNavigateWithParams();
|
||||
const location = useLocation();
|
||||
const isDir = !!props.node.children;
|
||||
const isActive = createMemo(() => props.currentPath === props.node.path);
|
||||
// 默认收起,除非当前文件在该文件夹内
|
||||
@@ -31,21 +33,28 @@ export const FileTreeNode: Component<{
|
||||
isDir && isPathInDir(props.currentPath, props.node.path),
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
const href = () => props.node.path + location.search;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isDir) {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded());
|
||||
} else {
|
||||
} else if (e.button === 0) {
|
||||
// Left-click: use SPA navigation
|
||||
e.preventDefault();
|
||||
navigate(props.node.path);
|
||||
props.onClose();
|
||||
}
|
||||
// Middle-click / right-click: let the browser handle the <a> natively
|
||||
};
|
||||
|
||||
const indent = props.depth * 12;
|
||||
|
||||
return (
|
||||
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||
<div
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
|
||||
<a
|
||||
href={href()}
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
|
||||
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
style={{ "padding-left": `${indent + 8}px` }}
|
||||
@@ -58,7 +67,7 @@ export const FileTreeNode: Component<{
|
||||
<span class="mr-1 text-gray-400">📄</span>
|
||||
</Show>
|
||||
<span class="text-sm truncate">{props.node.name}</span>
|
||||
</div>
|
||||
</a>
|
||||
<Show when={isDir && isExpanded() && props.node.children}>
|
||||
<div>
|
||||
{props.node.children!.map((child) => (
|
||||
@@ -87,8 +96,9 @@ export const HeadingNode: Component<{
|
||||
isHidden?: (node: TocNode) => boolean;
|
||||
}> = (props) => {
|
||||
const navigate = useNavigateWithParams();
|
||||
const location = useLocation();
|
||||
const anchor = props.node.id || "";
|
||||
const href = `${props.basePath}#${anchor}`;
|
||||
const href = () => `${props.basePath}${location.search}#${anchor}`;
|
||||
const hasChildren = !!props.node.children;
|
||||
// 默认收起,除非当前锚点在该节点内
|
||||
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
|
||||
@@ -102,8 +112,12 @@ export const HeadingNode: Component<{
|
||||
const scrollContainer = useScrollContainer();
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
if (e.button === 0) {
|
||||
// Left-click: use SPA navigation
|
||||
e.preventDefault();
|
||||
navigate(`${props.basePath}#${anchor}`);
|
||||
}
|
||||
// Middle-click / right-click: let the browser handle the <a> natively
|
||||
// 滚动到目标元素,考虑导航栏高度偏移
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(anchor);
|
||||
@@ -141,7 +155,7 @@ export const HeadingNode: Component<{
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={href}
|
||||
href={href()}
|
||||
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
||||
@@ -126,7 +126,7 @@ export const RevealManager: Component = () => {
|
||||
rect: sparkTable.getBoundingClientRect(),
|
||||
action: () =>
|
||||
setActionPrefill({
|
||||
command: "/spark",
|
||||
command: "/roll",
|
||||
text: combinedSlug,
|
||||
}),
|
||||
title: "Roll spark table",
|
||||
|
||||
@@ -4,16 +4,12 @@ export interface DocEntry {
|
||||
tag: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
syntax: string;
|
||||
props: { name: string; type: string; default?: string; desc: string }[];
|
||||
/** Markdown body (everything after frontmatter ---) */
|
||||
/** Full markdown body (everything after frontmatter ---) */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Splits frontmatter and markdown body from a raw .md string. */
|
||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
// Handle CRLF line endings by normalizing to LF first
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
@@ -27,7 +23,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
function getBody(raw: string): string {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||
return match ? match[1] : raw;
|
||||
return (match ? match[1] : raw).trim();
|
||||
}
|
||||
|
||||
function parseEntry(raw: string): DocEntry | null {
|
||||
@@ -37,9 +33,6 @@ function parseEntry(raw: string): DocEntry | null {
|
||||
tag: fm.tag as string,
|
||||
icon: fm.icon as string,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
syntax: fm.syntax as string,
|
||||
props: (fm.props as DocEntry["props"]) ?? [],
|
||||
body: getBody(raw),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import "./md-yarn-spinner";
|
||||
export { Article } from "./Article";
|
||||
export type { ArticleProps } from "./Article";
|
||||
export { RevealManager } from "./RevealManager";
|
||||
export { CommandLinkManager } from "./CommandLinkManager";
|
||||
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
|
||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||
export type { SidebarProps } from "./Sidebar";
|
||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
|
||||
@@ -4,9 +4,7 @@ export interface JournalDocEntry {
|
||||
tag: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
syntax: string;
|
||||
props: { name: string; type: string; default?: string; desc: string }[];
|
||||
/** Full markdown body (everything after frontmatter ---) */
|
||||
body: string;
|
||||
}
|
||||
|
||||
@@ -24,7 +22,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
function getBody(raw: string): string {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||
return match ? match[1] : raw;
|
||||
return (match ? match[1] : raw).trim();
|
||||
}
|
||||
|
||||
function parseEntry(raw: string): JournalDocEntry | null {
|
||||
@@ -34,18 +32,23 @@ function parseEntry(raw: string): JournalDocEntry | null {
|
||||
tag: fm.tag as string,
|
||||
icon: fm.icon as string,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
syntax: fm.syntax as string,
|
||||
props: (fm.props as JournalDocEntry["props"]) ?? [],
|
||||
body: getBody(raw),
|
||||
};
|
||||
}
|
||||
|
||||
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||
import journalStatRaw from "../doc-entries/journal-stat.md";
|
||||
import journalSetRaw from "../doc-entries/journal-set.md";
|
||||
import journalRollRaw from "../doc-entries/journal-roll.md";
|
||||
import journalLinkRaw from "../doc-entries/journal-link.md";
|
||||
|
||||
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw, journalStatRaw];
|
||||
const rawDocuments: string[] = [
|
||||
journalGmRaw,
|
||||
journalPlayerRaw,
|
||||
journalSetRaw,
|
||||
journalRollRaw,
|
||||
journalLinkRaw,
|
||||
];
|
||||
|
||||
let _entries: JournalDocEntry[] | null = null;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { Component, createSignal, For, Show, createMemo } from "solid-js";
|
||||
import { registeredTypes, canEmit } from "./registry";
|
||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||
import type { MessageTypeDef } from "./registry";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
|
||||
export const ComposePanel: Component = () => {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js";
|
||||
import { z } from "zod";
|
||||
import { getMessageType } from "./registry";
|
||||
import type { MessageTypeDef } from "./registry";
|
||||
|
||||
type FormData = Record<string, unknown>;
|
||||
|
||||
@@ -22,9 +21,6 @@ interface DynamicFormProps {
|
||||
export const DynamicForm: Component<DynamicFormProps> = (props) => {
|
||||
const def = createMemo(() => getMessageType(props.type));
|
||||
|
||||
// Generate default on type change
|
||||
const schema = createMemo(() => def()?.schema);
|
||||
|
||||
return (
|
||||
<div class="space-y-1.5">
|
||||
<Show when={def()}>
|
||||
@@ -136,7 +132,7 @@ const FieldInput: Component<{
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<label
|
||||
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
|
||||
class="text-xs text-gray-500 w-20 shrink-0 truncate"
|
||||
title={props.label}
|
||||
>
|
||||
{props.label}
|
||||
|
||||
@@ -7,26 +7,20 @@
|
||||
* - `/link path#section` → "link" type
|
||||
* - `/stat set key=value` → "stat" type
|
||||
* - `/` alone opens completions dropdown
|
||||
*
|
||||
* Delegates command dispatch to the shared command-dispatcher module.
|
||||
*/
|
||||
|
||||
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
|
||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||
import { resolveRollPayload } from "./types/roll";
|
||||
import { resolveSparkPayload } from "./types/spark";
|
||||
import {
|
||||
resolveStatRoll,
|
||||
resolveTemplateSet,
|
||||
canModifyStat,
|
||||
fullKey,
|
||||
findStatDef,
|
||||
} from "./stat-helpers";
|
||||
import { parseInput } from "./command-parser";
|
||||
import type { CompletionItem } from "./command-parser";
|
||||
import { buildCompletions } from "./command-completions";
|
||||
import { CompletionsDropdown } from "./CompletionsDropdown";
|
||||
import { ErrorPopup } from "./ErrorPopup";
|
||||
import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
|
||||
|
||||
// ---- Component ----
|
||||
|
||||
@@ -39,7 +33,6 @@ export const JournalInput: Component = () => {
|
||||
const isGm = () => stream.myRole === "gm";
|
||||
|
||||
const [text, setText] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
||||
@@ -79,201 +72,75 @@ export const JournalInput: Component = () => {
|
||||
// Observers: everything is plain chat
|
||||
if (isObserver()) {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
const r = unwrapSendResult(result);
|
||||
finish(r.ok, r.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseInput(raw);
|
||||
if (parsed.error) {
|
||||
setError(parsed.error);
|
||||
setDispatchError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setDispatchError(null);
|
||||
|
||||
// Players: chat + stat commands only
|
||||
// Players: chat + set commands only
|
||||
if (isPlayer()) {
|
||||
if (parsed.type === "chat") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
const r = unwrapSendResult(result);
|
||||
finish(r.ok, r.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "stat") {
|
||||
handleStat(parsed.payload);
|
||||
if (parsed.type !== "set" && parsed.type !== "rolltag") {
|
||||
setDispatchError("玩家只能发送聊天消息或使用 /set 命令");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||
return;
|
||||
}
|
||||
|
||||
// GM: all commands
|
||||
// GM: all commands, Player: set commands
|
||||
setSending(true);
|
||||
|
||||
if (parsed.type === "roll") {
|
||||
const p = resolveRollPayload(
|
||||
parsed.payload as { notation: string; label?: string },
|
||||
);
|
||||
const result = sendMessage("roll", p);
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "spark") {
|
||||
try {
|
||||
const key = (parsed.payload as { key: string }).key;
|
||||
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
||||
const csvPath = match?.csvPath ?? "";
|
||||
const remix = match?.remix ?? false;
|
||||
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||
const result = sendMessage("spark", p);
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "种子表掷骰失败");
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "stat") {
|
||||
handleStat(parsed.payload);
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = sendMessage(parsed.type, parsed.payload);
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
await dispatchCommand({
|
||||
role: stream.myRole as "gm" | "player" | "observer",
|
||||
myName: stream.myName,
|
||||
command: raw,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
variables: stream.variables,
|
||||
declarations: comp.data.declarations,
|
||||
tagModifiers: comp.data.tagModifiers,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
// dispatchCommand already set the shared error if it failed.
|
||||
// Only clear text on success (no error is shown).
|
||||
if (!dispatchError()) {
|
||||
setText("");
|
||||
}
|
||||
|
||||
finish(true);
|
||||
setSending(false);
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** Clear text or set shared dispatch error on failure. */
|
||||
function finish(success: boolean, err?: string) {
|
||||
if (success) {
|
||||
setText("");
|
||||
setDispatchError(null);
|
||||
} else if (err) {
|
||||
setError(err);
|
||||
setDispatchError(err);
|
||||
}
|
||||
setSending(false);
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
/** Unwrap a sendMessage result into (success, error?) for finish(). */
|
||||
function unwrap<R>(
|
||||
function unwrapSendResult<R>(
|
||||
r: { success: true; msg: R } | { success: false; error: string },
|
||||
) {
|
||||
return r.success
|
||||
? ({ ok: true, err: undefined } as const)
|
||||
: ({ ok: false, err: r.error } as const);
|
||||
? ({ ok: true, error: undefined } as const)
|
||||
: ({ ok: false, error: r.error } as const);
|
||||
}
|
||||
|
||||
// ---- Completions ----
|
||||
@@ -314,7 +181,7 @@ export const JournalInput: Component = () => {
|
||||
return;
|
||||
}
|
||||
const raw = text();
|
||||
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
||||
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||
e.preventDefault();
|
||||
openCompletions();
|
||||
return;
|
||||
@@ -355,7 +222,7 @@ export const JournalInput: Component = () => {
|
||||
const raw = input.value;
|
||||
setText(raw);
|
||||
|
||||
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
|
||||
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||
setShowCompletions(true);
|
||||
setSelectedIdx(0);
|
||||
} else {
|
||||
@@ -398,23 +265,23 @@ export const JournalInput: Component = () => {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isGm()
|
||||
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
|
||||
: "输入消息,或使用 /stat 命令..."
|
||||
? "输入消息,或使用 /roll、/link、/set 命令..."
|
||||
: "输入消息,或使用 /set 命令..."
|
||||
}
|
||||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
text-gray-800 placeholder-gray-400 focus:outline-none
|
||||
bg-transparent min-h-[60px]"
|
||||
bg-transparent min-h-15"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-2 pb-2">
|
||||
<Show when={error()}>
|
||||
<Show when={dispatchError()}>
|
||||
<button
|
||||
onClick={() => setShowErrorPopup((v) => !v)}
|
||||
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
|
||||
title={error() ?? undefined}
|
||||
title={dispatchError() ?? undefined}
|
||||
>
|
||||
{error()}
|
||||
{dispatchError()}
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
@@ -434,7 +301,7 @@ export const JournalInput: Component = () => {
|
||||
|
||||
{/* Error popup */}
|
||||
<ErrorPopup
|
||||
message={error()}
|
||||
message={dispatchError()}
|
||||
show={showErrorPopup()}
|
||||
onClose={() => setShowErrorPopup(false)}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { JournalHeader } from "./JournalHeader";
|
||||
import { ConnectDialog } from "./ConnectDialog";
|
||||
import { InviteDialog } from "./InviteDialog";
|
||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||
import { StatsView } from "./StatsView";
|
||||
import { VariableView } from "./VariableView";
|
||||
|
||||
export interface JournalPanelProps {
|
||||
open: boolean;
|
||||
@@ -26,7 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||
const stream = useJournalStream();
|
||||
const [showInvite, setShowInvite] = createSignal(false);
|
||||
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||
const [viewMode, setViewMode] = createSignal<"stream" | "stats">("stream");
|
||||
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
||||
|
||||
// Player list (exclude gm and observer)
|
||||
const playerEntries = () =>
|
||||
@@ -99,18 +99,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||
消息流
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("stats")}
|
||||
onClick={() => setViewMode("variables")}
|
||||
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||
viewMode() === "stats"
|
||||
viewMode() === "variables"
|
||||
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
属性
|
||||
变量
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={viewMode() === "stream"} fallback={<StatsView />}>
|
||||
<Show when={viewMode() === "stream"} fallback={<VariableView />}>
|
||||
<StreamView />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* ReactiveVariableManager — mounts as a child of <Article> and reactively
|
||||
* replaces {{$key}} template patterns in the rendered markdown DOM with
|
||||
* live variable values from the journal stream.
|
||||
*
|
||||
* On mount, walks all text nodes in the article content looking for
|
||||
* {{$key}} placeholders. Each match is wrapped in a <span> with a
|
||||
* data-reactive-var attribute so that subsequent updates (driven by
|
||||
* a Solid effect) only touch those spans.
|
||||
*/
|
||||
|
||||
import { Component, createEffect, onCleanup } from "solid-js";
|
||||
import { useArticleDom } from "../Article";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
|
||||
const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
||||
|
||||
/** Attribute storing the original template text on marker spans. */
|
||||
const ATTR_TEMPLATE = "data-reactive-var";
|
||||
|
||||
/** Attribute storing pre-computed key list (comma-separated) for fast updates. */
|
||||
const ATTR_KEYS = "data-reactive-keys";
|
||||
|
||||
export const ReactiveVariableManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const stream = useJournalStream();
|
||||
|
||||
const values = () => stream.variables;
|
||||
|
||||
// ---- Initial scan: wrap {{$key}} text in marker spans ----
|
||||
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
if (!dom) return;
|
||||
|
||||
// Only scan once — after the first scan, the spans exist and we
|
||||
// switch to the reactive update effect below.
|
||||
if (dom.querySelector(`[${ATTR_TEMPLATE}]`)) return;
|
||||
|
||||
scanAndWrap(dom);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
const dom = contentDom();
|
||||
if (dom) unwrapAll(dom);
|
||||
});
|
||||
|
||||
// ---- Reactive updates: whenever variable values change, update all spans ----
|
||||
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
if (!dom) return;
|
||||
|
||||
const v = values();
|
||||
const spans = dom.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
|
||||
|
||||
for (const span of spans) {
|
||||
const template = span.getAttribute(ATTR_TEMPLATE) ?? "";
|
||||
const keys = span.getAttribute(ATTR_KEYS)?.split(",") ?? [];
|
||||
let result = template;
|
||||
for (const key of keys) {
|
||||
result = result.replaceAll(`{{${key}}}`, v[key] ?? "");
|
||||
}
|
||||
if (span.textContent !== result) {
|
||||
span.textContent = result;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null; // renders nothing — works purely via DOM manipulation
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk all text nodes in a subtree and wrap `{{$key}}` patterns in
|
||||
* `<span data-reactive-var="originalText">` elements.
|
||||
*/
|
||||
function scanAndWrap(root: Element): void {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const replacements: { node: Text; html: string }[] = [];
|
||||
|
||||
let textNode: Text | null;
|
||||
while ((textNode = walker.nextNode() as Text | null)) {
|
||||
const text = textNode.textContent;
|
||||
if (!text) continue;
|
||||
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
if (!TEMPLATE_RE.test(text)) continue;
|
||||
|
||||
// Build replacement HTML: split on {{$key}}, wrap each match in a span
|
||||
// with pre-computed key for fast updates.
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_RE.exec(text)) !== null) {
|
||||
if (m.index > lastIndex) {
|
||||
parts.push(escapeHtml(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
const key = m[1]; // includes $ prefix
|
||||
parts.push(
|
||||
`<span ${ATTR_TEMPLATE}="${escapeAttr(m[0])}" ${ATTR_KEYS}="${escapeAttr(key)}">${escapeHtml(m[0])}</span>`,
|
||||
);
|
||||
lastIndex = TEMPLATE_RE.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(escapeHtml(text.slice(lastIndex)));
|
||||
}
|
||||
|
||||
replacements.push({ node: textNode, html: parts.join("") });
|
||||
}
|
||||
|
||||
for (const { node, html } of replacements) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.innerHTML = html;
|
||||
node.replaceWith(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse scanAndWrap — remove marker spans, restoring plain text.
|
||||
* Called on cleanup so subsequent remounts get a clean slate.
|
||||
*/
|
||||
function unwrapAll(root: Element): void {
|
||||
const spans = root.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
|
||||
// Process in reverse to avoid DOM mutation issues
|
||||
for (let i = spans.length - 1; i >= 0; i--) {
|
||||
const span = spans[i];
|
||||
const parent = span.parentNode;
|
||||
if (!parent) continue;
|
||||
const text = span.getAttribute(ATTR_TEMPLATE) ?? span.textContent ?? "";
|
||||
span.replaceWith(document.createTextNode(text));
|
||||
}
|
||||
// Normalize to merge adjacent text nodes
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export default ReactiveVariableManager;
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* SheetView — renders a stat sheet SVG with live reactive text bindings.
|
||||
*
|
||||
* Parses the SVG once via DOMParser, then uses a Solid effect to
|
||||
* update only <text> elements containing ${key} templates whenever
|
||||
* the stats store changes.
|
||||
*/
|
||||
|
||||
import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { fullKey } from "./stat-helpers";
|
||||
import type { StatDef } from "./completions";
|
||||
import { parseSheet } from "./stat-sheet";
|
||||
|
||||
export const SheetView: Component<{ sheetId: string }> = (props) => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
|
||||
/** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */
|
||||
const bareDefMap = createMemo(() => {
|
||||
const map = new Map<string, StatDef>();
|
||||
for (const def of statDefs()) {
|
||||
if (!map.has(def.key)) {
|
||||
map.set(def.key, def);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a bare key from an SVG template to its runtime value.
|
||||
* Uses StatDef scoping when available, falls back to player-first, global-second.
|
||||
*/
|
||||
function resolveBareKey(bareKey: string): string {
|
||||
const bareDef = bareDefMap().get(bareKey);
|
||||
if (bareDef) {
|
||||
const fk = fullKey(bareDef, stream.myName);
|
||||
return values()[fk] ?? bareDef.default ?? "";
|
||||
}
|
||||
const pk = `${stream.myName}:${bareKey}`;
|
||||
if (values()[pk] !== undefined) return values()[pk];
|
||||
return values()[bareKey] ?? "";
|
||||
}
|
||||
|
||||
const sheet = createMemo(() => {
|
||||
const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId);
|
||||
return s ?? null;
|
||||
});
|
||||
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
let disposeEffect: (() => void) | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
const s = sheet();
|
||||
const container = containerRef;
|
||||
|
||||
disposeEffect?.();
|
||||
disposeEffect = undefined;
|
||||
|
||||
if (!container || !s) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
const parsed = parseSheet(s.svg);
|
||||
// Force full-width, auto-height
|
||||
parsed.svgElement.setAttribute("width", "100%");
|
||||
parsed.svgElement.removeAttribute("height");
|
||||
parsed.svgElement.style.display = "block";
|
||||
container.appendChild(parsed.svgElement);
|
||||
|
||||
// Center template nodes so text stays centered as values change length
|
||||
for (const tpl of parsed.templates) {
|
||||
const el = tpl.el;
|
||||
try {
|
||||
const bbox = el.getBBox();
|
||||
const cx = bbox.x + bbox.width / 2;
|
||||
el.setAttribute("text-anchor", "middle");
|
||||
el.setAttribute("x", String(cx));
|
||||
} catch {
|
||||
// getBBox may fail if the node has no layout — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.templates.length === 0) return;
|
||||
|
||||
const dispose = createRoot((disposer) => {
|
||||
createEffect(() => {
|
||||
const v = values();
|
||||
for (const tpl of parsed.templates) {
|
||||
let result = tpl.template;
|
||||
for (const key of tpl.keys) {
|
||||
result = result.replace(`\${${key}}`, resolveBareKey(key));
|
||||
}
|
||||
tpl.el.textContent = result;
|
||||
}
|
||||
});
|
||||
return disposer;
|
||||
});
|
||||
|
||||
disposeEffect = dispose;
|
||||
});
|
||||
|
||||
onCleanup(() => disposeEffect?.());
|
||||
|
||||
const fallback = (
|
||||
<p class="text-center text-gray-400 text-xs py-8">未找到属性卡: {props.sheetId}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="w-full h-full overflow-y-auto p-2">
|
||||
{sheet() ? (
|
||||
<div ref={containerRef} class="w-full" />
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* StatsView — table view of all stat key-value pairs, plus optional
|
||||
* stat sheet rendering from *.sheet.svg files.
|
||||
*
|
||||
* Groups stats by scope (global, then per-player). Shows computed values
|
||||
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
|
||||
*
|
||||
* A floating button at bottom-right opens a sheet picker dropdown.
|
||||
* Selecting a sheet delegates to SheetView.
|
||||
*/
|
||||
|
||||
import { Component, For, createMemo, createSignal, Show } from "solid-js";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { fullKey, modifierLabel } from "./stat-helpers";
|
||||
import type { StatDef } from "./completions";
|
||||
import { SheetView } from "./SheetView";
|
||||
|
||||
export const StatsView: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
const sheets = createMemo(() => comp.data.statSheets);
|
||||
|
||||
/** Currently selected sheet id, or null for table view. Persisted in URL. */
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ sheet?: string }>();
|
||||
const selectedSheetId = () => searchParams.sheet || null;
|
||||
const setSelectedSheetId = (id: string | null) => {
|
||||
setSearchParams({ sheet: id || undefined });
|
||||
};
|
||||
const [pickerOpen, setPickerOpen] = createSignal(false);
|
||||
|
||||
/** 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 relative">
|
||||
<Show
|
||||
when={selectedSheetId()}
|
||||
fallback={
|
||||
<Show
|
||||
when={statDefs().length > 0}
|
||||
fallback={
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="mb-3">
|
||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-600">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={group.entries}>
|
||||
{({ fullKey: fk, def }) => {
|
||||
const val = () => values()[fk];
|
||||
const comp = () => computedValue(fk);
|
||||
const hasModifiers = () => {
|
||||
for (const [mfk, md] of defMap()) {
|
||||
if (
|
||||
md.type === "modifier" &&
|
||||
md.target &&
|
||||
fullKeyTarget(md.target, md, def)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const canRoll = () =>
|
||||
def.roll ||
|
||||
def.formula ||
|
||||
def.type === "enum" ||
|
||||
def.type === "template";
|
||||
|
||||
return (
|
||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span class="text-sm text-gray-800 truncate">
|
||||
{modifierLabel(def, statDefs())}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
||||
{def.key}
|
||||
</span>
|
||||
<Show when={def.type === "modifier"}>
|
||||
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
||||
→{def.target}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show
|
||||
when={val() !== undefined}
|
||||
fallback={
|
||||
<span class="text-sm text-gray-300">
|
||||
{def.default ?? "—"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="text-sm font-mono text-gray-700">
|
||||
{val()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
hasModifiers() &&
|
||||
comp() !== (val() ?? def.default ?? "")
|
||||
}
|
||||
>
|
||||
<span class="text-xs text-gray-400">
|
||||
= {comp()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show when={canRoll()}>
|
||||
<button
|
||||
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
||||
title="掷骰"
|
||||
onClick={() => {
|
||||
const textarea =
|
||||
document.querySelector<HTMLTextAreaElement>(
|
||||
"#journal-input-textarea",
|
||||
);
|
||||
if (textarea) {
|
||||
const nativeInputValueSetter =
|
||||
Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(
|
||||
textarea,
|
||||
`/stat roll ${def.key}`,
|
||||
);
|
||||
textarea.dispatchEvent(
|
||||
new Event("input", { bubbles: true }),
|
||||
);
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<SheetView sheetId={selectedSheetId()!} />
|
||||
</Show>
|
||||
|
||||
{/* Sheet picker — only show if sheets are available */}
|
||||
<Show when={sheets().length > 0}>
|
||||
<div class="sticky bottom-3 flex justify-end pr-3">
|
||||
{/* Dropdown trigger */}
|
||||
<button
|
||||
class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
title="选择属性卡"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<Show when={pickerOpen()}>
|
||||
<div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50">
|
||||
<button
|
||||
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
|
||||
selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSheetId(null);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
📋 表格
|
||||
</button>
|
||||
<div class="border-t border-gray-100 my-1" />
|
||||
<For each={sheets()}>
|
||||
{(sheet) => (
|
||||
<button
|
||||
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
|
||||
selectedSheetId() === sheet.id ? "text-blue-600 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSheetId(sheet.id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{sheet.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Backdrop to close picker */}
|
||||
<Show when={pickerOpen()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* VariableView — flat list of all variable key-value pairs.
|
||||
*
|
||||
* Hovering a value shows a popup with:
|
||||
* - Declaration expression (if any), styled distinctly
|
||||
* - Active tag modifiers: #tag +N (from $source)
|
||||
*/
|
||||
|
||||
import { Component, For, createMemo, Show, createSignal } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { getMods, getDeclExpr } from "./var-reactivity";
|
||||
|
||||
export const VariableView: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const values = createMemo(() => stream.variables);
|
||||
|
||||
/** Flat list of all variables */
|
||||
const items = createMemo(() => {
|
||||
const vars = values();
|
||||
return Object.entries(vars).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isTag: value.startsWith("#"),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasAnyData = () =>
|
||||
comp.data.declarations.length > 0 || Object.keys(values()).length > 0;
|
||||
|
||||
return (
|
||||
<div class="h-full overflow-y-auto bg-gray-50">
|
||||
<Show
|
||||
when={hasAnyData()}
|
||||
fallback={
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<VariableRow
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
isTag={item.isTag}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VariableRow — single row with hover popup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VariableRow: Component<{
|
||||
key: string;
|
||||
value: string;
|
||||
isTag: boolean;
|
||||
}> = (props) => {
|
||||
const [hovered, setHovered] = createSignal(false);
|
||||
|
||||
const declExpr = createMemo(() => getDeclExpr(props.key));
|
||||
const mods = createMemo(() => getMods(props.key));
|
||||
|
||||
const hasPopup = () => !!declExpr() || mods().length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<span class="flex-1 text-sm font-mono text-gray-800">{props.key}</span>
|
||||
<span
|
||||
class={props.isTag
|
||||
? "text-sm font-mono text-purple-700 font-semibold"
|
||||
: "text-sm font-mono text-gray-700 font-semibold"
|
||||
}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
|
||||
{/* Hover popup */}
|
||||
<Show when={hasPopup() && hovered()}>
|
||||
<div class="absolute right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-md shadow-lg p-2 min-w-45 text-xs">
|
||||
{/* Declaration expression */}
|
||||
<Show when={declExpr()}>
|
||||
<div class="mb-1">
|
||||
<span class="text-gray-400">expr: </span>
|
||||
<span class="font-mono text-gray-500 bg-gray-50 px-1 rounded">
|
||||
{declExpr()}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Tag modifiers */}
|
||||
<Show when={mods().length > 0}>
|
||||
<div class={declExpr() ? "border-t border-gray-100 pt-1 mt-1" : ""}>
|
||||
<For each={mods()}>
|
||||
{(mod) => (
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<span class="font-mono text-purple-600">{mod.tag}</span>
|
||||
<span>+{mod.value}</span>
|
||||
<span class="text-gray-400">(from {mod.source})</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableView;
|
||||
@@ -21,9 +21,8 @@ export function buildCompletions(
|
||||
|
||||
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 " },
|
||||
{ label: "/set", kind: "command" as const, insertText: "/set " },
|
||||
];
|
||||
|
||||
// Show commands when user types / or starts typing a command name
|
||||
@@ -33,53 +32,59 @@ export function buildCompletions(
|
||||
c.label.toLowerCase().startsWith(prefix),
|
||||
);
|
||||
if (!isGm) {
|
||||
matches = matches.filter((c) => c.label === "/stat");
|
||||
matches = matches.filter((c) => c.label === "/set");
|
||||
}
|
||||
return matches.length > 0
|
||||
? matches
|
||||
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
|
||||
// After /roll — show dice suggestions
|
||||
// After /roll — show dice AND spark table suggestions
|
||||
if (raw.startsWith("/roll ")) {
|
||||
const prefix = raw.slice("/roll ".length).toLowerCase();
|
||||
const matches = data.dice
|
||||
|
||||
const diceMatches = 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,
|
||||
}));
|
||||
}
|
||||
.slice(0, 4);
|
||||
|
||||
// After /spark — show spark table suggestions
|
||||
if (raw.startsWith("/spark ")) {
|
||||
const prefix = raw.slice("/spark ".length).toLowerCase();
|
||||
const matches = data.sparkTables
|
||||
const sparkMatches = 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: "" }];
|
||||
.slice(0, 4);
|
||||
|
||||
const items: CompletionItem[] = [];
|
||||
|
||||
for (const d of diceMatches) {
|
||||
items.push({
|
||||
label: `🎲 ${d.notation}`,
|
||||
kind: "value",
|
||||
insertText: "/roll " + d.notation,
|
||||
});
|
||||
}
|
||||
return matches.map((s) => ({
|
||||
label: `${s.slug} (${s.notation})`,
|
||||
kind: "value" as const,
|
||||
insertText: `/spark ${s.slug}`,
|
||||
}));
|
||||
|
||||
for (const s of sparkMatches) {
|
||||
items.push({
|
||||
label: `✨ ${s.slug} (${s.notation})`,
|
||||
kind: "value",
|
||||
insertText: `/roll ${s.slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return [{ label: "输入骰子表达式", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// After /link — show article and heading suggestions
|
||||
if (raw.startsWith("/link ")) {
|
||||
const prefix = raw.slice("/link ".length).toLowerCase();
|
||||
@@ -101,50 +106,43 @@ export function buildCompletions(
|
||||
});
|
||||
}
|
||||
|
||||
// After /stat — show subcommands or stat keys
|
||||
if (raw.startsWith("/stat ")) {
|
||||
const rest = raw.slice("/stat ".length).trim();
|
||||
// After /set — show known variable and tag names
|
||||
if (raw.startsWith("/set ")) {
|
||||
const rest = raw.slice("/set ".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))
|
||||
// Already has a key? Show tag suggestions
|
||||
if (rest.includes(" ")) {
|
||||
const afterSpace = rest.slice(rest.indexOf(" ") + 1).toLowerCase();
|
||||
const tags = data.tagModifiers.map((tm) => tm.tag);
|
||||
// Also gather unique tags from declarations
|
||||
const uniqueTags = [...new Set(tags)];
|
||||
const matches = uniqueTags
|
||||
.filter((t) => t.toLowerCase().startsWith(afterSpace))
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0)
|
||||
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
|
||||
return matches.map((s) => ({
|
||||
label: `${s.key} (${s.label})`,
|
||||
if (matches.length === 0) {
|
||||
return [{ label: "输入表达式", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
return matches.map((t) => ({
|
||||
label: t,
|
||||
kind: "value" as const,
|
||||
insertText: `/stat set ${s.key}=`,
|
||||
insertText: raw + t,
|
||||
}));
|
||||
}
|
||||
|
||||
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}`,
|
||||
}));
|
||||
// Show variable name suggestions
|
||||
const prefix = rest.toLowerCase();
|
||||
const varNames = data.declarations.map((d) => d.key);
|
||||
const matches = varNames
|
||||
.filter((v) => v.toLowerCase().startsWith(prefix))
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0) {
|
||||
return [{ label: "$variable", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
return matches.map((v) => ({
|
||||
label: v,
|
||||
kind: "value" as const,
|
||||
insertText: `/set ${v} `,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Command dispatcher — shared logic for parsing & dispatching commands
|
||||
* from text input or :cmd[] directive spans.
|
||||
*
|
||||
* Used by both JournalInput (manual typing) and CommandLinkManager (click).
|
||||
*/
|
||||
|
||||
import { sendMessage } from "../stores/journalStream";
|
||||
import { createSignal } from "solid-js";
|
||||
import { parseInput } from "./command-parser";
|
||||
import { resolveRollPayload } from "./types/roll";
|
||||
import { resolveSparkPayload } from "./types/spark";
|
||||
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
|
||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
function normalizeTagMap(expr: string): string {
|
||||
const t = expr.trim();
|
||||
if (BARE_TAG_PATTERN.test(t)) return t + ":1";
|
||||
return t;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DispatchResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
||||
* Components that show errors (JournalInput) read from here; callers that
|
||||
* want errors surfaced (CommandLinkManager) write to it.
|
||||
*/
|
||||
export const [dispatchError, setDispatchError] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DispatchContext {
|
||||
/** The role of the current user */
|
||||
role: "gm" | "player" | "observer";
|
||||
/** The user's name */
|
||||
myName: string;
|
||||
/** The raw text to dispatch (with or without leading `/`) */
|
||||
command: string;
|
||||
/** Spark table lookup data (from completions) */
|
||||
sparkTables: {
|
||||
slug: string;
|
||||
csvPath?: string;
|
||||
docPath?: string;
|
||||
remix?: boolean;
|
||||
}[];
|
||||
/** Current runtime variable values */
|
||||
variables: Record<string, string>;
|
||||
/** Variable declarations (from role=declare blocks) */
|
||||
declarations: VarDeclaration[];
|
||||
/** Tag modifiers (from role=declare blocks) */
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a command string. Works for both typed input and
|
||||
* :cmd[] directive clicks.
|
||||
*/
|
||||
export async function dispatchCommand(
|
||||
ctx: DispatchContext,
|
||||
): Promise<DispatchResult> {
|
||||
const raw = ctx.command.trim();
|
||||
if (!raw) return { ok: false, error: "Empty command" };
|
||||
|
||||
// Observers can only send chat
|
||||
if (ctx.role === "observer") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
|
||||
const parsed = parseInput(prefixed);
|
||||
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
||||
|
||||
// Players: chat + set commands only
|
||||
if (ctx.role === "player") {
|
||||
if (parsed.type === "chat") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||
return finish(
|
||||
dispatchSet(parsed.payload as Record<string, unknown>, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
|
||||
}
|
||||
|
||||
// GM: all commands
|
||||
if (parsed.type === "roll") {
|
||||
const arg = (parsed.payload as { arg: string }).arg;
|
||||
|
||||
// Try spark table first: if arg matches a known spark table slug, roll it
|
||||
const match = ctx.sparkTables.find((s) => s.slug === arg);
|
||||
if (match) {
|
||||
try {
|
||||
const csvPath = match.csvPath ?? "";
|
||||
const docPath = match.docPath;
|
||||
const remix = match.remix ?? false;
|
||||
const p = await resolveSparkPayload({
|
||||
key: arg,
|
||||
csvPath,
|
||||
docPath,
|
||||
remix,
|
||||
});
|
||||
const result = sendMessage("spark", p);
|
||||
return finish(unwrap(result));
|
||||
} catch (e) {
|
||||
return finish({
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Variable expression (e.g. "2d6 + $mod" or "$hp / 2")
|
||||
if (arg.includes("$")) {
|
||||
try {
|
||||
const ev = evaluateExpression(arg, {
|
||||
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
||||
});
|
||||
if (ev.value.kind !== "number") {
|
||||
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
||||
}
|
||||
const payload = {
|
||||
notation: arg,
|
||||
label: arg,
|
||||
result: {
|
||||
total: ev.value.value,
|
||||
detail: "",
|
||||
plainDetail: "",
|
||||
pools: [] as { rolls: number[]; subtotal: number }[],
|
||||
},
|
||||
};
|
||||
const result = sendMessage("roll", payload);
|
||||
return finish(unwrap(result));
|
||||
} catch (e) {
|
||||
return finish({
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "表达式求值失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, treat as a dice expression
|
||||
const p = resolveRollPayload({ notation: arg, label: arg });
|
||||
const result = sendMessage("roll", p);
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||
}
|
||||
|
||||
const result = sendMessage(parsed.type, parsed.payload);
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
/** Update the shared error signal and return the result (pass-through). */
|
||||
function finish(r: DispatchResult): DispatchResult {
|
||||
setDispatchError(r.ok ? null : r.error);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Set dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dispatchSet(
|
||||
payload: Record<string, unknown>,
|
||||
ctx: DispatchContext,
|
||||
): DispatchResult {
|
||||
const p = payload as { key?: string; expr?: string; tags?: string[] };
|
||||
|
||||
if (!p.key) {
|
||||
return { ok: false, error: "缺少变量名" };
|
||||
}
|
||||
|
||||
const key = p.key;
|
||||
// Use getCombined with stream fallback for old value so tag transitions
|
||||
// are detected correctly even when the variable has active mods.
|
||||
const oldValue = getCombined(key, ctx.variables);
|
||||
let newValue: string;
|
||||
|
||||
try {
|
||||
if (p.tags && p.tags.length > 0) {
|
||||
// Rolltag: pick random tag, format as tagmap entry
|
||||
const idx = Math.floor(Math.random() * p.tags.length);
|
||||
newValue = normalizeTagMap(p.tags[idx]);
|
||||
} else if (p.expr) {
|
||||
// Evaluate expression — handles both numeric and tagmap values
|
||||
const result = evaluateExpression(p.expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
// Use getCombined with stream fallback for accurate values
|
||||
return k === key ? undefined : getCombined(k, ctx.variables);
|
||||
},
|
||||
});
|
||||
newValue = exprValueToString(result.value);
|
||||
} else {
|
||||
return { ok: false, error: "缺少表达式" };
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "表达式求值失败",
|
||||
};
|
||||
}
|
||||
|
||||
// Update base value so getCombined returns the correct new value
|
||||
// during cascade computation
|
||||
setBase(key, newValue);
|
||||
|
||||
// Build working variable store for cascade (includes the new value)
|
||||
const workingVars = { ...ctx.variables, [key]: newValue };
|
||||
|
||||
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
||||
try {
|
||||
const cascade = computeCascade(key, oldValue, workingVars);
|
||||
for (const change of cascade) {
|
||||
sendMessage("var", {
|
||||
action: "set",
|
||||
key: change.key,
|
||||
value: change.value,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Cascade errors are non-fatal — the direct set already succeeded
|
||||
console.warn("[dispatch] cascade error:", e);
|
||||
}
|
||||
|
||||
// Send the direct set last so cascade messages arrive first.
|
||||
// Use getCombined with stream fallback so the stream store receives the
|
||||
// correct value (base + active mods) rather than just the raw base.
|
||||
const combinedValue = getCombined(key, ctx.variables);
|
||||
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
|
||||
const u1 = unwrap(r1);
|
||||
if (!u1.ok) return u1;
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unwrap a sendMessage result into DispatchResult. */
|
||||
function unwrap<R>(
|
||||
r: { success: true; msg: R } | { success: false; error: string },
|
||||
): DispatchResult {
|
||||
return r.success ? { ok: true } : { ok: false, error: r.error };
|
||||
}
|
||||
@@ -12,23 +12,17 @@ export interface CompletionItem {
|
||||
}
|
||||
|
||||
export interface ParsedInput {
|
||||
type: "chat" | "roll" | "spark" | "link" | "stat";
|
||||
type: "chat" | "roll" | "link" | "set" | "rolltag";
|
||||
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 } };
|
||||
const arg = raw.slice("/roll ".length).trim();
|
||||
if (!arg)
|
||||
return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
|
||||
return { type: "roll", payload: { arg } };
|
||||
}
|
||||
|
||||
if (raw.startsWith("/link ")) {
|
||||
@@ -41,41 +35,35 @@ export function parseInput(raw: string): ParsedInput {
|
||||
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 (raw.startsWith("/set ")) {
|
||||
const rest = raw.slice("/set ".length).trim();
|
||||
if (!rest)
|
||||
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||
|
||||
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 } };
|
||||
// Split on first space to get key and expression
|
||||
const spaceIdx = rest.indexOf(" ");
|
||||
if (spaceIdx === -1)
|
||||
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||
|
||||
const key = rest.slice(0, spaceIdx).trim();
|
||||
const expr = rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
if (!key || !expr)
|
||||
return { type: "set", payload: {}, error: "key 和 expression 不能为空" };
|
||||
|
||||
if (!key.startsWith("$"))
|
||||
return { type: "set", payload: {}, error: "key 必须以 $ 开头" };
|
||||
|
||||
// Check if expr is a rolltag (e.g. #w|#d|#s)
|
||||
if (expr.includes("|") && expr.split("|").every((t) => t.trim().startsWith("#"))) {
|
||||
const tags = expr.split("|").map((t) => t.trim());
|
||||
return { type: "rolltag", payload: { key, tags } };
|
||||
}
|
||||
|
||||
if (arg.startsWith("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" };
|
||||
return { type: "set", payload: { key, expr } };
|
||||
}
|
||||
|
||||
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat") {
|
||||
if (raw === "/roll" || raw === "/link" || raw === "/set") {
|
||||
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,69 +2,44 @@
|
||||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||
*
|
||||
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
||||
* back to scanning the in-memory file index for dice expressions and headings.
|
||||
* back to scanning the in-memory file index for dice expressions, headings,
|
||||
* and declare blocks.
|
||||
*
|
||||
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
||||
* from any Solid component to reactively read the state.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import Slugger from "github-slugger";
|
||||
import { extractHeadings } from "../../data-loader/toc";
|
||||
import {
|
||||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
setIndexedData,
|
||||
setInlineResolver,
|
||||
} from "../../data-loader/file-index";
|
||||
import {
|
||||
parseStatYaml,
|
||||
parseStatCsv,
|
||||
parseTemplateCsv,
|
||||
parseStatModifiers,
|
||||
} from "../../cli/completions/stat-parser";
|
||||
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
|
||||
import type { StatSheet } from "../../cli/completions/types";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
} from "../../cli/completions/block-scanner";
|
||||
import {
|
||||
scanDirectives,
|
||||
} from "../../cli/completions/directive-scanner";
|
||||
buildRegistryFromIndex,
|
||||
deriveCompletions,
|
||||
resolveInlineByPath,
|
||||
type ContentRegistry,
|
||||
} from "../../cli/content-registry";
|
||||
import type {
|
||||
CompletionsPayload,
|
||||
DiceCompletion,
|
||||
LinkCompletion,
|
||||
SparkTableCompletion,
|
||||
VarDeclaration,
|
||||
TagModifier,
|
||||
} from "../../cli/completions/types";
|
||||
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
||||
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
||||
|
||||
export type { StatDef, StatTemplate, StatSheet };
|
||||
export type { VarDeclaration, TagModifier };
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
// Re-export CLI types so consumers don't need to know about the CLI path
|
||||
export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
|
||||
|
||||
export interface DiceCompletion {
|
||||
label: string;
|
||||
notation: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface LinkCompletion {
|
||||
path: string;
|
||||
label: string;
|
||||
section: string | null;
|
||||
}
|
||||
|
||||
export interface SparkTableCompletion {
|
||||
label: string;
|
||||
notation: string;
|
||||
slug: string;
|
||||
filePath: string;
|
||||
csvPath: string;
|
||||
headers: string[];
|
||||
remix: boolean;
|
||||
}
|
||||
|
||||
export interface JournalCompletions {
|
||||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
statSheets: StatSheet[];
|
||||
}
|
||||
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
|
||||
export type JournalCompletions = CompletionsPayload;
|
||||
|
||||
export type CompletionsState =
|
||||
| { status: "loading" }
|
||||
@@ -78,6 +53,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
// The registry backing the completions. Populated in both CLI and client
|
||||
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
|
||||
/**
|
||||
* The registry backing the current completions.
|
||||
* In CLI mode this is fetched from the server; in browser mode it is built
|
||||
* client-side. Used to resolve inline content ids (e.g. spark table CSVs).
|
||||
*/
|
||||
export function getRegistry(): ContentRegistry {
|
||||
return activeRegistry;
|
||||
}
|
||||
|
||||
// Register the inline-content resolver so `getIndexedData` can resolve
|
||||
// directive refs (e.g. `./csv_abc123`) that aren't real files.
|
||||
setInlineResolver((path) => resolveInlineByPath(activeRegistry, path));
|
||||
|
||||
// ------------------- Fetch (CLI mode) -------------------
|
||||
|
||||
async function tryServer(): Promise<JournalCompletions | null> {
|
||||
@@ -89,132 +81,54 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||
statTemplates: Array.isArray(data.statTemplates)
|
||||
? data.statTemplates
|
||||
: [],
|
||||
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
|
||||
declarations: Array.isArray(data.declarations) ? data.declarations : [],
|
||||
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the content registry from the server (CLI mode). */
|
||||
async function tryServerRegistry(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/__CONTENT_REGISTRY.json");
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
activeRegistry = {
|
||||
pathIndex: data.pathIndex ?? {},
|
||||
docContent: data.docContent ?? {},
|
||||
};
|
||||
} catch {
|
||||
// Registry unavailable — leave empty; client scan will populate it.
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- Client-side fallback scan -------------------
|
||||
|
||||
async function scanClientSide(): Promise<JournalCompletions> {
|
||||
const paths = await getPathsByExtension("md");
|
||||
const dice: DiceCompletion[] = [];
|
||||
const links: LinkCompletion[] = [];
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
const stats: StatDef[] = [];
|
||||
const statTemplates: StatTemplate[] = [];
|
||||
|
||||
// Build a temporary index for resolving CSV paths
|
||||
const tempIndex: Record<string, string> = {};
|
||||
|
||||
// First pass: load all .md content into temp index
|
||||
// Load all .md content into a raw index, then build the registry through
|
||||
// the same shared pipeline as the CLI (scanDoc).
|
||||
const index: Record<string, string> = {};
|
||||
for (const filePath of paths) {
|
||||
const content = await getIndexedData(filePath);
|
||||
if (content) tempIndex[filePath] = content;
|
||||
}
|
||||
|
||||
for (const filePath of paths) {
|
||||
const content = tempIndex[filePath];
|
||||
if (!content) continue;
|
||||
|
||||
// Fresh slugger per file
|
||||
const slugger = new Slugger();
|
||||
|
||||
// ---- Links (headings) - from original content ----
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||
links.push({ path: basePath, label: fileName, section: null });
|
||||
for (const heading of extractHeadings(content)) {
|
||||
links.push({
|
||||
path: basePath,
|
||||
label: `${fileName} § ${heading.title}`,
|
||||
section: heading.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Unified block scanning (stats) ----
|
||||
FENCED_BLOCK_RE.lastIndex = 0;
|
||||
let blockMatch: RegExpExecArray | null;
|
||||
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
||||
const [, lang, infoString, body] = blockMatch;
|
||||
const attrs = parseBlockAttrs(infoString);
|
||||
attrs.lang = attrs.lang || lang;
|
||||
|
||||
if (attrs.role === "stat") {
|
||||
if (attrs.lang === "yaml" || attrs.lang === "yml") {
|
||||
stats.push(...parseStatYaml(body, filePath));
|
||||
} else if (attrs.lang === "csv") {
|
||||
stats.push(...parseStatCsv(body, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs.role === "stat-template") {
|
||||
const name = attrs.id || `_tpl_${filePath}_${stats.length}`;
|
||||
statTemplates.push(parseTemplateCsv(body, filePath, name));
|
||||
}
|
||||
|
||||
if (attrs.role === "stat-modifiers") {
|
||||
const id = attrs.id || `_mod_${filePath}_${stats.length}`;
|
||||
const result = parseStatModifiers(body, filePath, id, "player", attrs.extra["label"]);
|
||||
stats.push(result.statDef);
|
||||
stats.push(...result.modifierDefs);
|
||||
statTemplates.push(result.template);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Directive scanning (dice + spark tables) ----
|
||||
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
||||
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
|
||||
dice.push(...directiveResult.dice);
|
||||
sparkTables.push(...directiveResult.sparkTables);
|
||||
index[filePath] = content;
|
||||
}
|
||||
|
||||
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
|
||||
}
|
||||
const registry = buildRegistryFromIndex(index);
|
||||
activeRegistry = registry;
|
||||
|
||||
// ------------------- Stat sheet helpers -------------------
|
||||
|
||||
function extractSvgTitle(svg: string): string | null {
|
||||
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function labelFromId(id: string): string {
|
||||
return id
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
async function scanStatSheets(): Promise<StatSheet[]> {
|
||||
const paths = await getPathsByExtension("svg");
|
||||
const sheets: StatSheet[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!/\.sheet\.svg$/i.test(filePath)) continue;
|
||||
try {
|
||||
const content = await getIndexedData(filePath);
|
||||
if (!content) continue;
|
||||
const fileName = filePath.split("/").filter(Boolean).pop() || filePath;
|
||||
const id = fileName.replace(/\.sheet\.svg$/i, "");
|
||||
const title = extractSvgTitle(content);
|
||||
sheets.push({
|
||||
id,
|
||||
label: title || labelFromId(id),
|
||||
svg: content,
|
||||
source: filePath,
|
||||
});
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
// Write the processed (stripped) content back into the file index so
|
||||
// Article/md-embed render the same content as CLI mode (attributed blocks
|
||||
// processed by role).
|
||||
for (const [path, content] of Object.entries(registry.pathIndex)) {
|
||||
setIndexedData(path, content);
|
||||
}
|
||||
|
||||
return sheets;
|
||||
return deriveCompletions(registry);
|
||||
}
|
||||
|
||||
// ------------------- Init (runs eagerly at import time) -------------------
|
||||
@@ -225,15 +139,33 @@ const _initPromise: Promise<void> = (async () => {
|
||||
const serverData = await tryServer();
|
||||
if (serverData) {
|
||||
setCompletionsState({ status: "loaded", data: serverData });
|
||||
await tryServerRegistry();
|
||||
try {
|
||||
initReactivity({
|
||||
declarations: serverData.declarations,
|
||||
tagModifiers: serverData.tagModifiers,
|
||||
});
|
||||
seedDeclaredVariables();
|
||||
} catch (e) {
|
||||
console.warn("[completions] reactivity init error:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fall back to client-side scan (dev/browser mode)
|
||||
try {
|
||||
const data = await scanClientSide();
|
||||
data.statSheets = await scanStatSheets();
|
||||
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
|
||||
if (data.dice.length > 0 || data.links.length > 0) {
|
||||
setCompletionsState({ status: "loaded", data });
|
||||
try {
|
||||
initReactivity({
|
||||
declarations: data.declarations,
|
||||
tagModifiers: data.tagModifiers,
|
||||
});
|
||||
seedDeclaredVariables();
|
||||
} catch (e) {
|
||||
console.warn("[completions] reactivity init error:", e);
|
||||
}
|
||||
} else {
|
||||
setCompletionsState({ status: "empty" });
|
||||
}
|
||||
@@ -255,14 +187,6 @@ export function ensureCompletions(): Promise<void> {
|
||||
return _initPromise;
|
||||
}
|
||||
|
||||
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
|
||||
let _invalidated = false;
|
||||
export function invalidateCompletions(): void {
|
||||
_invalidated = true;
|
||||
// On next import (page reload), the module will re-init.
|
||||
// For a runtime invalidation, you could call init again.
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive hooks for the journal input.
|
||||
* Returns the current completions state + a convenience `data` extractor.
|
||||
@@ -281,9 +205,19 @@ export function useJournalCompletions(): {
|
||||
dice: [],
|
||||
links: [],
|
||||
sparkTables: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed declared variables into the store on load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seedDeclaredVariables(): void {
|
||||
const initial = computeInitialValues(journalStreamState.variables);
|
||||
for (const { key, value } of initial) {
|
||||
sendMessage("var", { action: "set", key, value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Declare parser re-export — delegates to the shared parser in cli/completions.
|
||||
*/
|
||||
|
||||
export {
|
||||
parseDeclareCsv,
|
||||
type VarDeclaration,
|
||||
type TagModifier,
|
||||
type DeclareResult,
|
||||
} from "../../cli/completions/declare-parser";
|
||||
@@ -48,6 +48,19 @@ export { StreamMessageCard } from "./StreamMessage";
|
||||
export { ComposePanel } from "./ComposePanel";
|
||||
export { JournalInput } from "./JournalInput";
|
||||
export { DynamicForm } from "./DynamicForm";
|
||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||
export { useJournalCompletions } from "./completions";
|
||||
export { dispatchCommand } from "./command-dispatcher";
|
||||
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||
export { parseInput } from "./command-parser";
|
||||
export type { ParsedInput, CompletionItem } from "./command-parser";
|
||||
export { buildCompletions } from "./command-completions";
|
||||
export type { CompletionsContext } from "./command-completions";
|
||||
export { VariableView } from "./VariableView";
|
||||
export { parseDeclareCsv } from "./declare-parser";
|
||||
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
||||
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
||||
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
||||
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||
export type { JournalContextValue } from "./JournalContext";
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* Stat command helpers — stat resolution, permission checking, and
|
||||
* dice-formula stat-reference expansion.
|
||||
*
|
||||
* Extracted from JournalInput to keep that component focused on UI.
|
||||
*/
|
||||
|
||||
import { rollFormula } from "../md-commander/hooks";
|
||||
import { evaluateFormula } from "./stat-formula";
|
||||
import type { StatDef, StatTemplate } from "./completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get the full runtime key for a stat def, given a player name. */
|
||||
export function fullKey(def: StatDef, playerName: string): string {
|
||||
return def.scope === "player" ? `${playerName}:${def.key}` : def.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display label for a modifier stat.
|
||||
*
|
||||
* If the key follows the `{parent}_{target}` convention (from stat-modifiers),
|
||||
* returns `{parent_label}/{target_label}` by looking up both defs.
|
||||
* Otherwise falls back to the def's own label.
|
||||
*/
|
||||
export function modifierLabel(
|
||||
def: StatDef,
|
||||
statDefs: StatDef[],
|
||||
): string {
|
||||
if (def.type !== "modifier") return def.label;
|
||||
|
||||
// Try to split key as parent_target
|
||||
const lastUnderscore = def.key.lastIndexOf("_");
|
||||
if (lastUnderscore === -1) return def.label;
|
||||
|
||||
const parentKey = def.key.slice(0, lastUnderscore);
|
||||
const targetKey = def.key.slice(lastUnderscore + 1);
|
||||
|
||||
const parentDef = statDefs.find((d) => d.key === parentKey);
|
||||
const targetDef = statDefs.find((d) => d.key === targetKey);
|
||||
|
||||
if (parentDef && targetDef) {
|
||||
return `${parentDef.label}/${targetDef.label}`;
|
||||
}
|
||||
|
||||
return def.label;
|
||||
}
|
||||
|
||||
/** Find a stat def by its full runtime key. */
|
||||
export function findStatDef(
|
||||
fk: string,
|
||||
statDefs: StatDef[],
|
||||
playerName: string,
|
||||
): StatDef | undefined {
|
||||
return statDefs.find((d) => fullKey(d, playerName) === fk);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula stat reference resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace stat key references in a dice formula (e.g. "1d20 + attack")
|
||||
* with their numeric values, so the result can be passed to rollFormula.
|
||||
*
|
||||
* Bare keys in formulas resolve relative to the caller's scope: if the
|
||||
* caller def is `scope: player`, then `attack` resolves to `alice:attack`.
|
||||
*/
|
||||
export function resolveStatRefs(
|
||||
formula: string,
|
||||
lookup: (key: string) => number,
|
||||
): string {
|
||||
return formula.replace(/[a-zA-Z_]\w*/g, (match) => {
|
||||
if (/^d\d/i.test(match)) return match;
|
||||
if (/^[kdh]\d/i.test(match)) return match;
|
||||
const val = lookup(match);
|
||||
return String(val);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stat lookup function that resolves bare keys to numbers.
|
||||
* Bare keys are scoped by `playerName` if the originating def is player-scoped.
|
||||
*/
|
||||
export function makeStatLookup(
|
||||
runtimeStats: Record<string, string>,
|
||||
statDefs: StatDef[],
|
||||
playerName: string,
|
||||
/** The def that references are being resolved for (for scoping bare keys). */
|
||||
callerDef?: StatDef,
|
||||
): (bareKey: string) => number {
|
||||
return (bareKey: string): number => {
|
||||
// Try as a full key first, then try scoped
|
||||
const candidates = [bareKey];
|
||||
if (callerDef && callerDef.scope === "player") {
|
||||
candidates.push(`${playerName}:${bareKey}`);
|
||||
}
|
||||
|
||||
for (const k of candidates) {
|
||||
const val = runtimeStats[k];
|
||||
if (val !== undefined) {
|
||||
const n = parseFloat(val);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
const sdef = statDefs.find((d) => fullKey(d, playerName) === k);
|
||||
if (sdef?.default !== undefined) {
|
||||
const n = parseFloat(sdef.default);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check whether a role can modify a given stat key. */
|
||||
export function canModifyStat(
|
||||
role: string,
|
||||
myName: string,
|
||||
fullKey: string,
|
||||
statDefs: StatDef[],
|
||||
): boolean {
|
||||
if (role === "gm") return true;
|
||||
if (role === "observer") return false;
|
||||
|
||||
const def = findStatDef(fullKey, statDefs, myName);
|
||||
if (!def) return false;
|
||||
if (def.scope === "player") {
|
||||
return fullKey.startsWith(myName + ":");
|
||||
}
|
||||
// Global stats: player can't modify
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roll resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StatRollResult {
|
||||
fullKey: string;
|
||||
value: string;
|
||||
error?: string;
|
||||
/** For template rolls: additional modifier keys → values to apply */
|
||||
modifiers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a template-type stat is set explicitly (not rolled), look up the
|
||||
* template entry by label and return the modifier overrides to apply.
|
||||
* Returns null if the stat is not a template type or the label doesn't match.
|
||||
*/
|
||||
export function resolveTemplateSet(
|
||||
def: StatDef,
|
||||
value: string,
|
||||
statDefs: StatDef[],
|
||||
runtimeStats: Record<string, string>,
|
||||
playerName: string,
|
||||
templates?: StatTemplate[],
|
||||
): Record<string, string> | null {
|
||||
if (def.type !== "template" || !def.template) return null;
|
||||
|
||||
const tpl = templates?.find((t) => t.name === def.template);
|
||||
if (!tpl) return null;
|
||||
|
||||
const entry = tpl.entries.find((e) => e.label === value);
|
||||
if (!entry || Object.keys(entry.modifiers).length === 0) return null;
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
|
||||
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||
resolved[modFullKey] = mv;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a /stat roll command: look up the stat definition (by bare or full
|
||||
* key), evaluate the appropriate resolution strategy, and return the string
|
||||
* value to publish.
|
||||
*/
|
||||
export function resolveStatRoll(
|
||||
inputKey: string,
|
||||
statDefs: StatDef[],
|
||||
runtimeStats: Record<string, string>,
|
||||
playerName: string,
|
||||
templates?: StatTemplate[],
|
||||
): StatRollResult {
|
||||
// Try exact match first, then resolve bare key → full key
|
||||
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
|
||||
if (!def) {
|
||||
// Try bare key: find a player-scoped def whose fullKey would match
|
||||
def = statDefs.find(
|
||||
(d) => d.scope === "player" && fullKey(d, playerName) === inputKey,
|
||||
);
|
||||
}
|
||||
if (!def) {
|
||||
// Try finding a bare key match (for global or player)
|
||||
def = statDefs.find((d) => d.key === inputKey);
|
||||
}
|
||||
|
||||
if (!def) {
|
||||
return { fullKey: inputKey, value: "", error: `未知属性: ${inputKey}` };
|
||||
}
|
||||
|
||||
const fk = fullKey(def, playerName);
|
||||
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
|
||||
|
||||
if (def.type === "template" && def.template) {
|
||||
const tpl = templates?.find((t) => t.name === def.template);
|
||||
if (!tpl || tpl.entries.length === 0) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: `未找到模板: ${def.template}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Roll the dice to pick an entry (use template's notation)
|
||||
const formula = tpl.notation || "1d" + String(tpl.entries.length);
|
||||
const resolvedFormula = resolveStatRefs(formula, lookup);
|
||||
const roll = rollFormula(resolvedFormula);
|
||||
const rolled = roll.result.total;
|
||||
|
||||
// Find matching entry by range
|
||||
const entry = matchTemplateRange(rolled, tpl.entries);
|
||||
if (!entry) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: `模板 "${def.template}" 中未找到匹配 ${rolled} 的条目`,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve modifier keys to full keys (same scope as def)
|
||||
// Template values are absolute — set the modifier stat directly.
|
||||
// The modifier stat (type: modifier) adds to its target in StatsView.
|
||||
const resolvedModifiers: Record<string, string> = {};
|
||||
for (const [mk, mv] of Object.entries(entry.modifiers)) {
|
||||
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
|
||||
resolvedModifiers[modFullKey] = mv;
|
||||
}
|
||||
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: entry.label,
|
||||
modifiers: resolvedModifiers,
|
||||
};
|
||||
}
|
||||
|
||||
if (def.type === "enum" && def.options && def.options.length > 0) {
|
||||
const idx = Math.floor(Math.random() * def.options.length);
|
||||
return { fullKey: fk, value: def.options[idx] };
|
||||
}
|
||||
|
||||
if (def.type === "derived" && def.formula) {
|
||||
try {
|
||||
const result = evaluateFormula(def.formula, lookup);
|
||||
return { fullKey: fk, value: String(result) };
|
||||
} catch (e) {
|
||||
return {
|
||||
fullKey: fk,
|
||||
value: "",
|
||||
error: e instanceof Error ? e.message : "公式计算失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (def.roll) {
|
||||
const resolvedFormula = resolveStatRefs(def.roll, lookup);
|
||||
const roll = rollFormula(resolvedFormula);
|
||||
return { fullKey: fk, value: String(roll.result.total) };
|
||||
}
|
||||
|
||||
return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template range matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Match a rolled number against template entries with range strings.
|
||||
*
|
||||
* Range formats:
|
||||
* "1-3" → inclusive range
|
||||
* "4" → exact match
|
||||
* "1-3,5" → multiple ranges
|
||||
*
|
||||
* Returns the first matching entry, or undefined.
|
||||
*/
|
||||
function matchTemplateRange(
|
||||
rolled: number,
|
||||
entries: {
|
||||
range: string;
|
||||
label: string;
|
||||
modifiers: Record<string, string>;
|
||||
}[],
|
||||
): (typeof entries)[number] | undefined {
|
||||
for (const entry of entries) {
|
||||
const parts = entry.range.split(",").map((s) => s.trim());
|
||||
for (const part of parts) {
|
||||
if (part.includes("-")) {
|
||||
const [lo, hi] = part.split("-").map(Number);
|
||||
if (!isNaN(lo) && !isNaN(hi) && rolled >= lo && rolled <= hi) {
|
||||
return entry;
|
||||
}
|
||||
} else {
|
||||
const n = Number(part);
|
||||
if (!isNaN(n) && rolled === n) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Stat sheet template engine — parses *.sheet.svg files and extracts
|
||||
* text nodes containing ${key} template patterns.
|
||||
*
|
||||
* Walks <text> elements. If a <text> has <tspan> children, we bind to
|
||||
* each <tspan> individually (preserving their position attributes).
|
||||
* If a <text> has no <tspan> children, we bind to the <text> directly.
|
||||
*/
|
||||
|
||||
/** Regex matching ${key} template patterns (captures the key name). */
|
||||
const TEMPLATE_RE = /\$\{(\w+)\}/g;
|
||||
|
||||
/** A single template binding targeting a specific text-bearing leaf node. */
|
||||
export interface TextTemplate {
|
||||
/** The DOM node to update — a <text> (no children) or a <tspan>. */
|
||||
el: SVGTextContentElement;
|
||||
/** Original text content with ${key} placeholders. */
|
||||
template: string;
|
||||
/** Bare key names extracted from the template. */
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
/** The result of parsing a stat sheet SVG string. */
|
||||
export interface ParsedSheet {
|
||||
svgElement: SVGSVGElement;
|
||||
templates: TextTemplate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text node's content has template patterns, and if so,
|
||||
* register a binding.
|
||||
*/
|
||||
function scanNode(el: Element, templates: TextTemplate[]): void {
|
||||
const content = el.textContent;
|
||||
if (!content) return;
|
||||
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
const keys: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_RE.exec(content)) !== null) {
|
||||
keys.push(m[1]);
|
||||
}
|
||||
|
||||
if (keys.length > 0) {
|
||||
templates.push({
|
||||
el: el as SVGTextContentElement,
|
||||
template: content,
|
||||
keys,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SVG string into a live DOM element and extract all template
|
||||
* bindings from <text> and <tspan> leaf nodes.
|
||||
*/
|
||||
export function parseSheet(svgString: string): ParsedSheet {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, "image/svg+xml");
|
||||
const svgElement = doc.documentElement as unknown as SVGSVGElement;
|
||||
|
||||
if (!svgElement || svgElement.tagName !== "svg") {
|
||||
return {
|
||||
svgElement: document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
) as SVGSVGElement,
|
||||
templates: [],
|
||||
};
|
||||
}
|
||||
|
||||
const templates: TextTemplate[] = [];
|
||||
|
||||
for (const textEl of svgElement.querySelectorAll("text")) {
|
||||
const tspans = textEl.querySelectorAll("tspan");
|
||||
|
||||
if (tspans.length > 0) {
|
||||
// Has children — bind to each <tspan> individually
|
||||
for (const tspan of tspans) {
|
||||
scanNode(tspan, templates);
|
||||
}
|
||||
} else {
|
||||
// Leaf <text> — bind directly
|
||||
scanNode(textEl, templates);
|
||||
}
|
||||
}
|
||||
|
||||
return { svgElement, templates };
|
||||
}
|
||||
@@ -10,4 +10,4 @@ import "./roll";
|
||||
import "./spark";
|
||||
import "./link";
|
||||
import "./intent";
|
||||
import "./stat";
|
||||
import "./var";
|
||||
|
||||
@@ -16,11 +16,9 @@ import { z } from "zod";
|
||||
import { For } from "solid-js";
|
||||
import { registerMessageType } from "../registry";
|
||||
import { rollFormula } from "../../md-commander/hooks";
|
||||
import {
|
||||
parseSparkTableCsv,
|
||||
rollSparkTable,
|
||||
} from "../../utils/spark-table";
|
||||
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
||||
import { getIndexedData } from "../../../data-loader/file-index";
|
||||
import { getRegistry } from "../../journal/completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
@@ -75,12 +73,27 @@ export type SparkPayload = z.infer<typeof schema>;
|
||||
export async function resolveSparkPayload(raw: {
|
||||
key: string;
|
||||
csvPath: string;
|
||||
docPath?: string;
|
||||
remix: boolean;
|
||||
}): Promise<SparkPayload> {
|
||||
let csv: string;
|
||||
try {
|
||||
csv = await getIndexedData(raw.csvPath);
|
||||
} catch {
|
||||
let csv: string | null;
|
||||
|
||||
// Inline content ids resolve through the registry (docPath + content id);
|
||||
// real file paths fall back to the file index.
|
||||
const registry = getRegistry();
|
||||
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
|
||||
const inline = docStore?.[raw.csvPath];
|
||||
if (inline) {
|
||||
csv = inline.body;
|
||||
} else {
|
||||
try {
|
||||
csv = await getIndexedData(raw.csvPath);
|
||||
} catch {
|
||||
csv = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (csv === null) {
|
||||
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Built-in message type: stat
|
||||
* Built-in message type: var
|
||||
*
|
||||
* Emitters: gm, player
|
||||
* Command: /stat set key=value | /stat del key | /stat roll key
|
||||
* Command: /set $key expression
|
||||
*
|
||||
* 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.
|
||||
* Variable declarations and tag modifiers are authored in ```csv role=declare
|
||||
* code blocks in markdown documents. The stream carries set/del mutations
|
||||
* that build up the runtime variable store additively.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
@@ -20,11 +20,11 @@ const schema = z.object({
|
||||
value: z.string().optional(),
|
||||
});
|
||||
|
||||
export type StatPayload = z.infer<typeof schema>;
|
||||
export type VarPayload = z.infer<typeof schema>;
|
||||
|
||||
registerMessageType<StatPayload>({
|
||||
type: "stat",
|
||||
label: "属性",
|
||||
registerMessageType<VarPayload>({
|
||||
type: "var",
|
||||
label: "变量",
|
||||
emitters: ["gm", "player"],
|
||||
schema,
|
||||
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||
@@ -32,7 +32,7 @@ registerMessageType<StatPayload>({
|
||||
if (p.action === "del") {
|
||||
return (
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">📊</span>
|
||||
<span class="text-lg">🔢</span>
|
||||
<span class="font-mono text-sm text-gray-500 line-through">
|
||||
{p.key}
|
||||
</span>
|
||||
@@ -41,9 +41,9 @@ registerMessageType<StatPayload>({
|
||||
}
|
||||
return (
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">📊</span>
|
||||
<span class="text-lg">🔢</span>
|
||||
<span class="font-mono text-sm">
|
||||
{p.key} → <span class="font-bold">{p.value}</span>
|
||||
{p.key} = <span class="font-bold">{p.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -52,9 +52,9 @@ registerMessageType<StatPayload>({
|
||||
journalSetState(
|
||||
produce((s) => {
|
||||
if (p.action === "del") {
|
||||
delete s.stats[p.key];
|
||||
delete s.variables[p.key];
|
||||
} else if (p.value !== undefined) {
|
||||
s.stats[p.key] = p.value;
|
||||
s.variables[p.key] = p.value;
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Variable reactivity engine — tracks variable declarations and tag
|
||||
* modifiers, then cascades changes when variables are set.
|
||||
*
|
||||
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||||
* Uses a base/mod separation:
|
||||
* - baseValues: what /set writes (or declaration evaluation produces)
|
||||
* - numericMods: per-target list of {tag, value, source} from tag activations
|
||||
* - tagMapMods: per-target list of {tag, value, source} for tagmap targets
|
||||
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
|
||||
* for deactivation
|
||||
*
|
||||
* Variables have one of two types:
|
||||
* - numeric: base + sum(numericMods)
|
||||
* - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated
|
||||
* modifier activation; tagmap variables do not receive numeric mods but
|
||||
* can receive tagmap mods (adding/subtracting to specific tag counts).
|
||||
*
|
||||
* All functions that resolve variable values accept an explicit `fallback`
|
||||
* (the stream VariableStore) rather than relying on mutable module state.
|
||||
* This ensures correctness regardless of call order.
|
||||
*/
|
||||
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VarReactivityState {
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
export type VariableStore = Record<string, string>;
|
||||
|
||||
/** A single modifier applied to a target variable. */
|
||||
export interface ActiveMod {
|
||||
tag: string; // "#warrior"
|
||||
value: number; // evaluated modifier expression result
|
||||
source: string; // "$class" — which variable activated this tag
|
||||
threshold: number; // the threshold that triggered this activation
|
||||
kind: "numeric" | "tagmap";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tagmap helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tagmap serialization format: "#warrior:1;#druid:2" */
|
||||
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
|
||||
|
||||
/** Parse a tagmap string. Returns null if the value is not a valid tagmap. */
|
||||
function parseTagMap(value: string | undefined): Record<string, number> | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("#")) return null;
|
||||
|
||||
const parts = trimmed.split(";");
|
||||
const map: Record<string, number> = {};
|
||||
|
||||
for (const part of parts) {
|
||||
const m = TAGMAP_RE.exec(part.trim());
|
||||
if (!m) return null; // invalid entry
|
||||
const tag = "#" + m[1];
|
||||
const count = parseInt(m[2], 10);
|
||||
if (count <= 0) continue; // skip zero/negative counts on parse
|
||||
map[tag] = (map[tag] ?? 0) + count;
|
||||
}
|
||||
|
||||
return Object.keys(map).length > 0 ? map : null;
|
||||
}
|
||||
|
||||
/** Serialize a tagmap back to string. Returns empty string if map is empty. */
|
||||
function tagMapToString(map: Record<string, number>): string {
|
||||
const entries = Object.entries(map).filter(([, c]) => c > 0);
|
||||
if (entries.length === 0) return "";
|
||||
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
|
||||
}
|
||||
|
||||
/** Check if a value is a tagmap string. */
|
||||
function isTagMapValue(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("#")) return false;
|
||||
return parseTagMap(trimmed) !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dependency graph: $dep → Set<$declaredVar> */
|
||||
let depGraph: Map<string, Set<string>> | null = null;
|
||||
|
||||
/** Reverse: $declaredVar → its expression */
|
||||
let declExprs: Map<string, string> | null = null;
|
||||
|
||||
/** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
|
||||
let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | null = null;
|
||||
|
||||
/** Base values set by /set or declaration evaluation */
|
||||
const baseValues = new Map<string, string>();
|
||||
|
||||
/** Numeric mods per target: $target → [{tag, value, source, ...}] */
|
||||
const numericMods = new Map<string, ActiveMod[]>();
|
||||
|
||||
/** Tagmap mods per target: $target → [{tag, value, source, ...}] */
|
||||
const tagMapMods = new Map<string, ActiveMod[]>();
|
||||
|
||||
/** Activations per source: $source → [{tag, target, value, threshold, kind}] */
|
||||
const sourceActivations = new Map<string, Array<{
|
||||
tag: string;
|
||||
target: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
kind: "numeric" | "tagmap";
|
||||
}>>();
|
||||
|
||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialize (or re-initialize) the reactivity engine from declarations
|
||||
* and tag modifiers parsed from role=declare blocks.
|
||||
*
|
||||
* Throws if a circular dependency is detected.
|
||||
*/
|
||||
export function initReactivity(state: VarReactivityState): void {
|
||||
depGraph = new Map();
|
||||
declExprs = new Map();
|
||||
tagModMap = new Map();
|
||||
baseValues.clear();
|
||||
numericMods.clear();
|
||||
tagMapMods.clear();
|
||||
sourceActivations.clear();
|
||||
|
||||
// Index tag modifiers
|
||||
for (const tm of state.tagModifiers) {
|
||||
let list = tagModMap.get(tm.tag);
|
||||
if (!list) {
|
||||
list = [];
|
||||
tagModMap.set(tm.tag, list);
|
||||
}
|
||||
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
||||
}
|
||||
|
||||
// Index declarations and build dependency graph
|
||||
for (const decl of state.declarations) {
|
||||
declExprs.set(decl.key, decl.expression);
|
||||
const deps = extractDependencies(decl.expression);
|
||||
for (const dep of deps) {
|
||||
let dependents = depGraph.get(dep);
|
||||
if (!dependents) {
|
||||
dependents = new Set();
|
||||
depGraph.set(dep, dependents);
|
||||
}
|
||||
dependents.add(decl.key);
|
||||
}
|
||||
}
|
||||
|
||||
checkCircular();
|
||||
}
|
||||
|
||||
/** Extract $var names from an expression string. */
|
||||
export function extractDependencies(expr: string): string[] {
|
||||
const vars: string[] = [];
|
||||
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(expr)) !== null) {
|
||||
const name = "$" + m[1];
|
||||
if (!vars.includes(name)) vars.push(name);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the combined value of a variable.
|
||||
* - For tagmap variables: returns the serialized tagmap (base + tagmap mods).
|
||||
* - For numeric variables: returns base + sum(numericMods).
|
||||
* Falls back to the provided stream store for variables not tracked locally.
|
||||
*/
|
||||
export function getCombined(key: string, fallback?: VariableStore): string {
|
||||
const base = baseValues.get(key);
|
||||
|
||||
if (base !== undefined && isTagMapValue(base)) {
|
||||
// Tagmap variable: apply tagmap mods, then serialize
|
||||
const baseMap = parseTagMap(base);
|
||||
const mods = tagMapMods.get(key) ?? [];
|
||||
if (baseMap && mods.length === 0) return base;
|
||||
const resultMap: Record<string, number> = { ...(baseMap ?? {}) };
|
||||
for (const m of mods) {
|
||||
resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value;
|
||||
if (resultMap[m.tag] <= 0) delete resultMap[m.tag];
|
||||
}
|
||||
const serialized = tagMapToString(resultMap);
|
||||
return serialized || "0";
|
||||
}
|
||||
|
||||
const baseNum = base !== undefined ? parseFloat(base) : NaN;
|
||||
const mods = numericMods.get(key) ?? [];
|
||||
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
|
||||
|
||||
if (!isNaN(baseNum)) {
|
||||
return String(baseNum + modSum);
|
||||
}
|
||||
|
||||
// Fall back to stream store. The stream value is authoritative for
|
||||
// variables not tracked locally — it already includes any mods from
|
||||
// the sender's engine, so we return it as-is without adding local mods.
|
||||
const fb = fallback?.[key];
|
||||
if (fb !== undefined) return fb;
|
||||
|
||||
// No base, but has numeric mods
|
||||
if (mods.length > 0) return String(modSum);
|
||||
|
||||
return "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective tagmap for a variable (base + tagmap mods).
|
||||
* Returns null if the variable is not a tagmap variable.
|
||||
*/
|
||||
export function getTagMap(key: string): Record<string, number> | null {
|
||||
const base = baseValues.get(key);
|
||||
if (base === undefined || !isTagMapValue(base)) return null;
|
||||
const baseMap = parseTagMap(base);
|
||||
if (!baseMap) return null;
|
||||
const mods = tagMapMods.get(key) ?? [];
|
||||
const result: Record<string, number> = { ...baseMap };
|
||||
for (const m of mods) {
|
||||
result[m.tag] = (result[m.tag] ?? 0) + m.value;
|
||||
if (result[m.tag] <= 0) delete result[m.tag];
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
/** Get the active mods for a variable (for UI hover display). */
|
||||
export function getMods(key: string): ActiveMod[] {
|
||||
const nums = numericMods.get(key) ?? [];
|
||||
const tags = tagMapMods.get(key) ?? [];
|
||||
return [...nums, ...tags];
|
||||
}
|
||||
|
||||
/** Get the declaration expression for a variable, if any. */
|
||||
export function getDeclExpr(key: string): string | undefined {
|
||||
return declExprs?.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the base value for a variable. Called by dispatchSet before
|
||||
* computing the cascade, so getCombined returns the correct new value.
|
||||
*/
|
||||
export function setBase(key: string, value: string): void {
|
||||
baseValues.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild local reactivity state (baseValues, mods, sourceActivations)
|
||||
* from the hydrated stream variable store. Must be called after replayReducers
|
||||
* so that tag activations are correctly tracked for subsequent cascade
|
||||
* computations.
|
||||
*/
|
||||
export function rebuildReactivityFromStore(variables: VariableStore): void {
|
||||
if (!tagModMap) return;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (!baseValues.has(key)) {
|
||||
baseValues.set(key, value);
|
||||
}
|
||||
|
||||
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
|
||||
if (tagMap && !sourceActivations.has(key)) {
|
||||
// Activate all tags in the tagmap
|
||||
const added = applyTagMapActivations(key, {}, tagMap, variables);
|
||||
// We don't return the added entries here — they'll be applied
|
||||
// to the store separately
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cascade effects after a variable change.
|
||||
*
|
||||
* @param changedVar - the variable that was just set (e.g. "$con")
|
||||
* @param oldValue - its previous combined value (for tag transition detection)
|
||||
* @param currentVars - the full stream variable store (as fallback)
|
||||
* @returns list of {key, value} pairs (combined values) to publish as var messages
|
||||
*/
|
||||
export function computeCascade(
|
||||
changedVar: string,
|
||||
oldValue: string | undefined,
|
||||
currentVars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs || !tagModMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
|
||||
// ---- Tag activation/deactivation ----
|
||||
const newValue = getCombined(changedVar, currentVars);
|
||||
const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
|
||||
const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
|
||||
|
||||
// Diff tagmaps and apply changes
|
||||
const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
|
||||
results.push(...tagResults);
|
||||
|
||||
// ---- Declaration re-evaluation ----
|
||||
const reevaluated = reevaluateDependents(changedVar, currentVars);
|
||||
for (const r of reevaluated) {
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute initial values for all declared variables.
|
||||
* Called once after initReactivity() to seed the store.
|
||||
*/
|
||||
export function computeInitialValues(
|
||||
currentVars: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!declExprs) return [];
|
||||
|
||||
const allKeys = [...declExprs.keys()];
|
||||
const sorted = topoSortAffected(new Set(allKeys));
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
|
||||
for (const key of sorted) {
|
||||
const expr = declExprs.get(key);
|
||||
if (!expr) continue;
|
||||
|
||||
try {
|
||||
const result = evaluateExpression(expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return getCombined(k, currentVars);
|
||||
},
|
||||
});
|
||||
|
||||
const rawValue = exprValueToString(result.value);
|
||||
baseValues.set(key, rawValue);
|
||||
|
||||
// Check if this is a tagmap value — if so, activate matching modifiers
|
||||
const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
|
||||
if (tagMap) {
|
||||
const added = applyTagMapActivations(key, {}, tagMap, currentVars);
|
||||
for (const r of added) {
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always emit the combined value for this key
|
||||
const combined = getCombined(key, currentVars);
|
||||
if (!results.some((x) => x.key === key)) {
|
||||
results.push({ key, value: combined });
|
||||
}
|
||||
} catch {
|
||||
// skip failed evaluations at init time
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag activation / deactivation (threshold-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply tagmap changes for a source variable. Compares old and new tagmaps,
|
||||
* activating/deactivating modifiers whose threshold crossing state changed.
|
||||
*/
|
||||
function applyTagMapActivations(
|
||||
source: string,
|
||||
oldTagMap: Record<string, number>,
|
||||
newTagMap: Record<string, number>,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!tagModMap) return [];
|
||||
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
||||
|
||||
for (const tag of allTags) {
|
||||
const mods = tagModMap.get(tag);
|
||||
if (!mods || mods.length === 0) continue;
|
||||
|
||||
const oldCount = oldTagMap[tag] ?? 0;
|
||||
const newCount = newTagMap[tag] ?? 0;
|
||||
|
||||
for (let modIdx = 0; modIdx < mods.length; modIdx++) {
|
||||
const mod = mods[modIdx];
|
||||
const wasActive = oldCount >= mod.threshold;
|
||||
const isActive = newCount >= mod.threshold;
|
||||
|
||||
if (!wasActive && isActive) {
|
||||
// Activate this modifier
|
||||
try {
|
||||
// Snapshot target's current value as base if needed
|
||||
if (!baseValues.has(mod.target)) {
|
||||
baseValues.set(mod.target, getCombined(mod.target, fallback));
|
||||
}
|
||||
|
||||
const evalResult = evaluateExpression(mod.expression, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return getCombined(k, fallback);
|
||||
},
|
||||
});
|
||||
|
||||
// Modifier expressions must evaluate to a number
|
||||
if (evalResult.value.kind !== "number") {
|
||||
throw new Error(
|
||||
`Modifier expression "${mod.expression}" must evaluate to a number`,
|
||||
);
|
||||
}
|
||||
const value = evalResult.value.value;
|
||||
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
||||
|
||||
if (targetIsTagMap) {
|
||||
// Apply as tagmap mod to the target's tag count
|
||||
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" };
|
||||
let targetMods = tagMapMods.get(mod.target);
|
||||
if (!targetMods) {
|
||||
targetMods = [];
|
||||
tagMapMods.set(mod.target, targetMods);
|
||||
}
|
||||
targetMods.push(entry);
|
||||
} else {
|
||||
// Apply as numeric mod
|
||||
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" };
|
||||
let targetMods = numericMods.get(mod.target);
|
||||
if (!targetMods) {
|
||||
targetMods = [];
|
||||
numericMods.set(mod.target, targetMods);
|
||||
}
|
||||
targetMods.push(entry);
|
||||
}
|
||||
|
||||
// Track in source activations for deactivation
|
||||
let sourceEntries = sourceActivations.get(source);
|
||||
if (!sourceEntries) {
|
||||
sourceEntries = [];
|
||||
sourceActivations.set(source, sourceEntries);
|
||||
}
|
||||
sourceEntries.push({
|
||||
tag,
|
||||
target: mod.target,
|
||||
value,
|
||||
threshold: mod.threshold,
|
||||
kind: targetIsTagMap ? "tagmap" : "numeric",
|
||||
});
|
||||
|
||||
// Emit new combined value
|
||||
const combined = getCombined(mod.target, fallback);
|
||||
const existing = results.findIndex((r) => r.key === mod.target);
|
||||
if (existing >= 0) {
|
||||
results[existing] = { key: mod.target, value: combined };
|
||||
} else {
|
||||
results.push({ key: mod.target, value: combined });
|
||||
}
|
||||
} catch {
|
||||
// skip failed modifier
|
||||
}
|
||||
} else if (wasActive && !isActive) {
|
||||
// Deactivate this modifier
|
||||
const sourceEntries = sourceActivations.get(source);
|
||||
if (!sourceEntries) continue;
|
||||
|
||||
const idx = sourceEntries.findIndex(
|
||||
(e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold,
|
||||
);
|
||||
if (idx < 0) continue;
|
||||
|
||||
const entry = sourceEntries[idx];
|
||||
sourceEntries.splice(idx, 1);
|
||||
|
||||
// Remove from the appropriate mod list
|
||||
if (entry.kind === "tagmap") {
|
||||
const targetMods = tagMapMods.get(entry.target);
|
||||
if (targetMods) {
|
||||
const modIdx = targetMods.findIndex(
|
||||
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
|
||||
);
|
||||
if (modIdx >= 0) targetMods.splice(modIdx, 1);
|
||||
if (targetMods.length === 0) tagMapMods.delete(entry.target);
|
||||
}
|
||||
} else {
|
||||
const targetMods = numericMods.get(entry.target);
|
||||
if (targetMods) {
|
||||
const modIdx = targetMods.findIndex(
|
||||
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
|
||||
);
|
||||
if (modIdx >= 0) targetMods.splice(modIdx, 1);
|
||||
if (targetMods.length === 0) numericMods.delete(entry.target);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit updated combined value
|
||||
const combined = getCombined(entry.target, fallback);
|
||||
const existing = results.findIndex((r) => r.key === entry.target);
|
||||
if (existing >= 0) {
|
||||
results[existing] = { key: entry.target, value: combined };
|
||||
} else {
|
||||
results.push({ key: entry.target, value: combined });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty sourceActivations
|
||||
if (sourceActivations.get(source)?.length === 0) {
|
||||
sourceActivations.delete(source);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Declaration re-evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function reevaluateDependents(
|
||||
changedVar: string,
|
||||
fallback: VariableStore,
|
||||
): Array<{ key: string; value: string }> {
|
||||
if (!depGraph || !declExprs) return [];
|
||||
|
||||
// Collect all dependents reachable from changedVar (BFS)
|
||||
const affected = new Set<string>();
|
||||
const queue = [changedVar];
|
||||
while (queue.length > 0) {
|
||||
const dep = queue.shift()!;
|
||||
const dependents = depGraph.get(dep);
|
||||
if (!dependents) continue;
|
||||
for (const d of dependents) {
|
||||
if (!affected.has(d)) {
|
||||
affected.add(d);
|
||||
queue.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = topoSortAffected(affected);
|
||||
const results: Array<{ key: string; value: string }> = [];
|
||||
|
||||
for (const key of sorted) {
|
||||
if (inFlight.has(key)) {
|
||||
throw new Error(
|
||||
`Circular dependency detected during evaluation of ${key}`,
|
||||
);
|
||||
}
|
||||
inFlight.add(key);
|
||||
|
||||
try {
|
||||
const expr = declExprs.get(key);
|
||||
if (!expr) continue;
|
||||
|
||||
const result = evaluateExpression(expr, {
|
||||
lookup: (name: string) => {
|
||||
const k = "$" + name;
|
||||
return getCombined(k, fallback);
|
||||
},
|
||||
});
|
||||
|
||||
const rawValue = exprValueToString(result.value);
|
||||
|
||||
// Check for tagmap transition on this declared variable
|
||||
const oldCombined = getCombined(key, fallback);
|
||||
const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
|
||||
const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
|
||||
|
||||
if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
|
||||
const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
|
||||
for (const r of tagResults) {
|
||||
if (!results.some((x) => x.key === r.key)) {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update base value
|
||||
baseValues.set(key, rawValue);
|
||||
|
||||
// Emit combined value
|
||||
const combined = getCombined(key, fallback);
|
||||
if (!results.some((x) => x.key === key)) {
|
||||
results.push({ key, value: combined });
|
||||
}
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort & circular check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function topoSortAffected(affected: Set<string>): string[] {
|
||||
if (!depGraph || !declExprs) return [...affected];
|
||||
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const temp = new Set<string>();
|
||||
|
||||
function visit(key: string): void {
|
||||
if (visited.has(key)) return;
|
||||
if (temp.has(key)) {
|
||||
throw new Error(`Circular dependency involving ${key}`);
|
||||
}
|
||||
temp.add(key);
|
||||
|
||||
const expr = declExprs!.get(key);
|
||||
if (expr) {
|
||||
const deps = extractDependencies(expr);
|
||||
for (const dep of deps) {
|
||||
if (affected.has(dep) || declExprs!.has(dep)) {
|
||||
visit(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
temp.delete(key);
|
||||
visited.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
|
||||
for (const key of affected) {
|
||||
visit(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkCircular(): void {
|
||||
if (!declExprs) return;
|
||||
|
||||
const allKeys = [...declExprs.keys()];
|
||||
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||||
for (const k of allKeys) state.set(k, "unvisited");
|
||||
|
||||
const path: string[] = [];
|
||||
|
||||
function dfs(key: string): void {
|
||||
const s = state.get(key);
|
||||
if (s === "visited") return;
|
||||
if (s === "visiting") {
|
||||
const cycleStart = path.indexOf(key);
|
||||
const cycle = path.slice(cycleStart).concat(key);
|
||||
throw new Error(
|
||||
`Circular dependency detected: ${cycle.join(" → ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
state.set(key, "visiting");
|
||||
path.push(key);
|
||||
|
||||
const expr = declExprs!.get(key);
|
||||
if (expr) {
|
||||
const deps = extractDependencies(expr);
|
||||
for (const dep of deps) {
|
||||
if (declExprs!.has(dep)) {
|
||||
dfs(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
state.set(key, "visited");
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
dfs(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Variable expression evaluator — parses and evaluates expressions used
|
||||
* in `/set` commands and `role=declare` code blocks.
|
||||
*
|
||||
* Supports:
|
||||
* - Number literals (integer or decimal)
|
||||
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
||||
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
||||
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
||||
* - Arithmetic: + - * / (type-checked via registry)
|
||||
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
||||
* - Parentheses for grouping
|
||||
*
|
||||
* Throws on:
|
||||
* - Type mismatch (e.g. number + tagmap, tagmap * number)
|
||||
* - Circular variable references (detected by caller)
|
||||
* - Division by zero
|
||||
* - Unknown functions
|
||||
* - Malformed expressions
|
||||
*/
|
||||
|
||||
import { rollFormula } from "../md-commander/hooks";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A value produced by the expression evaluator. */
|
||||
export type ExprValue =
|
||||
| { kind: "number"; value: number }
|
||||
| { kind: "tagmap"; value: Record<string, number> };
|
||||
|
||||
export interface EvalContext {
|
||||
/** Resolve $var → string (numeric or tagmap serialized form).
|
||||
* Return undefined if the variable doesn't exist. */
|
||||
lookup: (varName: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
value: ExprValue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate an expression string.
|
||||
* Throws if the expression is malformed or contains a type mismatch.
|
||||
*/
|
||||
export function evaluateExpression(
|
||||
expr: string,
|
||||
ctx: EvalContext,
|
||||
): EvalResult {
|
||||
const tokens = tokenize(expr);
|
||||
const result = parseExpression(tokens, 0, ctx);
|
||||
if (result.next < tokens.length) {
|
||||
throw new Error(
|
||||
`Unexpected token at position ${result.next}: "${tokens[result.next].raw}"`,
|
||||
);
|
||||
}
|
||||
return { value: result.value };
|
||||
}
|
||||
|
||||
/** Quick check: does this expression produce a tag value? */
|
||||
export function expressionIsTag(expr: string): boolean {
|
||||
const trimmed = expr.trim();
|
||||
return trimmed.startsWith("#");
|
||||
}
|
||||
|
||||
/** Serialize an ExprValue back to a string (for var-reactivity integration). */
|
||||
export function exprValueToString(v: ExprValue): string {
|
||||
if (v.kind === "number") return String(v.value);
|
||||
// tagmap
|
||||
const entries = Object.entries(v.value).filter(([, c]) => c > 0);
|
||||
if (entries.length === 0) return "0";
|
||||
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary operation registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BinaryOp = (a: ExprValue, b: ExprValue) => ExprValue;
|
||||
|
||||
/* Helpers that narrow ExprValue to specific kinds for use in registry callbacks. */
|
||||
const num = (a: ExprValue, b: ExprValue): [number, number] =>
|
||||
[a.value as number, b.value as number];
|
||||
const tmap = (a: ExprValue, b: ExprValue): [Record<string, number>, Record<string, number>] =>
|
||||
[a.value as Record<string, number>, b.value as Record<string, number>];
|
||||
|
||||
/** Registry: binaryOps[leftKind][rightKind][operator] → implementation.
|
||||
* Any undefined combination throws a type-mismatch error. */
|
||||
const binaryOps: Record<
|
||||
string,
|
||||
Record<string, Record<string, BinaryOp>>
|
||||
> = {
|
||||
number: {
|
||||
number: {
|
||||
"+": (a, b) => {
|
||||
const [l, r] = num(a, b);
|
||||
return { kind: "number", value: l + r };
|
||||
},
|
||||
"-": (a, b) => {
|
||||
const [l, r] = num(a, b);
|
||||
return { kind: "number", value: l - r };
|
||||
},
|
||||
"*": (a, b) => {
|
||||
const [l, r] = num(a, b);
|
||||
return { kind: "number", value: l * r };
|
||||
},
|
||||
"/": (a, b) => {
|
||||
const [l, r] = num(a, b);
|
||||
if (r === 0) throw new Error("Division by zero");
|
||||
return { kind: "number", value: l / r };
|
||||
},
|
||||
},
|
||||
},
|
||||
tagmap: {
|
||||
tagmap: {
|
||||
"+": (a, b) => {
|
||||
const [leftMap, rightMap] = tmap(a, b);
|
||||
const result: Record<string, number> = { ...leftMap };
|
||||
for (const [tag, count] of Object.entries(rightMap)) {
|
||||
result[tag] = (result[tag] ?? 0) + count;
|
||||
}
|
||||
return { kind: "tagmap", value: result };
|
||||
},
|
||||
"-": (a, b) => {
|
||||
const [leftMap, rightMap] = tmap(a, b);
|
||||
const result: Record<string, number> = { ...leftMap };
|
||||
for (const [tag, count] of Object.entries(rightMap)) {
|
||||
result[tag] = (result[tag] ?? 0) - count;
|
||||
if (result[tag] <= 0) delete result[tag];
|
||||
}
|
||||
return { kind: "tagmap", value: result };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getBinaryOp(
|
||||
left: ExprValue,
|
||||
right: ExprValue,
|
||||
op: string,
|
||||
): BinaryOp {
|
||||
const opFn = binaryOps[left.kind]?.[right.kind]?.[op];
|
||||
if (!opFn) {
|
||||
throw new Error(
|
||||
`Type mismatch: cannot ${op} ${left.kind} with ${right.kind}`,
|
||||
);
|
||||
}
|
||||
return opFn;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Token {
|
||||
kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
||||
value: string;
|
||||
raw: string;
|
||||
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
|
||||
tagmap?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||
|
||||
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
||||
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
// Whitespace
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number (integer or decimal, but NOT followed by 'd' which makes it a dice pattern)
|
||||
if (/[0-9]/.test(ch)) {
|
||||
let num = "";
|
||||
while (i < input.length && /[0-9.]/.test(input[i])) {
|
||||
num += input[i];
|
||||
i++;
|
||||
}
|
||||
// Peek ahead: if next char is 'd' (case-insensitive), this is a dice pattern
|
||||
if (i < input.length && /[dD]/.test(input[i])) {
|
||||
// Dice pattern
|
||||
let dice = num;
|
||||
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
|
||||
dice += input[i];
|
||||
i++;
|
||||
}
|
||||
if (!DICE_RE.test(dice)) {
|
||||
throw new Error(`Invalid dice notation: "${dice}"`);
|
||||
}
|
||||
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: "number", value: num, raw: num });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
||||
if (ch === "#") {
|
||||
let raw = "";
|
||||
const map: Record<string, number> = {};
|
||||
|
||||
while (i < input.length && input[i] === "#") {
|
||||
// Match one entry from the current position
|
||||
const remaining = input.slice(i);
|
||||
const m = TAGMAP_ENTRY_RE.exec(remaining);
|
||||
if (!m) {
|
||||
throw new Error(`Invalid tagmap entry at position ${i}: "${remaining.slice(0, 20)}..."`);
|
||||
}
|
||||
const matched = m[0];
|
||||
raw += (raw ? ";" : "") + matched;
|
||||
const tag = "#" + m[1];
|
||||
const count = m[2] !== undefined ? parseInt(m[2], 10) : 1;
|
||||
if (count > 0) {
|
||||
map[tag] = (map[tag] ?? 0) + count;
|
||||
}
|
||||
i += matched.length;
|
||||
|
||||
// Skip whitespace after the entry
|
||||
while (i < input.length && input[i] === " ") i++;
|
||||
|
||||
// Check for semicolon separator (continue to next entry)
|
||||
if (i < input.length && input[i] === ";") {
|
||||
raw += ";";
|
||||
i++;
|
||||
// Skip whitespace after semicolon
|
||||
while (i < input.length && input[i] === " ") i++;
|
||||
// If the next char is not '#', we're done with the tagmap
|
||||
if (i >= input.length || input[i] !== "#") break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(map).length === 0) {
|
||||
throw new Error(`Empty tagmap: "${raw}"`);
|
||||
}
|
||||
tokens.push({ kind: "tagmap", value: raw, raw, tagmap: map });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Variable reference: $var
|
||||
if (ch === "$") {
|
||||
let ident = "$";
|
||||
i++;
|
||||
if (i >= input.length || !/[a-zA-Z_]/.test(input[i])) {
|
||||
throw new Error(`Invalid variable reference at position ${i - 1}: expected identifier after $`);
|
||||
}
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "var", value: ident, raw: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier or function name
|
||||
if (/[a-zA-Z_]/.test(ch)) {
|
||||
let ident = "";
|
||||
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||
ident += input[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "ident", value: ident, raw: ident });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators and punctuation
|
||||
switch (ch) {
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
tokens.push({ kind: "op", value: ch, raw: ch });
|
||||
i++;
|
||||
break;
|
||||
case "(":
|
||||
tokens.push({ kind: "lparen", value: "(", raw: "(" });
|
||||
i++;
|
||||
break;
|
||||
case ")":
|
||||
tokens.push({ kind: "rparen", value: ")", raw: ")" });
|
||||
i++;
|
||||
break;
|
||||
case ",":
|
||||
tokens.push({ kind: "comma", value: ",", raw: "," });
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected character: "${ch}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rollDice(notation: string): number {
|
||||
const result = rollFormula(notation);
|
||||
if (!result.success) {
|
||||
throw new Error(`Dice roll failed: ${result.error ?? notation}`);
|
||||
}
|
||||
return result.result.total;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recursive descent parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ParseResult {
|
||||
value: ExprValue;
|
||||
next: number; // index of next unconsumed token
|
||||
}
|
||||
|
||||
/** expression := term (("+" | "-") term)* */
|
||||
function parseExpression(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
let result = parseTerm(tokens, pos, ctx);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tok = tokens[pos];
|
||||
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
||||
const right = parseTerm(tokens, pos + 1, ctx);
|
||||
const opFn = getBinaryOp(result.value, right.value, tok.value);
|
||||
result = { value: opFn(result.value, right.value), next: right.next };
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** term := factor (("*" | "/") factor)* */
|
||||
function parseTerm(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
let result = parseFactor(tokens, pos, ctx);
|
||||
pos = result.next;
|
||||
|
||||
while (pos < tokens.length) {
|
||||
const tok = tokens[pos];
|
||||
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
||||
const right = parseFactor(tokens, pos + 1, ctx);
|
||||
const opFn = getBinaryOp(result.value, right.value, tok.value);
|
||||
result = { value: opFn(result.value, right.value), next: right.next };
|
||||
pos = result.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
|
||||
function parseFactor(
|
||||
tokens: Token[],
|
||||
pos: number,
|
||||
ctx: EvalContext,
|
||||
): ParseResult {
|
||||
if (pos >= tokens.length) {
|
||||
throw new Error("Unexpected end of expression");
|
||||
}
|
||||
|
||||
const tok = tokens[pos];
|
||||
|
||||
// Unary minus (numbers only)
|
||||
if (tok.kind === "op" && tok.value === "-") {
|
||||
const inner = parseFactor(tokens, pos + 1, ctx);
|
||||
if (inner.value.kind !== "number") {
|
||||
throw new Error(`Type mismatch: cannot negate ${inner.value.kind}`);
|
||||
}
|
||||
return { value: { kind: "number", value: -inner.value.value }, next: inner.next };
|
||||
}
|
||||
|
||||
// Number literal (including already-rolled dice patterns)
|
||||
if (tok.kind === "number") {
|
||||
return { value: { kind: "number", value: parseFloat(tok.value) }, next: pos + 1 };
|
||||
}
|
||||
|
||||
// Tagmap literal
|
||||
if (tok.kind === "tagmap") {
|
||||
return { value: { kind: "tagmap", value: { ...tok.tagmap! } }, next: pos + 1 };
|
||||
}
|
||||
|
||||
// Variable reference: $var
|
||||
if (tok.kind === "var") {
|
||||
const varName = tok.value; // includes $ prefix
|
||||
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
||||
if (resolved === undefined) {
|
||||
return { value: { kind: "number", value: 0 }, next: pos + 1 };
|
||||
}
|
||||
// Auto-detect: tagmap or numeric
|
||||
if (resolved.startsWith("#")) {
|
||||
const map = parseTagMapValue(resolved);
|
||||
if (!map) {
|
||||
throw new Error(
|
||||
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
|
||||
);
|
||||
}
|
||||
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
|
||||
}
|
||||
const num = parseFloat(resolved);
|
||||
if (isNaN(num)) {
|
||||
throw new Error(
|
||||
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
|
||||
);
|
||||
}
|
||||
return { value: { kind: "number", value: num }, next: pos + 1 };
|
||||
}
|
||||
|
||||
// Parenthesized expression
|
||||
if (tok.kind === "lparen") {
|
||||
const inner = parseExpression(tokens, pos + 1, ctx);
|
||||
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
|
||||
throw new Error("Missing closing parenthesis");
|
||||
}
|
||||
return { value: inner.value, next: inner.next + 1 };
|
||||
}
|
||||
|
||||
// Function call: ident "(" expression ")"
|
||||
if (tok.kind === "ident") {
|
||||
const name = tok.value;
|
||||
|
||||
// Check for function call: ident "(" ...
|
||||
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
|
||||
const arg = parseExpression(tokens, pos + 2, ctx);
|
||||
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
|
||||
throw new Error(`Missing closing parenthesis after ${name}(...)`);
|
||||
}
|
||||
const value = applyFunction(name, arg.value);
|
||||
return { value, next: arg.next + 1 };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown identifier: "${name}" (use $ for variables)`);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: "${tok.raw}"`);
|
||||
}
|
||||
|
||||
function applyFunction(name: string, arg: ExprValue): ExprValue {
|
||||
if (arg.kind !== "number") {
|
||||
throw new Error(`Type mismatch: ${name}() requires a number, got ${arg.kind}`);
|
||||
}
|
||||
switch (name.toLowerCase()) {
|
||||
case "floor":
|
||||
return { kind: "number", value: Math.floor(arg.value) };
|
||||
case "ceil":
|
||||
return { kind: "number", value: Math.ceil(arg.value) };
|
||||
case "round":
|
||||
return { kind: "number", value: Math.round(arg.value) };
|
||||
default:
|
||||
throw new Error(`Unknown function: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tagmap parsing (shared with var-reactivity, duplicated to avoid circular deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
|
||||
|
||||
function parseTagMapValue(value: string): Record<string, number> | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("#")) return null;
|
||||
|
||||
const parts = trimmed.split(";");
|
||||
const map: Record<string, number> = {};
|
||||
|
||||
for (const part of parts) {
|
||||
const m = TAGMAP_RE.exec(part.trim());
|
||||
if (!m) return null;
|
||||
const tag = "#" + m[1];
|
||||
const count = parseInt(m[2], 10);
|
||||
if (count <= 0) continue;
|
||||
map[tag] = (map[tag] ?? 0) + count;
|
||||
}
|
||||
|
||||
return Object.keys(map).length > 0 ? map : null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store";
|
||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
||||
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
||||
import {resolvePath} from "../../utils/path";
|
||||
import {loadCSV} from "../../utils/csv-loader";
|
||||
import {loadCSVFromPath} from "../../utils/csv-loader";
|
||||
|
||||
const defaultCommands: MdCommanderCommandMap = {
|
||||
help: setupHelpCommand({}),
|
||||
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
|
||||
setCommandsError(undefined);
|
||||
|
||||
try {
|
||||
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path));
|
||||
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
|
||||
|
||||
// 按命令分组模板
|
||||
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { createMemo, For, Show, type JSX } from "solid-js";
|
||||
import { parseMarkdown } from "../../markdown";
|
||||
import { getLayerStyle } from "./hooks/dimensions";
|
||||
import type { CardData, CardSide, LayerConfig } from "./types";
|
||||
import type { Align, CardData, CardSide, LayerConfig } from "./types";
|
||||
import { DeckStore } from "./hooks/deckStore";
|
||||
import { processVariables } from "../utils/csv-loader";
|
||||
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
|
||||
@@ -31,15 +31,29 @@ export function CardLayer(props: CardLayerProps) {
|
||||
) as string;
|
||||
}
|
||||
|
||||
const getAlignStyle = (align?: "l" | "c" | "r") => {
|
||||
if (align === "l") return "left";
|
||||
if (align === "r") return "right";
|
||||
return "center";
|
||||
const getAlignStyle = (align?: Align) => {
|
||||
const horizontal = align?.includes("l")
|
||||
? "left"
|
||||
: align?.includes("r")
|
||||
? "right"
|
||||
: "center";
|
||||
const vertical = align?.includes("t")
|
||||
? "flex-start"
|
||||
: align?.includes("b")
|
||||
? "flex-end"
|
||||
: "center";
|
||||
return {
|
||||
"text-align": horizontal,
|
||||
"justify-content": vertical,
|
||||
} as JSX.CSSProperties;
|
||||
};
|
||||
|
||||
const isLayerSelected = (layerIndex: number) =>
|
||||
selectedLayer() === layerIndex;
|
||||
|
||||
const isEditing = () =>
|
||||
props.store.state.isEditing && !props.store.state.fixed;
|
||||
|
||||
const getFrameBounds = (layer: LayerConfig) => {
|
||||
const dims = dimensions();
|
||||
const left = (layer.x1 - 1) * dims.cellWidth;
|
||||
@@ -75,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm cursor-pointer"
|
||||
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm"
|
||||
classList={{
|
||||
"cursor-pointer": isEditing(),
|
||||
"ring-2 ring-blue-500 ring-offset-1":
|
||||
isSelected() && !draggingState(),
|
||||
isSelected() && !draggingState() && isEditing(),
|
||||
}}
|
||||
style={{
|
||||
...getLayerStyle(layer, dimensions()),
|
||||
"font-size": `${layer.fontSize || 3}mm`,
|
||||
"text-align": getAlignStyle(layer.align),
|
||||
...getAlignStyle(layer.align),
|
||||
}}
|
||||
innerHTML={renderLayerContent(props.cardData[layer.prop])}
|
||||
innerHTML={renderLayerContent(
|
||||
layer.template ?? props.cardData[layer.prop ?? ""],
|
||||
)}
|
||||
onClick={(e) => handleLayerClick(index(), e)}
|
||||
/>
|
||||
<Show when={isSelected()}>
|
||||
<Show when={isSelected() && isEditing()}>
|
||||
<div
|
||||
class="absolute border-2 border-blue-500 pointer-events-none z-10"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { For, Show, createEffect, createSignal, on } from "solid-js";
|
||||
import type { DeckStore } from "./hooks/deckStore";
|
||||
|
||||
export interface CardListProps {
|
||||
store: DeckStore;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 卡牌列表:左侧垂直导航,每行高度一致,按页分页
|
||||
*/
|
||||
export function CardList(props: CardListProps) {
|
||||
const { store } = props;
|
||||
const [page, setPage] = createSignal(0);
|
||||
|
||||
const totalPages = () =>
|
||||
Math.max(1, Math.ceil(store.state.cards.length / PAGE_SIZE));
|
||||
|
||||
const pageCards = () => {
|
||||
const start = page() * PAGE_SIZE;
|
||||
return store.state.cards.slice(start, start + PAGE_SIZE);
|
||||
};
|
||||
|
||||
// 当活动卡牌变化时,自动跳转到其所在页(只响应 activeTab,避免与手动翻页互相干扰)
|
||||
createEffect(
|
||||
on(
|
||||
() => store.state.activeTab,
|
||||
(tab) => {
|
||||
setPage(Math.floor(tab / PAGE_SIZE));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<nav class="w-44 shrink-0 border-r border-gray-200 pr-3 flex flex-col">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<For each={pageCards()}>
|
||||
{(card, index) => {
|
||||
const globalIndex = () => page() * PAGE_SIZE + index();
|
||||
const active = () => store.state.activeTab === globalIndex();
|
||||
return (
|
||||
<button
|
||||
onClick={() => store.actions.setActiveTab(globalIndex())}
|
||||
class={`flex items-center gap-2 w-full text-left px-3 py-2 rounded text-sm font-medium transition-colors cursor-pointer ${
|
||||
active()
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<span class="shrink-0 text-xs text-gray-400 tabular-nums w-5">
|
||||
{globalIndex() + 1}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{card.label || card.name || `Card ${globalIndex() + 1}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={totalPages() > 1}>
|
||||
<div class="flex items-center justify-between mt-2 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page() - 1))}
|
||||
disabled={page() === 0}
|
||||
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 tabular-nums">
|
||||
{page() + 1} / {totalPages()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages() - 1, page() + 1))}
|
||||
disabled={page() >= totalPages() - 1}
|
||||
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { For } from "solid-js";
|
||||
import { Show, createSignal, onMount } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import type { DeckStore } from "./hooks/deckStore";
|
||||
|
||||
export interface DeckHeaderProps {
|
||||
@@ -10,46 +11,93 @@ export interface DeckHeaderProps {
|
||||
*/
|
||||
export function DeckHeader(props: DeckHeaderProps) {
|
||||
const { store } = props;
|
||||
const [showCopyFallback, setShowCopyFallback] = createSignal(false);
|
||||
const [fallbackCode, setFallbackCode] = createSignal("");
|
||||
let textareaRef: HTMLTextAreaElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (showCopyFallback() && textareaRef) {
|
||||
textareaRef.focus();
|
||||
textareaRef.select();
|
||||
}
|
||||
});
|
||||
|
||||
const handleCopy = () => {
|
||||
store.actions.copyCode((code) => {
|
||||
setFallbackCode(code);
|
||||
setShowCopyFallback(true);
|
||||
});
|
||||
};
|
||||
|
||||
const closeFallback = () => setShowCopyFallback(false);
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
|
||||
{/* 编辑按钮 */}
|
||||
<button
|
||||
onClick={() => store.actions.setIsEditing(!store.state.isEditing)}
|
||||
class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${
|
||||
store.state.isEditing && !store.state.fixed
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
|
||||
</button>
|
||||
<>
|
||||
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
|
||||
{/* 编辑按钮 */}
|
||||
<button
|
||||
onClick={() => store.actions.setIsEditing(!store.state.isEditing)}
|
||||
class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${
|
||||
store.state.isEditing && !store.state.fixed
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
|
||||
</button>
|
||||
|
||||
{/* 导出 PDF 按钮 */}
|
||||
<button
|
||||
onClick={() => store.actions.exportDeck()}
|
||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
|
||||
>
|
||||
📥 导出 PDF
|
||||
</button>
|
||||
{/* 导出 PDF 按钮 */}
|
||||
<button
|
||||
onClick={() => store.actions.exportDeck()}
|
||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
|
||||
>
|
||||
📥 导出 PDF
|
||||
</button>
|
||||
|
||||
{/* Tab 选择器 */}
|
||||
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap">
|
||||
<For each={store.state.cards}>
|
||||
{(card, index) => (
|
||||
<button
|
||||
onClick={() => store.actions.setActiveTab(index())}
|
||||
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
|
||||
store.state.activeTab === index()
|
||||
? "bg-blue-100 text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{card.label || card.name || `Card ${index() + 1}`}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
{/* 复制代码按钮 */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
||||
>
|
||||
📋 复制代码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 剪贴板不可用时的手动复制弹窗 */}
|
||||
<Show when={showCopyFallback()}>
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
|
||||
onClick={closeFallback}
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-xl w-full max-w-lg p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 class="font-bold mb-2">复制代码</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
自动复制失败(当前站点可能不是 HTTPS),请手动复制下面的代码:
|
||||
</p>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readonly
|
||||
value={fallbackCode()}
|
||||
class="w-full h-40 border border-gray-300 rounded px-3 py-2 text-xs font-mono bg-gray-50 resize-none"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<div class="flex justify-end gap-2 mt-3">
|
||||
<button
|
||||
onClick={closeFallback}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium cursor-pointer bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import type { DeckStore } from "./hooks/deckStore";
|
||||
import { LayerEditorPanel, PropertiesEditorPanel } from "./editor-panel";
|
||||
|
||||
export interface EditorTabsProps {
|
||||
store: DeckStore;
|
||||
}
|
||||
|
||||
type TabKey = "sizing" | "front" | "back";
|
||||
|
||||
/**
|
||||
* 编辑面板:尺寸 / 正面图层 / 背面图层 用标签页切换,节省横向空间
|
||||
*/
|
||||
export function EditorTabs(props: EditorTabsProps) {
|
||||
const { store } = props;
|
||||
const [tab, setTab] = createSignal<TabKey>("sizing");
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: "sizing", label: "尺寸" },
|
||||
{ key: "front", label: "正面图层" },
|
||||
{ key: "back", label: "背面图层" },
|
||||
];
|
||||
|
||||
const selectTab = (key: TabKey) => {
|
||||
setTab(key);
|
||||
// 切换正/背面标签时,同步预览的显示面
|
||||
if (key === "front") store.actions.setActiveSide("front");
|
||||
if (key === "back") store.actions.setActiveSide("back");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="w-64 shrink-0">
|
||||
{/* 标签页切换 */}
|
||||
<div class="flex gap-1 mb-3">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
onClick={() => selectTab(t.key)}
|
||||
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer ${
|
||||
tab() === t.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab() === "sizing" ? (
|
||||
<PropertiesEditorPanel store={store} />
|
||||
) : (
|
||||
<LayerEditorPanel
|
||||
store={store}
|
||||
side={tab() === "front" ? "front" : "back"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -179,6 +179,20 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={store.state.pltDoubleCut}
|
||||
onChange={(e) =>
|
||||
store.actions.setPltDoubleCut(e.target.checked)
|
||||
}
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<span class="text-sm text-gray-600">PLT 双切</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">正面奇数页偏移:</label>
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { layersToConfigs, normalizeDeckConfig } from "./config";
|
||||
|
||||
describe("layersToConfigs", () => {
|
||||
test("parses compact string format (legacy)", () => {
|
||||
const layers = layersToConfigs("title:1,1-5,1f8 body:1,5-8,8f3");
|
||||
expect(layers).toHaveLength(2);
|
||||
expect(layers[0]).toMatchObject({
|
||||
prop: "title",
|
||||
visible: true,
|
||||
x1: 1,
|
||||
y1: 1,
|
||||
x2: 5,
|
||||
y2: 1,
|
||||
fontSize: 8,
|
||||
});
|
||||
expect(layers[1].prop).toBe("body");
|
||||
});
|
||||
|
||||
test("parses structured list with prop layers", () => {
|
||||
const layers = layersToConfigs([
|
||||
{ prop: "name", pos: "1,1-5,2", font: 12 },
|
||||
]);
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]).toMatchObject({
|
||||
prop: "name",
|
||||
template: undefined,
|
||||
visible: true,
|
||||
x1: 1,
|
||||
y1: 1,
|
||||
x2: 5,
|
||||
y2: 2,
|
||||
fontSize: 12,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses structured list with template layers", () => {
|
||||
const layers = layersToConfigs([
|
||||
{ template: "**{{name}}**", pos: "1,3-5,8", align: "l" },
|
||||
]);
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]).toMatchObject({
|
||||
prop: undefined,
|
||||
template: "**{{name}}**",
|
||||
visible: true,
|
||||
x1: 1,
|
||||
y1: 3,
|
||||
x2: 5,
|
||||
y2: 8,
|
||||
align: "l",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses combined vertical+horizontal align", () => {
|
||||
const layers = layersToConfigs([
|
||||
{ prop: "a", pos: "1,1-2,2", align: "tl" },
|
||||
{ prop: "b", pos: "1,1-2,2", align: "br" },
|
||||
{ prop: "c", pos: "1,1-2,2", align: "tc" },
|
||||
{ prop: "d", pos: "1,1-2,2", align: "bc" },
|
||||
]);
|
||||
expect(layers[0].align).toBe("tl");
|
||||
expect(layers[1].align).toBe("br");
|
||||
expect(layers[2].align).toBe("tc");
|
||||
expect(layers[3].align).toBe("bc");
|
||||
});
|
||||
|
||||
test("parses vertical+horizontal align in compact layers string", () => {
|
||||
const layers = layersToConfigs("title:1,1-4,1tl body:1,2-5,8bc");
|
||||
expect(layers[0].align).toBe("tl");
|
||||
expect(layers[1].align).toBe("bc");
|
||||
});
|
||||
|
||||
test("defaults placement when pos is missing or invalid", () => {
|
||||
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
|
||||
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||
expect(layers[1]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||
});
|
||||
|
||||
test("returns [] for absent/empty input", () => {
|
||||
expect(layersToConfigs()).toEqual([]);
|
||||
expect(layersToConfigs("")).toEqual([]);
|
||||
expect(layersToConfigs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDeckConfig", () => {
|
||||
test("parses string dimensions", () => {
|
||||
const cfg = normalizeDeckConfig({ size: "54x86", grid: "5x8" });
|
||||
expect(cfg.sizeW).toBe(54);
|
||||
expect(cfg.sizeH).toBe(86);
|
||||
expect(cfg.gridW).toBe(5);
|
||||
expect(cfg.gridH).toBe(8);
|
||||
});
|
||||
|
||||
test("parses array dimensions", () => {
|
||||
const cfg = normalizeDeckConfig({ size: [63, 88] });
|
||||
expect(cfg.sizeW).toBe(63);
|
||||
expect(cfg.sizeH).toBe(88);
|
||||
});
|
||||
|
||||
test("coerces numeric strings for bleed/padding", () => {
|
||||
const cfg = normalizeDeckConfig({ bleed: "2", padding: "3" });
|
||||
expect(cfg.bleed).toBe(2);
|
||||
expect(cfg.padding).toBe(3);
|
||||
});
|
||||
|
||||
test("normalizes layers and back_layers", () => {
|
||||
const cfg = normalizeDeckConfig({
|
||||
layers: [
|
||||
{ prop: "name", pos: "1,1-5,1" },
|
||||
{ template: "{{body}}", pos: "1,2-5,8" },
|
||||
],
|
||||
back_layers: "logo:1,1-2,2",
|
||||
});
|
||||
expect(cfg.frontLayers).toHaveLength(2);
|
||||
expect(cfg.frontLayers[1].template).toBe("{{body}}");
|
||||
expect(cfg.backLayers).toHaveLength(1);
|
||||
expect(cfg.backLayers[0].prop).toBe("logo");
|
||||
});
|
||||
|
||||
test("missing fields stay undefined", () => {
|
||||
const cfg = normalizeDeckConfig({});
|
||||
expect(cfg.sizeW).toBeUndefined();
|
||||
expect(cfg.sizeH).toBeUndefined();
|
||||
expect(cfg.gridW).toBeUndefined();
|
||||
expect(cfg.gridH).toBeUndefined();
|
||||
expect(cfg.bleed).toBeUndefined();
|
||||
expect(cfg.padding).toBeUndefined();
|
||||
expect(cfg.frontLayers).toEqual([]);
|
||||
expect(cfg.backLayers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Align, CardShape, LayerConfig } from "./types";
|
||||
import { parseLayers } from "./hooks/layer-parser";
|
||||
|
||||
/**
|
||||
* YAML/JSON shape of an `md-deck` `data-config`.
|
||||
*
|
||||
* Mirrors the frontmatter `deck:` block and the `:md-deck` directive attrs,
|
||||
* but allows `layers`/`back_layers` as structured lists where each layer is
|
||||
* either a CSV `prop` or a markdown `template`.
|
||||
*/
|
||||
export interface DeckConfigYaml {
|
||||
size?: string | [number, number];
|
||||
grid?: string | [number, number];
|
||||
bleed?: number | string;
|
||||
padding?: number | string;
|
||||
shape?: CardShape;
|
||||
fixed?: boolean;
|
||||
layers?: string | DeckLayerYaml[];
|
||||
back_layers?: string | DeckLayerYaml[];
|
||||
}
|
||||
|
||||
export interface DeckLayerYaml {
|
||||
prop?: string;
|
||||
template?: string;
|
||||
/** Grid placement "x1,y1-x2,y2" (1-based), same shape as the compact layers string. */
|
||||
pos?: string;
|
||||
font?: number;
|
||||
fontSize?: number;
|
||||
orientation?: "n" | "s" | "e" | "w";
|
||||
align?: Align;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedDeckConfig {
|
||||
sizeW?: number;
|
||||
sizeH?: number;
|
||||
gridW?: number;
|
||||
gridH?: number;
|
||||
bleed?: number;
|
||||
padding?: number;
|
||||
shape?: CardShape;
|
||||
fixed?: boolean;
|
||||
frontLayers: LayerConfig[];
|
||||
backLayers: LayerConfig[];
|
||||
}
|
||||
|
||||
/** Parse a "54x86" string or [54, 86] array into [w, h]. */
|
||||
function parseDimension(
|
||||
v?: string | [number, number],
|
||||
): [number, number] | undefined {
|
||||
if (!v) return undefined;
|
||||
if (Array.isArray(v)) {
|
||||
const [w, h] = v;
|
||||
if (typeof w === "number" && typeof h === "number") return [w, h];
|
||||
return undefined;
|
||||
}
|
||||
const parts = String(v)
|
||||
.toLowerCase()
|
||||
.split("x")
|
||||
.map((n) => Number(n.trim()));
|
||||
if (
|
||||
parts.length === 2 &&
|
||||
Number.isFinite(parts[0]) &&
|
||||
Number.isFinite(parts[1])
|
||||
) {
|
||||
return [parts[0], parts[1]];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Parse a "x1,y1-x2,y2" placement string into grid coordinates. */
|
||||
export function parsePos(
|
||||
pos?: string,
|
||||
): { x1: number; y1: number; x2: number; y2: number } | undefined {
|
||||
if (!pos) return undefined;
|
||||
const m = /^(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*,\s*(\d+)$/.exec(pos.trim());
|
||||
if (!m) return undefined;
|
||||
return {
|
||||
x1: Number(m[1]),
|
||||
y1: Number(m[2]),
|
||||
x2: Number(m[3]),
|
||||
y2: Number(m[4]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a structured YAML layer to a LayerConfig. */
|
||||
function layerYamlToConfig(l: DeckLayerYaml): LayerConfig {
|
||||
const pos = parsePos(l.pos);
|
||||
return {
|
||||
prop: l.prop,
|
||||
template: l.template,
|
||||
visible: l.visible ?? true,
|
||||
x1: pos?.x1 ?? 1,
|
||||
y1: pos?.y1 ?? 1,
|
||||
x2: pos?.x2 ?? 2,
|
||||
y2: pos?.y2 ?? 2,
|
||||
orientation: l.orientation,
|
||||
fontSize: l.fontSize ?? l.font,
|
||||
align: l.align,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `layers`/`back_layers` value (compact string or structured list)
|
||||
* into LayerConfig[]. Empty/absent → [].
|
||||
*/
|
||||
export function layersToConfigs(
|
||||
layers?: string | DeckLayerYaml[],
|
||||
): LayerConfig[] {
|
||||
if (!layers) return [];
|
||||
if (typeof layers === "string") {
|
||||
return parseLayers(layers).map((l) => ({
|
||||
prop: l.prop,
|
||||
visible: true,
|
||||
x1: l.x1,
|
||||
y1: l.y1,
|
||||
x2: l.x2,
|
||||
y2: l.y2,
|
||||
orientation: l.orientation,
|
||||
fontSize: l.fontSize,
|
||||
align: l.align,
|
||||
}));
|
||||
}
|
||||
return layers.map(layerYamlToConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a parsed `data-config` object into concrete numeric config +
|
||||
* ready-to-use layer lists. Missing fields are left undefined so callers can
|
||||
* apply defaults.
|
||||
*/
|
||||
export function normalizeDeckConfig(cfg: DeckConfigYaml): NormalizedDeckConfig {
|
||||
const size = parseDimension(cfg.size);
|
||||
const grid = parseDimension(cfg.grid);
|
||||
return {
|
||||
sizeW: size?.[0],
|
||||
sizeH: size?.[1],
|
||||
gridW: grid?.[0],
|
||||
gridH: grid?.[1],
|
||||
bleed: typeof cfg.bleed === "string" ? Number(cfg.bleed) : cfg.bleed,
|
||||
padding:
|
||||
typeof cfg.padding === "string" ? Number(cfg.padding) : cfg.padding,
|
||||
shape: cfg.shape,
|
||||
fixed: cfg.fixed,
|
||||
frontLayers: layersToConfigs(cfg.layers),
|
||||
backLayers: layersToConfigs(cfg.back_layers),
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
closestCenter,
|
||||
} from "@thisbeyond/solid-dnd";
|
||||
import type { DeckStore } from "../hooks/deckStore";
|
||||
import type { CardSide } from "../types";
|
||||
import { toSortableId } from "../hooks/layer-crud";
|
||||
import { LayerRow } from "./LayerRow";
|
||||
|
||||
export interface LayerEditorPanelProps {
|
||||
store: DeckStore;
|
||||
side?: CardSide;
|
||||
}
|
||||
|
||||
function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||
const [addMenuOpen, setAddMenuOpen] = createSignal(false);
|
||||
let addMenuRef: HTMLDivElement | undefined;
|
||||
|
||||
const side = () => store.state.activeSide;
|
||||
const side = () => props.side || "front";
|
||||
const layers = () =>
|
||||
side() === "front"
|
||||
? store.state.frontLayerConfigs
|
||||
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||
</SortableProvider>
|
||||
</DragDropSensors>
|
||||
</DragDropProvider>
|
||||
|
||||
<hr class="my-4" />
|
||||
<button
|
||||
onClick={() => store.actions.copyCode()}
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
|
||||
>
|
||||
<span>📋</span>
|
||||
<span>复制代码</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
||||
import type { DeckStore } from "../hooks/deckStore";
|
||||
import type { LayerConfig } from "../types";
|
||||
import type { Align, LayerConfig } from "../types";
|
||||
import alignLeftIcon from "./icons/align-left.png";
|
||||
import alignCenterIcon from "./icons/align-center.png";
|
||||
import alignRightIcon from "./icons/align-right.png";
|
||||
@@ -17,7 +17,7 @@ export interface LayerRowProps {
|
||||
setOpenDropdown: (val: string | null) => void;
|
||||
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
|
||||
onUpdateFontSize: (fs?: number) => void;
|
||||
onUpdateAlign: (a?: "l" | "c" | "r") => void;
|
||||
onUpdateAlign: (a?: Align) => void;
|
||||
onSelect: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
|
||||
{ value: "w" as const, label: "← 西" },
|
||||
];
|
||||
|
||||
const ALIGNS = [
|
||||
{ value: "" as const, icon: alignCenterIcon },
|
||||
{ value: "l" as const, icon: alignLeftIcon },
|
||||
{ value: "c" as const, icon: alignCenterIcon },
|
||||
{ value: "r" as const, icon: alignRightIcon },
|
||||
const ALIGNS: { value: Align | ""; icon: string }[] = [
|
||||
{ value: "", icon: alignCenterIcon },
|
||||
{ value: "l", icon: alignLeftIcon },
|
||||
{ value: "c", icon: alignCenterIcon },
|
||||
{ value: "r", icon: alignRightIcon },
|
||||
{ value: "tl", icon: "↖" },
|
||||
{ value: "tc", icon: "↑" },
|
||||
{ value: "tr", icon: "↗" },
|
||||
{ value: "bl", icon: "↙" },
|
||||
{ value: "bc", icon: "↓" },
|
||||
{ value: "br", icon: "↘" },
|
||||
];
|
||||
|
||||
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
||||
@@ -53,14 +59,28 @@ function orientChar(v: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function alignSrc(v: string) {
|
||||
function alignIcon(v: string): import("solid-js").JSX.Element {
|
||||
switch (v) {
|
||||
case "tl":
|
||||
return "↖";
|
||||
case "tc":
|
||||
return "↑";
|
||||
case "tr":
|
||||
return "↗";
|
||||
case "bl":
|
||||
return "↙";
|
||||
case "bc":
|
||||
return "↓";
|
||||
case "br":
|
||||
return "↘";
|
||||
case "l":
|
||||
return alignLeftIcon;
|
||||
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
|
||||
case "r":
|
||||
return alignRightIcon;
|
||||
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
|
||||
default:
|
||||
return alignCenterIcon;
|
||||
return (
|
||||
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +140,7 @@ export function LayerRow(props: LayerRowProps) {
|
||||
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
{props.layer.prop}
|
||||
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
|
||||
</span>
|
||||
|
||||
<DropdownButton
|
||||
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
|
||||
</DropdownButton>
|
||||
|
||||
<DropdownButton
|
||||
icon={
|
||||
<img
|
||||
src={alignSrc(props.layer.align || "")}
|
||||
alt="align"
|
||||
class="w-5 h-5 not-prose"
|
||||
/>
|
||||
}
|
||||
icon={alignIcon(props.layer.align || "")}
|
||||
visible={props.layer.visible}
|
||||
open={props.openDropdown === `align-${props.index}`}
|
||||
onToggle={() =>
|
||||
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
|
||||
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
||||
{o.icon.endsWith(".png") ? (
|
||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
||||
) : (
|
||||
<span class="w-4 h-4 flex items-center justify-center not-prose">
|
||||
{o.icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import type { DeckStore } from '../hooks/deckStore';
|
||||
import type { CardShape } from '../types';
|
||||
import type { DeckStore } from "../hooks/deckStore";
|
||||
import type { CardShape } from "../types";
|
||||
|
||||
export interface PropertiesEditorPanelProps {
|
||||
store: DeckStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡牌属性编辑面板:尺寸、网格、出血、内边距、正背面切换
|
||||
* 卡牌尺寸编辑面板:尺寸、网格、出血、内边距、形状
|
||||
*/
|
||||
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
||||
const { store } = props;
|
||||
|
||||
return (
|
||||
<div class="w-64 flex-shrink-0">
|
||||
<h3 class="font-bold mb-2 mt-0">卡牌属性</h3>
|
||||
const shapeOptions: { value: CardShape; label: string }[] = [
|
||||
{ value: "rectangle", label: "矩形" },
|
||||
{ value: "circle", label: "圆形" },
|
||||
{ value: "triangle", label: "三角形" },
|
||||
{ value: "hexagon", label: "六边形" },
|
||||
];
|
||||
|
||||
{/* 正面/背面切换标签页 */}
|
||||
<div class="mb-4">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onClick={() => store.actions.setActiveSide('front')}
|
||||
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
|
||||
store.state.activeSide === 'front'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
正面
|
||||
</button>
|
||||
<button
|
||||
onClick={() => store.actions.setActiveSide('back')}
|
||||
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
|
||||
store.state.activeSide === 'back'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
背面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div class="w-64 shrink-0">
|
||||
<h3 class="font-bold mb-2 mt-0">尺寸</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">尺寸 (mm)</label>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
尺寸 (mm)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">出血 / 内边距 (mm)</label>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
出血 / 内边距 (mm)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -93,34 +78,30 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
||||
placeholder="出血"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
value={store.state.padding}
|
||||
onChange={(e) => store.actions.setPadding(Number(e.target.value))}
|
||||
placeholder="内边距"
|
||||
type="number"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
value={store.state.padding}
|
||||
onChange={(e) => store.actions.setPadding(Number(e.target.value))}
|
||||
placeholder="内边距"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">卡片形状</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => (
|
||||
<button
|
||||
onClick={() => store.actions.setShape(shape)}
|
||||
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
|
||||
store.state.shape === shape
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{shape === 'rectangle' && '矩形'}
|
||||
{shape === 'circle' && '圆形'}
|
||||
{shape === 'triangle' && '三角形'}
|
||||
{shape === 'hexagon' && '六边形'}
|
||||
</button>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
卡片形状
|
||||
</label>
|
||||
<select
|
||||
value={store.state.shape}
|
||||
onChange={(e) =>
|
||||
store.actions.setShape(e.target.value as CardShape)
|
||||
}
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
|
||||
>
|
||||
{shapeOptions.map((s) => (
|
||||
<option value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createStore } from "solid-js/store";
|
||||
import yaml from "js-yaml";
|
||||
import { calculateDimensions } from "./dimensions";
|
||||
import { loadCSV, CSV } from "../../utils/csv-loader";
|
||||
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
|
||||
import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
|
||||
import { formatLayers } from "./layer-parser";
|
||||
import * as layerCrud from "./layer-crud";
|
||||
import type {
|
||||
CardData,
|
||||
@@ -41,6 +42,8 @@ export interface DeckState {
|
||||
cornerRadius: number;
|
||||
shape: CardShape;
|
||||
fixed: boolean;
|
||||
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
|
||||
isYamlBlock: boolean;
|
||||
src: string;
|
||||
rawSrc: string;
|
||||
|
||||
@@ -73,6 +76,7 @@ export interface DeckState {
|
||||
printFrontOddPageOffsetX: number;
|
||||
printFrontOddPageOffsetY: number;
|
||||
printDoubleSided: boolean;
|
||||
pltDoubleCut: boolean;
|
||||
}
|
||||
|
||||
export interface DeckActions {
|
||||
@@ -84,6 +88,7 @@ export interface DeckActions {
|
||||
setPadding: (padding: number) => void;
|
||||
setCornerRadius: (cornerRadius: number) => void;
|
||||
setShape: (shape: CardShape) => void;
|
||||
setIsYamlBlock: (isYamlBlock: boolean) => void;
|
||||
|
||||
setCards: (cards: CSV<CardData>) => void;
|
||||
setActiveTab: (index: number) => void;
|
||||
@@ -137,14 +142,14 @@ export interface DeckActions {
|
||||
loadCardsFromPath: (
|
||||
path: string,
|
||||
rawSrc: string,
|
||||
layersStr?: string,
|
||||
backLayersStr?: string,
|
||||
frontLayers?: LayerConfig[],
|
||||
backLayers?: LayerConfig[],
|
||||
) => Promise<void>;
|
||||
setError: (error: string | null) => void;
|
||||
clearError: () => void;
|
||||
|
||||
generateCode: (backLayersStr?: string) => string;
|
||||
copyCode: (backLayersStr?: string) => Promise<void>;
|
||||
copyCode: (fallback?: (code: string) => void) => Promise<void>;
|
||||
|
||||
setExporting: (exporting: boolean) => void;
|
||||
exportDeck: () => void;
|
||||
@@ -156,6 +161,7 @@ export interface DeckActions {
|
||||
setPrintFrontOddPageOffsetX: (offset: number) => void;
|
||||
setPrintFrontOddPageOffsetY: (offset: number) => void;
|
||||
setPrintDoubleSided: (doubleSided: boolean) => void;
|
||||
setPltDoubleCut: (doubleCut: boolean) => void;
|
||||
}
|
||||
|
||||
export interface DeckStore {
|
||||
@@ -174,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
|
||||
shape: "rectangle",
|
||||
fixed: false,
|
||||
isYamlBlock: false,
|
||||
src: initialSrc,
|
||||
rawSrc: initialSrc,
|
||||
dimensions: null,
|
||||
@@ -197,6 +204,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
printFrontOddPageOffsetX: 0,
|
||||
printFrontOddPageOffsetY: 0,
|
||||
printDoubleSided: false,
|
||||
pltDoubleCut: false,
|
||||
});
|
||||
|
||||
const updateDimensions = () => {
|
||||
@@ -241,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
const setShape = (shape: CardShape) => {
|
||||
setState({ shape });
|
||||
};
|
||||
const setIsYamlBlock = (isYamlBlock: boolean) => {
|
||||
setState({ isYamlBlock });
|
||||
};
|
||||
|
||||
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
|
||||
const setActiveTab = (index: number) => setState({ activeTab: index });
|
||||
@@ -439,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
const loadCardsFromPath = async (
|
||||
path: string,
|
||||
rawSrc: string,
|
||||
layersStr: string = "",
|
||||
backLayersStr: string = "",
|
||||
frontLayers: LayerConfig[] = [],
|
||||
backLayers: LayerConfig[] = [],
|
||||
) => {
|
||||
if (!path) {
|
||||
setState({ error: "未指定 CSV 文件路径" });
|
||||
@@ -450,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
|
||||
|
||||
try {
|
||||
const data = await loadCSV(path);
|
||||
const data = await loadCSVFromPath(path);
|
||||
|
||||
if (data.length === 0) {
|
||||
setState({
|
||||
@@ -463,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
setState({
|
||||
cards: data,
|
||||
activeTab: 0,
|
||||
frontLayerConfigs: layerCrud.withKeys(
|
||||
initLayerConfigsForSide(data, layersStr),
|
||||
),
|
||||
backLayerConfigs: layerCrud.withKeys(
|
||||
initLayerConfigsForSide(data, backLayersStr),
|
||||
),
|
||||
frontLayerConfigs: layerCrud.withKeys(frontLayers),
|
||||
backLayerConfigs: layerCrud.withKeys(backLayers),
|
||||
isLoading: false,
|
||||
});
|
||||
updateDimensions();
|
||||
@@ -484,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
const clearError = () => setState({ error: null });
|
||||
|
||||
const generateCode = (backLayersStr?: string) => {
|
||||
if (state.isYamlBlock) {
|
||||
return generateYamlCode();
|
||||
}
|
||||
const frontLayersStr = formatLayers(state.frontLayerConfigs);
|
||||
const backLayersString =
|
||||
backLayersStr || formatLayers(state.backLayerConfigs);
|
||||
@@ -511,14 +521,62 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
return parts.join("");
|
||||
};
|
||||
|
||||
const copyCode = async (backLayersStr?: string) => {
|
||||
const code = generateCode(backLayersStr);
|
||||
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
|
||||
const generateYamlCode = () => {
|
||||
const toYamlLayer = (l: LayerConfig) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (l.template) {
|
||||
out.template = l.template;
|
||||
} else {
|
||||
out.prop = l.prop ?? "";
|
||||
}
|
||||
out.pos = `${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
||||
if (l.fontSize) out.font = l.fontSize;
|
||||
if (l.orientation && l.orientation !== "n") out.orientation = l.orientation;
|
||||
if (l.align) out.align = l.align;
|
||||
if (!l.visible) out.visible = false;
|
||||
return out;
|
||||
};
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
size: `${state.sizeW}x${state.sizeH}`,
|
||||
grid: `${state.gridW}x${state.gridH}`,
|
||||
layers: state.frontLayerConfigs.map(toYamlLayer),
|
||||
};
|
||||
if (state.bleed !== DECK_DEFAULTS.BLEED) config.bleed = state.bleed;
|
||||
if (state.padding !== DECK_DEFAULTS.PADDING) config.padding = state.padding;
|
||||
if (state.shape !== "rectangle") config.shape = state.shape;
|
||||
if (state.backLayerConfigs.length > 0) {
|
||||
config.back_layers = state.backLayerConfigs.map(toYamlLayer);
|
||||
}
|
||||
|
||||
const doc = {
|
||||
tag: "md-deck",
|
||||
body: state.rawSrc || state.src,
|
||||
"data-config": config,
|
||||
};
|
||||
const yamlStr = yaml.dump(doc, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
});
|
||||
const fence = "```yaml role=tag";
|
||||
const backtick = "`";
|
||||
return `${fence}\n${yamlStr}${backtick.repeat(3)}`;
|
||||
};
|
||||
|
||||
const copyCode = async (fallback?: (code: string) => void) => {
|
||||
const code = generateCode();
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
alert("已复制到剪贴板!");
|
||||
} catch (err) {
|
||||
console.error("复制失败:", err);
|
||||
alert("复制失败,请手动复制");
|
||||
if (fallback) {
|
||||
fallback(code);
|
||||
} else {
|
||||
alert("复制失败,请手动复制");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -553,6 +611,10 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
setState({ printDoubleSided: doubleSided });
|
||||
};
|
||||
|
||||
const setPltDoubleCut = (doubleCut: boolean) => {
|
||||
setState({ pltDoubleCut: doubleCut });
|
||||
};
|
||||
|
||||
const actions: DeckActions = {
|
||||
setSizeW,
|
||||
setSizeH,
|
||||
@@ -562,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
setPadding,
|
||||
setCornerRadius,
|
||||
setShape,
|
||||
setIsYamlBlock,
|
||||
setCards,
|
||||
setActiveTab,
|
||||
updateCardData,
|
||||
@@ -599,6 +662,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||
setPrintFrontOddPageOffsetX,
|
||||
setPrintFrontOddPageOffsetY,
|
||||
setPrintDoubleSided,
|
||||
setPltDoubleCut,
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
|
||||
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
|
||||
/**
|
||||
* 解析 layers 字符串
|
||||
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
|
||||
* f[fontSize] 表示字体大小(可选),方向字母(可选),对齐字母 l/c/r(可选)
|
||||
* f[fontSize] 表示字体大小(可选),方向字母(可选),
|
||||
* 对齐字母(可选):水平 l/c/r,可加垂直前缀 t/b 组成 tl/tr/bl/br
|
||||
*/
|
||||
export function parseLayers(layersStr: string): Layer[] {
|
||||
if (!layersStr) return [];
|
||||
|
||||
const layers: Layer[] = [];
|
||||
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
|
||||
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
|
||||
const regex =
|
||||
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
|
||||
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(layersStr)) !== null) {
|
||||
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
|
||||
y2: parseInt(match[5]),
|
||||
fontSize: match[6] ? parseFloat(match[6]) : undefined,
|
||||
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
|
||||
align: match[8] as "l" | "c" | "r" | undefined,
|
||||
align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,8 +36,10 @@ export function parseLayers(layersStr: string): Layer[] {
|
||||
* 格式化 layers 为字符串
|
||||
*/
|
||||
export function formatLayers(layers: LayerConfig[]): string {
|
||||
// Template-only layers have no prop and can't be represented in the
|
||||
// compact string format, so they are skipped here.
|
||||
return layers
|
||||
.filter((l) => l.visible)
|
||||
.filter((l) => l.visible && l.prop)
|
||||
.map((l) => {
|
||||
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
||||
if (l.fontSize) {
|
||||
|
||||
@@ -87,6 +87,8 @@ export function useLayerInteraction(
|
||||
const handleLayerClick = (index: number, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!store.state.isEditing || store.state.fixed) return;
|
||||
|
||||
const currentlySelected = store.state.selectedLayer;
|
||||
|
||||
if (currentlySelected === index) {
|
||||
@@ -100,6 +102,7 @@ export function useLayerInteraction(
|
||||
|
||||
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
|
||||
if (store.state.draggingState) return;
|
||||
if (!store.state.isEditing || store.state.fixed) return;
|
||||
|
||||
const { gridX, gridY } = calculateGridCoords(e, cardEl);
|
||||
const overlapping = getOverlappingLayers(gridX, gridY);
|
||||
@@ -147,6 +150,7 @@ export function useLayerInteraction(
|
||||
edge?: "n" | "s" | "e" | "w",
|
||||
e?: MouseEvent,
|
||||
) => {
|
||||
if (!store.state.isEditing || store.state.fixed) return;
|
||||
if (store.state.selectedLayer === null) return;
|
||||
if (e) e.stopPropagation();
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
|
||||
const cardHeight = () => store.state.dimensions?.cardHeight || 88;
|
||||
const shape = () => store.state.shape;
|
||||
const orientation = () => store.state.printOrientation || 'landscape';
|
||||
const doubleCut = () => store.state.pltDoubleCut;
|
||||
|
||||
/**
|
||||
* 生成单页满排时的 PLT 数据
|
||||
@@ -60,7 +61,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
|
||||
|
||||
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
|
||||
// 原点在 frameBoundsWithMargin 的左上角
|
||||
const relativePaths = layout.cardPaths.map(cardPath => {
|
||||
const relativePaths = layout.cardPaths.flatMap(cardPath => {
|
||||
const relativePoints = cardPath.points.map(([x, y]) => {
|
||||
// 转换为相对于 frameBounds 左上角的坐标
|
||||
const relativeX = x - frameBounds.x;
|
||||
@@ -71,7 +72,11 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
|
||||
// 所以 plotterY = pltHeight - relativeY
|
||||
return [relativeX, pltHeight - relativeY] as [number, number];
|
||||
});
|
||||
return relativePoints;
|
||||
if (doubleCut()) {
|
||||
// 重复一次:某些切割机需要每张卡牌切两次
|
||||
return [relativePoints, relativePoints];
|
||||
}
|
||||
return [relativePoints];
|
||||
});
|
||||
|
||||
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
|
||||
|
||||
@@ -2,17 +2,16 @@ import { customElement, noShadowDOM } from "solid-element";
|
||||
import { Show, onCleanup } from "solid-js";
|
||||
import { resolvePath } from "../utils/path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { createDeckStore } from "./hooks/deckStore";
|
||||
import { createDeckStore, DECK_DEFAULTS } from "./hooks/deckStore";
|
||||
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
|
||||
import type { CardShape } from "./types";
|
||||
import type { CardShape, LayerConfig } from "./types";
|
||||
import { normalizeDeckConfig, layersToConfigs } from "./config";
|
||||
import { DeckHeader } from "./DeckHeader";
|
||||
import { CardList } from "./CardList";
|
||||
import { DeckContent } from "./DeckContent";
|
||||
import { EditorTabs } from "./EditorTabs";
|
||||
import { PrintPreview } from "./PrintPreview";
|
||||
import {
|
||||
DataEditorPanel,
|
||||
LayerEditorPanel,
|
||||
PropertiesEditorPanel,
|
||||
} from "./editor-panel";
|
||||
import { DataEditorPanel } from "./editor-panel";
|
||||
|
||||
interface DeckProps {
|
||||
size?: string;
|
||||
@@ -70,49 +69,77 @@ customElement<DeckProps>(
|
||||
const deckId = `deck-${uuidv4()}`;
|
||||
registerDeck(deckId, store, resolvedSrc, csvPath);
|
||||
|
||||
// 读取 data-config(yaml role=tag 代码块方式):结构化配置优先
|
||||
let config:
|
||||
| ReturnType<typeof normalizeDeckConfig>
|
||||
| undefined;
|
||||
const dataConfigAttr = element?.getAttribute("data-config");
|
||||
if (dataConfigAttr) {
|
||||
try {
|
||||
config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
|
||||
// 记录来源,复制代码时输出 yaml role=tag 代码块
|
||||
store.actions.setIsYamlBlock(true);
|
||||
} catch (e) {
|
||||
console.error("Invalid data-config on md-deck:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
|
||||
if (props.size && props.size.includes("x")) {
|
||||
if (config?.sizeW !== undefined && config.sizeH !== undefined) {
|
||||
store.actions.setSizeW(config.sizeW);
|
||||
store.actions.setSizeH(config.sizeH);
|
||||
} else if (props.size && props.size.includes("x")) {
|
||||
const [w, h] = props.size.split("x").map(Number);
|
||||
store.actions.setSizeW(w);
|
||||
store.actions.setSizeH(h);
|
||||
} else {
|
||||
store.actions.setSizeW(props.sizeW ?? 54);
|
||||
store.actions.setSizeH(props.sizeH ?? 86);
|
||||
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
|
||||
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
|
||||
}
|
||||
|
||||
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
|
||||
if (props.grid && props.grid.includes("x")) {
|
||||
if (config?.gridW !== undefined && config.gridH !== undefined) {
|
||||
store.actions.setGridW(config.gridW);
|
||||
store.actions.setGridH(config.gridH);
|
||||
} else if (props.grid && props.grid.includes("x")) {
|
||||
const [w, h] = props.grid.split("x").map(Number);
|
||||
store.actions.setGridW(w);
|
||||
store.actions.setGridH(h);
|
||||
} else {
|
||||
store.actions.setGridW(props.gridW ?? 5);
|
||||
store.actions.setGridH(props.gridH ?? 8);
|
||||
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
|
||||
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
|
||||
}
|
||||
|
||||
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
|
||||
if (typeof props.bleed === "string") {
|
||||
if (config?.bleed !== undefined) {
|
||||
store.actions.setBleed(config.bleed);
|
||||
} else if (typeof props.bleed === "string") {
|
||||
store.actions.setBleed(Number(props.bleed));
|
||||
} else {
|
||||
store.actions.setBleed(props.bleed ?? 1);
|
||||
store.actions.setBleed(props.bleed ?? DECK_DEFAULTS.BLEED);
|
||||
}
|
||||
|
||||
if (typeof props.padding === "string") {
|
||||
if (config?.padding !== undefined) {
|
||||
store.actions.setPadding(config.padding);
|
||||
} else if (typeof props.padding === "string") {
|
||||
store.actions.setPadding(Number(props.padding));
|
||||
} else {
|
||||
store.actions.setPadding(props.padding ?? 2);
|
||||
store.actions.setPadding(props.padding ?? DECK_DEFAULTS.PADDING);
|
||||
}
|
||||
|
||||
// 设置形状
|
||||
store.actions.setShape(props.shape ?? "rectangle");
|
||||
store.actions.setShape(config?.shape ?? props.shape ?? "rectangle");
|
||||
|
||||
// 确定前后图层(data-config 优先,回退旧 layers 字符串)
|
||||
const frontLayers: LayerConfig[] = config
|
||||
? config.frontLayers
|
||||
: layersToConfigs((props.layers as string) || "");
|
||||
const backLayers: LayerConfig[] = config
|
||||
? config.backLayers
|
||||
: layersToConfigs((props.backLayers as string) || "");
|
||||
|
||||
// 加载 CSV 数据
|
||||
store.actions.loadCardsFromPath(
|
||||
resolvedSrc,
|
||||
csvPath,
|
||||
(props.layers as string) || "",
|
||||
(props.backLayers as string) || "",
|
||||
);
|
||||
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
|
||||
|
||||
// 清理函数
|
||||
onCleanup(() => {
|
||||
@@ -139,29 +166,19 @@ customElement<DeckProps>(
|
||||
</Show>
|
||||
|
||||
<div class="flex gap-4">
|
||||
{/* 内容区域:错误/加载/卡牌预览/空状态 */}
|
||||
{/* 左侧:CSV 数据编辑 */}
|
||||
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/}
|
||||
{/* <DataEditorPanel*/}
|
||||
{/* activeTab={store.state.activeTab}*/}
|
||||
{/* cards={store.state.cards}*/}
|
||||
{/* updateCardData={store.actions.updateCardData}*/}
|
||||
{/* />*/}
|
||||
{/*</Show>*/}
|
||||
|
||||
<Show when={store.state.isEditing && !store.state.fixed}>
|
||||
<div class="flex-1">
|
||||
<PropertiesEditorPanel store={store} />
|
||||
</div>
|
||||
{/* 左侧:卡牌列表导航 */}
|
||||
<Show when={store.state.cards.length > 0 && !store.state.error}>
|
||||
<CardList store={store} />
|
||||
</Show>
|
||||
|
||||
<DeckContent store={store} isLoading={store.state.isLoading} />
|
||||
{/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
|
||||
<div class="flex-1 min-w-0">
|
||||
<DeckContent store={store} isLoading={store.state.isLoading} />
|
||||
</div>
|
||||
|
||||
{/* 右侧:属性/图层编辑面板 */}
|
||||
{/* 右侧:属性/图层编辑面板(标签页切换) */}
|
||||
<Show when={store.state.isEditing && !store.state.fixed}>
|
||||
<div class="flex-1">
|
||||
<LayerEditorPanel store={store} />
|
||||
</div>
|
||||
<EditorTabs store={store} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,21 +4,43 @@ export interface CardData {
|
||||
|
||||
export type CardSide = "front" | "back";
|
||||
|
||||
/**
|
||||
* Layer alignment: horizontal (l/c/r) optionally combined with vertical
|
||||
* (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
|
||||
* "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
|
||||
*/
|
||||
export type Align =
|
||||
| "l"
|
||||
| "c"
|
||||
| "r"
|
||||
| "tl"
|
||||
| "tc"
|
||||
| "tr"
|
||||
| "bl"
|
||||
| "bc"
|
||||
| "br";
|
||||
|
||||
export type { CardShape } from "../../plotcutter/contour";
|
||||
|
||||
export interface Layer {
|
||||
prop: string;
|
||||
/** CSV column the layer reads, when it renders a prop value. */
|
||||
prop?: string;
|
||||
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
|
||||
template?: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
orientation?: "n" | "s" | "e" | "w";
|
||||
fontSize?: number;
|
||||
align?: "l" | "c" | "r";
|
||||
align?: Align;
|
||||
}
|
||||
|
||||
export interface LayerConfig {
|
||||
prop: string;
|
||||
/** CSV column the layer reads, when it renders a prop value. */
|
||||
prop?: string;
|
||||
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
|
||||
template?: string;
|
||||
visible: boolean;
|
||||
x1: number;
|
||||
y1: number;
|
||||
@@ -26,7 +48,7 @@ export interface LayerConfig {
|
||||
y2: number;
|
||||
orientation?: "n" | "s" | "e" | "w";
|
||||
fontSize?: number;
|
||||
align?: "l" | "c" | "r";
|
||||
align?: Align;
|
||||
_key?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="md-embed not-prose">
|
||||
<div class="md-embed">
|
||||
<Show when={content.loading}>
|
||||
<div class="text-gray-400 italic">加载中...</div>
|
||||
</Show>
|
||||
@@ -54,7 +54,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
||||
<Show when={!content.loading && !content.error && content()}>
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
||||
<div
|
||||
class="prose"
|
||||
class="prose text-black prose-sm"
|
||||
innerHTML={parseMarkdown(content()!, resolvedPath)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
+32
-19
@@ -7,13 +7,14 @@ import {
|
||||
createMemo,
|
||||
createResource,
|
||||
} from "solid-js";
|
||||
import { marked } from "../markdown";
|
||||
import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader";
|
||||
import { resolvePath } from "./utils/path";
|
||||
import { parseMarkdown } from "../markdown";
|
||||
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
|
||||
import { resolveContentRef } from "./utils/resolve-content";
|
||||
import { parseSparkTableCsv } from "./utils/spark-table";
|
||||
import {
|
||||
areAllLabelsNumeric,
|
||||
weightedRandomIndex,
|
||||
} from "./utils/weighted-random";
|
||||
} from "./utils/weighted-random";;
|
||||
|
||||
export interface TableProps {
|
||||
roll?: boolean;
|
||||
@@ -51,20 +52,31 @@ customElement(
|
||||
const articleEl = element?.closest("article[data-src]");
|
||||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||
|
||||
// 如果是 inline CSV,直接使用;否则解析相对路径
|
||||
const contentOrPath = isCSV(rawContent)
|
||||
? rawContent
|
||||
: resolvePath(articlePath, rawContent);
|
||||
// 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id)
|
||||
const [csvData] = createResource(
|
||||
() => ({ ref: rawContent, docPath: articlePath }),
|
||||
async ({ ref, docPath }) => {
|
||||
const content = await resolveContentRef(ref, docPath);
|
||||
if (content === null) {
|
||||
throw new Error(`Failed to resolve table content: "${ref}"`);
|
||||
}
|
||||
return content;
|
||||
},
|
||||
);
|
||||
|
||||
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
|
||||
const [csvData] = createResource(() => contentOrPath, loadCSV);
|
||||
|
||||
// 当数据加载完成后更新 rows
|
||||
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
|
||||
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
|
||||
createEffect(() => {
|
||||
const data = csvData();
|
||||
if (data) {
|
||||
// 将加载的数据赋值给 rows,CSV 类型已经包含 sourcePath 等属性
|
||||
setRows(data as unknown as CSV<TableRow>);
|
||||
const content = csvData();
|
||||
if (!content) return;
|
||||
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
|
||||
if (element) {
|
||||
const meta = parseSparkTableCsv(content);
|
||||
if (meta) {
|
||||
element.setAttribute("data-spark", meta.slug);
|
||||
} else {
|
||||
element.removeAttribute("data-spark");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,10 +109,11 @@ customElement(
|
||||
|
||||
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
||||
const processBody = (body: string, currentRow: TableRow): string => {
|
||||
// 使用 marked 解析 markdown
|
||||
return marked.parse(
|
||||
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
|
||||
return parseMarkdown(
|
||||
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
|
||||
) as string;
|
||||
articlePath,
|
||||
);
|
||||
};
|
||||
|
||||
// 更新 body 内容
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* Journal Stream — Client Store
|
||||
* Journal Stream — Client Store (Worker Proxy)
|
||||
*
|
||||
* Reactive state for a single session's message stream. Manages MQTT
|
||||
* connection, local message log, per-sender sequence tracking, and the
|
||||
* revealed-paths set (populated by the link reducer).
|
||||
* This is the main-thread side of the journal stream. It spawns a Shared
|
||||
* Worker that owns the MQTT connection. All state flows from the worker
|
||||
* to this store via Comlink callbacks.
|
||||
*
|
||||
* Session lifecycle (create/list/delete) is handled via MQTT retained
|
||||
* topics — ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
|
||||
*
|
||||
* No persistence here — that's the CLI server's job via JSONL append.
|
||||
* The public API is unchanged — components still call `useJournalStream()`,
|
||||
* `sendMessage()`, etc. exactly as before.
|
||||
*/
|
||||
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createSignal } from "solid-js";
|
||||
import * as Comlink from "comlink";
|
||||
import type { StreamMessage } from "../journal/registry";
|
||||
import { getMessageType, validatePayload } from "../journal/registry";
|
||||
import { rebuildReactivityFromStore } from "../journal/var-reactivity";
|
||||
import type { WorkerState, WorkerPatch, SessionManifest } from "../../workers/journal.worker";
|
||||
import type { JournalWorkerAPI } from "../../workers/journal.worker";
|
||||
import {
|
||||
loadPersisted,
|
||||
saveName,
|
||||
@@ -32,39 +34,18 @@ import {
|
||||
|
||||
export interface JournalStreamState {
|
||||
sessionId: string | null;
|
||||
/** Human-readable name for the current session (from manifest) */
|
||||
sessionName: string | null;
|
||||
/** Full message log, oldest-first */
|
||||
messages: StreamMessage[];
|
||||
/** Last sequence number per sender */
|
||||
senderSeq: Record<string, number>;
|
||||
/**
|
||||
* Paths and sections revealed by link messages.
|
||||
* Key: normalized path (no .md). Value: set of revealed section slugs.
|
||||
* An empty set means the whole article is revealed.
|
||||
* Populated during hydration and live receipt via the type's reducer.
|
||||
*/
|
||||
revealedPaths: Record<string, Set<string>>;
|
||||
/** MQTT connection status */
|
||||
connected: boolean;
|
||||
/** Granular connection state for UI indicators */
|
||||
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
|
||||
/** Last connection error message, if any */
|
||||
connectionError: string | null;
|
||||
/** This client's identity */
|
||||
myName: string;
|
||||
/** Role: gm | player | observer. Immutable while connected. */
|
||||
myRole: "gm" | "player" | "observer";
|
||||
/** Broker URL, set after connect */
|
||||
brokerUrl: string | null;
|
||||
/** Active player list (keyed by player name) */
|
||||
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>;
|
||||
variables: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SessionMeta {
|
||||
@@ -73,8 +54,24 @@ export interface SessionMeta {
|
||||
players: string[];
|
||||
}
|
||||
|
||||
export interface SessionManifest {
|
||||
sessions: Record<string, SessionMeta>;
|
||||
export type { SessionManifest } from "../../workers/journal.worker";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _workerAPI: Comlink.Remote<JournalWorkerAPI> | null = null;
|
||||
|
||||
|
||||
function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
|
||||
if (!_workerAPI) {
|
||||
const worker = new SharedWorker(
|
||||
new URL("../../workers/journal.worker.ts", import.meta.url),
|
||||
);
|
||||
_workerAPI = Comlink.wrap<JournalWorkerAPI>(worker.port);
|
||||
worker.port.start();
|
||||
}
|
||||
return _workerAPI;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,7 +81,6 @@ export interface SessionManifest {
|
||||
const persisted = loadPersisted();
|
||||
const urlParams = readUrlParams();
|
||||
|
||||
// URL params override localStorage if present
|
||||
const initialName = urlParams.playerName ?? persisted.myName;
|
||||
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
|
||||
|
||||
@@ -101,10 +97,9 @@ const [state, setState] = createStore<JournalStreamState>({
|
||||
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
||||
brokerUrl: persisted.brokerUrl,
|
||||
players: {},
|
||||
stats: {},
|
||||
variables: {},
|
||||
});
|
||||
|
||||
// Sync initial URL params if they came from localStorage (not URL)
|
||||
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
||||
if (initialSession && !urlParams.sessionId)
|
||||
syncUrlParam("session", initialSession);
|
||||
@@ -117,56 +112,10 @@ const [sessionList, setSessionList] = createSignal<SessionManifest>({
|
||||
|
||||
export { sessionList as sessions };
|
||||
|
||||
/**
|
||||
* Change the current player's name. Persisted to localStorage so it
|
||||
* survives page reloads. Also syncs to URL search param.
|
||||
*/
|
||||
export function setMyName(name: string): void {
|
||||
setState("myName", name);
|
||||
saveName(name);
|
||||
syncUrlParam("player", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current player's role. Persisted to localStorage.
|
||||
* Only callable when disconnected.
|
||||
*/
|
||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||
setState("myRole", role);
|
||||
saveRole(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active session ID and sync to URL. Also resolves the human-readable
|
||||
* session name from the cached manifest.
|
||||
*/
|
||||
export function setSessionId(id: string | null): void {
|
||||
setState("sessionId", id);
|
||||
if (id) {
|
||||
saveSessionId(id);
|
||||
syncUrlParam("session", id);
|
||||
// Resolve session name from current manifest
|
||||
const manifest = sessionList();
|
||||
const name = manifest.sessions[id]?.name ?? null;
|
||||
setState("sessionName", name);
|
||||
} else {
|
||||
removeUrlParam("session");
|
||||
setState("sessionName", null);
|
||||
}
|
||||
}
|
||||
|
||||
// Will hold the MQTT client instance after connect()
|
||||
let _mqttClient: import("mqtt").MqttClient | null = null;
|
||||
let _mqttConnected = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Reducer runner — runs locally in each tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMessageId(sender: string, seq: number): string {
|
||||
return `${sender}-${seq}`;
|
||||
}
|
||||
|
||||
function runReducer(msg: StreamMessage): void {
|
||||
const def = getMessageType(msg.type);
|
||||
if (def?.reducer) {
|
||||
@@ -174,260 +123,195 @@ function runReducer(msg: StreamMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hydration (initial load from server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load the full message history from the static server's JSONL file.
|
||||
* The file is served from the same HTTP origin as the web app.
|
||||
* Runs all reducers in order.
|
||||
*/
|
||||
export async function hydrateFromServer(sessionId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return; // fresh session, no file yet
|
||||
}
|
||||
throw new Error(`Failed to load session: ${response.statusText}`);
|
||||
/** Replay all messages through reducers to rebuild derived state. */
|
||||
function replayReducers(): void {
|
||||
// Reset derived state
|
||||
setState("revealedPaths", {});
|
||||
setState("variables", {});
|
||||
for (const msg of state.messages) {
|
||||
runReducer(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.split("\n").filter((l) => l.trim());
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker patch handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const messages: StreamMessage[] = [];
|
||||
const senderSeq: Record<string, number> = {};
|
||||
function handlePatch(patch: WorkerPatch): void {
|
||||
switch (patch.type) {
|
||||
case "fullState": {
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.sessionId = patch.state.sessionId;
|
||||
s.sessionName = patch.state.sessionName;
|
||||
s.messages = patch.state.messages;
|
||||
s.senderSeq = patch.state.senderSeq;
|
||||
s.connected = patch.state.connected;
|
||||
s.connectionStatus = patch.state.connectionStatus;
|
||||
s.connectionError = patch.state.connectionError;
|
||||
s.myName = patch.state.myName;
|
||||
s.myRole = patch.state.myRole;
|
||||
s.brokerUrl = patch.state.brokerUrl;
|
||||
s.players = patch.state.players;
|
||||
}),
|
||||
);
|
||||
// Persist name/role so URL and localStorage stay in sync
|
||||
if (patch.state.myName) {
|
||||
saveName(patch.state.myName);
|
||||
syncUrlParam("player", patch.state.myName);
|
||||
}
|
||||
saveRole(patch.state.myRole);
|
||||
// Rebuild derived state (revealedPaths, variables) by replaying reducers
|
||||
replayReducers();
|
||||
// Rebuild local reactivity engine state so tag activations from
|
||||
// hydrated variables are tracked for subsequent cascade computations.
|
||||
rebuildReactivityFromStore(state.variables);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(line);
|
||||
messages.push(msg);
|
||||
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
|
||||
case "message": {
|
||||
const msg = patch.message;
|
||||
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
|
||||
if (existingIdx !== -1) {
|
||||
setState("messages", existingIdx, msg);
|
||||
} else {
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.messages.push(msg);
|
||||
s.senderSeq[msg.sender] = Math.max(
|
||||
s.senderSeq[msg.sender] ?? 0,
|
||||
msg.seq,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
runReducer(msg);
|
||||
} catch {
|
||||
/* skip corrupt */
|
||||
break;
|
||||
}
|
||||
|
||||
case "connectionStatus": {
|
||||
setState("connectionStatus", patch.status);
|
||||
if (patch.error !== undefined) {
|
||||
setState("connectionError", patch.error);
|
||||
}
|
||||
if (patch.status === "connected") {
|
||||
setState("connected", true);
|
||||
} else if (patch.status === "disconnected") {
|
||||
setState("connected", false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "players": {
|
||||
setState("players", patch.players);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessionName": {
|
||||
setState("sessionName", patch.name);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessionList": {
|
||||
setSessionList(patch.sessions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.messages = messages;
|
||||
s.senderSeq = senderSeq;
|
||||
s.sessionId = sessionId;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscribe to worker (called once per tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _subscribed = false;
|
||||
|
||||
async function ensureSubscribed(): Promise<void> {
|
||||
if (_subscribed) return;
|
||||
_subscribed = true;
|
||||
|
||||
const api = getWorkerAPI();
|
||||
await api.subscribe(
|
||||
Comlink.proxy((patch: WorkerPatch) => {
|
||||
handlePatch(patch);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Start subscription eagerly
|
||||
ensureSubscribed();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT Connect
|
||||
// Public API — same signatures as before
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Connect to the MQTT broker, subscribe to the session stream, session
|
||||
* list, and session meta. Must be called after `hydrateFromServer`.
|
||||
*/
|
||||
export function setMyName(name: string): void {
|
||||
setState("myName", name);
|
||||
saveName(name);
|
||||
syncUrlParam("player", name);
|
||||
}
|
||||
|
||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||
setState("myRole", role);
|
||||
saveRole(role);
|
||||
}
|
||||
|
||||
export function setSessionId(id: string | null): void {
|
||||
setState("sessionId", id);
|
||||
if (id) {
|
||||
saveSessionId(id);
|
||||
syncUrlParam("session", id);
|
||||
} else {
|
||||
removeUrlParam("session");
|
||||
setState("sessionName", null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectStream(
|
||||
sessionId: string,
|
||||
brokerUrl: string,
|
||||
): Promise<void> {
|
||||
const { default: mqtt } = await import("mqtt");
|
||||
|
||||
const api = getWorkerAPI();
|
||||
setState("connectionStatus", "connecting");
|
||||
setState("connectionError", null);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = mqtt.connect(brokerUrl, {
|
||||
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
|
||||
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
|
||||
reconnectPeriod: 2000,
|
||||
});
|
||||
|
||||
_mqttClient = client;
|
||||
|
||||
client.on("connect", () => {
|
||||
_mqttConnected = true;
|
||||
setState("connected", true);
|
||||
setState("connectionStatus", "connected");
|
||||
setState("connectionError", null);
|
||||
setState("brokerUrl", brokerUrl);
|
||||
|
||||
// Persist connection info for next time
|
||||
saveBrokerUrl(brokerUrl);
|
||||
saveSessionId(sessionId);
|
||||
|
||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] stream sub err:", err);
|
||||
});
|
||||
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] sessions sub err:", err);
|
||||
});
|
||||
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
||||
// Presence tracking
|
||||
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
||||
|
||||
// Publish own presence (retained)
|
||||
const presenceData = JSON.stringify({
|
||||
name: state.myName,
|
||||
role: state.myRole,
|
||||
});
|
||||
client.publish(
|
||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||
presenceData,
|
||||
{ qos: 1, retain: true },
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
console.error("[stream] mqtt error:", err);
|
||||
setState("connectionStatus", "error");
|
||||
setState("connectionError", err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
_mqttConnected = false;
|
||||
setState("connected", false);
|
||||
setState("connectionStatus", "disconnected");
|
||||
});
|
||||
|
||||
client.on("message", (topic, payload) => {
|
||||
const raw = payload.toString();
|
||||
const parts = topic.split("/");
|
||||
|
||||
if (topic === $SESSIONS) {
|
||||
// Session manifest update
|
||||
try {
|
||||
const manifest: SessionManifest = JSON.parse(raw);
|
||||
setSessionList(manifest);
|
||||
// Refresh the sessionName if we're in a session now
|
||||
const currentId = state.sessionId;
|
||||
if (currentId && manifest.sessions[currentId]) {
|
||||
setState("sessionName", manifest.sessions[currentId].name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[stream] manifest parse err:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
||||
// Session meta update — manifest will be republished by server,
|
||||
// handled via $SESSIONS subscription above.
|
||||
return;
|
||||
}
|
||||
|
||||
// Presence: ttrpg/{sessionId}/presence/{playerName}
|
||||
if (
|
||||
parts.length >= 4 &&
|
||||
parts[0] === "ttrpg" &&
|
||||
parts[2] === "presence"
|
||||
) {
|
||||
const playerName = parts[3];
|
||||
if (raw) {
|
||||
try {
|
||||
const presence = JSON.parse(raw);
|
||||
setState("players", playerName, {
|
||||
role: presence.role || "player",
|
||||
});
|
||||
} catch {
|
||||
setState("players", playerName, { role: "player" });
|
||||
}
|
||||
} else {
|
||||
// Tombstone — player disconnected
|
||||
setState(
|
||||
produce((s) => {
|
||||
delete s.players[playerName];
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(raw);
|
||||
receiveMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[stream] malformed message:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
|
||||
saveBrokerUrl(brokerUrl);
|
||||
saveSessionId(sessionId);
|
||||
} catch (err) {
|
||||
setState("connectionStatus", "error");
|
||||
setState(
|
||||
"connectionError",
|
||||
err instanceof Error ? err.message : "Connection failed",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new session by publishing retained metadata to its meta topic.
|
||||
* The server picks it up and adds it to the $SESSIONS manifest.
|
||||
*/
|
||||
export function createSession(name: string, players: string[] = []): string | null {
|
||||
if (!_mqttClient || !_mqttConnected) return null;
|
||||
|
||||
const id =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "session";
|
||||
|
||||
const meta: SessionMeta = { name, created: Date.now(), players };
|
||||
|
||||
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session: publish tombstone (empty payload) to its meta topic.
|
||||
*/
|
||||
export function deleteSession(sessionId: string): void {
|
||||
if (!_mqttClient || !_mqttConnected) return;
|
||||
|
||||
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Publish a message to the stream. Validates the payload against the
|
||||
* registered Zod schema before sending. Auto-increments the sender's seq.
|
||||
*/
|
||||
export function sendMessage<T>(
|
||||
type: string,
|
||||
payload: T,
|
||||
):
|
||||
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } {
|
||||
if (!_mqttClient || !_mqttConnected) {
|
||||
return { success: false, error: "Not connected to stream" };
|
||||
}
|
||||
|
||||
const sessionId = state.sessionId;
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "No active session" };
|
||||
}
|
||||
| { success: true; msg: StreamMessage<T> }
|
||||
| { success: false; error: string } {
|
||||
const api = getWorkerAPI();
|
||||
|
||||
// Validate locally first
|
||||
const validation = validatePayload(type, payload);
|
||||
if (!validation.success) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Send to worker (which publishes to MQTT)
|
||||
// The worker will broadcast back via the message patch
|
||||
const result = api.sendMessage(type, validation.data);
|
||||
|
||||
// We can't await the proxy result synchronously, so we optimistically
|
||||
// return success. The actual message will arrive via the patch callback.
|
||||
// For the sync API compatibility, we construct a placeholder.
|
||||
const sender = state.myName;
|
||||
const seq = (state.senderSeq[sender] ?? 0) + 1;
|
||||
const id = makeMessageId(sender, seq);
|
||||
const id = `${sender}-${seq}`;
|
||||
|
||||
const msg: StreamMessage<T> = {
|
||||
id,
|
||||
@@ -439,116 +323,52 @@ export function sendMessage<T>(
|
||||
reverted: false,
|
||||
};
|
||||
|
||||
const topic = `ttrpg/${sessionId}/stream`;
|
||||
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] publish error:", err);
|
||||
});
|
||||
|
||||
// Optimistic local insert
|
||||
receiveMessage(msg as StreamMessage);
|
||||
|
||||
return { success: true, msg };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function receiveMessage(msg: StreamMessage): void {
|
||||
const existing = state.messages.find((m) => m.id === msg.id);
|
||||
if (existing) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const idx = s.messages.findIndex((m) => m.id === msg.id);
|
||||
if (idx !== -1) s.messages[idx] = msg;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.messages.push(msg);
|
||||
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
|
||||
}),
|
||||
);
|
||||
|
||||
runReducer(msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Revert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Revert the current sender's latest (highest seq) message.
|
||||
* Only works if it's still the latest — a subsequent message locks it.
|
||||
*/
|
||||
export function revertLatest():
|
||||
{ success: true } | { success: false; error: string } {
|
||||
if (!_mqttClient || !_mqttConnected) {
|
||||
return { success: false, error: "Not connected to stream" };
|
||||
}
|
||||
|
||||
const sessionId = state.sessionId;
|
||||
if (!sessionId) return { success: false, error: "No active session" };
|
||||
|
||||
const sender = state.myName;
|
||||
const latestSeq = state.senderSeq[sender];
|
||||
if (!latestSeq) return { success: false, error: "No messages to revert" };
|
||||
|
||||
const id = makeMessageId(sender, latestSeq);
|
||||
const original = state.messages.find((m) => m.id === id);
|
||||
if (!original) return { success: false, error: "Message not found" };
|
||||
if (original.reverted) return { success: false, error: "Already reverted" };
|
||||
|
||||
const reverted: StreamMessage = { ...original, reverted: true };
|
||||
const topic = `ttrpg/${sessionId}/stream`;
|
||||
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] revert publish error:", err);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
| { success: true }
|
||||
| { success: false; error: string } {
|
||||
const api = getWorkerAPI();
|
||||
return api.revertLatest() as unknown as
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function disconnectStream(): void {
|
||||
if (_mqttClient) {
|
||||
// Clear our presence before disconnecting (tombstone)
|
||||
const sessionId = state.sessionId;
|
||||
if (sessionId) {
|
||||
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
}
|
||||
// Force disconnect without reconnect
|
||||
_mqttClient.end(true, void 0, () => {
|
||||
// noop
|
||||
});
|
||||
_mqttClient = null;
|
||||
_mqttConnected = false;
|
||||
}
|
||||
setState("connected", false);
|
||||
setState("connectionStatus", "disconnected");
|
||||
setState("players", {});
|
||||
|
||||
// Strip autojoin param so the dialog shows normally on next connect
|
||||
const api = getWorkerAPI();
|
||||
api.disconnect();
|
||||
removeUrlParam("autojoin");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived / helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export function createSession(
|
||||
name: string,
|
||||
players: string[] = [],
|
||||
): string | null {
|
||||
const api = getWorkerAPI();
|
||||
// This is sync in the worker but async over Comlink.
|
||||
// We generate the ID locally using the same algorithm.
|
||||
const id =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "session";
|
||||
api.createSession(name, players);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string): void {
|
||||
const api = getWorkerAPI();
|
||||
api.deleteSession(sessionId);
|
||||
}
|
||||
|
||||
export function canRevert(): boolean {
|
||||
const sender = state.myName;
|
||||
const seq = state.senderSeq[sender];
|
||||
if (!seq) return false;
|
||||
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
|
||||
const msg = state.messages.find(
|
||||
(m) => m.id === `${sender}-${seq}`,
|
||||
);
|
||||
return msg !== undefined && !msg.reverted;
|
||||
}
|
||||
|
||||
@@ -561,3 +381,11 @@ export function useJournalStream() {
|
||||
}
|
||||
|
||||
export { state as journalStreamState };
|
||||
|
||||
/**
|
||||
* Hydrate from server — now handled by the worker during connect().
|
||||
* Kept for API compatibility; does nothing.
|
||||
*/
|
||||
export async function hydrateFromServer(_sessionId: string): Promise<void> {
|
||||
// Hydration is now handled by the worker during connect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* useHeadingFlash — watches location.hash and applies a fading highlight
|
||||
* animation to the target heading element on every hash change.
|
||||
*
|
||||
* Handles page refresh / initial load by retrying up to ~500ms until the
|
||||
* target element exists in the DOM.
|
||||
*/
|
||||
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
|
||||
const FLASH_CLASS = "heading-flash";
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_INTERVAL = 50;
|
||||
|
||||
function flashElement(el: HTMLElement) {
|
||||
// Re-trigger the animation: remove and re-add the class
|
||||
el.classList.remove(FLASH_CLASS);
|
||||
void el.offsetWidth; // force reflow
|
||||
el.classList.add(FLASH_CLASS);
|
||||
}
|
||||
|
||||
export function useHeadingFlash() {
|
||||
const location = useLocation();
|
||||
|
||||
createEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash) return;
|
||||
|
||||
const id = decodeURIComponent(hash.startsWith("#") ? hash.slice(1) : hash);
|
||||
if (!id) return;
|
||||
|
||||
let retries = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tryFlash = () => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
flashElement(el);
|
||||
el.addEventListener("animationend", function onEnd() {
|
||||
el.classList.remove(FLASH_CLASS);
|
||||
el.removeEventListener("animationend", onEnd);
|
||||
}, { once: true });
|
||||
} else if (retries < MAX_RETRIES) {
|
||||
retries++;
|
||||
timer = setTimeout(tryFlash, RETRY_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
// Delay slightly so Solid has a chance to flush DOM updates from
|
||||
// a concurrent SPA navigation before we look for the element.
|
||||
timer = setTimeout(tryFlash, 0);
|
||||
|
||||
onCleanup(() => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
@@ -31,42 +31,6 @@ function parseFrontMatter(content: string): { frontmatter?: JSONObject; remainin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测字符串是否是 CSV 格式
|
||||
* @param str 待检测的字符串
|
||||
* @returns 如果是 CSV 格式返回 true
|
||||
*/
|
||||
export function isCSV(str: string): boolean {
|
||||
const trimmed = str.trim();
|
||||
|
||||
// 检查是否以 YAML front matter 开头
|
||||
if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否包含 CSV 特征:多行且有分隔符
|
||||
const lines = trimmed.split(/\r?\n/).filter(line => line.trim() !== '');
|
||||
if (lines.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检测常见 CSV 分隔符
|
||||
const separators = [',', '\t', ';', '|'];
|
||||
const firstLine = lines[0];
|
||||
|
||||
for (const sep of separators) {
|
||||
if (firstLine.includes(sep)) {
|
||||
// 检查其他行是否也有相同的分隔符
|
||||
const hasSeparatorInOtherLines = lines.slice(1).some(line => line.includes(sep));
|
||||
if (hasSeparatorInOtherLines) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CSV 字符串
|
||||
* @template T 返回数据的类型,默认为 Record<string, string>
|
||||
@@ -102,18 +66,12 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
|
||||
/**
|
||||
* 加载 CSV 文件
|
||||
* @template T 返回数据的类型,默认为 Record<string, string>
|
||||
* @param pathOrContent 文件路径或 inline CSV 字符串
|
||||
* @param path 文件路径(通过 file-index 获取内容)
|
||||
* @returns 解析后的 CSV 数据
|
||||
*/
|
||||
export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> {
|
||||
// 检测是否是 inline CSV 数据
|
||||
if (isCSV(pathOrContent)) {
|
||||
return parseCSVString<T>(pathOrContent, 'inline');
|
||||
}
|
||||
|
||||
// 从索引获取文件内容
|
||||
const content = await getIndexedData(pathOrContent);
|
||||
return parseCSVString<T>(content, pathOrContent);
|
||||
export async function loadCSVFromPath<T = Record<string, string>>(path: string): Promise<CSV<T>> {
|
||||
const content = await getIndexedData(path);
|
||||
return parseCSVString<T>(content, path);
|
||||
}
|
||||
|
||||
type JSONData = JSONArray | JSONObject | string | number | boolean | null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { resolveContent } from "../../cli/content-registry";
|
||||
import { getRegistry } from "../journal/completions";
|
||||
import { getIndexedData } from "../../data-loader/file-index";
|
||||
import { resolvePath } from "./path";
|
||||
|
||||
/**
|
||||
* Resolve a content reference (a directive body) to raw content.
|
||||
*
|
||||
* Prefers the shared content-registry resolution, which understands:
|
||||
* - inline CSV bodies (returned as-is)
|
||||
* - inline doc content refs like `./csv_abc123` (scoped to the current doc)
|
||||
* - absolute paths and relative paths resolved against the current doc
|
||||
*
|
||||
* Falls back to path resolution + the file index when the registry isn't
|
||||
* populated yet (e.g. before completions load) or the ref isn't indexed.
|
||||
*
|
||||
* Returns `null` when nothing matches.
|
||||
*/
|
||||
export async function resolveContentRef(
|
||||
ref: string,
|
||||
docPath: string,
|
||||
): Promise<string | null> {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const resolved = resolveContent(getRegistry(), docPath, trimmed);
|
||||
if (resolved !== null) return resolved;
|
||||
|
||||
// Fallback: resolve relative path and fetch through the file index.
|
||||
const path = resolvePath(docPath, trimmed);
|
||||
try {
|
||||
return await getIndexedData(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,9 @@ export interface SparkTableMeta {
|
||||
// CSV parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DICE_HEADER_RE = /^d\d+$/i;
|
||||
// Matches the scanner's `isSparkTableHeader` (content-registry): plain dice,
|
||||
// counts, and modifiers all count as spark-table first columns.
|
||||
const DICE_HEADER_RE = /^\d*d\d+(?:[+-]\d+)?$/i;
|
||||
|
||||
/**
|
||||
* Parse a CSV string into a SparkTableMeta.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
removeHandle,
|
||||
ensurePermission,
|
||||
} from "./file-index-db";
|
||||
import { normalizePathKey } from "../cli/content-registry";
|
||||
|
||||
type FileIndex = Record<string, string>;
|
||||
|
||||
@@ -24,6 +25,20 @@ let fileIndex: FileIndex | null = null;
|
||||
let indexLoadPromise: Promise<void> | null = null;
|
||||
let activeSource: "cli" | "folder" | null = null;
|
||||
|
||||
/**
|
||||
* Optional registry for resolving inline content ids (set by the journal
|
||||
* completions module). When present, `getIndexedData` resolves ids that
|
||||
* aren't real files through it.
|
||||
*/
|
||||
let inlineResolver: ((path: string) => string | null) | null = null;
|
||||
|
||||
/** Register a resolver for inline content ids (see journal/completions). */
|
||||
export function setInlineResolver(
|
||||
fn: ((path: string) => string | null) | null,
|
||||
): void {
|
||||
inlineResolver = fn;
|
||||
}
|
||||
|
||||
/** Currently active directory handle (if folder source) */
|
||||
let activeDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
|
||||
@@ -85,7 +100,7 @@ async function scanDirectory(
|
||||
Object.assign(index, sub);
|
||||
} else if (entry.kind === "file" && acceptedExt.test(name)) {
|
||||
const file = await (entry as FileSystemFileHandle).getFile();
|
||||
const path = prefix + name;
|
||||
const path = normalizePathKey(prefix + name);
|
||||
index[path] = await file.text();
|
||||
}
|
||||
}
|
||||
@@ -181,6 +196,15 @@ export async function getIndexedData(path: string): Promise<string> {
|
||||
if (fileIndex && fileIndex[path]) {
|
||||
return fileIndex[path];
|
||||
}
|
||||
// Resolve inline content ids through the registry before fetching.
|
||||
if (inlineResolver) {
|
||||
const inline = inlineResolver(path);
|
||||
if (inline !== null) {
|
||||
fileIndex = fileIndex || {};
|
||||
fileIndex[path] = inline;
|
||||
return inline;
|
||||
}
|
||||
}
|
||||
const res = await fetch(path);
|
||||
const content = await res.text();
|
||||
fileIndex = fileIndex || {};
|
||||
@@ -188,6 +212,16 @@ export async function getIndexedData(path: string): Promise<string> {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入/覆盖索引中的文件内容。
|
||||
* 用于将处理后的内容(如 registry 的 stripped markdown)写回索引,
|
||||
* 使浏览器模式与 CLI 模式渲染一致。
|
||||
*/
|
||||
export function setIndexedData(path: string, content: string): void {
|
||||
fileIndex = fileIndex || {};
|
||||
fileIndex[normalizePathKey(path)] = content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定扩展名的文件路径
|
||||
*/
|
||||
|
||||
@@ -2,20 +2,12 @@
|
||||
tag: journal-gm
|
||||
icon: 🎭
|
||||
title: 主持人指南
|
||||
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
|
||||
syntax: '点击导航栏 📋 图标打开 Journal 面板'
|
||||
props:
|
||||
- name: 会话管理
|
||||
type: —
|
||||
desc: 创建、切换、删除会话;通过邀请链接添加玩家
|
||||
- name: 消息类型
|
||||
type: —
|
||||
desc: 发送叙述、掷骰、表单等多种消息类型
|
||||
- name: 动态表单
|
||||
type: —
|
||||
desc: 创建自定义表单收集玩家输入
|
||||
---
|
||||
|
||||
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
|
||||
|
||||
点击顶部导航栏的 📋 按钮打开 Journal 面板。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
||||
@@ -32,25 +24,20 @@ props:
|
||||
|
||||
玩家通过邀请链接加入后会自动以玩家身份连接。
|
||||
|
||||
## 消息类型
|
||||
## 消息与命令
|
||||
|
||||
Journal 支持多种消息类型,通过输入框上方的标签切换:
|
||||
Journal 使用斜杠命令来发送不同类型的消息。在输入框中输入命令,按 Enter 发送:
|
||||
|
||||
- **叙述**:普通的文字描述,用于推进剧情
|
||||
- **掷骰**:发送掷骰结果,可附带公式
|
||||
- **表单**:创建动态表单,收集玩家选择或输入
|
||||
- **聊天**:直接输入文字,按 Enter 发送普通叙述消息
|
||||
- **`/roll 表达式`**:掷骰或抽取火花表。传入骰子表达式(如 `/roll 3d6`)则掷骰;传入火花表键名(如 `/roll npc`)则抽取火花表
|
||||
- **`/link 路径#章节`**:发送可点击的文档链接
|
||||
- **`/set $key 值`**:设置变量值,支持数值、标签映射和随机标签
|
||||
|
||||
## 动态表单
|
||||
|
||||
表单消息允许你创建交互式问卷:
|
||||
|
||||
- 添加文本输入、选择框、复选框等字段
|
||||
- 玩家提交后,GM 可以看到汇总结果
|
||||
- 适用于投票、决策、角色创建等场景
|
||||
输入 `/` 后按 Tab 可打开命令补全下拉菜单。
|
||||
|
||||
## 撤回消息
|
||||
|
||||
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。
|
||||
GM 可以撤回自己发送的最新消息,点击消息卡片右上角的 × 按钮即可。
|
||||
|
||||
## 连接状态
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tag: journal-link
|
||||
icon: 🔗
|
||||
title: 链接命令
|
||||
---
|
||||
|
||||
在 Journal 中发送可点击的文档链接,可指向特定章节。
|
||||
|
||||
**语法:** `/link 路径#章节`
|
||||
|
||||
## 概述
|
||||
|
||||
`/link` 命令用于在 Journal 流中发送文档链接,点击后可在文章区域打开对应文档。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| 路径 | string | 文档路径(不含 `.md` 扩展名) |
|
||||
| 章节 | string | 可选,文档中的章节标题 |
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/link rules/combat
|
||||
/link rules/combat#伤害计算
|
||||
/link npc/商人
|
||||
```
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可使用
|
||||
- **玩家**:不可使用
|
||||
- **观察者**:不可使用
|
||||
@@ -2,20 +2,12 @@
|
||||
tag: journal-player
|
||||
icon: 🎲
|
||||
title: 玩家指南
|
||||
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
|
||||
syntax: '通过 GM 发送的邀请链接加入'
|
||||
props:
|
||||
- name: 加入方式
|
||||
type: —
|
||||
desc: 点击邀请链接自动加入,或手动输入名字和角色
|
||||
- name: 查看消息
|
||||
type: —
|
||||
desc: 实时查看 GM 和其他玩家的消息
|
||||
- name: 提交表单
|
||||
type: —
|
||||
desc: 填写 GM 发送的动态表单并提交
|
||||
---
|
||||
|
||||
作为玩家加入 GM 的会话,查看消息、使用 `/set` 命令设置变量、参与互动。
|
||||
|
||||
通过 GM 发送的邀请链接加入。
|
||||
|
||||
## 加入会话
|
||||
|
||||
有两种方式加入 GM 的会话:
|
||||
@@ -38,20 +30,17 @@ GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接
|
||||
|
||||
- GM 发送的叙述消息
|
||||
- 掷骰结果
|
||||
- 火花表抽取结果
|
||||
- 其他玩家的消息
|
||||
- 动态表单
|
||||
|
||||
消息实时更新,无需刷新页面。
|
||||
|
||||
## 填写表单
|
||||
## 使用命令
|
||||
|
||||
当 GM 发送动态表单时:
|
||||
玩家可在输入框中使用以下命令:
|
||||
|
||||
1. 表单会显示在消息流中
|
||||
2. 根据表单类型填写(文本、选择、复选框等)
|
||||
3. 点击提交按钮发送你的回答
|
||||
|
||||
GM 可以看到所有玩家的提交结果。
|
||||
- **聊天**:直接输入文字,按 Enter 发送普通消息
|
||||
- **`/set $key 值`**:设置变量值,支持数值和标签映射
|
||||
|
||||
## 角色标识
|
||||
|
||||
@@ -63,4 +52,5 @@ GM 可以看到所有玩家的提交结果。
|
||||
|
||||
- 玩家无法创建或切换会话
|
||||
- 玩家无法撤回消息
|
||||
- 断开连接后重新加入会保留之前的消息记录
|
||||
- 玩家无法使用 `/roll` 和 `/link` 命令
|
||||
- 在 CLI 模式下,断开连接后重新加入会保留之前的消息记录
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
tag: journal-roll
|
||||
icon: 🎲
|
||||
title: 掷骰与火花表命令
|
||||
---
|
||||
|
||||
在 Journal 中掷骰或随机抽取火花表内容。优先匹配火花表,否则作为骰子表达式处理。
|
||||
|
||||
**语法:** `/roll 骰子表达式 | 火花表键名`
|
||||
|
||||
## 概述
|
||||
|
||||
`/roll` 命令用于掷骰或随机抽取火花表,并将结果同步到所有客户端。
|
||||
|
||||
- 如果参数匹配已定义的**火花表** slug,则抽取并发布对应火花表的结果。
|
||||
- 否则,作为**骰子表达式**求值并发布掷骰结果。
|
||||
|
||||
## 示例
|
||||
|
||||
### 掷骰
|
||||
|
||||
```
|
||||
/roll d20
|
||||
/roll 3d6
|
||||
/roll 2d8kh1
|
||||
/roll 1d20+5
|
||||
/roll 4d6k3
|
||||
```
|
||||
|
||||
### 火花表
|
||||
|
||||
```
|
||||
/roll npc
|
||||
/roll encounter
|
||||
/roll loot
|
||||
```
|
||||
|
||||
## 支持的骰子表达式
|
||||
|
||||
| 表达式 | 说明 |
|
||||
|---|---|
|
||||
| `d20` | 单个 20 面骰 |
|
||||
| `3d6` | 3 个 6 面骰求和 |
|
||||
| `2d8kh1` | 2 个 8 面骰保留最高 1 个 |
|
||||
| `1d20+5` | 1 个 20 面骰加 5 |
|
||||
| `4d6k3` | 4 个 6 面骰保留最高 3 个 |
|
||||
|
||||
## 火花表定义
|
||||
|
||||
火花表在 markdown 文档中通过 `:spark[CSV路径]` 标记声明,系统扫描文档时自动发现。CSV 文件的第一列表头为骰子公式(如 `d6`、`d20`),后续列为数据列。
|
||||
|
||||
抽取时根据骰子公式投掷,查找对应行并将各列数据作为火花表结果发布。
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可使用
|
||||
- **玩家**:不可使用
|
||||
- **观察者**:不可使用
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
tag: journal-set
|
||||
icon: 🔢
|
||||
title: 变量系统
|
||||
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
|
||||
syntax: '```csv role=declare'
|
||||
props:
|
||||
- name: 定义文件
|
||||
type: —
|
||||
desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签
|
||||
- name: 命令
|
||||
type: —
|
||||
desc: /set $key expression | /set $key #tag1:count1;#tag2:count2
|
||||
- name: 权限
|
||||
type: —
|
||||
desc: GM 和玩家均可设置变量
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
变量系统允许你定义数值变量和标签,通过命令设置值,系统自动计算派生变量和标签效果。
|
||||
|
||||
核心概念:
|
||||
- **变量**(`$name`):存储数值或标签映射,通过 `/set` 命令修改
|
||||
- **标签映射**(`#tag:count`):变量可以存储一个标签映射(如 `#warrior:2;#druid:1`),每个标签有一个计数值
|
||||
- **标签修饰符**:当变量中的标签计数值达到阈值时,修饰符生效
|
||||
- **声明**(`declare`):在 `role=declare` 代码块中定义派生变量和标签修饰符
|
||||
|
||||
## 代码块语法
|
||||
|
||||
```csv role=declare
|
||||
tag,threshold,key,expr
|
||||
,,$hp,$con*5+$mod_hp
|
||||
,,$ac,10+$dex
|
||||
#warrior,1,$mod_hp,20
|
||||
#warrior,2,$mod_str,1
|
||||
```
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| `tag` | 标签名(空表示普通变量声明) |
|
||||
| `threshold` | 激活阈值(可选,默认 `1`)。标签计数值 ≥ 阈值时激活 |
|
||||
| `key` | 变量名,以 `$` 开头 |
|
||||
| `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 |
|
||||
|
||||
### 声明类型
|
||||
|
||||
**普通声明**(tag 为空):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
|
||||
|
||||
```csv role=declare
|
||||
tag,key,expr
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex
|
||||
```
|
||||
|
||||
**标签修饰符**(tag 非空):当源变量的标签映射中 `#tag` 的计数值 ≥ `threshold` 时,`$key` 加上表达式的计算结果。修饰符按阈值独立激活。
|
||||
|
||||
```csv role=declare
|
||||
tag,threshold,key,expr
|
||||
#warrior,1,$mod_hp,20
|
||||
#warrior,2,$mod_str,1
|
||||
```
|
||||
|
||||
上例中:
|
||||
- `#warrior` 计数 ≥ 1 时,`$mod_hp` 获得 +20
|
||||
- `#warrior` 计数 ≥ 2 时,`$mod_str` 获得 +1(需要更高等级)
|
||||
|
||||
## 表达式语法
|
||||
|
||||
支持以下表达式元素:
|
||||
|
||||
| 元素 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| 数字 | `10`, `3.5` | 整数或小数 |
|
||||
| 变量引用 | `$str`, `$mod_hp` | 引用其他变量的值 |
|
||||
| 骰子 | `3d6`, `2d8kh1` | 每次求值时重新掷骰 |
|
||||
| 算术 | `+ - * /` | 四则运算 |
|
||||
| 函数 | `floor(x)`, `ceil(x)`, `round(x)` | 取整函数 |
|
||||
| 括号 | `(2+3)*4` | 分组 |
|
||||
|
||||
变量引用必须解析为数值,不能是标签映射值(`#warrior:1`)。如果引用的变量是标签类型,求值会报错。
|
||||
|
||||
## 命令
|
||||
|
||||
所有命令在 Journal 输入框中输入:
|
||||
|
||||
| 命令 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| `/set $key expr` | `/set $str 16` | 设置变量为数值 |
|
||||
| `/set $key #tag` | `/set $class #warrior` | 设置单个标签(等价 `#warrior:1`) |
|
||||
| `/set $key #tag:count` | `/set $class #warrior:3` | 设置标签及计数值 |
|
||||
| `/set $key #t1:c1;#t2:c2` | `/set $class #warrior:2;#druid:1` | 设置多个标签 |
|
||||
| `/set $key #a\|#b\|#c` | `/set $class #w\|#d\|#s` | 随机选择一个标签 |
|
||||
|
||||
### 设置数值
|
||||
|
||||
```
|
||||
/set $con 14
|
||||
/set $dex 12
|
||||
```
|
||||
|
||||
设置后,依赖 `$con` 和 `$dex` 的派生变量(如 `$hp`、`$ac`)会自动重新计算。
|
||||
|
||||
### 设置标签映射
|
||||
|
||||
```
|
||||
/set $class #warrior
|
||||
```
|
||||
|
||||
等价于 `/set $class #warrior:1`。当 `$class` 的 `#warrior` 计数 ≥ 1 时:
|
||||
1. `#warrior` 修饰符中阈值 ≤ 1 的生效
|
||||
2. 如 `$mod_hp += 20`,依赖 `$mod_hp` 的派生变量自动重新计算
|
||||
|
||||
**多标签示例:**
|
||||
|
||||
```
|
||||
/set $class #warrior:2;#druid:1
|
||||
```
|
||||
|
||||
变量 `$class` 同时拥有 `#warrior` 计数 2 和 `#druid` 计数 1,两者的修饰符会同时生效(各自按阈值判断)。
|
||||
|
||||
### 随机标签
|
||||
|
||||
```
|
||||
/set $class #warrior|#druid|#sorcerer|#wizard
|
||||
```
|
||||
|
||||
从给定标签中随机选择一个,计数值为 1。
|
||||
|
||||
## 标签激活
|
||||
|
||||
标签的激活状态由变量中标签映射的计数值决定:
|
||||
|
||||
- 如果变量的标签映射中 `#tag` 的计数值 = 0(或不存在),该标签停用
|
||||
- 计数值 ≥ 修饰符的 `threshold` 时,该修饰符激活
|
||||
- 计数值 < `threshold` 时,修饰符停用
|
||||
- 标签激活时,其修饰符**加到**目标变量上
|
||||
- 标签停用时,修饰符**从**目标变量中减去
|
||||
|
||||
### 阈值机制
|
||||
|
||||
阈值允许同一标签产生不同层级的效果:
|
||||
|
||||
```csv role=declare
|
||||
tag,threshold,key,expr
|
||||
#warrior,1,$mod_hp,10
|
||||
#warrior,3,$mod_hp,20
|
||||
```
|
||||
|
||||
- `#warrior` 计数 1-2:`$mod_hp` +10
|
||||
- `#warrior` 计数 ≥ 3:`$mod_hp` +30(两个修饰符叠加)
|
||||
|
||||
一个变量可以存储多个标签(标签映射),支持多职业、混合属性等场景。
|
||||
|
||||
## 变量视图
|
||||
|
||||
在 Journal 面板顶部点击 **变量** 标签切换到变量视图,显示所有已定义的变量及其当前值。
|
||||
|
||||
- 每行显示变量名和当前值,标签类型变量以紫色高亮显示
|
||||
- 鼠标悬停在变量行上,会弹出气泡显示:声明表达式(如有)和当前激活的标签修饰符(标签名、数值、来源变量)
|
||||
|
||||
## 文档模板
|
||||
|
||||
在 markdown 文章中使用 `{{$key}}` 语法显示变量的实时值:
|
||||
|
||||
```
|
||||
当前生命值:{{$hp}}
|
||||
护甲等级:{{$ac}}
|
||||
```
|
||||
|
||||
变量值会随着 Journal 流中的 `/set` 命令实时更新。
|
||||
|
||||
## 完整示例
|
||||
|
||||
在文档中定义:
|
||||
|
||||
```csv role=declare
|
||||
tag,threshold,key,expr
|
||||
,,$con,10
|
||||
,,$dex,12
|
||||
,,$hp,$con*5+$mod_hp
|
||||
,,$ac,10+$dex
|
||||
#warrior,1,$mod_hp,20
|
||||
#warrior,2,$mod_str,1
|
||||
```
|
||||
|
||||
在 Journal 中输入:
|
||||
|
||||
```
|
||||
/set $con 14
|
||||
/set $class #warrior:2
|
||||
```
|
||||
|
||||
结果:
|
||||
- `$con = 14`
|
||||
- `$dex = 12`
|
||||
- `$hp = 14*5 + 20 = 90`
|
||||
- `$ac = 10 + 12 = 22`
|
||||
- `$mod_hp = 20`(来自 `#warrior` 修饰符,阈值 1,计数 2 ≥ 1)
|
||||
- `$mod_str = 1`(来自 `#warrior` 修饰符,阈值 2,计数 2 ≥ 2)
|
||||
|
||||
## 循环依赖
|
||||
|
||||
系统在加载声明时检测循环依赖。如果声明之间存在循环引用(如 `$a = $b + 1`, `$b = $a + 1`),会抛出错误提示。
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可设置所有变量
|
||||
- **玩家**:可设置变量
|
||||
- **观察者**:不能设置变量
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
tag: journal-spark
|
||||
icon: 🎰
|
||||
title: 火花表
|
||||
---
|
||||
|
||||
在 Journal 中通过 `/roll` 命令随机抽取火花表内容,用于生成随机遭遇、NPC、物品等。
|
||||
|
||||
**语法:** `/roll 火花表键名`
|
||||
|
||||
## 概述
|
||||
|
||||
火花表通过 `/roll` 命令触发:当参数匹配已定义的火花表 slug 时,自动抽取并发布结果,而非作为骰子表达式处理。
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/roll npc
|
||||
/roll encounter
|
||||
/roll loot
|
||||
```
|
||||
|
||||
## 火花表定义
|
||||
|
||||
火花表在 markdown 文档中通过 `:spark[CSV路径]` 标记声明,CSV 文件的第一列表头为骰子公式(如 `d6`、`d20`),后续列为数据列。系统扫描文档时自动发现这些标记。
|
||||
|
||||
```
|
||||
:spark[npc]
|
||||
:spark[encounter]
|
||||
:spark[loot]
|
||||
```
|
||||
|
||||
抽取时根据骰子公式投掷,查找对应行并发布结果。
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可使用
|
||||
- **玩家**:不可使用
|
||||
- **观察者**:不可使用
|
||||
@@ -1,317 +0,0 @@
|
||||
---
|
||||
tag: journal-stat
|
||||
icon: 📊
|
||||
title: 属性系统
|
||||
description: 在文档中定义属性,通过命令设置/删除/掷骰,在面板中查看属性表。
|
||||
syntax: '```yaml role=stat'
|
||||
props:
|
||||
- name: 定义文件
|
||||
type: —
|
||||
desc: 在任意 .md 文档中使用 ```yaml role=stat 或 ```csv role=stat 代码块定义属性
|
||||
- name: 命令
|
||||
type: —
|
||||
desc: /stat set key=value | /stat del key | /stat roll key
|
||||
- name: 属性类型
|
||||
type: —
|
||||
desc: number, string, enum, modifier, derived, template
|
||||
- name: 权限
|
||||
type: —
|
||||
desc: GM 可修改所有属性,玩家只能修改自己的属性
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
属性系统允许你在文档中定义角色属性,通过命令设置和掷骰,
|
||||
并在 Journal 面板的属性视图中查看当前值。
|
||||
|
||||
属性分为两层:
|
||||
- **定义**(Schema):在 markdown 文档的 ` ```yaml role=stat ` 或 ` ```csv role=stat ` 代码块中定义
|
||||
- **值**(State):通过 `/stat set/del/roll` 命令在游戏过程中动态修改
|
||||
|
||||
## 代码块语法
|
||||
|
||||
所有属性相关代码块使用统一的属性语法:
|
||||
|
||||
```lang role=xxx [id=xxx]
|
||||
|
||||
| 属性 | 说明 | 示例 |
|
||||
|---|---|---|
|
||||
| `lang` | 代码语言 | `yaml`, `csv` |
|
||||
| `role` | 块用途 | `stat`(属性定义), `stat-template`(模板表) |
|
||||
| `id` | 引用标识 | 模板名称,如 `id=年龄` |
|
||||
|
||||
代码块默认会被**剥离**(不渲染到页面),仅用于数据提取。
|
||||
如需保留为可见代码块,添加 `as=codeblock`。
|
||||
|
||||
## 属性定义
|
||||
|
||||
支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。
|
||||
|
||||
### YAML 格式
|
||||
|
||||
```yaml role=stat
|
||||
- key: strength
|
||||
scope: player
|
||||
label: "力量"
|
||||
type: number
|
||||
default: 10
|
||||
|
||||
- key: str_mod
|
||||
scope: player
|
||||
label: "力量调整"
|
||||
type: modifier
|
||||
target: strength
|
||||
|
||||
- key: attack
|
||||
scope: player
|
||||
label: "近战攻击"
|
||||
type: number
|
||||
default: 0
|
||||
roll: "1d20 + attack"
|
||||
|
||||
- key: loot
|
||||
scope: player
|
||||
label: "战利品"
|
||||
type: enum
|
||||
options:
|
||||
- 金币 x10
|
||||
- 魔法药水
|
||||
- 破旧长剑
|
||||
|
||||
- key: hp_max
|
||||
scope: player
|
||||
label: "最大生命值"
|
||||
type: derived
|
||||
formula: "strength * 2 + 10"
|
||||
|
||||
- key: notes
|
||||
scope: player
|
||||
label: "备注"
|
||||
type: string
|
||||
|
||||
- key: weather
|
||||
scope: global
|
||||
label: "天气"
|
||||
type: enum
|
||||
options:
|
||||
- 晴天
|
||||
- 阴天
|
||||
- 雨天
|
||||
- 暴风雨
|
||||
```
|
||||
|
||||
### CSV 格式
|
||||
|
||||
```csv role=stat
|
||||
key,label,type,roll
|
||||
mind,心智,number,2d10+20
|
||||
heart,心灵,number,2d10+20
|
||||
strength,力量,number,2d10+20
|
||||
speed,速度,number,2d10+20
|
||||
```
|
||||
|
||||
CSV 列说明:
|
||||
|
||||
| 列 | 必填 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `key` | ✅ | — | 属性标识符 |
|
||||
| `label` | — | key 的值 | 显示名称 |
|
||||
| `type` | — | `number` | 属性类型 |
|
||||
| `scope` | — | `player` | `player` 或 `global` |
|
||||
| `default` | — | — | 默认值 |
|
||||
| `roll` | — | — | 掷骰公式 |
|
||||
| `target` | — | — | modifier 的目标 key |
|
||||
| `template` | — | — | template 类型引用的模板 id |
|
||||
| `formula` | — | — | derived 的计算公式 |
|
||||
| `options` | — | — | enum 选项,用 `\|` 分隔 |
|
||||
|
||||
### 属性类型
|
||||
|
||||
| 类型 | 说明 | 支持掷骰 |
|
||||
|---|---|---|
|
||||
| `number` | 数值,可声明 `roll` 公式 | ✅ |
|
||||
| `string` | 自由文本 | ❌ |
|
||||
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
||||
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
|
||||
| `derived` | 通过公式从其他属性计算 | ✅ |
|
||||
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
|
||||
|
||||
### 关键字段说明
|
||||
|
||||
- **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀
|
||||
- **`scope`**:`player` 或 `global`。`player` 表示每个玩家各自独立的值,运行时 key 为 `玩家名:strength`;`global` 表示所有玩家共享
|
||||
- **`label`**:在属性视图中显示的名称
|
||||
- **`default`**:默认值,在未通过命令设置时使用
|
||||
- **`roll`**:掷骰公式(`number` 类型),支持引用其他属性值(bare key,自动同 scope 解析)
|
||||
- **`target`**:`modifier` 类型的目标属性 key
|
||||
- **`options`**:`enum` 类型的选项列表,YAML 支持多行 `- value` 语法,CSV 用 `|` 分隔
|
||||
- **`formula`**:`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`
|
||||
- **`template`**:`template` 类型引用的模板 id(对应 `id=xxx` 的 stat-template 块)
|
||||
|
||||
### 作用域
|
||||
|
||||
`scope: player` 的属性在运行时自动加上玩家名前缀。Alice 连接时,`strength` 的实际 key 是 `alice:strength`。
|
||||
|
||||
在公式(`roll`、`formula`)和 `target` 中,使用 bare key 即可,系统自动在相同 scope 内查找:
|
||||
|
||||
```yaml role=stat
|
||||
- key: attack
|
||||
scope: player
|
||||
roll: "1d20 + attack" # attack 自动解析为 alice:attack
|
||||
|
||||
- key: str_mod
|
||||
scope: player
|
||||
target: strength # 自动解析为 alice:strength
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
所有命令在 Journal 输入框中输入,前缀为 `/stat`:
|
||||
|
||||
| 命令 | 示例 | 说明 |
|
||||
|---|---|---|
|
||||
| `/stat set key=value` | `/stat set strength=16` | 设置属性值 |
|
||||
| `/stat del key` | `/stat del strength` | 删除属性值,恢复默认 |
|
||||
| `/stat roll key` | `/stat roll attack` | 掷骰并发布结果 |
|
||||
|
||||
### 掷骰行为
|
||||
|
||||
- **`number` + `roll`**:解析公式中的属性引用,掷骰,结果写入属性值
|
||||
- **`enum`**:从选项列表中随机选择一项
|
||||
- **`derived` + `formula`**:计算公式,结果写入属性值
|
||||
- **`template`**:掷模板头部骰子,匹配范围,应用修饰符
|
||||
|
||||
例如 `/stat roll attack`:
|
||||
1. 在当前玩家 scope 下查找 `attack` 的定义
|
||||
2. 解析 `roll` 公式 `1d20 + attack`,将 `attack` 替换为当前值(含修饰符)→ `1d20 + 3`
|
||||
3. 掷骰 → `15`
|
||||
4. 将 `15` 写入 `alice:attack`,同步到所有客户端
|
||||
|
||||
## 属性视图
|
||||
|
||||
在 Journal 面板顶部点击 **属性** 标签切换视图:
|
||||
|
||||
- 属性按 scope 分组(全局 + 玩家)
|
||||
- 显示属性名、当前值、默认值
|
||||
- 修饰符自动合并显示
|
||||
- 可掷骰的行显示 🎲 按钮
|
||||
|
||||
## 权限
|
||||
|
||||
- **GM**:可修改所有属性
|
||||
- **玩家**:只能修改 `scope: player` 的属性
|
||||
- **观察者**:不能修改任何属性
|
||||
|
||||
## 修饰符(modifier)
|
||||
|
||||
修饰符自动加到 `target` 属性上:
|
||||
|
||||
```yaml role=stat
|
||||
- key: strength
|
||||
scope: player
|
||||
type: number
|
||||
default: 10
|
||||
|
||||
- key: str_mod
|
||||
scope: player
|
||||
type: modifier
|
||||
target: strength
|
||||
```
|
||||
|
||||
`/stat set str_mod=3` 后,`strength` 的计算值为 `10 + 3 = 13`。
|
||||
多个修饰符指向同一目标时累加。
|
||||
|
||||
## 派生属性(derived)
|
||||
|
||||
通过公式从其他属性计算:
|
||||
|
||||
```yaml role=stat
|
||||
- key: hp_max
|
||||
scope: player
|
||||
type: derived
|
||||
formula: "strength * 2 + 10"
|
||||
```
|
||||
|
||||
公式引用其他属性时自动使用计算值(含修饰符)。
|
||||
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
||||
|
||||
## 模板属性(template)
|
||||
|
||||
模板属性用于查表掷骰(年龄表、职业表等)。
|
||||
|
||||
### 方式一:stat-modifiers(推荐)
|
||||
|
||||
一个代码块同时生成模板 stat 和修饰符 stat defs:
|
||||
|
||||
```csv id=年龄 role=stat-modifiers
|
||||
1d10,label,mind,heart,strength,speed
|
||||
1-3,青少年,-10,+20,,
|
||||
4-7,成年,,-10,,+20
|
||||
8-9,老年,,+20,-10,
|
||||
10,换躯者,,,+30,-10
|
||||
```
|
||||
|
||||
这会自动生成:
|
||||
- `age`(type: template, template: 年龄)
|
||||
- `age_mind`(type: modifier, target: mind)
|
||||
- `age_heart`(type: modifier, target: heart)
|
||||
- `age_strength`(type: modifier, target: strength)
|
||||
- `age_speed`(type: modifier, target: speed)
|
||||
|
||||
修饰符键名规则:`{id}_{列名}`,目标为列名本身。
|
||||
|
||||
### 方式二:手动定义
|
||||
|
||||
分别定义模板表和修饰符 stat:
|
||||
|
||||
```csv id=年龄 role=stat-template
|
||||
1d10,label,age_mind,age_heart,age_strength,age_speed
|
||||
1-3,青少年,-10,+20,,
|
||||
4-7,成年,,-10,,+20
|
||||
8-9,老年,,+20,-10,
|
||||
10,换躯者,,,+30,-10
|
||||
```
|
||||
|
||||
```yaml role=stat
|
||||
- key: age_mind
|
||||
type: modifier
|
||||
target: mind
|
||||
- key: age_heart
|
||||
type: modifier
|
||||
target: heart
|
||||
- key: age
|
||||
type: template
|
||||
template: 年龄
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
### 使用
|
||||
|
||||
- `/stat roll age` — 掷 `1d10`,匹配范围,应用修饰符
|
||||
- `/stat set age=换躯者` — 直接设置,同样应用修饰符
|
||||
|
||||
`/stat roll age` 或 `/stat set age=青少年` 时:
|
||||
1. 发布 `set age=青少年`
|
||||
2. 同时发布 `set alice:age_mind=-10`、`set alice:age_heart=+20` 等
|
||||
|
||||
修饰符值中的 `+`/`-` 前缀表示相对调整,基于当前值计算。
|
||||
|
||||
### 模板表语法
|
||||
|
||||
- **第一列(header)**:骰子表达式,如 `1d10`
|
||||
- **第一列(rows)**:匹配范围,`1-3`(区间)、`4`(精确)、`1-3,5`(多个)
|
||||
- **`label`**:显示名称
|
||||
- **其余列**:修饰符键名,值为变化量(`+`加、`-`减、空不修改)
|
||||
|
||||
## 掷骰公式中的属性引用
|
||||
|
||||
`roll` 字段中的标识符自动替换为当前属性值:
|
||||
|
||||
```yaml role=stat
|
||||
- key: attack
|
||||
scope: player
|
||||
type: number
|
||||
default: 0
|
||||
roll: "1d20 + attack + str_mod"
|
||||
```
|
||||
@@ -2,15 +2,12 @@
|
||||
tag: md-bg
|
||||
icon: 🖼️
|
||||
title: 背景组件
|
||||
description: 设置背景图片或纯色作为文章卡片背景,支持多种适配方式。
|
||||
syntax: ':md-bg[#ff00dd]'
|
||||
props:
|
||||
- name: fit
|
||||
type: cover | contain | fill | none | scale-down
|
||||
default: cover
|
||||
desc: 背景适配方式(仅图片时生效)
|
||||
---
|
||||
|
||||
设置背景图片或纯色作为文章卡片背景,支持多种适配方式。
|
||||
|
||||
**语法:** `:md-bg[颜色或图片路径]{选项}`
|
||||
|
||||
**设置背景图:**
|
||||
:md-bg[./images/dungeon-bg.jpg]{fit="cover"}
|
||||
|
||||
@@ -23,6 +20,12 @@ props:
|
||||
|
||||
支持图片路径或任意 CSS 颜色值(hex、rgb、颜色名等)。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `fit` | `cover` \| `contain` \| `fill` \| `none` \| `scale-down` | `cover` | 背景适配方式(仅图片时生效) |
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于营造场景氛围,如地牢、森林、城镇等不同环境的背景,或纯色区分不同主题。
|
||||
@@ -2,19 +2,12 @@
|
||||
tag: md-border
|
||||
icon: 🖼️
|
||||
title: 边框组件
|
||||
description: 为文档卡片添加纯色或图片边框,支持指定边、样式和重复模式。
|
||||
syntax: ':md-border[#3b82f6]{.l .dashed}'
|
||||
props:
|
||||
- name: width
|
||||
type: string
|
||||
default: 纯色 .2mm,图片 2mm
|
||||
desc: 边框宽度
|
||||
- name: slice
|
||||
type: string
|
||||
default: '"10"'
|
||||
desc: border-image-slice(仅图片模式)
|
||||
---
|
||||
|
||||
为文档卡片添加纯色或图片边框,支持指定边、样式和重复模式。
|
||||
|
||||
**语法:** `:md-border[颜色或图片路径]{方位类 样式类 选项}`
|
||||
|
||||
**纯色左边框:**
|
||||
:md-border[#3b82f6]{.l}
|
||||
|
||||
@@ -27,9 +20,45 @@ props:
|
||||
**左右图片竖条:**
|
||||
:md-border[./images/ornament.png]{.l .r .repeat}
|
||||
|
||||
边 class:`t` `b` `l` `r` `all`(默认)
|
||||
样式 class:`solid` `dashed` `dotted` `double`
|
||||
图片 class:`stretch` `repeat` `round` `space`
|
||||
## 方位类
|
||||
|
||||
| class | 说明 |
|
||||
|---|---|
|
||||
| `t` | 上边框 |
|
||||
| `b` | 下边框 |
|
||||
| `l` | 左边框 |
|
||||
| `r` | 右边框 |
|
||||
| `all` | 全部(默认) |
|
||||
|
||||
## 样式类
|
||||
|
||||
| class | 说明 |
|
||||
|---|---|
|
||||
| `solid` | 实线 |
|
||||
| `dashed` | 虚线 |
|
||||
| `dotted` | 点线 |
|
||||
| `double` | 双线 |
|
||||
| `groove` | 凹槽 |
|
||||
| `ridge` | 凸脊 |
|
||||
| `inset` | 内嵌 |
|
||||
| `outset` | 外凸 |
|
||||
| `none` | 无边框 |
|
||||
|
||||
## 图片类
|
||||
|
||||
| class | 说明 |
|
||||
|---|---|
|
||||
| `stretch` | 拉伸 |
|
||||
| `repeat` | 重复 |
|
||||
| `round` | 缩放 |
|
||||
| `space` | 间隔 |
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `width` | string | 纯色 `.2mm`,图片 `2mm` | 边框宽度 |
|
||||
| `slice` | string | `"10"` | `border-image-slice`(仅图片模式) |
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
@@ -2,28 +2,56 @@
|
||||
tag: md-commander
|
||||
icon: 📋
|
||||
title: 命令追踪器
|
||||
description: 支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项。
|
||||
syntax: ':md-commander'
|
||||
props: []
|
||||
---
|
||||
|
||||
支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项,支持 CSV 命令模板加载。
|
||||
|
||||
**语法:** `:md-commander[./templates.csv]{选项}`
|
||||
|
||||
**追踪 NPC 血量和防御:**
|
||||
```
|
||||
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
||||
```
|
||||
|
||||
**语法规则:**
|
||||
## 语法规则
|
||||
|
||||
- `#id` 设置 ID
|
||||
- `.class` 添加类别
|
||||
- `[attr=value]` 设置属性
|
||||
|
||||
**属性类型:**
|
||||
## 属性类型
|
||||
|
||||
| 格式 | 显示 |
|
||||
|---|---|
|
||||
| x/y | 进度条 |
|
||||
| `x/y` | 进度条 |
|
||||
| 整数 | 计数器 |
|
||||
| 文本 | 文本字段 |
|
||||
|
||||
## 视图模式
|
||||
|
||||
组件有两个标签页,通过顶部标签栏切换:
|
||||
|
||||
- **历史**:显示命令执行历史,点击可重新填入命令
|
||||
- **追踪**:显示当前追踪的所有项目,支持属性编辑、类管理、排序和删除
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
| 快捷键 | 说明 |
|
||||
|---|---|
|
||||
| `Enter` | 执行命令 |
|
||||
| `Tab` | 接受自动补全 |
|
||||
| `↑` / `↓` | 补全列表导航 / 命令历史导航 |
|
||||
| `Escape` | 关闭补全菜单 |
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `placeholder` | string | `"输入命令..."` | 输入框占位符 |
|
||||
| `class` | string | — | 额外 CSS 类 |
|
||||
| `height` | string | `"400px"` | 组件高度 |
|
||||
| `commandTemplates` | string | — | CSV 模板文件路径 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于追踪战斗中的 NPC 血量、AC、状态等游戏信息。
|
||||
+58
-10
@@ -2,24 +2,72 @@
|
||||
tag: md-deck
|
||||
icon: 🃏
|
||||
title: 卡牌组件
|
||||
description: 将 CSV 数据渲染为卡牌布局,支持自定义网格和图层的排版。
|
||||
syntax: ':md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}'
|
||||
props:
|
||||
- name: grid
|
||||
type: string
|
||||
desc: 卡牌布局,格式 行x列
|
||||
- name: layers
|
||||
type: string
|
||||
desc: 图层定义,格式 字段:行,列-列,字号
|
||||
---
|
||||
|
||||
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
||||
|
||||
**语法:** `:md-deck[./cards.csv]{选项}` 或 yaml 代码块
|
||||
|
||||
**基础卡牌:**
|
||||
:md-deck[./spells.csv]{grid="3x3"}
|
||||
|
||||
**多层卡牌:**
|
||||
:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}
|
||||
|
||||
CSV 包含 label 和显示字段列,通过图层定义控制各字段的位置和大小。
|
||||
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
|
||||
|
||||
## 结构化配置 (yaml 代码块)
|
||||
|
||||
推荐 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
|
||||
|
||||
````markdown
|
||||
```yaml role=tag
|
||||
tag: md-deck
|
||||
body: ./cards.csv
|
||||
data-config:
|
||||
size: 63x88
|
||||
grid: 5x5
|
||||
layers:
|
||||
- prop: title
|
||||
pos: 1,1-5,1
|
||||
font: 12
|
||||
- template: |
|
||||
**{{name}}** — {{type}}
|
||||
{{description}}
|
||||
pos: 1,3-5,8
|
||||
font: 3
|
||||
align: l
|
||||
```
|
||||
````
|
||||
|
||||
`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式)。
|
||||
|
||||
## 图层格式
|
||||
|
||||
`layers` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
|
||||
|
||||
- **字段**:CSV 中的列名
|
||||
- **起始行,起始列-结束列**:图层在网格中的位置(1-based)
|
||||
- **字号**:末尾带 `f` 前缀,如 `f8` 表示 8mm
|
||||
|
||||
示例:`"title:1,1-5,1f8 body:1,5-8,8f3"` 表示 title 字段占第 1 行、第 1 到 5 列、字号 8mm;body 字段占第 1 行、第 5 到 8 列、字号 3mm。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `grid` | string | — | 卡牌网格布局,格式 `行x列`(如 `"5x8"`) |
|
||||
| `gridW` | number | `5` | 网格列数 |
|
||||
| `gridH` | number | `8` | 网格行数 |
|
||||
| `size` | string | — | 卡牌尺寸,格式 `宽x高`(如 `"54x86"`,单位 mm) |
|
||||
| `sizeW` | number | `54` | 卡牌宽度(mm) |
|
||||
| `sizeH` | number | `86` | 卡牌高度(mm) |
|
||||
| `bleed` | number | `1` | 出血线(mm) |
|
||||
| `padding` | number | `2` | 内边距(mm) |
|
||||
| `shape` | string | `"rectangle"` | 卡牌形状(`rectangle` 等) |
|
||||
| `layers` | string | — | 正面图层定义 |
|
||||
| `backLayers` | string | — | 背面图层定义 |
|
||||
| `fixed` | boolean | `false` | 固定模式(只读不可编辑) |
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
tag: md-dice
|
||||
icon: 🎲
|
||||
title: 骰子组件
|
||||
description: 点击文字执行掷骰,可用于属性检定、伤害投掷等场景。
|
||||
syntax: ':md-dice[2d6+d8]{key="attack"}'
|
||||
props:
|
||||
- name: key
|
||||
type: string
|
||||
desc: URL 参数标识,结果记录到 ?dice-key=15
|
||||
---
|
||||
|
||||
点击骰子图标执行掷骰,点击文字重置为公式,可用于属性检定、伤害投掷等场景。
|
||||
|
||||
**语法:** `:md-dice[公式]{key="标识"}`
|
||||
|
||||
**攻击检定:** :md-dice[1d20+5]{key="attack"}
|
||||
|
||||
**伤害掷骰:** :md-dice[2d6+3]{key="damage"}
|
||||
|
||||
**优势检定:** :md-dice[2d20k1+5]{key="advantage"}
|
||||
|
||||
点击掷骰文字执行投掷,再次点击重置为公式。
|
||||
点击 🎲 图标执行投掷,显示结果;点击文字重置为原始公式。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `key` | string | URL 参数标识,结果记录到 `?dice-key=15` |
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
tag: md-embed
|
||||
icon: 📄
|
||||
title: 嵌入组件
|
||||
description: 将另一个 Markdown 文件的内容内联嵌入到当前文档中。
|
||||
syntax: ':md-embed[./rules.md#combat]'
|
||||
props: []
|
||||
---
|
||||
|
||||
将另一个 Markdown 文件的内容内联嵌入到当前文档中。
|
||||
|
||||
**语法:** `:md-embed[路径#章节]{选项}`
|
||||
|
||||
**嵌入完整文档:**
|
||||
:md-embed[./rules.md]
|
||||
|
||||
@@ -15,6 +16,12 @@ props: []
|
||||
|
||||
嵌入后内容直接在当前位置显示,包括其中的表格、骰子等组件也会正常渲染。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `headingBase` | number | `0` | 提升嵌入内容中每一级标题的层级(如 `1` 将 `#` 提升为 `##`) |
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于在文档中引用通用规则、重复使用的数据表格等。
|
||||
+12
-14
@@ -2,22 +2,12 @@
|
||||
tag: md-font
|
||||
icon: 🔤
|
||||
title: 字体组件
|
||||
description: 设置整个文档的字体和文字颜色,支持 Google Fonts、emfont 和系统本地字体三种来源。
|
||||
syntax: ':md-font[Noto Sans SC]{source="google" weight="400" color="#ff00dd"}'
|
||||
props:
|
||||
- name: source
|
||||
type: google | emfont | local
|
||||
default: local
|
||||
desc: 字体来源
|
||||
- name: weight
|
||||
type: string
|
||||
default: '"400"'
|
||||
desc: 字体粗细
|
||||
- name: color
|
||||
type: string
|
||||
desc: 文字颜色(CSS 颜色值,如 #ff00dd、rgb(255,0,0))
|
||||
---
|
||||
|
||||
设置整个文档的字体和文字颜色,支持 Google Fonts、emfont 和系统本地字体三种来源。
|
||||
|
||||
**语法:** `:md-font[字体名]{选项}`
|
||||
|
||||
**Google 字体:**
|
||||
:md-font[Noto Sans SC]{source="google"}
|
||||
|
||||
@@ -34,6 +24,14 @@ props:
|
||||
|
||||
字体和颜色将应用到整个文档卡片。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `source` | `google` \| `emfont` \| `local` | `local` | 字体来源 |
|
||||
| `weight` | string | `"400"` | 字体粗细 |
|
||||
| `color` | string | — | 文字颜色(CSS 颜色值,如 `#ff00dd`、`rgb(255,0,0)`) |
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于切换文档的显示字体和文字颜色,适配不同风格需求。
|
||||
@@ -2,11 +2,12 @@
|
||||
tag: md-link
|
||||
icon: 🔗
|
||||
title: 链接组件
|
||||
description: 点击链接在当前页面内展开显示目标文章内容,支持章节定位。
|
||||
syntax: ':md-link[./rules.md#combat]'
|
||||
props: []
|
||||
---
|
||||
|
||||
点击链接在当前页面内展开显示目标文章内容,支持章节定位。
|
||||
|
||||
**语法:** `:md-link[路径#章节]`
|
||||
|
||||
**展开完整文档:**
|
||||
:md-link[./rules.md]
|
||||
|
||||
|
||||
+17
-16
@@ -2,23 +2,12 @@
|
||||
tag: md-pins
|
||||
icon: 📍
|
||||
title: 标记组件
|
||||
description: 在地图或图片上添加可编辑或固定的位置标记,支持字母或数字标签。
|
||||
syntax: ':md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}'
|
||||
props:
|
||||
- name: pins
|
||||
type: string
|
||||
default: '""'
|
||||
desc: 标记列表,格式 "A:x,y B:x,y"
|
||||
- name: fixed
|
||||
type: boolean
|
||||
default: "false"
|
||||
desc: 固定模式(只读不可编辑)
|
||||
- name: labelStart
|
||||
type: string
|
||||
default: '"A"'
|
||||
desc: 标签起始值,支持字母或数字
|
||||
---
|
||||
|
||||
在地图或图片上添加可编辑或固定的位置标记,支持字母或数字标签。
|
||||
|
||||
**语法:** `:md-pins[图片路径]{选项}`
|
||||
|
||||
**固定标记(只读):**
|
||||
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30" fixed}
|
||||
|
||||
@@ -28,7 +17,19 @@ props:
|
||||
**数字标签:**
|
||||
:md-pins[./images/dungeon.png]{labelStart="1"}
|
||||
|
||||
非 fixed 模式下点击图片添加标记,点击标记删除。
|
||||
非 `fixed` 模式下点击图片添加标记,点击标记删除。
|
||||
|
||||
## 复制坐标
|
||||
|
||||
非 `fixed` 模式下,图片右上角会显示 📋 复制按钮,点击可将所有标记的坐标复制到剪贴板,方便粘贴回文档的 `pins` 属性中。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `pins` | string | `""` | 标记列表,格式 `A:x,y B:x,y` |
|
||||
| `fixed` | boolean | `false` | 固定模式(只读不可编辑) |
|
||||
| `labelStart` | string | `"A"` | 标签起始值,支持字母或数字 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
+37
-10
@@ -2,17 +2,12 @@
|
||||
tag: md-table
|
||||
icon: 📊
|
||||
title: 表格组件
|
||||
description: 将 CSV 数据转换为可切换标签页的表格,支持随机抽取和变量引用。
|
||||
syntax: ':md-table[./data.csv]{roll=true remix=true}'
|
||||
props:
|
||||
- name: roll
|
||||
type: boolean
|
||||
desc: 显示随机切换按钮
|
||||
- name: remix
|
||||
type: boolean
|
||||
desc: 支持 {{prop}} 引用同行其他列
|
||||
---
|
||||
|
||||
将 CSV 数据转换为可切换标签页的表格,支持内联 CSV、随机抽取、加权随机和变量引用。
|
||||
|
||||
**语法:** `:md-table[./data.csv]{选项}`
|
||||
|
||||
**基础表格:**
|
||||
```markdown
|
||||
:md-table[./npcs.csv]
|
||||
@@ -28,7 +23,39 @@ props:
|
||||
:md-table[./quests.csv]{roll=true remix=true}
|
||||
```
|
||||
|
||||
CSV 要求包含 label, body 列,可选 group 列分组。支持 YAML front matter 继承行属性。
|
||||
**内联 CSV(直接写入内容):**
|
||||
```markdown
|
||||
:md-table[label,body,group
|
||||
A,这是一段描述,第一章
|
||||
B,另一段描述,第二章]{}
|
||||
```
|
||||
|
||||
支持通过文件路径加载 CSV,也可以直接将 CSV 数据内联写在指令中。CSV 要求包含 `label`、`body` 列,可选 `group` 列分组。
|
||||
|
||||
## 分组
|
||||
|
||||
当 CSV 包含 `group` 列时,表格顶部会显示分组标签页,可切换查看不同分组的数据。
|
||||
|
||||
## 随机抽取
|
||||
|
||||
当 `roll=true` 时,表格顶部显示 🎲 随机按钮:
|
||||
- 普通随机:从所有行中均匀随机选取
|
||||
- **加权随机**:当 `label` 列全部为整数或整数范围格式(如 `1-3`)时,以 label 值为权重进行加权随机抽取
|
||||
|
||||
## 变量引用
|
||||
|
||||
当 `remix=true` 时,`body` 列中可使用 `{{prop}}` 语法引用同行其他列或 YAML front matter 中的数据。开启 `remix` 后每次随机抽取会从所有行中随机选取一行来解析变量。
|
||||
|
||||
## YAML Front Matter
|
||||
|
||||
CSV 文件可包含 YAML front matter(文件开头的 `---` 块),其中定义的属性可通过 `{{prop}}` 在 `body` 中引用。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `roll` | boolean | 显示随机切换按钮 |
|
||||
| `remix` | boolean | 支持 `{{prop}}` 引用同行其他列 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
@@ -2,16 +2,30 @@
|
||||
tag: md-yarn-spinner
|
||||
icon: 🧶
|
||||
title: 叙事线组件
|
||||
description: 展示 Yarn Spinner 格式的分支叙事结构,支持对话选择和分支。
|
||||
syntax: ':md-yarn-spinner[./story.yarn]'
|
||||
props: []
|
||||
---
|
||||
|
||||
展示 Yarn Spinner 格式的分支叙事结构,支持对话选择和分支。
|
||||
|
||||
**语法:** `:md-yarn-spinner[./story.yarn]{选项}`
|
||||
|
||||
**加载叙事文件:**
|
||||
:md-yarn-spinner[./story.yarn]
|
||||
|
||||
Yarn Spinner 是用于游戏对话系统的格式,支持选项、条件分支和变量。
|
||||
|
||||
## 交互方式
|
||||
|
||||
- **对话历史**:上半部分显示已进行的对话,说话者以蓝色粗体显示,命令以灰色斜体显示
|
||||
- **当前选项**:下半部分显示可选的对话选项,点击选项推进剧情
|
||||
- **⏩ 继续**:点击右上角继续按钮推进到下一段对话
|
||||
- **🔄 重新开始**:点击右上角重启按钮从头开始对话
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `start` | string | `"start"` | 起始节点名称 |
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于互动故事、分支对话、冒险剧本等。
|
||||
@@ -1,334 +0,0 @@
|
||||
import { createStore } from "solid-js/store";
|
||||
import type {
|
||||
CharacterStats,
|
||||
CharacterSaves,
|
||||
InventoryItem,
|
||||
MothershipCharacter,
|
||||
MothershipStoreState,
|
||||
VitalValue,
|
||||
StressValue,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 创建默认角色数据
|
||||
*/
|
||||
export function createDefaultCharacter(): MothershipCharacter {
|
||||
return {
|
||||
stats: {
|
||||
strength: 50,
|
||||
agility: 50,
|
||||
combat: 50,
|
||||
intellect: 50,
|
||||
},
|
||||
saves: {
|
||||
fear: 50,
|
||||
sanity: 50,
|
||||
body: 50,
|
||||
},
|
||||
skills: [],
|
||||
inventory: [],
|
||||
status: [],
|
||||
hp: { current: 0, max: 0 },
|
||||
stress: { current: 0, min: 0 },
|
||||
wounds: { current: 0, max: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制数值在 0-99 范围内
|
||||
*/
|
||||
function clampStat(value: number): number {
|
||||
return Math.max(0, Math.min(99, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制数值在指定范围内
|
||||
*/
|
||||
function clampValue(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<MothershipStoreState>({
|
||||
character: createDefaultCharacter(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新单个统计值
|
||||
*/
|
||||
export function setStat<K extends keyof CharacterStats>(
|
||||
key: K,
|
||||
value: number
|
||||
): void {
|
||||
setStore("character", "stats", key, clampStat(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新统计值
|
||||
*/
|
||||
export function setStats(stats: Partial<CharacterStats>): void {
|
||||
setStore("character", "stats", (prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
Object.entries(stats).map(([key, value]) => [key, clampStat(value)])
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个豁免值
|
||||
*/
|
||||
export function setSave<K extends keyof CharacterSaves>(
|
||||
key: K,
|
||||
value: number
|
||||
): void {
|
||||
setStore("character", "saves", key, clampStat(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新豁免值
|
||||
*/
|
||||
export function setSaves(saves: Partial<CharacterSaves>): void {
|
||||
setStore("character", "saves", (prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
Object.entries(saves).map(([key, value]) => [key, clampStat(value)])
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加技能
|
||||
*/
|
||||
export function addSkill(skill: string): void {
|
||||
setStore("character", "skills", (prev) => [...prev, skill]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除技能
|
||||
*/
|
||||
export function removeSkill(skill: string): void {
|
||||
setStore("character", "skills", (prev) =>
|
||||
prev.filter((s) => s !== skill)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置技能列表
|
||||
*/
|
||||
export function setSkills(skills: string[]): void {
|
||||
setStore("character", "skills", [...skills]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加物品到物品栏
|
||||
*/
|
||||
export function addInventoryItem(
|
||||
name: string,
|
||||
quantity: number = 1,
|
||||
attributes?: Record<string, any>
|
||||
): void {
|
||||
setStore("character", "inventory", (prev) => [
|
||||
...prev,
|
||||
{ name, quantity: Math.max(1, quantity), attributes },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从物品栏移除物品(通过名称)
|
||||
*/
|
||||
export function removeInventoryItem(name: string): void {
|
||||
setStore("character", "inventory", (prev) =>
|
||||
prev.filter((item) => item.name !== name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物品数量
|
||||
*/
|
||||
export function updateInventoryItemQuantity(
|
||||
name: string,
|
||||
quantity: number
|
||||
): void {
|
||||
setStore("character", "inventory", (prev) =>
|
||||
prev.map((item) =>
|
||||
item.name === name
|
||||
? { ...item, quantity: Math.max(0, quantity) }
|
||||
: item
|
||||
).filter((item) => item.quantity > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物品属性
|
||||
*/
|
||||
export function updateInventoryItemAttributes(
|
||||
name: string,
|
||||
attributes: Record<string, any>
|
||||
): void {
|
||||
setStore("character", "inventory", (prev) =>
|
||||
prev.map((item) =>
|
||||
item.name === name
|
||||
? { ...item, attributes }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加状态效果
|
||||
*/
|
||||
export function addStatus(
|
||||
name: string,
|
||||
quantity: number = 1,
|
||||
attributes?: Record<string, any>
|
||||
): void {
|
||||
setStore("character", "status", (prev) => [
|
||||
...prev,
|
||||
{ name, quantity: Math.max(1, quantity), attributes },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除状态效果
|
||||
*/
|
||||
export function removeStatus(name: string): void {
|
||||
setStore("character", "status", (prev) =>
|
||||
prev.filter((item) => item.name !== name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 HP
|
||||
*/
|
||||
export function setHP(value: Partial<VitalValue>): void {
|
||||
setStore("character", "hp", (prev) => ({
|
||||
...prev,
|
||||
...value,
|
||||
current: value.current !== undefined
|
||||
? clampValue(value.current, 0, value.max ?? prev.max)
|
||||
: prev.current,
|
||||
max: value.max !== undefined
|
||||
? Math.max(0, value.max)
|
||||
: prev.max,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 受到伤害
|
||||
*/
|
||||
export function takeDamage(amount: number): void {
|
||||
setStore("character", "hp", (prev) => ({
|
||||
...prev,
|
||||
current: Math.max(0, prev.current - amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 治疗 HP
|
||||
*/
|
||||
export function healHP(amount: number): void {
|
||||
setStore("character", "hp", (prev) => ({
|
||||
...prev,
|
||||
current: Math.min(prev.max, prev.current + amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置压力值
|
||||
*/
|
||||
export function setStress(value: Partial<StressValue>): void {
|
||||
setStore("character", "stress", (prev) => ({
|
||||
...prev,
|
||||
...value,
|
||||
current: value.current !== undefined
|
||||
? clampValue(value.current, value.min ?? prev.min, Number.MAX_SAFE_INTEGER)
|
||||
: prev.current,
|
||||
min: value.min !== undefined ? Math.max(0, value.min) : prev.min,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加压力
|
||||
*/
|
||||
export function addStress(amount: number): void {
|
||||
setStore("character", "stress", (prev) => ({
|
||||
...prev,
|
||||
current: prev.current + amount,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少压力
|
||||
*/
|
||||
export function reduceStress(amount: number): void {
|
||||
setStore("character", "stress", (prev) => ({
|
||||
...prev,
|
||||
current: Math.max(prev.min, prev.current - amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置伤口值
|
||||
*/
|
||||
export function setWounds(value: Partial<VitalValue>): void {
|
||||
setStore("character", "wounds", (prev) => ({
|
||||
...prev,
|
||||
...value,
|
||||
current: value.current !== undefined
|
||||
? clampValue(value.current, 0, value.max ?? prev.max)
|
||||
: prev.current,
|
||||
max: value.max !== undefined
|
||||
? Math.max(0, value.max)
|
||||
: prev.max,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加伤口
|
||||
*/
|
||||
export function addWound(amount: number = 1): void {
|
||||
setStore("character", "wounds", (prev) => ({
|
||||
...prev,
|
||||
current: Math.min(prev.max, prev.current + amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 治疗伤口
|
||||
*/
|
||||
export function healWound(amount: number = 1): void {
|
||||
setStore("character", "wounds", (prev) => ({
|
||||
...prev,
|
||||
current: Math.max(0, prev.current - amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置角色到默认状态
|
||||
*/
|
||||
export function resetCharacter(): void {
|
||||
setStore("character", createDefaultCharacter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置完整角色数据
|
||||
*/
|
||||
export function setCharacter(character: MothershipCharacter): void {
|
||||
setStore("character", character);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前角色数据
|
||||
*/
|
||||
export function getCharacter(): MothershipCharacter {
|
||||
return store.character;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Store 订阅(用于 SolidJS 组件)
|
||||
*/
|
||||
export function useCharacterStore() {
|
||||
return store;
|
||||
}
|
||||
|
||||
export { store, setStore };
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Mothership TRPG 角色表 Store
|
||||
*
|
||||
* @module journals/mothership
|
||||
*/
|
||||
|
||||
export type {
|
||||
CharacterStats,
|
||||
CharacterSaves,
|
||||
InventoryItem,
|
||||
VitalValue,
|
||||
StressValue,
|
||||
MothershipCharacter,
|
||||
MothershipStoreState,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
// Store 核心
|
||||
store,
|
||||
setStore,
|
||||
useCharacterStore,
|
||||
getCharacter,
|
||||
|
||||
// 初始化
|
||||
createDefaultCharacter,
|
||||
resetCharacter,
|
||||
setCharacter,
|
||||
|
||||
// Stats 操作
|
||||
setStat,
|
||||
setStats,
|
||||
|
||||
// Saves 操作
|
||||
setSave,
|
||||
setSaves,
|
||||
|
||||
// Skills 操作
|
||||
addSkill,
|
||||
removeSkill,
|
||||
setSkills,
|
||||
|
||||
// Inventory 操作
|
||||
addInventoryItem,
|
||||
removeInventoryItem,
|
||||
updateInventoryItemQuantity,
|
||||
updateInventoryItemAttributes,
|
||||
|
||||
// Status 操作
|
||||
addStatus,
|
||||
removeStatus,
|
||||
|
||||
// HP 操作
|
||||
setHP,
|
||||
takeDamage,
|
||||
healHP,
|
||||
|
||||
// Stress 操作
|
||||
setStress,
|
||||
addStress,
|
||||
reduceStress,
|
||||
|
||||
// Wounds 操作
|
||||
setWounds,
|
||||
addWound,
|
||||
healWound,
|
||||
} from "./characterStore";
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Mothership TRPG 角色表统计值
|
||||
* 所有值范围为 0-99
|
||||
*/
|
||||
export interface CharacterStats {
|
||||
/** 力量 */
|
||||
strength: number;
|
||||
/** 敏捷 */
|
||||
agility: number;
|
||||
/** 战斗 */
|
||||
combat: number;
|
||||
/** 智力 */
|
||||
intellect: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mothership TRPG 角色表豁免值
|
||||
* 所有值范围为 0-99
|
||||
*/
|
||||
export interface CharacterSaves {
|
||||
/** 恐惧豁免 */
|
||||
fear: number;
|
||||
/** 理智豁免 */
|
||||
sanity: number;
|
||||
/** 体质豁免 */
|
||||
body: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品栏物品
|
||||
*/
|
||||
export interface InventoryItem {
|
||||
/** 物品名称 */
|
||||
name: string;
|
||||
/** 数量 */
|
||||
quantity: number;
|
||||
/** 自定义属性,如护甲值 { ap: 3 } */
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命值/伤口等有最大值和当前值的属性
|
||||
*/
|
||||
export interface VitalValue {
|
||||
current: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压力值(有最小值和当前值)
|
||||
*/
|
||||
export interface StressValue {
|
||||
current: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mothership 角色表完整数据结构
|
||||
*/
|
||||
export interface MothershipCharacter {
|
||||
stats: CharacterStats;
|
||||
saves: CharacterSaves;
|
||||
skills: string[];
|
||||
inventory: InventoryItem[];
|
||||
status: InventoryItem[];
|
||||
hp: VitalValue;
|
||||
stress: StressValue;
|
||||
wounds: VitalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store 状态类型
|
||||
*/
|
||||
export type MothershipStoreState = {
|
||||
character: MothershipCharacter;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Fenced code block attribute parsing — shared by the content scanner
|
||||
* (CLI + browser registry) and the markdown render extensions.
|
||||
*
|
||||
* Parses info strings like:
|
||||
* ```lang id=xxx role=xxx as=xxx key=value
|
||||
*/
|
||||
|
||||
export interface BlockAttrs {
|
||||
lang: string;
|
||||
id?: string;
|
||||
role?: string;
|
||||
/** Any other attributes not in the standard set */
|
||||
extra: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 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, "");
|
||||
}
|
||||
|
||||
// `as` was a render-target override; roles now fully determine behavior,
|
||||
// so it is parsed out and ignored (kept out of `extra` for directives).
|
||||
const { lang, id, role, as: _legacyAs, ...extra } = attrs;
|
||||
return { lang: lang || "", id, role, extra };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user