Compare commits

..
13 Commits
Author SHA1 Message Date
hypercross c81cf221ef docs: correct stale routing and url references
Document that the CLI mounts the content directory at the URL root and
that the app uses History API routing, not the old /content hash scheme.
Replace the removed <Article> iconPath prop with the actual upward
assets/ lookup, and fix the deck_preview preview_url example.
2026-09-13 14:24:34 +08:00
hypercross e0e7250180 fix(dev): proxy content assets to cli server
The CLI content server serves the content directory at the URL root, so
images and other content assets are requested as root-relative paths
(e.g. /adventures/map.png). The dev proxy only forwarded /content, so
those requests fell through to historyApiFallback and the browser got
index.html instead of the asset.

Forward any request that maps to a real file under content/ to the CLI
server, keeping /static/ bundles on the web dev server.
2026-09-13 14:24:29 +08:00
hypercross 12f8d8542c feat(ui): make desktop sidebar collapsible
Add a header toggle and a panel close button so the desktop file
sidebar collapses with a width transition, mirroring the journal
panel.

- split onClose (close button) from onNavigate (file selection) so
  clicking a file no longer collapses the sidebar on desktop
- give the aside h-full so the file tree and outline keep bounded
  scroll regions inside the new wrapper
- swap the aside shadow for a gray right border to match the journal
2026-09-13 14:09:18 +08:00
hypercross 49bed7d279 docs: add icon migration reference
Record the Tabler pack choice, icon conventions, and the per-file
glyph-to-icon mapping for the unicode-to-solid-icons migration.
2026-09-13 14:01:10 +08:00
hypercross 40b6fbe157 feat(ui): replace unicode icons with solid-icons
Swap emoji and unicode glyphs for Tabler components so icons inherit
currentColor and font-size and render consistently across platforms.

- add src/components/doc-icons.ts to resolve doc-entry frontmatter icon
  keys to components; frontmatter now stores semantic keys
- replace the align-*.png assets with TbOutlineAlign* icons
- render RevealManager action SVGs as components instead of injected
  innerHTML strings
- carry completion icons via an optional CompletionItem.icon field
2026-09-13 14:01:03 +08:00
hypercross b5f25e6b60 build(deps): add solid-icons 2026-09-13 14:00:52 +08:00
hypercross be247cd48c chore: pin generated reference docs to LF
The generator emits LF, but with core.autocrlf=true git checked the files out as CRLF, causing the drift guard in known.test.ts to fail on Windows.
2026-09-13 13:43:54 +08:00
hypercross c3cdc724bd docs(skills): add git-commits skill
Document Conventional Commits style and one-commit-per-concern splitting for this project.
2026-09-13 13:42:00 +08:00
hypercross 421af125bb feat(journal): delete sessions from the ui
Add a confirmation dialog that spells out the removal is irreversible, and expose delete from the GM session dropdown and the lobby session list.
2026-09-13 13:36:42 +08:00
hypercross a65ca2ab93 fix(session): validate ids and repair deletion
Validate the id extracted from ttrpg/+/stream and ttrpg/+/meta before it is used to build a path, closing a traversal where a .. topic level (or ..\.. on Windows) could escape the sessions directory and delete the whole data root. Meta writes now go through the schema, so malformed payloads are ignored instead of deleting.

Deletion was also a no-op: an empty payload threw in JSON.parse before the delete branch ran, so sessions were never removed. Check for an empty/null payload before parsing. The worker validates ids and surfaces publish/connection failures.
2026-09-13 13:36:38 +08:00
hypercross 68b976575a feat(session): add shared session id validation
Session ids become directory names server-side and topic levels on the broker, so they must reject path separators, ./.., whitespace and Windows reserved device names. toSessionId derives a safe id from a display name; isValidSessionId is the boundary check shared by the CLI and the web client.
2026-09-13 13:36:33 +08:00
hypercross 30ec8a43cf fix(journal): decouple broker from session
The broker connection now persists across session changes. Joining or switching a session swaps subscriptions instead of reconnecting, and a lobby lets sessions be listed and created before joining one.

Fixes session switching being a no-op (the store only updated local state) and session creation being unreachable before connecting. Also removes the dead ConnectionBar and the no-op hydrateFromServer/setSessionId store calls.
2026-09-13 13:23:50 +08:00
hypercross 59f87bffda fix(journal): make session dirs source of truth
Replaces the central manifest.json with per-session meta.json so the filesystem is the single source of truth. Metadata writes are now atomic (temp file + fsync + rename), startup adopts orphan dirs, corrupt meta is quarantined instead of silently wiped, truncated stream tails are repaired, and appends to unknown sessions are dropped so deleted sessions cannot be resurrected.

Existing manifests are migrated to per-session metadata on first run.
2026-09-13 13:17:14 +08:00
71 changed files with 2006 additions and 598 deletions
+68
View File
@@ -0,0 +1,68 @@
---
name: git-commits
description: Write Git commit messages for this project using Conventional Commits, splitting work into one commit per concern. Use when staging changes, writing or amending commit messages, or deciding how to break a set of changes into commits.
---
# Git Commits
Use this skill whenever you write a commit message or decide how to group changes into commits.
## Commit message style: Conventional Commits
Every commit message must follow the Conventional Commits format:
```
<type>(<optional scope>): <description>
<optional body>
<optional footer(s)>
```
### Types
- `feat`: a new feature
- `fix`: a bug fix
- `docs`: documentation only
- `style`: formatting, whitespace, or other changes that do not affect behavior
- `refactor`: a code change that neither fixes a bug nor adds a feature
- `perf`: a performance improvement
- `test`: adding or correcting tests
- `build`: changes to the build system or dependencies
- `ci`: changes to CI configuration and scripts
- `chore`: other changes that do not modify source or tests
- `revert`: revert a previous commit
### Rules
- Use the imperative mood in the description ("add", not "added" or "adds").
- Keep the description lowercase and do not end it with a period.
- Limit the subject line to 50 characters where practical; never exceed 72.
- Add a scope in parentheses when it clarifies the area of change, e.g. `fix(parser): ...`.
- Separate the subject from the body with a blank line.
- Use the body only when it adds useful information; do not restate the subject.
- Wrap the body at 72 characters.
- Reference issues in the footer when relevant, e.g. `Closes #123`.
- Mark breaking changes with `!` after the type/scope (e.g. `feat(api)!: ...`) and explain them in a `BREAKING CHANGE:` footer.
## Separate commits by concern
Do not bundle unrelated changes into a single commit. Split work so that each commit represents one logical concern:
- One type of change per commit (a feature and its unrelated refactor are separate commits).
- Keep formatting-only changes separate from behavior changes.
- Keep test additions separate from the implementation they cover when they are independently meaningful, but do not split so finely that a commit is not self-contained.
- Each commit should build and pass tests on its own where feasible.
- Stage files selectively (e.g. `git add -p`, `git add <path>`) to isolate concerns rather than committing everything at once.
When a set of changes spans multiple concerns, propose the split to the user before committing.
## Workflow
1. Inspect what changed with `git --no-optional-locks status` and `git --no-pager diff`.
2. Group the changes by concern.
3. For each group, stage only the relevant files or hunks.
4. Write a Conventional Commits message for that group.
5. Repeat until all changes are committed.
Do not commit or create branches unless the user explicitly asks.
+6
View File
@@ -0,0 +1,6 @@
# Generated reference docs are emitted with LF by scripts/generate-reference.ts.
# Pin them to LF so the drift guard in src/cli/validate/known.test.ts compares
# like-for-like on Windows, where core.autocrlf would otherwise check them out
# as CRLF.
docs/reference.md text eol=lf
.agents/skills/tttk-authoring/reference.md text eol=lf
+20 -13
View File
@@ -39,14 +39,14 @@ tttk serve [dir] -p 3000
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `-p, --port <port>` | 端口号 | `3000` |
| `-h, --host <host>` | 主机地址 | `0.0.0.0` |
| `--host <host>` | 主机地址 | `0.0.0.0` |
**功能:**
- 扫描目录下的所有 `.md`、`.csv`、`.yarn` 文件
- 为每个文件创建路由
- 提供实时文件监听,修改后自动刷新
- 通过 `/__CONTENT_INDEX.json` 提供文件索引
- 扫描目录下的 `.md`、`.csv`、`.yarn`、`.svg` 文件(跳过以 `.` 开头的文件和 `node_modules`)
- 把内容目录挂在 URL 根路径下,文档 `rules/combat.md` 对应路由 `/rules/combat`
- 监听文件变化并重建内存中的内容注册表
- 提供 `/__CONTENT_INDEX.json`、`/__CONTENT_REGISTRY.json`、`/__COMPLETIONS.json` 等接口(见[开发服务器特性](#开发服务器特性))
**示例:**
@@ -218,20 +218,27 @@ my-ttrpg-content/
## 开发服务器特性
### 实时索引
### 内容接口
访问 `http://localhost:3000/__CONTENT_INDEX.json` 可获取当前内容目录的文件索引。
- `/__CONTENT_INDEX.json` — 原始文件索引(`{ 路径: 文件内容 }`)
- `/__CONTENT_REGISTRY.json` — 内容注册表(文件路径索引 + 文档内联内容)
- `/__COMPLETIONS.json` — 补全数据(骰点、链接、spark 表格、声明、标签修饰符)
### 自动刷新
### 内容目录与 URL 的对应关系
文件变化时,服务器会自动重新加载内容,无需手动刷新页面。
内容目录挂在 URL 根路径下,文档去掉 `.md` 扩展名后即为路由:
- 文档 `rules/combat.md` → `http://localhost:3000/rules/combat`
- 图片 `rules/maps/city.png` → `http://localhost:3000/rules/maps/city.png`
### 文件监听
服务器会监听内容目录的变化并重建内存中的注册表;重新加载页面即可看到最新内容。
### SPA 路由
使用 hash 路由支持单页应用导航:
- `/content/rules/combat.md` → `#/content/rules/combat.md`
- 支持浏览器前进/后退
路由基于 History API(而非 hash)。直接访问深层链接或刷新页面时,未知路径会回退到 `index.html`,
因此浏览器前进/后退与深链接均可正常工作。
## 常见问题
+7 -2
View File
@@ -252,8 +252,13 @@ npm test -- --watch
- Web 前端:rsbuild 开发服务器,端口 **5173**
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/content`、`/.ttrpg` 代理到 CLI 的
3000 端口,因此必须先启动 CLI,否则前端拿不到内容。
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/.ttrpg`
以及内容目录中的静态资源(图片、CSS、CSV 等)代理到 CLI 的 3000 端口,因此必须先启动 CLI,
否则前端拿不到内容。
CLI 把内容目录挂在 URL 根路径下(例如 `/adventures/map.png`,**不是** `/content/...`),
所以 rsbuild 会按“请求路径是否对应内容目录中的真实文件”来决定是否代理;`/static/` 下的
前端构建产物仍由 web dev server 自己提供。
### Web 开发
+191
View File
@@ -0,0 +1,191 @@
# Icon migration — unicode/emoji → `solid-icons`
**Status: implemented.** This document is both the audit and the record of the
migration. It lists the pack/conventions used and the per-file mapping.
## Goal
Replace ad-hoc unicode symbols and emoji in **UI chrome** with real SVG icon
components from [`solid-icons`](https://solid-icons.vercel.app), so icons are
consistent, themeable (`currentColor`, `size`), and render identically across
platforms (emoji fonts vary wildly between OSes).
## Scope
**In scope — UI chrome** (`src/components/**`, `src/App.tsx`, doc-entry
frontmatter, `RevealManager` inline SVGs, the editor-panel align PNGs).
**Out of scope — left as-is:**
| Thing | Why |
|---|---|
| `:[icon-name]` content directive (`src/markdown/icon.ts`, `assets/`) | Game-content icons resolved from user `assets/` dirs via CSS `background-image`. Not UI chrome. |
| CLI console output (`✅ ❌ 📦` in `src/cli/**`) | Terminal output, not a browser. `solid-icons` is a SolidJS/browser library. |
| Emoji in `README.md`, `docs/*.md` prose | Documentation text, not rendered UI. |
| Dimension text `×` in `PltPreview` / `PrintPreviewHeader` | A multiplication sign in "100mm × 50mm", not an icon. |
| `content/**` sample content | User-authored data. |
| `PltPreview.tsx` / `PrintPreview.tsx` `<svg>` | Rendered card/page artwork, not icons. |
## Pack
**Tabler** (`solid-icons/tb`). `solid-icons@1.2.0` does **not** ship Lucide; the
available packs are `ai, bi, bs, cg, fa, fi, hi, im, io, md, oc, ri, si, tb, ti,
vs, wi`. Tabler was chosen because it is stroke-based, uses `currentColor`, and
covers everything needed (dice, ruler, grip, alignment, arrows, …).
> Exports are prefixed `TbOutline*` (outline) and `TbFill*` (filled). Always use
> `TbOutline*`.
## Conventions
```tsx
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
<TbOutlineFolderOpen size={16} class="text-gray-400" />
```
- `size?: string | number` defaults to `"1em"`; `color` defaults to
`currentColor`. Icons therefore inherit the surrounding font-size and text
color, so existing Tailwind text-color/size classes keep working.
- **Inline alignment:** an inline SVG sits on the text baseline, so icons placed
inline next to text get `class="inline-block align-[-0.125em]"`. Icons inside
a `flex`/`inline-flex` container don't need it.
- **Fixed-size buttons** get an explicit `size` (`16` for `w-7 h-7`/`w-8 h-8`,
`14` for `w-5 h-5`/`w-6 h-6`).
- Text labels next to a glyph (e.g. `📥 导出 PDF`) are preserved; only the glyph
is replaced.
## Shared modules
- **`src/components/doc-icons.ts`** — maps doc-entry frontmatter `icon:` keys
(e.g. `dice`, `table`) to Tabler components. Frontmatter is YAML, so it can't
hold a component; it stores a semantic key instead. `docIcon(key)` falls back
to `TbOutlineFile`.
- **`CompletionItem.icon`** (`src/components/journal/command-parser.ts`) — an
optional `IconTypes` rendered by `CompletionsDropdown.tsx` before the label,
so completion entries carry real icons instead of emoji in the label string.
## Inventory
### Shell / navigation
| File | Glyph | Purpose | Icon |
|---|---|---|---|
| `src/App.tsx` | `☰` | mobile menu | `TbOutlineMenu2` |
| `src/App.tsx` | `📋` | journal panel toggle | `TbOutlineClipboardList` |
| `src/App.tsx` | `?` | docs/help | `TbOutlineHelpCircle` |
| `src/components/Sidebar.tsx` | `📂` | content source | `TbOutlineFolderOpen` |
| `src/components/Sidebar.tsx` | `✕` | close sidebar | `TbOutlineX` |
| `src/components/FileTree.tsx` | `📂` / `📁` | folder open / closed | `TbOutlineFolderOpen` / `TbOutlineFolder` |
| `src/components/FileTree.tsx` | `📄` | file | `TbOutlineFile` |
| `src/components/FileTree.tsx` | `▶` | expand chevron | `TbOutlineChevronRight` |
| `src/components/DocDialog.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
| `src/components/DocDialog.tsx` | `📖` | directives doc set | `TbOutlineBook2` |
| `src/components/DocDialog.tsx` | `✓` | selected doc set | `TbOutlineCheck` |
| `src/components/DocDialog.tsx` | `📋` | journal doc set | `TbOutlineClipboardList` |
| `src/components/DocDialog.tsx` | `✕` | close dialog | `TbOutlineX` |
| `src/components/DocDialog.tsx` | entry `icon:` | doc entry icon | `<Dynamic component={docIcon(entry.icon)} />` |
### md-* components
| File | Glyph | Purpose | Icon |
|---|---|---|---|
| `src/components/md-dice.tsx` | `🎲` | dice reset | `TbOutlineDice5` |
| `src/components/md-table.tsx` | `🎲` | random row | `TbOutlineDice5` |
| `src/components/md-pins.tsx` | `📋` | copy pin coords | `TbOutlineCopy` |
| `src/components/md-yarn-spinner.tsx` | `🔄` | restart | `TbOutlineRotate` |
| `src/components/md-yarn-spinner.tsx` | `⏩` | advance | `TbOutlinePlayerTrackNext` |
| `src/components/md-yarn-spinner.tsx` | `→` | option bullet | `TbOutlineArrowRight` |
### Journal
| File | Glyph | Purpose | Icon |
|---|---|---|---|
| `src/components/journal/ConnectDialog.tsx` | `🗑` | delete session | `TbOutlineTrash` |
| `src/components/journal/CreateSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
| `src/components/journal/DeleteSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
| `src/components/journal/ErrorPopup.tsx` | `✕` | close | `TbOutlineX` |
| `src/components/journal/InviteDialog.tsx` | `✕` | close | `TbOutlineX` |
| `src/components/journal/JournalHeader.tsx` | `←` | back to session list | `TbOutlineArrowLeft` |
| `src/components/journal/JournalHeader.tsx` | `▸` | hide panel | `TbOutlineLayoutSidebarRightCollapse` |
| `src/components/journal/SessionDropdown.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
| `src/components/journal/SessionDropdown.tsx` | `✓` | active session | `TbOutlineCheck` |
| `src/components/journal/SessionDropdown.tsx` | `🗑` | delete session | `TbOutlineTrash` |
| `src/components/journal/StreamMessage.tsx` | `×` | revert message | `TbOutlineArrowBackUp` |
| `src/components/journal/types/intent.tsx` | `🙋` | intent | `TbOutlineHandStop` |
| `src/components/journal/types/intent.tsx` | `✅` | verdict | `TbOutlineCircleCheck` |
| `src/components/journal/types/link.tsx` | `📄` | link | `TbOutlineFileText` |
| `src/components/journal/types/roll.tsx` | `🎲` | roll | `TbOutlineDice5` |
| `src/components/journal/types/roll.tsx` | `→` | notation → total | `TbOutlineArrowRight` |
| `src/components/journal/types/var.tsx` | `🔢` | variable | `TbOutlineHash` |
| `src/components/journal/command-completions.ts` | `🎲` | dice completion | `TbOutlineDice5` (via `item.icon`) |
| `src/components/journal/command-completions.ts` | `✨` | spark completion | `TbOutlineSparkles` (via `item.icon`) |
### md-deck
| File | Glyph | Purpose | Icon |
|---|---|---|---|
| `src/components/md-deck/DeckHeader.tsx` | `✓` / `✏️` | editing toggle | `TbOutlineCheck` / `TbOutlinePencil` |
| `src/components/md-deck/DeckHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
| `src/components/md-deck/DeckHeader.tsx` | `📋` | copy code | `TbOutlineCopy` |
| `src/components/md-deck/PltPreview.tsx` | `📥` | download PLT | `TbOutlineDownload` |
| `src/components/md-deck/PrintPreviewFooter.tsx` | `❌` | export error | `TbOutlineCircleX` |
| `src/components/md-deck/PrintPreviewFooter.tsx` | `✕` | close | `TbOutlineX` |
| `src/components/md-deck/PrintPreviewHeader.tsx` | `➕` | add deck | `TbOutlinePlus` |
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📐` | PLT preview | `TbOutlineRuler` |
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
| `src/components/md-deck/CardList.tsx` | `‹` | previous page | `TbOutlineChevronLeft` |
| `src/components/md-deck/CardList.tsx` | `›` | next page | `TbOutlineChevronRight` |
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `⠿` | drag handle | `TbOutlineGripVertical` |
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `👁` / `👁‍🗨` | visible / hidden | `TbOutlineEye` / `TbOutlineEyeOff` |
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↑ → ↓ ←` | orientation | `TbOutlineArrowUp` / `TbOutlineArrowRight` / `TbOutlineArrowDown` / `TbOutlineArrowLeft` |
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↖ ↑ ↗ ↙ ↓ ↘` | alignment | `TbOutlineArrowUpLeft` / `TbOutlineArrowUp` / `TbOutlineArrowUpRight` / `TbOutlineArrowDownLeft` / `TbOutlineArrowDown` / `TbOutlineArrowDownRight` |
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `✕` | delete layer | `TbOutlineTrash` |
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `align-*.png` | align presets | `TbOutlineAlignLeft` / `TbOutlineAlignCenter` / `TbOutlineAlignRight` |
The three `align-*.png` assets were deleted; the `icons/` directory is gone.
### `RevealManager.tsx` (formerly inline SVG strings)
| Former | Purpose | Icon |
|---|---|---|
| `LINK_SVG` | floating `/link` action | `TbOutlineLink` |
| `SPARK_SVG` | floating `/roll` spark action | `TbOutlineSparkles` |
`FloatingButton.svg: string` (rendered via `innerHTML`) became
`FloatingButton.icon: IconTypes`, rendered as `<Dynamic component={icon} />`
children.
### `src/doc-entries/*.md` frontmatter `icon:`
| File / tag | Key |
|---|---|
| `journal-gm.md` | `gm` |
| `journal-link.md` | `link` |
| `journal-player.md` | `player` |
| `journal-roll.md` | `roll` |
| `journal-set.md` | `set` |
| `journal-spark.md` | `spark` |
| `md-bg.md` | `background` |
| `md-border.md` | `border` |
| `md-deck.md` | `deck` |
| `md-dice.md` | `dice` |
| `md-embed.md` | `embed` |
| `md-font.md` | `font` |
| `md-link.md` | `link` |
| `md-pins.md` | `pins` |
| `md-table.md` | `table` |
| `md-yarn-spinner.md` | `yarn` |
Keys are resolved through `docIcon()` in `src/components/doc-icons.ts`.
## Follow-ups / not migrated (by design)
- Doc-entry **body prose** that referenced a replaced button glyph was reworded
to name the button (e.g. `md-dice.md` now says "点击骰子图标"). Remaining emoji
in `src/doc-entries/*.md` are illustrative — coloured status dots
(`🟢🟡🔴⚪` in `journal-gm.md`) — not UI icons.
- CLI console / MCP output (`✅ ❌ 📦` in `src/cli/**`) keeps its emoji: it is
terminal text, not rendered UI.
- `command-completions.ts` `CompletionItem` now carries an optional `icon`; any
future completion source can use it.
+9 -9
View File
@@ -100,19 +100,19 @@
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
### 配置图标前缀
### 图标查找路径
可通过 `iconPath` 属性指定图标目录:
图标没有单独的配置项。`:[name]` 会从当前文档所在目录开始,逐级向上查找 `assets/` 目录,
直到内容目录根:
```markdown
<Article src="/content/page.md" iconPath="./assets/icons" />
```text
rules/combat.md 中的 :[attack]
→ rules/assets/attack.png
→ assets/attack.png
```
或使用空字符串禁用图标前缀:
```markdown
<Article src="/content/page.md" iconPath="" />
```
因此把图标放在内容目录(或其子目录)的 `assets/` 下即可。`tttk check` 会用同样的规则
校验图标是否存在(诊断 `TTTK-A001`)。
## 组件库
+3 -1
View File
@@ -246,10 +246,12 @@ ttrpg mcp generate-card-deck \
{
"success": true,
"message": "预览文件已创建",
"preview_url": "http://localhost:3000/#/content/cards.md"
"preview_url": "http://localhost:3000/cards"
}
```
`preview_url` 以服务器工作目录为基准,去掉了 `.md` 扩展名(需要 `tttk serve` 正在运行)。
## CSV 文件格式
CSV 文件使用 YAML frontmatter 定义模板和配置:
+10
View File
@@ -26,6 +26,7 @@
"mermaid": "^11.0.0",
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-icons": "^1.2.0",
"solid-js": "^1.9.3",
"uuid": "^14.0.1",
"ws": "^8.21.0",
@@ -9589,6 +9590,15 @@
"solid-js": "^1.9.3"
}
},
"node_modules/solid-icons": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/solid-icons/-/solid-icons-1.2.0.tgz",
"integrity": "sha512-yjQxWQMi9l19P5Af9vjsvalTMDFPDL4XD6etrUdNxNVhFp1bMYF0SFwUa5VYDfJ7u0SF3Qkz/F7BZxqcLUwklw==",
"license": "MIT",
"peerDependencies": {
"solid-js": "*"
}
},
"node_modules/solid-js": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
+1
View File
@@ -64,6 +64,7 @@
"mermaid": "^11.0.0",
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-icons": "^1.2.0",
"solid-js": "^1.9.3",
"uuid": "^14.0.1",
"ws": "^8.21.0",
+55 -7
View File
@@ -3,6 +3,43 @@ import { pluginBabel } from "@rsbuild/plugin-babel";
import { pluginSolid } from "@rsbuild/plugin-solid";
import tailwindcss from "@tailwindcss/postcss";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
import { statSync } from "node:fs";
import { join, resolve } from "node:path";
// The CLI content server (`npm run dev:cli`, i.e. `tttk serve ./content`) keeps
// port 3000. It serves the content directory at the URL *root*, so content
// assets are requested as root-relative paths (e.g. `/adventures/map.png`),
// not under `/content`.
const CLI_TARGET = "http://localhost:3000";
const CONTENT_DIR = resolve(process.cwd(), "content");
/**
* True when a request targets a real file inside the content directory.
*
* Mirrors the CLI server's static handling: only files are served, so a
* directory path falls through to the SPA fallback. Requests for content
* assets must be proxied to the CLI; otherwise they reach
* `historyApiFallback` and the browser receives `index.html` instead of the
* asset (which is why images failed to render in dev).
*/
function isContentFile(pathname: string): boolean {
// The web dev server owns its own bundled assets under /static/.
if (pathname.startsWith("/static/")) return false;
let relativePath: string;
try {
relativePath = decodeURIComponent(pathname).replace(/^\/+/, "");
} catch {
return false;
}
if (!relativePath) return false;
try {
return statSync(join(CONTENT_DIR, relativePath)).isFile();
} catch {
return false;
}
}
export default defineConfig({
plugins: [
@@ -51,14 +88,25 @@ export default defineConfig({
// Dev web server runs on its own port; the CLI content server keeps
// port 3000 (see the proxy targets below).
port: 5173,
proxy: {
// Proxy content API requests to the CLI dev server (npm run tttk)
"/__CONTENT_INDEX.json": "http://localhost:3000",
"/__COMPLETIONS.json": "http://localhost:3000",
"/__CONTENT_REGISTRY.json": "http://localhost:3000",
"/content": "http://localhost:3000",
"/.ttrpg": "http://localhost:3000",
proxy: [
{
// Content API requests served by the CLI dev server (npm run dev:cli).
context: [
"/__CONTENT_INDEX.json",
"/__COMPLETIONS.json",
"/__CONTENT_REGISTRY.json",
"/.ttrpg",
],
target: CLI_TARGET,
},
{
// Content assets (images, css, csv, …) live at the URL root on the CLI
// server, so forward any request that maps to a real file under
// content/ instead of letting it hit the SPA fallback.
context: (pathname: string) => isContentFile(pathname),
target: CLI_TARGET,
},
],
historyApiFallback: true,
},
output: {
+35 -5
View File
@@ -7,6 +7,13 @@ import {
createSignal,
} from "solid-js";
import { useLocation } from "@solidjs/router";
import {
TbOutlineMenu2,
TbOutlineClipboardList,
TbOutlineHelpCircle,
TbOutlineLayoutSidebarLeftCollapse,
TbOutlineLayoutSidebarLeftExpand,
} from "solid-icons/tb";
import { useJournalStream } from "./components/stores/journalStream";
// 导入组件以注册自定义元素
@@ -46,6 +53,7 @@ const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = createSignal(true);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
@@ -93,13 +101,25 @@ const App: Component = () => {
{/* Header */}
<header class="shrink-0 h-16 bg-white shadow z-30">
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
{/* 仅在移动端显示菜单按钮 */}
{/* 移动端:打开抽屉式侧边栏 */}
<button
onClick={() => setIsSidebarOpen(true)}
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
title="目录"
>
☰
<TbOutlineMenu2 class="inline-block align-[-0.125em]" />
</button>
{/* 桌面端:折叠/展开固定侧边栏 */}
<button
onClick={() => setIsDesktopSidebarOpen((v) => !v)}
class="hidden md:block text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
title={isDesktopSidebarOpen() ? "收起目录" : "展开目录"}
>
{isDesktopSidebarOpen() ? (
<TbOutlineLayoutSidebarLeftCollapse class="inline-block align-[-0.125em]" />
) : (
<TbOutlineLayoutSidebarLeftExpand class="inline-block align-[-0.125em]" />
)}
</button>
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
<div class="flex-1" />
@@ -122,14 +142,14 @@ const App: Component = () => {
"bg-red-500": stream.connectionStatus === "error",
}}
/>
📋
<TbOutlineClipboardList size={16} />
</button>
<button
onClick={() => setIsDocOpen(true)}
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
title="指令文档"
>
?
<TbOutlineHelpCircle size={16} />
</button>
</div>
</header>
@@ -145,12 +165,22 @@ const App: Component = () => {
{/* Body: sidebar + main + journal */}
<div class="flex flex-1 min-h-0">
{/* 桌面端固定侧边栏 (flex child) */}
{/* 桌面端侧边栏 — flex child,可折叠 */}
<div
class="hidden md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
classList={{
"md:w-64": isDesktopSidebarOpen(),
"md:w-0": !isDesktopSidebarOpen(),
}}
inert={!isDesktopSidebarOpen()}
>
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
onClose={() => setIsDesktopSidebarOpen(false)}
/>
</div>
{/* Main content — the scrollable region */}
<ScrollContainerCtx.Provider value={() => mainRef}>
+279
View File
@@ -0,0 +1,279 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import {
appendStream,
extractSessionId,
handleMetaChange,
metaPath,
migrateLegacyManifest,
repairStreamTail,
scanSessions,
sessionDir,
streamPath,
writeFileAtomic,
type SessionMeta,
} from "./journal";
function makeRoot(): string {
return mkdtempSync(join(tmpdir(), "ttrpg-journal-"));
}
function cleanup(root: string): void {
rmSync(root, { recursive: true, force: true });
}
describe("writeFileAtomic", () => {
test("writes content and leaves no temp file behind", () => {
const root = makeRoot();
const p = join(root, "meta.json");
writeFileAtomic(p, '{"a":1}');
expect(readFileSync(p, "utf-8")).toBe('{"a":1}');
expect(readdirSync(root)).toEqual(["meta.json"]);
cleanup(root);
});
});
describe("repairStreamTail", () => {
test("truncates a partial trailing line", () => {
const root = makeRoot();
const p = join(root, "stream.jsonl");
writeFileSync(p, '{"a":1}\n{"b":2');
repairStreamTail(p);
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
cleanup(root);
});
test("leaves a well-formed file untouched", () => {
const root = makeRoot();
const p = join(root, "stream.jsonl");
writeFileSync(p, '{"a":1}\n');
repairStreamTail(p);
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
cleanup(root);
});
});
describe("scanSessions", () => {
test("adopts a directory that has a stream but no meta", () => {
const root = makeRoot();
mkdirSync(sessionDir(root, "default"), { recursive: true });
writeFileSync(streamPath(root, "default"), '{"id":"gm-1"}\n');
const index = scanSessions(root);
expect(index["default"]).toMatchObject({ name: "default", players: [] });
expect(existsSync(metaPath(root, "default"))).toBe(true);
cleanup(root);
});
test("quarantines corrupt meta and adopts the session", () => {
const root = makeRoot();
const dir = sessionDir(root, "broken");
mkdirSync(dir, { recursive: true });
writeFileSync(metaPath(root, "broken"), "{ not json");
writeFileSync(streamPath(root, "broken"), '{"id":"gm-1"}\n');
const index = scanSessions(root);
expect(index["broken"]).toBeDefined();
const quarantined = readdirSync(dir).filter((f) =>
f.startsWith("meta.json.corrupt-"),
);
expect(quarantined).toHaveLength(1);
cleanup(root);
});
test("keeps a valid session that has no stream yet", () => {
const root = makeRoot();
mkdirSync(sessionDir(root, "test"), { recursive: true });
writeFileAtomic(
metaPath(root, "test"),
JSON.stringify({ name: "test", created: 1, players: [] }),
);
const index = scanSessions(root);
expect(index["test"]).toEqual({ name: "test", created: 1, players: [] });
cleanup(root);
});
});
describe("migrateLegacyManifest", () => {
test("splits a legacy manifest into per-session meta and renames it", () => {
const root = makeRoot();
writeFileSync(
join(root, "manifest.json"),
JSON.stringify({
sessions: {
aaa: { name: "aaa", created: 1, players: [] },
bbb: { name: "bbb", created: 2, players: [] },
},
}),
);
migrateLegacyManifest(root);
expect(existsSync(join(root, "manifest.json"))).toBe(false);
expect(existsSync(join(root, "manifest.json.migrated"))).toBe(true);
expect(existsSync(metaPath(root, "aaa"))).toBe(true);
expect(existsSync(metaPath(root, "bbb"))).toBe(true);
cleanup(root);
});
});
describe("appendStream", () => {
test("drops unknown sessions and appends known ones", () => {
const root = makeRoot();
const index = { known: { name: "known", created: 1, players: [] } };
mkdirSync(sessionDir(root, "known"), { recursive: true });
appendStream(root, "ghost", index, '{"x":1}');
expect(existsSync(streamPath(root, "ghost"))).toBe(false);
appendStream(root, "known", index, '{"x":1}');
expect(readFileSync(streamPath(root, "known"), "utf-8")).toBe('{"x":1}\n');
cleanup(root);
});
test("refuses to build a path from a malformed id", () => {
const root = makeRoot();
mkdirSync(join(root, "sessions"), { recursive: true });
const index: Record<string, SessionMeta> = {
"..": { name: "evil", created: 1, players: [] },
};
appendStream(root, "..", index, '{"x":1}');
expect(existsSync(join(root, "stream.jsonl"))).toBe(false);
cleanup(root);
});
});
describe("extractSessionId", () => {
test("returns a valid id from a session topic", () => {
expect(extractSessionId("ttrpg/my-game/stream")).toBe("my-game");
expect(extractSessionId("ttrpg/A_1/meta")).toBe("A_1");
});
test("rejects traversal, empty and malformed ids", () => {
expect(extractSessionId("ttrpg/../meta")).toBeNull();
expect(extractSessionId("ttrpg/./meta")).toBeNull();
expect(extractSessionId("ttrpg/..\\..\\evil/meta")).toBeNull();
expect(extractSessionId("ttrpg/a b/meta")).toBeNull();
expect(extractSessionId("ttrpg//meta")).toBeNull();
expect(extractSessionId("ttrpg/meta")).toBeNull();
});
});
describe("handleMetaChange", () => {
// Minimal broker stub: handleMetaChange only needs publish() for the
// manifest broadcast.
function makeBroker() {
const published: { topic: string; payload: string }[] = [];
const broker = {
publish: (
packet: { topic: string; payload: Buffer },
cb?: () => void,
) => {
published.push({
topic: packet.topic,
payload: packet.payload.toString(),
});
cb?.();
},
} as unknown as import("aedes").Aedes;
return { broker, published };
}
test("writes validated meta, updates the index and publishes the manifest", () => {
const root = makeRoot();
const index: Record<string, SessionMeta> = {};
const { broker, published } = makeBroker();
handleMetaChange(
root,
"game",
broker,
index,
JSON.stringify({ name: "Game", created: 10, players: ["alice"] }),
);
expect(index["game"]).toEqual({
name: "Game",
created: 10,
players: ["alice"],
});
expect(readFileSync(metaPath(root, "game"), "utf-8")).toContain("Game");
expect(published.some((p) => p.topic === "ttrpg/$SESSIONS")).toBe(true);
cleanup(root);
});
test("deletes a session on an empty payload", () => {
const root = makeRoot();
mkdirSync(sessionDir(root, "doomed"), { recursive: true });
writeFileAtomic(
metaPath(root, "doomed"),
JSON.stringify({ name: "Doomed", created: 1, players: [] }),
);
const index: Record<string, SessionMeta> = {
doomed: { name: "Doomed", created: 1, players: [] },
};
const { broker } = makeBroker();
handleMetaChange(root, "doomed", broker, index, "");
expect(index["doomed"]).toBeUndefined();
expect(existsSync(sessionDir(root, "doomed"))).toBe(false);
cleanup(root);
});
test("refuses malformed ids and never touches the filesystem", () => {
const root = makeRoot();
mkdirSync(join(root, "sessions"), { recursive: true });
const index: Record<string, SessionMeta> = {};
const { broker } = makeBroker();
handleMetaChange(root, "..", broker, index, JSON.stringify({ name: "evil" }));
handleMetaChange(root, ".", broker, index, "");
handleMetaChange(root, "..\\..\\evil", broker, index, "");
expect(existsSync(join(root, "meta.json"))).toBe(false);
expect(existsSync(join(root, "sessions", "meta.json"))).toBe(false);
expect(Object.keys(index)).toHaveLength(0);
cleanup(root);
});
test("ignores malformed meta without deleting the session", () => {
const root = makeRoot();
mkdirSync(sessionDir(root, "keep"), { recursive: true });
writeFileAtomic(
metaPath(root, "keep"),
JSON.stringify({ name: "Keep", created: 1, players: [] }),
);
const index: Record<string, SessionMeta> = {
keep: { name: "Keep", created: 1, players: [] },
};
const { broker } = makeBroker();
handleMetaChange(
root,
"keep",
broker,
index,
JSON.stringify({ players: "not-an-array" }),
);
expect(index["keep"]).toEqual({ name: "Keep", created: 1, players: [] });
expect(existsSync(metaPath(root, "keep"))).toBe(true);
cleanup(root);
});
});
+307 -83
View File
@@ -1,5 +1,5 @@
/**
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 会话清单
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 按会话元数据
*
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
* 使用 ws v8 内置的 createWebSocketStream。
@@ -9,31 +9,52 @@
*
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
* 以 MQTT 客户端身份连接自身。
*
* 存储模型:目录即事实来源。会话存在当且仅当
* `sessions/<id>/meta.json` 存在且可解析;不再维护中心化清单,
* 会话列表在内存中派生并通过保留主题 `ttrpg/$SESSIONS` 发布。
* 元数据写入使用「临时文件 + fsync + 原子重命名」,避免崩溃时
* 截断损坏;损坏的元数据会被隔离而非静默丢弃。
*
* 会话 ID 会直接决定文件系统路径,因此在 MQTT 边界严格校验
* (见 shared/session-id)。
*/
import { join, resolve } from "path";
import {
appendFileSync,
closeSync,
existsSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
rmdirSync,
unlinkSync,
writeFileSync,
readdirSync,
readSync,
renameSync,
rmSync,
statSync,
truncateSync,
writeSync,
} from "fs";
import type { Server as HttpServer } from "http";
import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws";
import { z } from "zod";
import { isValidSessionId } from "../shared/session-id.js";
// ---------------------------------------------------------------------------
// 类型定义
// ---------------------------------------------------------------------------
interface SessionMeta {
name: string;
created: number;
players: string[];
}
const SessionMetaSchema = z.object({
name: z.string().min(1),
created: z.number().default(() => Date.now()),
players: z.array(z.string()).default([]),
});
type SessionMeta = z.infer<typeof SessionMetaSchema>;
export type { SessionMeta };
interface SessionManifest {
sessions: Record<string, SessionMeta>;
@@ -49,42 +70,196 @@ export interface JournalServer {
// 路径工具函数
// ---------------------------------------------------------------------------
function dataDir(contentDir: string): string {
export function dataDir(contentDir: string): string {
const d = resolve(contentDir, ".ttrpg");
mkdirSync(d, { recursive: true });
return d;
}
function manifestPath(dataRoot: string): string {
return join(dataRoot, "manifest.json");
}
function sessionDir(dataRoot: string, id: string): string {
export function sessionDir(dataRoot: string, id: string): string {
return join(dataRoot, "sessions", id);
}
function streamPath(dataRoot: string, id: string): string {
export function metaPath(dataRoot: string, id: string): string {
return join(sessionDir(dataRoot, id), "meta.json");
}
export function streamPath(dataRoot: string, id: string): string {
return join(sessionDir(dataRoot, id), "stream.jsonl");
}
function loadManifest(dataRoot: string): SessionManifest {
const p = manifestPath(dataRoot);
if (!existsSync(p)) return { sessions: {} };
// ---------------------------------------------------------------------------
// 原子写入
// ---------------------------------------------------------------------------
let _tmpCounter = 0;
/**
* 原子写入:先写临时文件并 fsync,再重命名覆盖目标。
* rename 在同一文件系统上是原子操作(Windows 上由 libuv 以
* MOVEFILE_REPLACE_EXISTING 实现),因此崩溃时目标文件要么是旧内容,
* 要么是新内容,绝不会是截断的中间态。
*/
export function writeFileAtomic(path: string, data: string): void {
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${_tmpCounter++}`;
const fd = openSync(tmp, "w");
try {
return JSON.parse(readFileSync(p, "utf-8"));
} catch {
return { sessions: {} };
writeSync(fd, data);
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(tmp, path);
}
// ---------------------------------------------------------------------------
// 流文件修复
// ---------------------------------------------------------------------------
/**
* 修复崩溃时被截断的尾行:若文件不以换行结尾,则截断到最后一个换行。
* 否则后续追加会与残行粘连,产生一条损坏记录。
*/
export function repairStreamTail(path: string): void {
if (!existsSync(path)) return;
try {
const size = statSync(path).size;
if (size === 0) return;
const fd = openSync(path, "r");
const last = Buffer.alloc(1);
try {
readSync(fd, last, 0, 1, size - 1);
} finally {
closeSync(fd);
}
if (last[0] === 0x0a) return;
const buf = readFileSync(path);
const lastNl = buf.lastIndexOf(0x0a);
truncateSync(path, lastNl === -1 ? 0 : lastNl + 1);
console.warn(`[日志] 已修复截断的流尾行:${path}`);
} catch (e) {
console.error(`[日志] 修复流文件失败:${path}`, e);
}
}
function saveManifest(dataRoot: string, m: SessionManifest): void {
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
// ---------------------------------------------------------------------------
// 会话扫描 / 迁移
// ---------------------------------------------------------------------------
function safeMtime(path: string): number {
try {
return statSync(path).mtimeMs;
} catch {
return Date.now();
}
}
function appendStream(dataRoot: string, id: string, line: string): void {
const sd = sessionDir(dataRoot, id);
mkdirSync(sd, { recursive: true });
appendFileSync(streamPath(dataRoot, id), line + "\n", "utf-8");
/**
* 扫描 sessions/ 目录,构建内存索引。
*
* - 有合法 meta.json 的目录 → 直接纳入索引;
* - meta.json 损坏 → 隔离为 meta.json.corrupt-<ts>,再尝试接管;
* - 无 meta.json 但有 stream.jsonl → 接管(用目录名与流文件 mtime 合成元数据);
* - 其余目录 → 忽略。
*/
export function scanSessions(dataRoot: string): Record<string, SessionMeta> {
const sessionsRoot = join(dataRoot, "sessions");
mkdirSync(sessionsRoot, { recursive: true });
const index: Record<string, SessionMeta> = {};
for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const id = entry.name;
if (!isValidSessionId(id)) {
console.warn(`[日志] 跳过非法会话目录:${id}`);
continue;
}
const mp = metaPath(dataRoot, id);
const sp = streamPath(dataRoot, id);
if (existsSync(mp)) {
try {
const meta = SessionMetaSchema.parse(
JSON.parse(readFileSync(mp, "utf-8")),
);
index[id] = meta;
repairStreamTail(sp);
continue;
} catch (e) {
const quarantine = `${mp}.corrupt-${Date.now()}`;
try {
renameSync(mp, quarantine);
console.error(
`[日志] 会话 ${id} 元数据损坏,已隔离到 ${quarantine}:`,
e,
);
} catch (renameErr) {
console.error(`[日志] 会话 ${id} 元数据损坏且隔离失败:`, renameErr);
}
}
}
if (existsSync(sp)) {
const meta: SessionMeta = {
name: id,
created: safeMtime(sp),
players: [],
};
try {
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
index[id] = meta;
repairStreamTail(sp);
console.log(`[日志] 已接管孤立会话:${id}`);
} catch (e) {
console.error(`[日志] 接管孤立会话失败:${id}`, e);
}
}
}
return index;
}
/**
* 迁移旧的中心化 manifest.json:把每个会话条目拆分为
* sessions/<id>/meta.json,然后把旧文件重命名为 manifest.json.migrated。
* 已存在 meta.json 的会话会被跳过(支持中断后重跑)。
*/
export function migrateLegacyManifest(dataRoot: string): void {
const legacy = join(dataRoot, "manifest.json");
if (!existsSync(legacy)) return;
try {
const parsed = JSON.parse(readFileSync(legacy, "utf-8")) as SessionManifest;
const sessions = parsed?.sessions ?? {};
let migrated = 0;
for (const [id, raw] of Object.entries(sessions)) {
if (!isValidSessionId(id)) {
console.warn(`[日志] 跳过非法会话 ID:${id}`);
continue;
}
const mp = metaPath(dataRoot, id);
if (existsSync(mp)) continue;
try {
const meta = SessionMetaSchema.parse(raw);
mkdirSync(sessionDir(dataRoot, id), { recursive: true });
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
migrated++;
} catch (e) {
console.error(`[日志] 跳过无法迁移的会话 ${id}:`, e);
}
}
renameSync(legacy, `${legacy}.migrated`);
console.log(
`[日志] 已迁移 ${migrated} 个会话到按会话元数据;旧清单重命名为 manifest.json.migrated`,
);
} catch (e) {
console.error("[日志] 旧清单迁移失败,保留原文件:", e);
}
}
// ---------------------------------------------------------------------------
@@ -93,6 +268,24 @@ function appendStream(dataRoot: string, id: string, line: string): void {
const $SESSIONS = "ttrpg/$SESSIONS";
function publishManifest(
broker: import("aedes").Aedes,
index: Record<string, SessionMeta>,
): void {
const manifest: SessionManifest = { sessions: index };
broker.publish(
{
topic: $SESSIONS,
payload: Buffer.from(JSON.stringify(manifest)),
qos: 1,
retain: true,
cmd: "publish" as const,
dup: false,
},
() => {},
);
}
export async function createJournalServer(
contentDir: string,
httpServer: HttpServer,
@@ -100,6 +293,11 @@ export async function createJournalServer(
const root = dataDir(contentDir);
console.log(`[日志] 数据目录:${root}`);
// 迁移旧清单并扫描现有会话,构建内存索引(目录即事实来源)
migrateLegacyManifest(root);
const index = scanSessions(root);
console.log(`[日志] 已加载 ${Object.keys(index).length} 个会话`);
// ---- MQTT 代理 ----
const { Aedes: AedesFactory } = await import("aedes");
// aedes 需要先 listen() 初始化持久化,再处理连接
@@ -128,7 +326,7 @@ export async function createJournalServer(
const sessionId = extractSessionId(packet.topic);
if (sessionId) {
try {
appendStream(root, sessionId, packet.payload.toString());
appendStream(root, sessionId, index, packet.payload.toString());
} catch (e) {
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
}
@@ -143,7 +341,13 @@ export async function createJournalServer(
(packet, cb) => {
const sessionId = extractSessionId(packet.topic);
if (sessionId) {
handleMetaChange(root, sessionId, broker, packet.payload.toString());
handleMetaChange(
root,
sessionId,
broker,
index,
packet.payload.toString(),
);
}
cb();
},
@@ -151,18 +355,7 @@ export async function createJournalServer(
);
// 启动时发布初始清单,让客户端能看到已有会话
const initialManifest = loadManifest(root);
broker.publish(
{
topic: $SESSIONS,
payload: Buffer.from(JSON.stringify(initialManifest)),
qos: 1,
retain: true,
cmd: "publish" as const,
dup: false,
},
() => {},
);
publishManifest(broker, index);
console.log("[日志] 持久化监听已启动");
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
@@ -182,60 +375,91 @@ export async function createJournalServer(
// 工具函数
// ---------------------------------------------------------------------------
function extractSessionId(topic: string): string | null {
export function extractSessionId(topic: string): string | null {
const parts = topic.split("/");
return parts.length >= 3 ? parts[1] : null;
if (parts.length < 3) return null;
const id = parts[1];
if (!isValidSessionId(id)) {
console.warn(`[日志] 拒绝非法会话 ID:${JSON.stringify(id)}`);
return null;
}
return id;
}
// ---------------------------------------------------------------------------
// 会话生命周期管理
// ---------------------------------------------------------------------------
function handleMetaChange(
/**
* 追加一条流记录。会话必须已在索引中(即存在合法 meta.json),
* 否则丢弃——这样已删除的会话不会被迟到的消息「复活」。
* 目录由元数据处理函数创建,此处不再 mkdir。
*/
export function appendStream(
root: string,
id: string,
index: Record<string, SessionMeta>,
line: string,
): void {
if (!isValidSessionId(id) || !index[id]) {
console.warn(`[日志] 丢弃未知会话 ${id} 的流记录`);
return;
}
appendFileSync(streamPath(root, id), line + "\n", "utf-8");
}
export function handleMetaChange(
root: string,
sessionId: string,
broker: import("aedes").Aedes,
index: Record<string, SessionMeta>,
rawPayload: string,
): void {
const manifest = loadManifest(root);
try {
const meta = JSON.parse(rawPayload) as SessionMeta | null;
if (!meta || !meta.name) {
if (manifest.sessions[sessionId]) {
delete manifest.sessions[sessionId];
const sp = streamPath(root, sessionId);
if (existsSync(sp)) unlinkSync(sp);
const sd = sessionDir(root, sessionId);
try {
rmdirSync(sd);
} catch {
/* */
}
console.log(`[日志] 会话已删除:${sessionId}`);
}
} else {
manifest.sessions[sessionId] = {
name: meta.name,
created: meta.created || Date.now(),
players: meta.players || [],
};
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
}
saveManifest(root, manifest);
broker.publish(
{
topic: $SESSIONS,
payload: Buffer.from(JSON.stringify(manifest)),
qos: 1,
retain: true,
cmd: "publish" as const,
dup: false,
},
() => {},
// The id reaches us straight from an MQTT topic level; never build a path
// from it without validating, or `..` can escape the sessions directory.
if (!isValidSessionId(sessionId)) {
console.warn(
`[日志] 拒绝非法会话 ID 的元数据操作:${JSON.stringify(sessionId)}`,
);
return;
}
const payload = rawPayload.trim();
// 空载荷(或 JSON null)= 删除会话:递归删除目录,移除索引项。
// 必须在 JSON.parse 之前判断:JSON.parse("") 会抛错,导致删除永远无法生效。
if (payload === "" || payload === "null") {
delete index[sessionId];
rmSync(sessionDir(root, sessionId), { recursive: true, force: true });
console.log(`[日志] 会话已删除:${sessionId}`);
publishManifest(broker, index);
return;
}
let raw: unknown;
try {
raw = JSON.parse(payload);
} catch (e) {
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
return;
}
// 用 schema 校验,避免写入无法回读的数据(例如非数组的 players),
// 那会让下次启动扫描时把元数据隔离掉。
const parsed = SessionMetaSchema.safeParse(raw);
if (!parsed.success) {
console.error(`[日志] 会话 ${sessionId} 元数据无效,已忽略:`, parsed.error);
return;
}
const meta = parsed.data;
try {
mkdirSync(sessionDir(root, sessionId), { recursive: true });
writeFileAtomic(metaPath(root, sessionId), JSON.stringify(meta, null, 2));
index[sessionId] = meta;
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
publishManifest(broker, index);
} catch (e) {
console.error(`[日志] 会话 ${sessionId} 元数据写入失败:`, e);
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
e.preventDefault();
// Resolve relative hrefs against the current page path so that
// e.g. "blah.md" on /content/page.md becomes /content/blah
// e.g. "blah.md" on /rules/page becomes /rules/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
+24 -8
View File
@@ -6,8 +6,17 @@ import {
onCleanup,
onMount,
} from "solid-js";
import { Dynamic } from "solid-js/web";
import {
TbOutlineBook2,
TbOutlineCheck,
TbOutlineChevronDown,
TbOutlineClipboardList,
TbOutlineX,
} from "solid-icons/tb";
import { docEntries, type DocEntry } from "./doc-data";
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
import { docIcon } from "./doc-icons";
import { parseMarkdown } from "../markdown";
type DocSet = "directives" | "journal";
@@ -77,7 +86,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
>
{docSetLabel()}
<span class="text-gray-400 text-sm">▾</span>
<TbOutlineChevronDown class="text-gray-400 text-sm" />
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
@@ -89,10 +98,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
: "text-gray-700"
}`}
>
<span>📖</span>
<TbOutlineBook2 />
<span>指令组件文档</span>
<Show when={docSet() === "directives"}>
<span class="ml-auto text-blue-500">✓</span>
<TbOutlineCheck class="ml-auto text-blue-500" />
</Show>
</button>
<button
@@ -103,10 +112,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
: "text-gray-700"
}`}
>
<span>📋</span>
<TbOutlineClipboardList />
<span>Journal 服务器文档</span>
<Show when={docSet() === "journal"}>
<span class="ml-auto text-blue-500">✓</span>
<TbOutlineCheck class="ml-auto text-blue-500" />
</Show>
</button>
</div>
@@ -117,7 +126,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
title="关闭"
>
✕
<TbOutlineX />
</button>
</div>
@@ -133,7 +142,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
: "text-gray-700 hover:bg-gray-100"
}`}
>
<span class="mr-2">{entry.icon}</span>
<Dynamic
component={docIcon(entry.icon)}
class="mr-2 inline-block align-[-0.125em]"
/>
<Show
when={docSet() === "directives"}
fallback={<span class="text-xs">{entry.title}</span>}
@@ -164,7 +176,11 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
return (
<div class="max-w-none">
<h2 class="text-2xl font-bold text-gray-900 mb-6">
{e.icon} {e.title}
<Dynamic
component={docIcon(e.icon)}
class="inline-block align-[-0.125em]"
/>{" "}
{e.title}
</h2>
<article
+13 -3
View File
@@ -1,5 +1,11 @@
import { Component, createMemo, createSignal, Show } from "solid-js";
import { useLocation } from "@solidjs/router";
import {
TbOutlineFolderOpen,
TbOutlineFolder,
TbOutlineFile,
TbOutlineChevronRight,
} from "solid-icons/tb";
import { type FileNode, type TocNode } from "../data-loader";
import { useNavigateWithParams } from "./useNavigateWithParams";
import { useScrollContainer } from "../App";
@@ -61,10 +67,14 @@ export const FileTreeNode: Component<{
onClick={handleClick}
>
<Show when={isDir}>
<span class="mr-1 text-gray-400">{isExpanded() ? "📂" : "📁"}</span>
{isExpanded() ? (
<TbOutlineFolderOpen class="mr-1 text-gray-400" />
) : (
<TbOutlineFolder class="mr-1 text-gray-400" />
)}
</Show>
<Show when={!isDir}>
<span class="mr-1 text-gray-400">📄</span>
<TbOutlineFile class="mr-1 text-gray-400" />
</Show>
<span class="text-sm truncate">{props.node.name}</span>
</a>
@@ -151,7 +161,7 @@ export const HeadingNode: Component<{
<span
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
>
▶
<TbOutlineChevronRight class="inline-block align-[-0.125em]" />
</span>
</span>
<a
+9 -25
View File
@@ -15,8 +15,10 @@ import {
onCleanup,
Show,
} from "solid-js";
import { Portal } from "solid-js/web";
import { Portal, Dynamic } from "solid-js/web";
import { useLocation } from "@solidjs/router";
import type { IconTypes } from "solid-icons";
import { TbOutlineLink, TbOutlineSparkles } from "solid-icons/tb";
import { useArticleDom } from "./Article";
import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
@@ -27,25 +29,6 @@ import {
normalizePath,
} from "./stores/reveal";
// ---------------------------------------------------------------------------
// SVG icons
// ---------------------------------------------------------------------------
const LINK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
"</svg>";
const SPARK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M12 2l1.5 6.5L18 7l-4.5 4.5L16 16l-4-2.5L8 16l1.5-4.5L5 7l4.5-.5z"/>' +
"</svg>";
// ---------------------------------------------------------------------------
// Floating button state
// ---------------------------------------------------------------------------
@@ -54,7 +37,7 @@ interface FloatingButton {
rect: DOMRect;
action: () => void;
title: string;
svg: string;
icon: IconTypes;
color: string;
}
@@ -130,7 +113,7 @@ export const RevealManager: Component = () => {
text: combinedSlug,
}),
title: "Roll spark table",
svg: SPARK_SVG,
icon: TbOutlineSparkles,
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
});
return;
@@ -150,7 +133,7 @@ export const RevealManager: Component = () => {
text: `${normalized}#${text}`,
}),
title: "Send /link to stream",
svg: LINK_SVG,
icon: TbOutlineLink,
color: "text-blue-500 hover:bg-blue-50",
});
}
@@ -183,14 +166,15 @@ export const RevealManager: Component = () => {
left: `${btn().rect.left - 20}px`,
}}
title={btn().title}
innerHTML={btn().svg}
onClick={(e) => {
e.stopPropagation();
btn().action();
}}
onMouseEnter={() => clearTimeout(hideTimer)}
onMouseLeave={hide}
/>
>
<Dynamic component={btn().icon} size={12} />
</button>
</Portal>
)}
</Show>
+9 -9
View File
@@ -1,6 +1,7 @@
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
import { generateToc, type FileNode, type TocNode } from "../data-loader";
import { useLocation } from "@solidjs/router";
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
import { FileTreeNode, HeadingNode } from "./FileTree";
import { isPathRevealed } from "./stores/reveal";
@@ -17,7 +18,7 @@ interface SidebarContentProps {
pathHeadings: Record<string, TocNode[]>;
currentPath: string;
onClose: () => void;
isDesktop?: boolean;
onNavigate?: () => void;
onDataSourceOpen?: () => void;
}
@@ -57,17 +58,15 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
title="Content Source"
>
📂
<TbOutlineFolderOpen class="inline-block align-[-0.125em]" />
</button>
<Show when={!props.isDesktop}>
<button
onClick={props.onClose}
class="text-gray-500 hover:text-gray-700"
title="关闭"
>
✕
<TbOutlineX class="inline-block align-[-0.125em]" />
</button>
</Show>
</div>
</div>
</div>
@@ -83,7 +82,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
currentPath={props.currentPath}
pathHeadings={props.pathHeadings}
depth={0}
onClose={props.onClose}
onClose={() => props.onNavigate?.()}
isHidden={isFileHidden}
/>
))}
@@ -150,6 +149,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
pathHeadings={pathHeadings()}
currentPath={location.pathname}
onClose={props.onClose}
onNavigate={props.onClose}
onDataSourceOpen={props.onDataSourceOpen}
/>
</aside>
@@ -163,6 +163,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
export const DesktopSidebar: Component<{
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
onClose: () => void;
onDataSourceOpen?: () => void;
}> = (props) => {
const location = useLocation();
@@ -182,13 +183,12 @@ export const DesktopSidebar: Component<{
});
return (
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
<aside class="hidden md:flex flex-col h-full w-64 shrink-0 bg-white border-r border-gray-200 overflow-hidden">
<SidebarContent
fileTree={fileTree()}
pathHeadings={pathHeadings()}
currentPath={location.pathname}
onClose={() => {}}
isDesktop
onClose={props.onClose}
onDataSourceOpen={props.onDataSourceOpen}
/>
</aside>
+48
View File
@@ -0,0 +1,48 @@
/**
* Icon registry for built-in doc entries.
*
* Doc-entry frontmatter `icon:` values are semantic keys (e.g. `dice`), not
* glyphs — YAML can't reference a component. This maps each key to a Tabler
* icon component; unknown keys fall back to a generic file icon.
*/
import type { IconTypes } from "solid-icons";
import {
TbOutlineCards,
TbOutlineDice5,
TbOutlineFile,
TbOutlineFileText,
TbOutlineGitBranch,
TbOutlineHash,
TbOutlineLink,
TbOutlineMapPin,
TbOutlinePhoto,
TbOutlineSparkles,
TbOutlineSquare,
TbOutlineTable,
TbOutlineTheater,
TbOutlineTypography,
} from "solid-icons/tb";
const DOC_ICONS: Record<string, IconTypes> = {
dice: TbOutlineDice5,
table: TbOutlineTable,
link: TbOutlineLink,
pins: TbOutlineMapPin,
font: TbOutlineTypography,
background: TbOutlinePhoto,
border: TbOutlineSquare,
embed: TbOutlineFileText,
deck: TbOutlineCards,
yarn: TbOutlineGitBranch,
gm: TbOutlineTheater,
player: TbOutlineDice5,
roll: TbOutlineDice5,
set: TbOutlineHash,
spark: TbOutlineSparkles,
};
/** Resolve a frontmatter icon key to a Tabler icon component. */
export function docIcon(key: string): IconTypes {
return DOC_ICONS[key] ?? TbOutlineFile;
}
@@ -3,6 +3,7 @@
*/
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
import { Dynamic } from "solid-js/web";
import type { CompletionItem } from "./command-parser";
import type { CompletionsState } from "./completions";
@@ -102,6 +103,11 @@ export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
{item.kind === "command" ? "cmd" : "val"}
</span>
</Show>
<Show when={item.icon}>
{(icon) => (
<Dynamic component={icon()} size={14} class="shrink-0" />
)}
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
+147 -46
View File
@@ -1,27 +1,90 @@
/**
* ConnectDialog — name + role picker to join a session
* ConnectDialog — broker connection + session lobby (list / create / join)
*
* The broker connection and the active session are decoupled: this dialog
* connects to the broker, which makes the session list available, and joining
* or creating a session is a separate step that reuses that connection.
*/
import { Component, createSignal, onMount, Show } from "solid-js";
import { Component, createSignal, For, onMount, Show } from "solid-js";
import { TbOutlineTrash } from "solid-icons/tb";
import {
connectStream,
hydrateFromServer,
useJournalStream,
connectBroker,
createSession,
joinSession,
resolveBrokerUrl,
sessions,
setMyName,
setMyRole,
setSessionId,
useJournalStream,
} from "../stores/journalStream";
import { DeleteSessionDialog } from "./DeleteSessionDialog";
export const ConnectDialog: Component = () => {
const stream = useJournalStream();
const manifest = sessions;
const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole,
);
const [connecting, setConnecting] = createSignal(false);
const [newSessionName, setNewSessionName] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [autoJoined, setAutoJoined] = createSignal(false);
const [sessionToDelete, setSessionToDelete] = createSignal<{
id: string;
name: string;
} | null>(null);
// Auto-join when URL has both session and player params
const ensureBroker = async () => {
if (stream.connected) return;
await connectBroker(resolveBrokerUrl());
};
const identity = () => ({
name: playerName().trim() || stream.myName,
role: role(),
});
const applyIdentity = () => {
const { name, role: r } = identity();
setMyName(name);
setMyRole(r);
};
const handleJoin = async (sessionId: string) => {
setBusy(true);
setError(null);
try {
applyIdentity();
await ensureBroker();
await joinSession(sessionId, identity());
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setBusy(false);
}
};
const handleCreate = async () => {
const name = newSessionName().trim();
if (!name) return;
setBusy(true);
setError(null);
try {
applyIdentity();
await ensureBroker();
const id = await createSession(name);
if (id) await joinSession(id, identity());
setNewSessionName("");
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setBusy(false);
}
};
// Auto-join when the URL carries session + player + autojoin params;
// otherwise enter the lobby so the session list is available up front.
onMount(() => {
const params = new URL(window.location.href).searchParams;
if (
@@ -32,51 +95,26 @@ export const ConnectDialog: Component = () => {
setPlayerName(params.get("player") || "");
setRole("player");
setAutoJoined(true);
handleConnect();
handleJoin(params.get("session")!);
return;
}
ensureBroker().catch((e) => {
setError(e instanceof Error ? e.message : "Connection failed");
});
});
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
// rsbuild dev server. In production, the CLI serves everything itself.
const brokerUrl =
process.env.NODE_ENV === "development"
? `ws://localhost:3000`
: `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return (
<Show
when={!autoJoined() || error()}
fallback={
<p class="text-sm text-gray-500 text-center">
{connecting() ? "正在加入会话…" : "已加入!"}
{busy() ? "正在加入会话…" : "已加入!"}
</p>
}
>
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
请输入你的名字和角色来加入会话。
输入你的名字和角色,选择或新建一个会话。
<br />
<span class="text-xs text-gray-400">
连接到 {window.location.host}
@@ -86,7 +124,7 @@ export const ConnectDialog: Component = () => {
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="你的名字"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
@@ -106,17 +144,80 @@ export const ConnectDialog: Component = () => {
</button>
))}
</div>
{/* Session list */}
<div class="space-y-1">
<p class="text-xs text-gray-500">会话</p>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<div class="flex items-center">
<button
onClick={handleConnect}
disabled={connecting() || !playerName().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50"
onClick={() => handleJoin(id)}
disabled={busy()}
class="flex-1 min-w-0 text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 truncate"
>
{connecting() ? "连接中..." : "加入"}
{meta.name || id}
</button>
<Show when={role() === "gm"}>
<button
onClick={() =>
setSessionToDelete({ id, name: meta.name || id })
}
disabled={busy()}
class="shrink-0 px-2 py-1.5 text-[10px] text-gray-300 hover:text-red-500 disabled:opacity-50"
title="删除会话"
>
<TbOutlineTrash />
</button>
</Show>
</div>
)}
</For>
<Show when={Object.keys(manifest().sessions).length === 0}>
<p class="px-2 py-2 text-xs text-gray-400">
{stream.connected ? "还没有会话" : "正在加载会话…"}
</p>
</Show>
</div>
</div>
{/* Create a new session and join it (GM only) */}
<Show when={role() === "gm"}>
<div class="flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="新会话名称"
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
/>
<button
onClick={handleCreate}
disabled={busy() || !newSessionName().trim()}
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
>
新建并加入
</button>
</div>
</Show>
<Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>
<Show when={sessionToDelete()}>
{(target) => (
<DeleteSessionDialog
sessionId={target().id}
sessionName={target().name}
active={stream.sessionId === target().id}
onClose={() => setSessionToDelete(null)}
/>
)}
</Show>
</div>
</Show>
);
-148
View File
@@ -1,148 +0,0 @@
/**
* ConnectionBar — session picker, player info (connected state only)
*/
import { Component, createSignal, Show, For } from "solid-js";
import {
hydrateFromServer,
useJournalStream,
sessions,
createSession,
deleteSession,
disconnectStream,
setSessionId,
} from "../stores/journalStream";
import { journalSetState } from "../stores/journalStream";
export const ConnectionBar: Component = () => {
const stream = useJournalStream();
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewSession, setShowNewSession] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const manifest = sessions;
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
const id = createSession(name);
if (id) {
setSessionId(id);
hydrateFromServer(id).catch(() => {});
}
setNewSessionName("");
setShowNewSession(false);
};
const handleDeleteSession = (id: string) => {
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
deleteSession(id);
}
};
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch (e) {
setError("无法加载会话");
}
};
const handleDisconnect = () => {
disconnectStream();
};
return (
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
{/* Status row */}
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-500">已连接</span>
<Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionName || stream.sessionId}
</span>
</Show>
<div class="flex-1" />
<Show when={stream.myName !== "gm"}>
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
</Show>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500"
title="断开连接"
>
⏻
</button>
</div>
{/* GM session management */}
<Show when={stream.myName === "gm"}>
<div class="space-y-1">
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">会话列表:</span>
<button
onClick={() => setShowNewSession((v) => !v)}
class="text-xs text-blue-600 hover:underline"
>
+ 新建
</button>
</div>
<Show when={showNewSession()}>
<div class="flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
placeholder="会话名称"
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
/>
<button
onClick={handleCreateSession}
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
>
Create Create
</button>
</div>
</Show>
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<div
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
id === stream.sessionId
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
}`}
>
<span
class="flex-1 truncate"
onClick={() => handleSessionSelect(id)}
>
{meta.name || id}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(id);
}}
class="text-gray-400 hover:text-red-500 text-xs"
title="删除会话"
>
✕
</button>
</div>
)}
</For>
</div>
</Show>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
);
};
+10 -8
View File
@@ -2,7 +2,8 @@
* CreateSessionDialog — modal for naming a new session
*/
import { Component, createSignal, onMount } from "solid-js";
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
import { TbOutlineX } from "solid-icons/tb";
import { createSession, joinSession } from "../stores/journalStream";
interface CreateSessionDialogProps {
onClose: () => void;
@@ -19,15 +20,16 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
const trimmed = name().trim();
if (!trimmed) return;
setCreating(true);
const id = createSession(trimmed);
if (id) {
setSessionId(id);
// Hydrate the new (empty) session so the UI reflects it immediately
await hydrateFromServer(id).catch(() => {});
}
try {
const id = await createSession(trimmed);
if (id) await joinSession(id);
} catch (e) {
console.error("[journal] failed to create session:", e);
} finally {
setName("");
setCreating(false);
props.onClose();
}
};
onMount(() => inputRef?.focus());
@@ -47,7 +49,7 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
✕
<TbOutlineX />
</button>
</div>
<p class="text-xs text-gray-500">输入新会话的名称。</p>
@@ -0,0 +1,86 @@
/**
* DeleteSessionDialog — destructive confirmation before removing a session.
*
* Deletion removes the session directory on the server (meta + full message
* log), so the warning spells out that it cannot be undone.
*/
import { Component, createSignal, Show } from "solid-js";
import { TbOutlineX } from "solid-icons/tb";
import { deleteSession } from "../stores/journalStream";
interface DeleteSessionDialogProps {
sessionId: string;
sessionName: string;
/** Whether this is the session the user is currently in. */
active: boolean;
onClose: () => void;
}
export const DeleteSessionDialog: Component<DeleteSessionDialogProps> = (
props,
) => {
const [deleting, setDeleting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const handleDelete = async () => {
setDeleting(true);
setError(null);
try {
await deleteSession(props.sessionId);
props.onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setDeleting(false);
}
};
return (
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20"
onClick={props.onClose}
>
<div
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[300px] p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-red-600">删除会话</h3>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
<TbOutlineX />
</button>
</div>
<p class="text-xs text-gray-600">
确定要删除会话{" "}
<span class="font-semibold">{props.sessionName}</span>
(<span class="font-mono">{props.sessionId}</span>)吗?
</p>
<p class="text-xs text-red-500">
此操作不可恢复:该会话的全部消息记录、玩家名单和派生状态都会从服务器删除。
{props.active && " 你当前正在该会话中,删除后将返回会话列表。"}
</p>
<Show when={error()}>
<p class="text-xs text-red-500">{error()}</p>
</Show>
<div class="flex justify-end gap-1">
<button
onClick={props.onClose}
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
>
取消
</button>
<button
onClick={handleDelete}
disabled={deleting()}
class="bg-red-600 text-white rounded px-3 py-1 text-xs font-medium hover:bg-red-700 disabled:opacity-50"
>
{deleting() ? "删除中…" : "删除"}
</button>
</div>
</div>
</div>
);
};
+2 -1
View File
@@ -3,6 +3,7 @@
* the journal input area. Toggled by clicking the truncated error text.
*/
import { Component, Show } from "solid-js";
import { TbOutlineX } from "solid-icons/tb";
interface ErrorPopupProps {
message: string | null;
@@ -21,7 +22,7 @@ export const ErrorPopup: Component<ErrorPopupProps> = (props) => {
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
title="关闭"
>
✕
<TbOutlineX />
</button>
</div>
</div>
+2 -1
View File
@@ -2,6 +2,7 @@
* InviteDialog — input player name, copy invite link
*/
import { Component, createSignal } from "solid-js";
import { TbOutlineX } from "solid-icons/tb";
import { useJournalStream } from "../stores/journalStream";
interface InviteDialogProps {
@@ -51,7 +52,7 @@ export const InviteDialog: Component<InviteDialogProps> = (props) => {
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
✕
<TbOutlineX />
</button>
</div>
<p class="text-xs text-gray-500">
+21 -11
View File
@@ -1,19 +1,24 @@
/**
* JournalHeader — status dot, player name, session dropdown, disconnect & close
* JournalHeader — status dot, player name, session dropdown, leave & close
*/
import { Component, Show } from "solid-js";
import { useJournalStream, disconnectStream } from "../stores/journalStream";
import {
TbOutlineArrowLeft,
TbOutlineLayoutSidebarRightCollapse,
} from "solid-icons/tb";
import { useJournalStream, leaveSession } from "../stores/journalStream";
import { SessionDropdown } from "./SessionDropdown";
interface JournalHeaderProps {
onClose: () => void;
onCreateSession: () => void;
onDeleteSession: (id: string, name: string) => void;
}
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const handleDisconnect = () => disconnectStream();
const handleLeave = () => leaveSession();
return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
@@ -32,7 +37,9 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
<h2 class="text-sm font-semibold text-gray-700 truncate">
{stream.connected ? "选择会话" : "未连接"}
</h2>
}
>
<div class="min-w-0">
@@ -52,19 +59,22 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
</p>
{/* Clickable subtitle — session dropdown (GM only) */}
<Show when={stream.myRole === "gm"}>
<SessionDropdown onCreate={props.onCreateSession} />
<SessionDropdown
onCreate={props.onCreateSession}
onDelete={props.onDeleteSession}
/>
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<Show when={stream.sessionId}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="断开连接"
onClick={handleLeave}
class="text-xs text-gray-400 hover:text-blue-500 px-1"
title="返回会话列表"
>
⏻
<TbOutlineArrowLeft />
</button>
</Show>
<button
@@ -72,7 +82,7 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="隐藏面板"
>
▸
<TbOutlineLayoutSidebarRightCollapse />
</button>
</div>
</div>
+20 -2
View File
@@ -15,6 +15,7 @@ import { JournalHeader } from "./JournalHeader";
import { ConnectDialog } from "./ConnectDialog";
import { InviteDialog } from "./InviteDialog";
import { CreateSessionDialog } from "./CreateSessionDialog";
import { DeleteSessionDialog } from "./DeleteSessionDialog";
import { VariableView } from "./VariableView";
export interface JournalPanelProps {
@@ -26,6 +27,10 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream();
const [showInvite, setShowInvite] = createSignal(false);
const [showCreateSession, setShowCreateSession] = createSignal(false);
const [sessionToDelete, setSessionToDelete] = createSignal<{
id: string;
name: string;
} | null>(null);
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
// Player list (exclude gm and observer)
@@ -56,10 +61,11 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
<JournalHeader
onClose={props.onClose}
onCreateSession={() => setShowCreateSession(true)}
onDeleteSession={(id, name) => setSessionToDelete({ id, name })}
/>
{/* Player list tabs */}
<Show when={stream.connected}>
<Show when={stream.connected && stream.sessionId}>
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
<For each={playerEntries()}>
{([name]) => (
@@ -79,7 +85,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</Show>
<Show
when={stream.connected}
when={stream.connected && stream.sessionId}
fallback={
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<ConnectDialog />
@@ -126,6 +132,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
<Show when={showCreateSession()}>
<CreateSessionDialog onClose={() => setShowCreateSession(false)} />
</Show>
{/* Delete session modal */}
<Show when={sessionToDelete()}>
{(target) => (
<DeleteSessionDialog
sessionId={target().id}
sessionName={target().name}
active={stream.sessionId === target().id}
onClose={() => setSessionToDelete(null)}
/>
)}
</Show>
</JournalContext.Provider>
</aside>
</>
+31 -10
View File
@@ -2,15 +2,20 @@
* SessionDropdown — session switcher for the journal header (GM only)
*/
import { Component, createSignal, For, Show } from "solid-js";
import {
TbOutlineCheck,
TbOutlineChevronDown,
TbOutlineTrash,
} from "solid-icons/tb";
import {
sessions,
setSessionId,
hydrateFromServer,
joinSession,
useJournalStream,
} from "../stores/journalStream";
interface SessionDropdownProps {
onCreate: () => void;
onDelete: (id: string, name: string) => void;
}
export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
@@ -25,8 +30,9 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
return;
}
setDropdownOpen(false);
setSessionId(id);
hydrateFromServer(id).catch(() => {});
joinSession(id).catch((e) => {
console.error("[journal] failed to switch session:", e);
});
};
// Close dropdown on outside click
@@ -45,7 +51,7 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
>
{stream.sessionName || stream.sessionId}{" "}
<span class="text-gray-300">▾</span>
<TbOutlineChevronDown class="text-gray-300 inline-block align-[-0.125em]" />
</button>
<Show when={dropdownOpen()}>
<div
@@ -54,22 +60,37 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
>
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<div
class={`flex items-center ${
id === stream.sessionId ? "bg-blue-50" : ""
}`}
>
<button
onClick={(e) => {
e.stopPropagation();
handleSessionSelect(id);
}}
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId
? "bg-blue-50 text-blue-700"
: "text-gray-700"
class={`flex-1 min-w-0 text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId ? "text-blue-700" : "text-gray-700"
}`}
>
<span class="truncate flex-1">{meta.name || id}</span>
<Show when={id === stream.sessionId}>
<span class="text-blue-500 shrink-0">✓</span>
<TbOutlineCheck class="text-blue-500 shrink-0" />
</Show>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(false);
props.onDelete(id, meta.name || id);
}}
class="shrink-0 px-2 py-1 text-[10px] text-gray-300 hover:text-red-500"
title="删除会话"
>
<TbOutlineTrash />
</button>
</div>
)}
</For>
<div class="border-t border-gray-100 my-0.5" />
+2 -1
View File
@@ -3,6 +3,7 @@
*/
import { Component, Show, createMemo } from "solid-js";
import { TbOutlineArrowBackUp } from "solid-icons/tb";
import type { StreamMessage as StreamMessageType } from "./registry";
import { getMessageType } from "./registry";
import { revertLatest, useJournalStream } from "../stores/journalStream";
@@ -47,7 +48,7 @@ export const StreamMessageCard: Component<{
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
title="撤回此消息"
>
×
<TbOutlineArrowBackUp />
</button>
</Show>
<span
@@ -7,6 +7,7 @@
import type { CompletionItem } from "./command-parser";
import type { JournalCompletions } from "./completions";
import { TbOutlineDice5, TbOutlineSparkles } from "solid-icons/tb";
export interface CompletionsContext {
data: JournalCompletions;
@@ -63,7 +64,8 @@ export function buildCompletions(
for (const d of diceMatches) {
items.push({
label: `🎲 ${d.notation}`,
label: d.notation,
icon: TbOutlineDice5,
kind: "value",
insertText: "/roll " + d.notation,
});
@@ -71,7 +73,8 @@ export function buildCompletions(
for (const s of sparkMatches) {
items.push({
label: `✨ ${s.slug} (${s.notation})`,
label: `${s.slug} (${s.notation})`,
icon: TbOutlineSparkles,
kind: "value",
insertText: `/roll ${s.slug}`,
});
+4
View File
@@ -5,10 +5,14 @@
* module and keep JournalInput small.
*/
import type { IconTypes } from "solid-icons";
export interface CompletionItem {
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
/** Optional icon rendered before the label in the completions dropdown. */
icon?: IconTypes;
}
export interface ParsedInput {
+6 -3
View File
@@ -14,9 +14,12 @@ export {
} from "./registry";
export type { MessageTypeDef, StreamMessage } from "./registry";
export {
connectStream,
connectBroker,
joinSession,
switchSession,
leaveSession,
disconnectStream,
hydrateFromServer,
resolveBrokerUrl,
sendMessage,
revertLatest,
canRevert,
@@ -42,7 +45,7 @@ export { ConnectDialog } from "./ConnectDialog";
export { InviteDialog } from "./InviteDialog";
export { SessionDropdown } from "./SessionDropdown";
export { CreateSessionDialog } from "./CreateSessionDialog";
export { ConnectionBar } from "./ConnectionBar";
export { DeleteSessionDialog } from "./DeleteSessionDialog";
export { StreamView } from "./StreamView";
export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel";
+3 -2
View File
@@ -3,6 +3,7 @@
*/
import { z } from "zod";
import { TbOutlineCircleCheck, TbOutlineHandStop } from "solid-icons/tb";
import { registerMessageType } from "../registry";
// ---------------------------------------------------------------------------
@@ -25,7 +26,7 @@ registerMessageType<IntentPayload>({
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg">🙋</span>
<TbOutlineHandStop class="text-lg" />
<span class="text-sm font-medium text-yellow-700">意图</span>
</div>
<p class="text-sm">{p.description}</p>
@@ -58,7 +59,7 @@ registerMessageType<ResolutionPayload>({
render: (p) => (
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-lg">✅</span>
<TbOutlineCircleCheck class="text-lg" />
<span class="text-sm font-medium text-green-700">裁决</span>
<span class="text-xs text-gray-400">re: {p.intentId}</span>
</div>
+2 -1
View File
@@ -6,6 +6,7 @@
*/
import { Component } from "solid-js";
import { TbOutlineFileText } from "solid-icons/tb";
import { useNavigateWithParams } from "../../useNavigateWithParams";
import { z } from "zod";
import { registerMessageType } from "../registry";
@@ -69,7 +70,7 @@ const RevealLink: Component<LinkPayload> = (p) => {
return (
<div class="flex items-center gap-2">
<span class="text-lg">📄</span>
<TbOutlineFileText class="text-lg" />
<div>
<a
href={target()}
+4 -2
View File
@@ -7,6 +7,7 @@
*/
import { z } from "zod";
import { TbOutlineArrowRight, TbOutlineDice5 } from "solid-icons/tb";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../dice";
@@ -55,9 +56,10 @@ registerMessageType<RollPayload>({
return (
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-lg">🎲</span>
<TbOutlineDice5 class="text-lg" />
<span class="font-mono text-sm">
{p.notation} →{" "}
{p.notation}{" "}
<TbOutlineArrowRight class="inline-block align-[-0.125em]" />{" "}
<span class="font-bold text-base">{result.total}</span>
</span>
</div>
+2 -1
View File
@@ -10,6 +10,7 @@
*/
import { z } from "zod";
import { TbOutlineHash } from "solid-icons/tb";
import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream";
import { produce } from "solid-js/store";
@@ -35,7 +36,7 @@ registerMessageType<VarPayload>({
render: (p) => {
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">🔢</span>
<TbOutlineHash class="text-lg" />
<span class="font-mono text-sm">
{p.key} = <span class="font-bold">{p.value}</span>
</span>
+3 -2
View File
@@ -1,4 +1,5 @@
import { For, Show, createEffect, createSignal, on } from "solid-js";
import { TbOutlineChevronLeft, TbOutlineChevronRight } from "solid-icons/tb";
import type { DeckStore } from "./hooks/deckStore";
export interface CardListProps {
@@ -67,7 +68,7 @@ export function CardList(props: CardListProps) {
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"
>
‹
<TbOutlineChevronLeft />
</button>
<span class="text-xs text-gray-500 tabular-nums">
{page() + 1} / {totalPages()}
@@ -77,7 +78,7 @@ export function CardList(props: CardListProps) {
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"
>
›
<TbOutlineChevronRight />
</button>
</div>
</Show>
+17 -3
View File
@@ -1,5 +1,11 @@
import { Show, createSignal, onMount } from "solid-js";
import { Portal } from "solid-js/web";
import {
TbOutlineCheck,
TbOutlineCopy,
TbOutlineDownload,
TbOutlinePencil,
} from "solid-icons/tb";
import type { DeckStore } from "./hooks/deckStore";
export interface DeckHeaderProps {
@@ -43,7 +49,15 @@ export function DeckHeader(props: DeckHeaderProps) {
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
{store.state.isEditing ? (
<>
<TbOutlineCheck class="inline-block align-[-0.125em]" /> 编辑中
</>
) : (
<>
<TbOutlinePencil class="inline-block align-[-0.125em]" /> 编辑
</>
)}
</button>
{/* 导出 PDF 按钮 */}
@@ -51,7 +65,7 @@ export function DeckHeader(props: DeckHeaderProps) {
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
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 导出 PDF
</button>
{/* 复制代码按钮 */}
@@ -59,7 +73,7 @@ export function DeckHeader(props: DeckHeaderProps) {
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"
>
📋 复制代码
<TbOutlineCopy class="inline-block align-[-0.125em]" /> 复制代码
</button>
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
import { createSignal, For, Show, createMemo } from "solid-js";
import { Portal } from "solid-js/web";
import { TbOutlineDownload } from "solid-icons/tb";
import { parsePlt, extractCutPaths } from "../../plotcutter/parser";
import { generateTravelPaths, travelPathsToSvg } from "../../plotcutter/layout";
import type { CardPath } from "../../plotcutter/types";
@@ -115,7 +116,7 @@ export function PltPreview(props: PltPreviewProps) {
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-1"
disabled={parsedData().cardPaths.length === 0}
>
📥 下载 PLT
<TbOutlineDownload /> 下载 PLT
</button>
<button
@@ -1,4 +1,5 @@
import { Show } from 'solid-js';
import { TbOutlineCircleX, TbOutlineX } from 'solid-icons/tb';
import type { DeckStore } from './hooks/deckStore';
export interface PrintPreviewFooterProps {
@@ -26,14 +27,14 @@ export function PrintPreviewFooter(props: PrintPreviewFooterProps) {
<Show when={store.state.exportError}>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 text-red-600">
<span>❌</span>
<TbOutlineCircleX />
<span class="text-sm font-medium">{store.state.exportError}</span>
</div>
<button
onClick={() => store.actions.clearExportError()}
class="text-gray-500 hover:text-gray-700 cursor-pointer"
>
✕
<TbOutlineX />
</button>
</div>
</Show>
@@ -1,4 +1,9 @@
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import {
TbOutlineDownload,
TbOutlinePlus,
TbOutlineRuler,
} from "solid-icons/tb";
import type { DeckStore } from "./hooks/deckStore";
import { getCompatibleDecks } from "./hooks/deck-registry";
@@ -77,7 +82,7 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
: "添加其他牌组到打印"
}
>
<span>➕</span>
<TbOutlinePlus />
<span>添加牌组</span>
<Show when={props.selectedDeckIds.length > 0}>
<span class="bg-purple-600 text-white text-xs rounded-full w-5 h-5 inline-flex items-center justify-center">
@@ -232,14 +237,14 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
onClick={props.onOpenPltPreview}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
>
<span>📐</span>
<TbOutlineRuler />
<span>PLT 预览</span>
</button>
<button
onClick={props.onExport}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
>
<span>📥</span>
<TbOutlineDownload />
<span>导出 PDF</span>
</button>
<button
@@ -1,10 +1,27 @@
import { For, createSignal, onCleanup, onMount } from "solid-js";
import type { JSX } from "solid-js";
import { Dynamic } from "solid-js/web";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { IconTypes } from "solid-icons";
import {
TbOutlineAlignCenter,
TbOutlineAlignLeft,
TbOutlineAlignRight,
TbOutlineArrowDown,
TbOutlineArrowDownLeft,
TbOutlineArrowDownRight,
TbOutlineArrowLeft,
TbOutlineArrowRight,
TbOutlineArrowUp,
TbOutlineArrowUpLeft,
TbOutlineArrowUpRight,
TbOutlineEye,
TbOutlineEyeOff,
TbOutlineGripVertical,
TbOutlineTrash,
} from "solid-icons/tb";
import type { DeckStore } from "../hooks/deckStore";
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";
export interface LayerRowProps {
store: DeckStore;
@@ -22,65 +39,67 @@ export interface LayerRowProps {
onRemove: () => void;
}
const ORIENTATIONS = [
{ value: "n" as const, label: "↑ 北" },
{ value: "e" as const, label: "→ 东" },
{ value: "s" as const, label: "↓ 南" },
{ value: "w" as const, label: "← 西" },
const ORIENTATIONS: {
value: "n" | "e" | "s" | "w";
label: string;
icon: IconTypes;
}[] = [
{ value: "n", label: "北", icon: TbOutlineArrowUp },
{ value: "e", label: "东", icon: TbOutlineArrowRight },
{ value: "s", label: "南", icon: TbOutlineArrowDown },
{ value: "w", label: "西", icon: TbOutlineArrowLeft },
];
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 ALIGNS: { value: Align | ""; icon: IconTypes }[] = [
{ value: "", icon: TbOutlineAlignCenter },
{ value: "l", icon: TbOutlineAlignLeft },
{ value: "c", icon: TbOutlineAlignCenter },
{ value: "r", icon: TbOutlineAlignRight },
{ value: "tl", icon: TbOutlineArrowUpLeft },
{ value: "tc", icon: TbOutlineArrowUp },
{ value: "tr", icon: TbOutlineArrowUpRight },
{ value: "bl", icon: TbOutlineArrowDownLeft },
{ value: "bc", icon: TbOutlineArrowDown },
{ value: "br", icon: TbOutlineArrowDownRight },
];
const FONT_PRESETS = [3, 5, 8, 12] as const;
function orientChar(v: string) {
function orientIcon(v: string): IconTypes {
switch (v) {
case "n":
return "↑";
return TbOutlineArrowUp;
case "e":
return "→";
return TbOutlineArrowRight;
case "s":
return "↓";
return TbOutlineArrowDown;
case "w":
return "←";
return TbOutlineArrowLeft;
default:
return "↑";
return TbOutlineArrowUp;
}
}
function alignIcon(v: string): import("solid-js").JSX.Element {
function alignIcon(v: string): IconTypes {
switch (v) {
case "tl":
return "↖";
return TbOutlineArrowUpLeft;
case "tc":
return "↑";
return TbOutlineArrowUp;
case "tr":
return "↗";
return TbOutlineArrowUpRight;
case "bl":
return "↙";
return TbOutlineArrowDownLeft;
case "bc":
return "↓";
return TbOutlineArrowDown;
case "br":
return "↘";
return TbOutlineArrowDownRight;
case "l":
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
return TbOutlineAlignLeft;
case "r":
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
return TbOutlineAlignRight;
default:
return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
return TbOutlineAlignCenter;
}
}
@@ -125,7 +144,7 @@ export function LayerRow(props: LayerRowProps) {
class="cursor-grab text-gray-400 hover:text-gray-600 text-sm px-0.5 select-none shrink-0"
title="拖动排序"
>
⠿
<TbOutlineGripVertical class="inline-block align-[-0.125em]" />
</button>
<button
@@ -133,7 +152,11 @@ export function LayerRow(props: LayerRowProps) {
class="shrink-0 text-sm cursor-pointer select-none"
title={props.layer.visible ? "隐藏" : "显示"}
>
{props.layer.visible ? "👁" : "👁‍🗨"}
{props.layer.visible ? (
<TbOutlineEye class="inline-block align-[-0.125em]" />
) : (
<TbOutlineEyeOff class="inline-block align-[-0.125em]" />
)}
</button>
<span
@@ -144,7 +167,7 @@ export function LayerRow(props: LayerRowProps) {
</span>
<DropdownButton
icon={orientChar(props.layer.orientation || "n")}
icon={orientIcon(props.layer.orientation || "n")}
visible={props.layer.visible}
open={props.openDropdown === `orient-${props.index}`}
onToggle={() =>
@@ -162,6 +185,10 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateOrientation(o.value)}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
<Dynamic
component={o.icon}
class="inline-block align-[-0.125em]"
/>{" "}
{o.label}
</button>
)}
@@ -187,13 +214,11 @@ 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"
>
{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>
)}
<Dynamic
component={o.icon}
size={16}
class="not-prose max-w-none"
/>
</button>
)}
</For>
@@ -260,19 +285,19 @@ export function LayerRow(props: LayerRowProps) {
class={`shrink-0 w-5 h-5 text-xs rounded cursor-pointer flex items-center justify-center text-red-400 hover:text-red-600 hover:bg-red-50 transition-opacity ${props.hovered ? "opacity-100" : "opacity-0"}`}
title="删除图层"
>
✕
<TbOutlineTrash size={14} />
</button>
</div>
);
}
function DropdownButton(props: {
icon: import("solid-js").JSX.Element;
icon: IconTypes;
visible: boolean;
open: boolean;
onToggle: () => void;
title: string;
children: import("solid-js").JSX.Element;
children: JSX.Element;
}) {
return (
<div class="relative">
@@ -284,7 +309,7 @@ function DropdownButton(props: {
class={`w-7 h-7 rounded cursor-pointer flex items-center justify-center bg-gray-200 hover:bg-gray-300 ${!props.visible ? "invisible pointer-events-none" : ""}`}
title={props.title}
>
{props.icon}
<Dynamic component={props.icon} size={16} />
</button>
{props.open && (
<div
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 B

+2 -1
View File
@@ -1,5 +1,6 @@
import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount } from "solid-js";
import { TbOutlineDice5 } from "solid-icons/tb";
import { rollFormula } from "./dice";
export interface DiceProps {
@@ -86,7 +87,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
class="text-blue-600 hover:text-blue-800 cursor-pointer"
title={rollDetail() || "掷骰子"}
>
🎲
<TbOutlineDice5 class="inline-block align-[-0.125em]" />
</span>
<a
onClick={handleRoll}
+2 -1
View File
@@ -1,5 +1,6 @@
import { customElement, noShadowDOM } from "solid-element";
import {Show, For, createResource, createMemo, createSignal} from "solid-js";
import { TbOutlineCopy } from "solid-icons/tb";
import { resolvePath } from "./utils/path";
import { createPinsStore } from "./stores/pinsStore";
@@ -108,7 +109,7 @@ customElement("md-pins", { pins: "", fixed: false, labelStart: "A" }, (props, {
class="bg-gray-800 hover:bg-gray-700 text-white px-3 py-1.5 rounded shadow-lg text-sm flex items-center gap-1 transition-colors"
title="复制所有 pin 坐标"
>
📋 复制
<TbOutlineCopy /> 复制
</button>
</div>
</Show>
+2 -1
View File
@@ -7,6 +7,7 @@ import {
createMemo,
createResource,
} from "solid-js";
import { TbOutlineDice5 } from "solid-icons/tb";
import { parseMarkdown } from "../markdown";
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
import { resolveContentRef } from "./utils/resolve-content";
@@ -210,7 +211,7 @@ customElement(
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
title="随机切换"
>
🎲
<TbOutlineDice5 class="inline-block align-[-0.125em]" />
</button>
</Show>
<div
+8 -3
View File
@@ -1,5 +1,10 @@
import { customElement, noShadowDOM } from 'solid-element';
import { For, Show, createEffect } from 'solid-js';
import {
TbOutlineRotate,
TbOutlinePlayerTrackNext,
TbOutlineArrowRight,
} from 'solid-icons/tb';
import type {TextResult, RuntimeResult, OptionsResult} from 'yarn-spinner-loader';
import { createYarnStore } from './stores/yarnStore';
@@ -82,7 +87,7 @@ customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { el
rounded-bl-md transition-colors cursor-pointer"
title="重新开始"
>
🔄
<TbOutlineRotate class="inline-block align-[-0.125em]" />
</button>
<button
onClick={() => advance()}
@@ -90,7 +95,7 @@ customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { el
transition-colors cursor-pointer"
title="继续"
>
⏩
<TbOutlinePlayerTrackNext class="inline-block align-[-0.125em]" />
</button>
</div>
@@ -104,7 +109,7 @@ customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { el
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
rounded border border-blue-200 transition-colors cursor-pointer"
>
→ {option.text}
<TbOutlineArrowRight class="inline-block align-[-0.125em]" /> {option.text}
</button>
)}
</For>
+45 -38
View File
@@ -282,29 +282,25 @@ export function setMyRole(role: "gm" | "player" | "observer"): void {
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);
}
/**
* Resolve the broker URL for the current environment. In dev the MQTT broker
* lives on the CLI server (port 3000); in production the CLI serves everything.
*/
export function resolveBrokerUrl(): string {
return process.env.NODE_ENV === "development"
? "ws://localhost:3000"
: `ws://${window.location.host}`;
}
export async function connectStream(
sessionId: string,
brokerUrl: string,
): Promise<void> {
/** Connect to the broker and enter the lobby (no active session). */
export async function connectBroker(brokerUrl: string): Promise<void> {
const api = getWorkerAPI();
setState("connectionStatus", "connecting");
setState("connectionError", null);
try {
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
await api.connect(brokerUrl, state.myName, state.myRole);
saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
} catch (err) {
setState("connectionStatus", "error");
setState(
@@ -315,6 +311,32 @@ export async function connectStream(
}
}
/**
* Join (or switch to) a session. The broker connection is reused, so switching
* sessions is a subscription swap rather than a reconnect.
*/
export async function joinSession(
sessionId: string,
identity?: { name?: string; role?: "gm" | "player" | "observer" },
): Promise<void> {
const api = getWorkerAPI();
await api.joinSession(sessionId, identity?.name, identity?.role);
saveSessionId(sessionId);
syncUrlParam("session", sessionId);
}
/** Alias for `joinSession` — reading `switchSession` at call sites is clearer. */
export const switchSession = joinSession;
/** Leave the active session, returning to the lobby (broker stays connected). */
export function leaveSession(): void {
const api = getWorkerAPI();
api.leaveSession();
removeUrlParam("session");
setState("sessionId", null);
setState("sessionName", null);
}
export function sendMessage<T>(
type: string,
payload: T,
@@ -365,28 +387,21 @@ export function revertLatest():
export function disconnectStream(): void {
const api = getWorkerAPI();
api.disconnect();
removeUrlParam("session");
removeUrlParam("autojoin");
}
export function createSession(
export async 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;
): Promise<string | null> {
return getWorkerAPI().createSession(name, players);
}
export function deleteSession(sessionId: string): void {
const api = getWorkerAPI();
api.deleteSession(sessionId);
export async function deleteSession(sessionId: string): Promise<void> {
await getWorkerAPI().deleteSession(sessionId);
// Deleting the active session drops you back to the lobby.
if (state.sessionId === sessionId) leaveSession();
}
export function canRevert(): boolean {
@@ -408,11 +423,3 @@ 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()
}
+2 -1
View File
@@ -8,7 +8,8 @@
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
* > npm run tttk serve ./content
* > npm run dev
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/__CONTENT_REGISTRY.json、/content/ 代理到 CLI 服务器
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/__CONTENT_REGISTRY.json、
* /.ttrpg 以及内容目录中的静态资源代理到 CLI 服务器
*/
import {
+3 -3
View File
@@ -1,16 +1,16 @@
---
tag: journal-gm
icon: 🎭
icon: gm
title: 主持人指南
---
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
点击顶部导航栏的 📋 按钮打开 Journal 面板。
点击顶部导航栏的剪贴板按钮打开 Journal 面板。
## 快速开始
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
1. 点击顶部导航栏的剪贴板按钮打开 Journal 面板
2. 输入你的名字,选择"主持人"角色,点击"加入"
3. 连接成功后,你可以创建新会话或选择已有会话
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: journal-link
icon: 🔗
icon: link
title: 链接命令
---
+2 -2
View File
@@ -1,6 +1,6 @@
---
tag: journal-player
icon: 🎲
icon: player
title: 玩家指南
---
@@ -19,7 +19,7 @@ GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接
### 手动加入
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
1. 点击顶部导航栏的剪贴板按钮打开 Journal 面板
2. 输入你的名字
3. 选择"玩家"角色
4. 点击"加入"
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: journal-roll
icon: 🎲
icon: roll
title: 掷骰与火花表命令
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: journal-set
icon: 🔢
icon: set
title: 变量系统
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
syntax: '```yaml role=declare'
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: journal-spark
icon: 🎰
icon: spark
title: 火花表
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: md-bg
icon: 🖼️
icon: background
title: 背景组件
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: md-border
icon: 🖼️
icon: border
title: 边框组件
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: md-deck
icon: 🃏
icon: deck
title: 卡牌组件
---
+2 -2
View File
@@ -1,6 +1,6 @@
---
tag: md-dice
icon: 🎲
icon: dice
title: 骰子组件
---
@@ -23,7 +23,7 @@ title: 骰子组件
:md-dice[2d20k1+5]{key="advantage"}
```
点击 🎲 图标执行投掷,显示结果;点击文字重置为原始公式。
点击骰子图标执行投掷,显示结果;点击文字重置为原始公式。
## 属性
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: md-embed
icon: 📄
icon: embed
title: 嵌入组件
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: md-font
icon: 🔤
icon: font
title: 字体组件
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
tag: md-link
icon: 🔗
icon: link
title: 链接组件
---
+2 -2
View File
@@ -1,6 +1,6 @@
---
tag: md-pins
icon: 📍
icon: pins
title: 标记组件
---
@@ -27,7 +27,7 @@ title: 标记组件
## 复制坐标
非 `fixed` 模式下,图片右上角会显示 📋 复制按钮,点击可将所有标记的坐标复制到剪贴板,方便粘贴回文档的 `pins` 属性中。
非 `fixed` 模式下,图片右上角会显示复制按钮,点击可将所有标记的坐标复制到剪贴板,方便粘贴回文档的 `pins` 属性中。
## 属性
+2 -2
View File
@@ -1,6 +1,6 @@
---
tag: md-table
icon: 📊
icon: table
title: 表格组件
---
@@ -38,7 +38,7 @@ B,另一段描述,第二章]{}
## 随机抽取
当 `roll=true` 时,表格顶部显示 🎲 随机按钮:
当 `roll=true` 时,表格顶部显示骰子随机按钮:
- 普通随机:从所有行中均匀随机选取
- **加权随机**:当 `label` 列全部为整数或整数范围格式(如 `1-3`)时,以 label 值为权重进行加权随机抽取
+3 -3
View File
@@ -1,6 +1,6 @@
---
tag: md-yarn-spinner
icon: 🧶
icon: yarn
title: 叙事线组件
---
@@ -19,8 +19,8 @@ Yarn Spinner 是用于游戏对话系统的格式,支持选项、条件分支
- **对话历史**:上半部分显示已进行的对话,说话者以蓝色粗体显示,命令以灰色斜体显示
- **当前选项**:下半部分显示可选的对话选项,点击选项推进剧情
- **⏩ 继续**:点击右上角继续按钮推进到下一段对话
- **🔄 重新开始**:点击右上角重启按钮从头开始对话
- **继续**:点击右上角继续按钮推进到下一段对话
- **重新开始**:点击右上角重启按钮从头开始对话
## 属性
+73
View File
@@ -0,0 +1,73 @@
import {
SESSION_ID_MAX_LENGTH,
isValidSessionId,
toSessionId,
} from "./session-id";
describe("isValidSessionId", () => {
test.each(["default", "a", "A1", "my-game", "under_score", "-"])(
"accepts %p",
(id) => {
expect(isValidSessionId(id)).toBe(true);
},
);
test.each([
"",
".",
"..",
"../evil",
"..\\..\\evil",
"a/b",
"a\\b",
"a b",
"a.b",
"a/b/../c",
])("rejects %p", (id) => {
expect(isValidSessionId(id)).toBe(false);
});
test("rejects non-strings and over-long ids", () => {
expect(isValidSessionId(null)).toBe(false);
expect(isValidSessionId(undefined)).toBe(false);
expect(isValidSessionId(42)).toBe(false);
expect(isValidSessionId("a".repeat(SESSION_ID_MAX_LENGTH + 1))).toBe(false);
});
test("rejects Windows reserved device names", () => {
expect(isValidSessionId("con")).toBe(false);
expect(isValidSessionId("COM1")).toBe(false);
expect(isValidSessionId("Nul")).toBe(false);
});
});
describe("toSessionId", () => {
test("slugifies names", () => {
expect(toSessionId("My Game")).toBe("my-game");
expect(toSessionId(" Curse of Strahd!! ")).toBe("curse-of-strahd");
expect(toSessionId("")).toBe("session");
expect(toSessionId("!!!")).toBe("session");
});
test("avoids Windows reserved device names", () => {
expect(toSessionId("CON")).toBe("con-session");
expect(isValidSessionId(toSessionId("CON"))).toBe(true);
});
test("always yields a valid id, including for hostile input", () => {
const names = [
"My Game",
"",
"..",
"../etc/passwd",
"a/b\\c",
"CON",
"Ünïcödé",
"x".repeat(200),
"\0\0",
];
for (const name of names) {
expect(isValidSessionId(toSessionId(name))).toBe(true);
}
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Session id rules — shared by the CLI broker (server) and the web client.
*
* A session id becomes a directory name under `<contentDir>/.ttrpg/sessions/`
* on the server, and is embedded in MQTT topics (`ttrpg/<id>/...`). Because the
* server derives filesystem paths directly from that topic, ids must be
* strictly validated at every trust boundary: without it, a topic level such as
* `..`, `.` or (on Windows) `..\..\evil` escapes the sessions directory.
*
* The pattern is intentionally conservative — only ASCII letters, digits, `_`
* and `-` — while still accepting ids that older versions may have written so
* existing sessions remain manageable.
*/
export const SESSION_ID_MAX_LENGTH = 64;
/** Valid session ids: 1-64 chars of `[A-Za-z0-9_-]`, never a path segment. */
export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
/**
* Windows reserves these names (case-insensitively) for devices, so they can
* never be used as a directory name — even with an extension.
*/
const RESERVED_DEVICE_NAMES = new Set<string>([
"con",
"prn",
"aux",
"nul",
"com1",
"com2",
"com3",
"com4",
"com5",
"com6",
"com7",
"com8",
"com9",
"lpt1",
"lpt2",
"lpt3",
"lpt4",
"lpt5",
"lpt6",
"lpt7",
"lpt8",
"lpt9",
]);
/** True when `id` is safe to use as a sessions-directory name and topic level. */
export function isValidSessionId(id: unknown): id is string {
return (
typeof id === "string" &&
id.length > 0 &&
id.length <= SESSION_ID_MAX_LENGTH &&
SESSION_ID_PATTERN.test(id) &&
!RESERVED_DEVICE_NAMES.has(id.toLowerCase())
);
}
/**
* Derive a filesystem-safe session id from a human-readable name.
* The result is always a valid session id.
*/
export function toSessionId(name: string): string {
const slug =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, SESSION_ID_MAX_LENGTH)
.replace(/-+$/g, "") || "session";
// Some names slugify onto a reserved device name (`Con` -> `con`); suffix
// those so the session can actually be created on Windows.
return RESERVED_DEVICE_NAMES.has(slug) ? `${slug}-session` : slug;
}
+189 -36
View File
@@ -12,6 +12,11 @@
import * as Comlink from "comlink";
import type { StreamMessage } from "../components/journal/registry";
import {
SESSION_ID_MAX_LENGTH,
isValidSessionId,
toSessionId,
} from "../shared/session-id.js";
// ---------------------------------------------------------------------------
// Types
@@ -139,11 +144,50 @@ async function hydrateFromServer(sessionId: string): Promise<void> {
}
// ---------------------------------------------------------------------------
// MQTT Connect
// MQTT Connect — the broker connection is independent of the active session
// ---------------------------------------------------------------------------
async function connectStream(
function subscribeSession(
client: import("mqtt").MqttClient,
sessionId: string,
): void {
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[worker] stream sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
JSON.stringify({ name: state.myName, role: state.myRole }),
{ qos: 1, retain: true },
);
}
function unsubscribeSession(
client: import("mqtt").MqttClient,
sessionId: string,
): void {
// Clear own retained presence before dropping the subscription.
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
"",
{ qos: 1, retain: true },
);
client.unsubscribe(
[
`ttrpg/${sessionId}/stream`,
`ttrpg/${sessionId}/meta`,
`ttrpg/${sessionId}/presence/+`,
],
(err) => {
if (err) console.error("[worker] unsubscribe err:", err);
},
);
}
async function connectBroker(
brokerUrl: string,
name: string,
role: "gm" | "player" | "observer",
@@ -172,22 +216,13 @@ async function connectStream(
state.connectionError = null;
state.brokerUrl = brokerUrl;
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[worker] stream sub err:", err);
});
// Broker-level subscription: the session list is available in the lobby.
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[worker] sessions sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
const presenceData = JSON.stringify({ name, role });
client.publish(
`ttrpg/${sessionId}/presence/${name}`,
presenceData,
{ qos: 1, retain: true },
);
// Restore the active session's subscriptions after an auto-reconnect.
if (state.sessionId) subscribeSession(client, state.sessionId);
notifySubscribers({ type: "connectionStatus", status: "connected" });
broadcastFullState();
@@ -229,6 +264,10 @@ async function connectStream(
return;
}
// Ignore session-scoped traffic that isn't for the active session, so a
// late message from the previous session can't leak into the new one.
if (parts[1] !== state.sessionId) return;
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
return;
}
@@ -268,6 +307,67 @@ async function connectStream(
});
}
// ---------------------------------------------------------------------------
// Session lifecycle — join/leave without reconnecting the broker
// ---------------------------------------------------------------------------
async function joinSession(
sessionId: string,
name?: string,
role?: "gm" | "player" | "observer",
): Promise<void> {
if (!isValidSessionId(sessionId)) {
throw new Error(`Invalid session id: ${JSON.stringify(sessionId)}`);
}
const client = _mqttClient;
if (!client || !_mqttConnected) {
throw new Error("Not connected to broker");
}
if (sessionId === state.sessionId) {
if (name) state.myName = name;
if (role) state.myRole = role;
return;
}
// Leave the old session under the old identity before applying any change.
if (state.sessionId) unsubscribeSession(client, state.sessionId);
if (name) state.myName = name;
if (role) state.myRole = role;
// Reset session-scoped state before hydrating (a 404 leaves it empty).
state.sessionId = sessionId;
state.sessionName = sessionList.sessions[sessionId]?.name ?? null;
state.messages = [];
state.senderSeq = {};
state.players = {};
await hydrateFromServer(sessionId);
subscribeSession(client, sessionId);
notifySubscribers({ type: "sessionName", name: state.sessionName });
notifySubscribers({ type: "players", players: {} });
broadcastFullState();
}
function leaveSession(): void {
const client = _mqttClient;
if (client && _mqttConnected && state.sessionId) {
unsubscribeSession(client, state.sessionId);
}
state.sessionId = null;
state.sessionName = null;
state.messages = [];
state.senderSeq = {};
state.players = {};
notifySubscribers({ type: "sessionName", name: null });
notifySubscribers({ type: "players", players: {} });
broadcastFullState();
}
// ---------------------------------------------------------------------------
// Receive
// ---------------------------------------------------------------------------
@@ -376,10 +476,9 @@ function revertLatest():
function disconnectStream(): void {
if (_mqttClient) {
const sessionId = state.sessionId;
if (sessionId) {
if (state.sessionId) {
_mqttClient.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
`ttrpg/${state.sessionId}/presence/${state.myName}`,
"",
{ qos: 1, retain: true },
);
@@ -390,6 +489,10 @@ function disconnectStream(): void {
}
state.connected = false;
state.connectionStatus = "disconnected";
state.sessionId = null;
state.sessionName = null;
state.messages = [];
state.senderSeq = {};
state.players = {};
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
notifySubscribers({ type: "players", players: {} });
@@ -399,28 +502,68 @@ function disconnectStream(): void {
// Session lifecycle
// ---------------------------------------------------------------------------
function createSession(name: string, players: string[] = []): string | null {
if (!_mqttClient || !_mqttConnected) return null;
/**
* Avoid silently overwriting an existing session whose name slugifies to the
* same id (e.g. "My Game" and "my game"). Best-effort: the retained manifest
* may not have arrived yet.
*/
function uniqueSessionId(base: string): string {
if (!sessionList.sessions[base]) return base;
const stem = base.slice(0, SESSION_ID_MAX_LENGTH - 5);
for (let n = 2; n < 1000; n++) {
const candidate = `${stem}-${n}`;
if (!sessionList.sessions[candidate]) return candidate;
}
return `${stem}-${Date.now()}`.slice(0, SESSION_ID_MAX_LENGTH);
}
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
function createSession(
name: string,
players: string[] = [],
): Promise<string | null> {
if (!_mqttClient || !_mqttConnected) return Promise.resolve(null);
const id = uniqueSessionId(toSessionId(name));
if (!isValidSessionId(id)) return Promise.resolve(null);
const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
qos: 1,
retain: true,
return new Promise((resolve) => {
_mqttClient!.publish(
`ttrpg/${id}/meta`,
JSON.stringify(meta),
{ qos: 1, retain: true },
(err) => {
if (err) {
console.error("[worker] create session publish error:", err);
resolve(null);
} else {
resolve(id);
}
},
);
});
return id;
}
function deleteSession(sessionId: string): void {
if (!_mqttClient || !_mqttConnected) return;
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
function deleteSession(sessionId: string): Promise<void> {
if (!isValidSessionId(sessionId)) {
return Promise.reject(
new Error(`Invalid session id: ${JSON.stringify(sessionId)}`),
);
}
if (!_mqttClient || !_mqttConnected) {
return Promise.reject(new Error("Not connected to stream"));
}
return new Promise((resolve, reject) => {
// Empty retained payload: the broker clears the retained meta and the
// server treats the empty payload as a delete.
_mqttClient!.publish(
`ttrpg/${sessionId}/meta`,
"",
{ qos: 1, retain: true },
(err) => (err ? reject(err) : resolve()),
);
});
}
// ---------------------------------------------------------------------------
@@ -445,13 +588,23 @@ function visibleMessages(): StreamMessage[] {
const api = {
async connect(
sessionId: string,
brokerUrl: string,
name: string,
role: "gm" | "player" | "observer",
): Promise<void> {
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl, name, role);
await connectBroker(brokerUrl, name, role);
},
async joinSession(
sessionId: string,
name?: string,
role?: "gm" | "player" | "observer",
): Promise<void> {
await joinSession(sessionId, name, role);
},
leaveSession(): void {
leaveSession();
},
disconnect(): void {
@@ -471,7 +624,7 @@ const api = {
},
deleteSession(sessionId: string) {
deleteSession(sessionId);
return deleteSession(sessionId);
},
getState(): WorkerState {