Compare commits

...

12 Commits

Author SHA1 Message Date
hypercross 86abf34c10 feat: implement shared dispatch error signal
Introduce a shared `dispatchError` signal in the command dispatcher
to unify error reporting across different components. This allows
`CommandLinkManager` to surface errors through the `JournalInput`
UI, ensuring that errors from `:cmd[]` directive clicks are visible
to the user.
2026-07-12 14:38:06 +08:00
hypercross ffbfa65716 refactor: extract command dispatch logic to shared module
Move command parsing and dispatching logic from `CommandLinkManager`
and `JournalInput` into a new `command-dispatcher.ts` module. This
unifies how commands are handled whether they are typed manually or
triggered via `:cmd[]` directive clicks.
2026-07-12 14:26:32 +08:00
hypercross d690d5922e refactor: simplify command dispatch and update cmd-link
- Simplify `dispatchCommand` by removing role-based logic and ensuring
  commands are prefixed with a slash.
- Change `:cmd[]` directive output from a `span` to an `a` tag.
- Update `.cmd-link` styles to improve link appearance and prevent
  text selection.
2026-07-12 14:18:01 +08:00
hypercross 42e8971ff7 refactor: replace command link interception with :cmd[] directive
Replace the previous mechanism of intercepting specific command-prefixed
anchor tags with a dedicated `:cmd[]` markdown directive.

- Implement `cmdDirective` for `marked-directive` support.
- Update `CommandLinkManager` to listen for clicks on `[data-cmd]` spans
  instead of parsing `href` attributes.
- Add CSS styles for the new `.cmd-link` class.
2026-07-12 14:05:07 +08:00
hypercross 182d7ff28d feat: add CommandLinkManager to intercept command links
Implement a component that uses event delegation to intercept clicks
on markdown links starting with command prefixes (e.g., >roll, >spark).
It dispatches these as journal stream messages based on the user's
role (Observer, Player, or GM).
2026-07-12 13:48:34 +08:00
hypercross 736bcf4bb2 feat: add ReactiveStatManager for markdown template support
Introduce ReactiveStatManager to allow dynamic stat replacement within
rendered markdown content. This component scans the article DOM for
`${key}` patterns and reactively updates them based on the journal
stream.

As part of this change, the SVG-based StatSheet system is removed in
favor of this more flexible text-based approach.
2026-07-12 13:32:49 +08:00
hypercross 722a8110e6 feat(file-tree): use anchor tags for better navigation
Convert div elements to anchor tags in FileTree and HeadingNode to
enable native browser features like middle-click to open in new tabs.
The implementation preserves SPA navigation for left-clicks while
ensuring search parameters are preserved in the href.
2026-07-12 10:46:35 +08:00
hypercross 40f2190307 feat: sync player name and role on state patch
Persist name and role to localStorage and URL parameters when
received via patch to ensure synchronization. Broadcast full state
upon successful connection.
2026-07-12 10:39:53 +08:00
hypercross 241c8609f1 refactor: expose Comlink API via MessagePort in SharedWorker
Update the worker to use the 'onconnect' event to handle incoming
MessagePorts, ensuring each connecting tab receives its own port.
2026-07-12 10:14:36 +08:00
hypercross 2187b7ed82 feat: implement journal stream via Shared Worker
Move MQTT connection and message log management to a Shared Worker
using Comlink. This allows multiple browser tabs to share a single
connection and synchronized state.
2026-07-12 10:08:14 +08:00
hypercross fc6e37a13d fix(journal): update min-height for JournalInput textarea 2026-07-12 09:46:11 +08:00
hypercross e46cc879ae refactor: remove mothership journal module 2026-07-12 00:37:10 +08:00
26 changed files with 1470 additions and 1563 deletions

7
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@thisbeyond/solid-dnd": "^0.7.5", "@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1", "aedes": "^1.1.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0", "csv-stringify": "^6.7.0",
@ -4332,6 +4333,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
"license": "Apache-2.0"
},
"node_modules/commander": { "node_modules/commander": {
"version": "14.0.3", "version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",

View File

@ -47,6 +47,7 @@
"@thisbeyond/solid-dnd": "^0.7.5", "@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1", "aedes": "^1.1.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0", "csv-stringify": "^6.7.0",

View File

@ -18,6 +18,8 @@ import {
DocDialog, DocDialog,
DataSourceDialog, DataSourceDialog,
RevealManager, RevealManager,
ReactiveStatManager,
CommandLinkManager,
} from "./components"; } from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal"; import { JournalPanel } from "./components/journal";
@ -156,6 +158,8 @@ const App: Component = () => {
src={currentPath()} src={currentPath()}
> >
<RevealManager /> <RevealManager />
<ReactiveStatManager />
<CommandLinkManager />
</Article> </Article>
</div> </div>
</main> </main>

View File

@ -19,7 +19,6 @@ import {
scanDirectives, scanDirectives,
type DirectiveScanResult, type DirectiveScanResult,
} from "../completions/directive-scanner.js"; } from "../completions/directive-scanner.js";
import type { StatSheet } from "../completions/types.js";
interface ContentIndex { interface ContentIndex {
[path: string]: string; [path: string]: string;
@ -88,22 +87,6 @@ function getBestIP(): string {
return best || "localhost"; return best || "localhost";
} }
/**
* SVG <title> null
*/
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
return m ? m[1].trim() : null;
}
/**
*
* "character-sheet" -> "Character Sheet"
*/
function labelFromId(id: string): string {
return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** /**
* .md * .md
*/ */
@ -116,7 +99,6 @@ export function scanDirectory(dir: string): {
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
const directiveResults: DirectiveScanResult[] = []; const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = []; const mdFiles: { content: string; relPath: string }[] = [];
@ -148,17 +130,6 @@ export function scanDirectory(dir: string): {
blocks.stats.push(...result.blocks.stats); blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates); blocks.statTemplates.push(...result.blocks.statTemplates);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath }); mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
} else if (entry.endsWith(".sheet.svg")) {
index[normalizedRelPath] = content;
const id = entry.replace(/\.sheet\.svg$/i, "");
const title = extractSvgTitle(content);
const sheet: StatSheet = {
id,
label: title || labelFromId(id),
svg: content,
source: normalizedRelPath,
};
blocks.statSheets.push(sheet);
} else { } else {
index[normalizedRelPath] = content; index[normalizedRelPath] = content;
} }
@ -341,7 +312,6 @@ export function createContentServer(
let collectedBlocks: ProcessedBlocks = { let collectedBlocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
let directiveResults: DirectiveScanResult[] = []; let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
@ -350,14 +320,13 @@ export function createContentServer(
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
/** 从当前内容索引和已收集的块重新扫描补全数据 */ /** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log( console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`, `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
); );
} }
@ -399,12 +368,6 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
} }
console.log(`[新增] ${relPath}`); console.log(`[新增] ${relPath}`);
} catch (e) { } catch (e) {
@ -431,12 +394,6 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
} }
console.log(`[更新] ${relPath}`); console.log(`[更新] ${relPath}`);
} catch (e) { } catch (e) {
@ -454,7 +411,7 @@ export function createContentServer(
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath]; delete contentIndex[relPath];
console.log(`[删除] ${relPath}`); console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) { if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults; directiveResults = rescan.directiveResults;

View File

@ -20,7 +20,6 @@ import {
parseBlockAttrs, parseBlockAttrs,
resolveBlockAs, resolveBlockAs,
} from "./block-scanner.js"; } from "./block-scanner.js";
import type { StatSheet } from "./types.js";
// Re-export shared pieces for convenience // Re-export shared pieces for convenience
export { export {
@ -37,7 +36,6 @@ export {
export interface ProcessedBlocks { export interface ProcessedBlocks {
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export interface BlockResult { export interface BlockResult {
@ -78,7 +76,6 @@ export function processBlocks(
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
const stripped = content.replace( const stripped = content.replace(

View File

@ -14,7 +14,6 @@ export type {
SparkTableCompletion, SparkTableCompletion,
StatDef, StatDef,
StatTemplate, StatTemplate,
StatSheet,
} from "./types.js"; } from "./types.js";
/** /**
@ -43,6 +42,5 @@ export function scanCompletions(
sparkTables, sparkTables,
stats: blocks.stats, stats: blocks.stats,
statTemplates: blocks.statTemplates, statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,
}; };
} }

View File

@ -6,18 +6,6 @@ import type { StatDef, StatTemplate } from "./stat-parser.js";
export type { StatDef, StatTemplate }; export type { StatDef, StatTemplate };
/** A stat sheet discovered from a *.sheet.svg file */
export interface StatSheet {
/** Unique id — filename sans .sheet.svg */
id: string;
/** Display label — from <title> or derived from filename */
label: string;
/** Raw SVG content */
svg: string;
/** Source file path for attribution */
source: string;
}
/** A single dice expression found in a markdown file */ /** A single dice expression found in a markdown file */
export interface DiceCompletion { export interface DiceCompletion {
/** Display text shown in the dropdown */ /** Display text shown in the dropdown */
@ -62,7 +50,6 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
/** /**

View File

@ -0,0 +1,58 @@
/**
* CommandLinkManager mounts as a child of <Article> and intercepts
* clicks on :cmd[] directive spans to dispatch them via the shared
* command dispatcher. Errors are surfaced through the shared
* dispatchError signal, shown by JournalInput above the textarea.
*/
import { Component, onCleanup } from "solid-js";
import { useArticleDom } from "./Article";
import { useJournalStream } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
import { dispatchCommand, setDispatchError } from "./journal/command-dispatcher";
export const CommandLinkManager: Component = () => {
const contentDom = useArticleDom();
const stream = useJournalStream();
const comp = useJournalCompletions();
const onClick = (e: MouseEvent) => {
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
if (!cmdSpan) return;
const command = cmdSpan.getAttribute("data-cmd");
if (!command) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
dispatchCommand({
role: stream.myRole as "gm" | "player" | "observer",
myName: stream.myName,
command,
sparkTables: comp.data.sparkTables,
statValues: stream.stats,
statDefs: comp.data.stats,
statTemplates: comp.data.statTemplates,
}).then((result) => {
if (!result.ok) {
setDispatchError(result.error);
}
});
};
const dom = contentDom();
if (dom) {
dom.addEventListener("click", onClick, true);
}
onCleanup(() => {
const d = contentDom();
if (d) d.removeEventListener("click", onClick, true);
});
return null;
};
export default CommandLinkManager;

View File

@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js"; import { Component, createMemo, createSignal, Show } from "solid-js";
import { useLocation } from "@solidjs/router";
import { type FileNode, type TocNode } from "../data-loader"; import { type FileNode, type TocNode } from "../data-loader";
import { useNavigateWithParams } from "./useNavigateWithParams"; import { useNavigateWithParams } from "./useNavigateWithParams";
import { useScrollContainer } from "../App"; import { useScrollContainer } from "../App";
@ -24,6 +25,7 @@ export const FileTreeNode: Component<{
isHidden?: (node: FileNode) => boolean; isHidden?: (node: FileNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigateWithParams(); const navigate = useNavigateWithParams();
const location = useLocation();
const isDir = !!props.node.children; const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path); const isActive = createMemo(() => props.currentPath === props.node.path);
// 默认收起,除非当前文件在该文件夹内 // 默认收起,除非当前文件在该文件夹内
@ -31,21 +33,28 @@ export const FileTreeNode: Component<{
isDir && isPathInDir(props.currentPath, props.node.path), isDir && isPathInDir(props.currentPath, props.node.path),
); );
const handleClick = () => { const href = () => props.node.path + location.search;
const handleClick = (e: MouseEvent) => {
if (isDir) { if (isDir) {
e.preventDefault();
setIsExpanded(!isExpanded()); setIsExpanded(!isExpanded());
} else { } else if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault();
navigate(props.node.path); navigate(props.node.path);
props.onClose(); props.onClose();
} }
// Middle-click / right-click: let the browser handle the <a> natively
}; };
const indent = props.depth * 12; const indent = props.depth * 12;
return ( return (
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}> <div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div <a
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${ href={href()}
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700" isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
}`} }`}
style={{ "padding-left": `${indent + 8}px` }} style={{ "padding-left": `${indent + 8}px` }}
@ -58,7 +67,7 @@ export const FileTreeNode: Component<{
<span class="mr-1 text-gray-400">📄</span> <span class="mr-1 text-gray-400">📄</span>
</Show> </Show>
<span class="text-sm truncate">{props.node.name}</span> <span class="text-sm truncate">{props.node.name}</span>
</div> </a>
<Show when={isDir && isExpanded() && props.node.children}> <Show when={isDir && isExpanded() && props.node.children}>
<div> <div>
{props.node.children!.map((child) => ( {props.node.children!.map((child) => (
@ -87,8 +96,9 @@ export const HeadingNode: Component<{
isHidden?: (node: TocNode) => boolean; isHidden?: (node: TocNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigateWithParams(); const navigate = useNavigateWithParams();
const location = useLocation();
const anchor = props.node.id || ""; const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`; const href = () => `${props.basePath}${location.search}#${anchor}`;
const hasChildren = !!props.node.children; const hasChildren = !!props.node.children;
// 默认收起,除非当前锚点在该节点内 // 默认收起,除非当前锚点在该节点内
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0); const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
@ -102,8 +112,12 @@ export const HeadingNode: Component<{
const scrollContainer = useScrollContainer(); const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault(); e.preventDefault();
navigate(href); navigate(href());
}
// Middle-click / right-click: let the browser handle the <a> natively
// 滚动到目标元素,考虑导航栏高度偏移 // 滚动到目标元素,考虑导航栏高度偏移
requestAnimationFrame(() => { requestAnimationFrame(() => {
const element = document.getElementById(anchor); const element = document.getElementById(anchor);
@ -141,7 +155,7 @@ export const HeadingNode: Component<{
</span> </span>
</span> </span>
<a <a
href={href} href={href()}
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer" class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
onClick={handleClick} onClick={handleClick}
> >

View File

@ -15,6 +15,8 @@ import "./md-yarn-spinner";
export { Article } from "./Article"; export { Article } from "./Article";
export type { ArticleProps } from "./Article"; export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager"; export { RevealManager } from "./RevealManager";
export { CommandLinkManager } from "./CommandLinkManager";
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar"; export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree"; export { FileTreeNode, HeadingNode } from "./FileTree";

View File

@ -7,26 +7,20 @@
* - `/link path#section` "link" type * - `/link path#section` "link" type
* - `/stat set key=value` "stat" type * - `/stat set key=value` "stat" type
* - `/` alone opens completions dropdown * - `/` alone opens completions dropdown
*
* Delegates command dispatch to the shared command-dispatcher module.
*/ */
import { Component, createSignal, createEffect, onMount, Show } from "solid-js"; import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream"; import { sendMessage, useJournalStream } from "../stores/journalStream";
import { actionPrefill, setActionPrefill } from "../stores/reveal"; import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions"; import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import {
resolveStatRoll,
resolveTemplateSet,
canModifyStat,
fullKey,
findStatDef,
} from "./stat-helpers";
import { parseInput } from "./command-parser"; import { parseInput } from "./command-parser";
import type { CompletionItem } from "./command-parser"; import type { CompletionItem } from "./command-parser";
import { buildCompletions } from "./command-completions"; import { buildCompletions } from "./command-completions";
import { CompletionsDropdown } from "./CompletionsDropdown"; import { CompletionsDropdown } from "./CompletionsDropdown";
import { ErrorPopup } from "./ErrorPopup"; import { ErrorPopup } from "./ErrorPopup";
import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
// ---- Component ---- // ---- Component ----
@ -39,7 +33,6 @@ export const JournalInput: Component = () => {
const isGm = () => stream.myRole === "gm"; const isGm = () => stream.myRole === "gm";
const [text, setText] = createSignal(""); const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [showErrorPopup, setShowErrorPopup] = createSignal(false); const [showErrorPopup, setShowErrorPopup] = createSignal(false);
const [sending, setSending] = createSignal(false); const [sending, setSending] = createSignal(false);
const [showCompletions, setShowCompletions] = createSignal(false); const [showCompletions, setShowCompletions] = createSignal(false);
@ -79,201 +72,75 @@ export const JournalInput: Component = () => {
// Observers: everything is plain chat // Observers: everything is plain chat
if (isObserver()) { if (isObserver()) {
const result = sendMessage("chat", { text: raw }); const result = sendMessage("chat", { text: raw });
const r = unwrap(result); const r = unwrapSendResult(result);
finish(r.ok, r.err); finish(r.ok, r.error);
return; return;
} }
const parsed = parseInput(raw); const parsed = parseInput(raw);
if (parsed.error) { if (parsed.error) {
setError(parsed.error); setDispatchError(parsed.error);
return; return;
} }
setError(null); setDispatchError(null);
// Players: chat + stat commands only // Players: chat + stat commands only
if (isPlayer()) { if (isPlayer()) {
if (parsed.type === "chat") { if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw }); const result = sendMessage("chat", { text: raw });
const r = unwrap(result); const r = unwrapSendResult(result);
finish(r.ok, r.err); finish(r.ok, r.error);
return; return;
} }
if (parsed.type === "stat") { if (parsed.type !== "stat") {
handleStat(parsed.payload); setDispatchError("玩家只能发送聊天消息或使用 /stat 命令");
return; return;
} }
setError("玩家只能发送聊天消息或使用 /stat 命令");
return;
} }
// GM: all commands // GM: all commands, Player: stat commands
setSending(true); setSending(true);
if (parsed.type === "roll") { await dispatchCommand({
const p = resolveRollPayload( role: stream.myRole as "gm" | "player" | "observer",
parsed.payload as { notation: string; label?: string }, myName: stream.myName,
); command: raw,
const result = sendMessage("roll", p); sparkTables: comp.data.sparkTables,
const r = unwrap(result); statValues: stream.stats,
finish(r.ok, r.err); statDefs: comp.data.stats,
return; statTemplates: comp.data.statTemplates,
} });
if (parsed.type === "spark") { // dispatchCommand already set the shared error if it failed.
try { // Only clear text on success (no error is shown).
const key = (parsed.payload as { key: string }).key; if (!dispatchError()) {
const match = comp.data.sparkTables.find((s) => s.slug === key); setText("");
const csvPath = match?.csvPath ?? ""; }
const remix = match?.remix ?? false;
const p = await resolveSparkPayload({ key, csvPath, remix });
const result = sendMessage("spark", p);
const r = unwrap(result);
finish(r.ok, r.err);
} catch (e) {
setError(e instanceof Error ? e.message : "种子表掷骰失败");
setSending(false); setSending(false);
} textareaRef?.focus();
return;
} }
if (parsed.type === "stat") { /** Clear text or set shared dispatch error on failure. */
handleStat(parsed.payload);
setSending(false);
return;
}
const result = sendMessage(parsed.type, parsed.payload);
const r = unwrap(result);
finish(r.ok, r.err);
}
/** Handle a /stat command payload (set/del/roll) */
function handleStat(payload: Record<string, unknown>) {
const p = payload as { action?: string; key?: string; value?: string };
if (p.action === "roll" && p.key) {
const resolved = resolveStatRoll(
p.key,
comp.data.stats,
stream.stats,
stream.myName,
comp.data.statTemplates,
);
if (resolved.error) {
setError(resolved.error);
} else {
// Send the primary stat value
const result = sendMessage("stat", {
action: "set",
key: resolved.fullKey,
value: resolved.value,
});
const r = unwrap(result);
if (!r.ok) {
finish(false, r.err);
return;
}
// Send any modifier overrides from template
if (resolved.modifiers) {
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
sendMessage("stat", {
action: "set",
key: mk,
value: mv,
});
}
}
finish(true);
}
return;
}
if (!p.action || !p.key) {
setError("无效的 stat 命令");
return;
}
// Resolve bare key to full key for set/del
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
setError(`无权修改属性: ${p.key}`);
return;
}
const result = sendMessage("stat", {
action: p.action,
key: fk,
value: p.value,
});
const r = unwrap(result);
if (!r.ok) {
finish(false, r.err);
return;
}
// If setting a template-type stat, apply its modifier overrides
if (p.action === "set" && p.value) {
const def = findStatDef(fk, comp.data.stats, stream.myName);
if (def) {
const modifiers = resolveTemplateSet(
def,
p.value,
comp.data.stats,
stream.stats,
stream.myName,
comp.data.statTemplates,
);
if (modifiers) {
for (const [mk, mv] of Object.entries(modifiers)) {
sendMessage("stat", { action: "set", key: mk, value: mv });
}
}
}
}
finish(true);
}
/** Resolve a bare or full key to the actual runtime key. */
function resolveKey(
inputKey: string,
statDefs: typeof comp.data.stats,
playerName: string,
): string {
// Exact match
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
return inputKey;
// Bare key → full key
const def = statDefs.find((d) => d.key === inputKey);
if (def) return fullKey(def, playerName);
return inputKey;
}
/** Clear text + error on success, or set error on failure. */
function finish(success: boolean, err?: string) { function finish(success: boolean, err?: string) {
if (success) { if (success) {
setText(""); setText("");
setDispatchError(null);
} else if (err) { } else if (err) {
setError(err); setDispatchError(err);
} }
setSending(false); setSending(false);
textareaRef?.focus(); textareaRef?.focus();
} }
/** Unwrap a sendMessage result into (success, error?) for finish(). */ /** Unwrap a sendMessage result into (success, error?) for finish(). */
function unwrap<R>( function unwrapSendResult<R>(
r: { success: true; msg: R } | { success: false; error: string }, r: { success: true; msg: R } | { success: false; error: string },
) { ) {
return r.success return r.success
? ({ ok: true, err: undefined } as const) ? ({ ok: true, error: undefined } as const)
: ({ ok: false, err: r.error } as const); : ({ ok: false, error: r.error } as const);
} }
// ---- Completions ---- // ---- Completions ----
@ -404,17 +271,17 @@ export const JournalInput: Component = () => {
rows={2} rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]" bg-transparent min-h-15"
/> />
<div class="flex items-center justify-between px-2 pb-2"> <div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}> <Show when={dispatchError()}>
<button <button
onClick={() => setShowErrorPopup((v) => !v)} onClick={() => setShowErrorPopup((v) => !v)}
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline" class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
title={error() ?? undefined} title={dispatchError() ?? undefined}
> >
{error()} {dispatchError()}
</button> </button>
</Show> </Show>
<div class="flex items-center gap-1 ml-auto"> <div class="flex items-center gap-1 ml-auto">
@ -434,7 +301,7 @@ export const JournalInput: Component = () => {
{/* Error popup */} {/* Error popup */}
<ErrorPopup <ErrorPopup
message={error()} message={dispatchError()}
show={showErrorPopup()} show={showErrorPopup()}
onClose={() => setShowErrorPopup(false)} onClose={() => setShowErrorPopup(false)}
/> />

View File

@ -0,0 +1,183 @@
/**
* ReactiveStatManager mounts as a child of <Article> and reactively
* replaces ${key} template patterns in the rendered markdown DOM with
* live stat values from the journal stream.
*
* On mount, walks all text nodes in the article content looking for
* ${key} placeholders. Each match is wrapped in a <span> with a
* data-reactive-stat attribute so that subsequent updates (driven by
* a Solid effect) only touch those spans.
*/
import { Component, createEffect, onCleanup, createMemo } from "solid-js";
import { useArticleDom } from "../Article";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "../journal/completions";
import { fullKey } from "../journal/stat-helpers";
import type { StatDef } from "../journal/completions";
const TEMPLATE_RE = /\$\{(\w+)\}/g;
/** Tag name used for marker spans — must stay in sync with the scan pass. */
const MARKER = "data-reactive-stat";
export const ReactiveStatManager: Component = () => {
const contentDom = useArticleDom();
const stream = useJournalStream();
const comp = useJournalCompletions();
const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats);
/** Build a lookup: bare key -> StatDef (for scope-aware resolution) */
const bareDefMap = createMemo(() => {
const map = new Map<string, StatDef>();
for (const def of statDefs()) {
if (!map.has(def.key)) {
map.set(def.key, def);
}
}
return map;
});
/**
* Resolve a bare key from markdown template to its runtime value.
* Uses StatDef scoping when available, falls back to player-first, global-second.
*/
function resolveBareKey(bareKey: string): string {
const bareDef = bareDefMap().get(bareKey);
if (bareDef) {
const fk = fullKey(bareDef, stream.myName);
return values()[fk] ?? bareDef.default ?? "";
}
const pk = `${stream.myName}:${bareKey}`;
if (values()[pk] !== undefined) return values()[pk];
return values()[bareKey] ?? "";
}
// ---- Initial scan: wrap ${key} text in marker spans ----
createEffect(() => {
const dom = contentDom();
if (!dom) return;
// Only scan once — after the first scan, the spans exist and we
// switch to the reactive update effect below.
if (dom.querySelector(`[${MARKER}]`)) return;
scanAndWrap(dom);
});
onCleanup(() => {
const dom = contentDom();
if (dom) unwrapAll(dom);
});
// ---- Reactive updates: whenever stat values change, update all spans ----
createEffect(() => {
const dom = contentDom();
if (!dom) return;
const v = values();
const spans = dom.querySelectorAll<HTMLElement>(`[${MARKER}]`);
for (const span of spans) {
const template = span.getAttribute(MARKER) ?? "";
let result = template;
TEMPLATE_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TEMPLATE_RE.exec(template)) !== null) {
result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1]));
}
if (span.textContent !== result) {
span.textContent = result;
}
}
});
return null; // renders nothing — works purely via DOM manipulation
};
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
/**
* Walk all text nodes in a subtree and wrap `${key}` patterns in
* `<span data-reactive-stat="originalText">` elements.
*/
function scanAndWrap(root: Element): void {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; html: string }[] = [];
let textNode: Text | null;
while ((textNode = walker.nextNode() as Text | null)) {
const text = textNode.textContent;
if (!text) continue;
TEMPLATE_RE.lastIndex = 0;
if (!TEMPLATE_RE.test(text)) continue;
// Build replacement HTML: split on ${key} and wrap each key span
TEMPLATE_RE.lastIndex = 0;
const parts: string[] = [];
let lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TEMPLATE_RE.exec(text)) !== null) {
if (m.index > lastIndex) {
parts.push(escapeHtml(text.slice(lastIndex, m.index)));
}
parts.push(
`<span ${MARKER}="${escapeAttr(m[0])}">${escapeHtml(m[0])}</span>`,
);
lastIndex = TEMPLATE_RE.lastIndex;
}
if (lastIndex < text.length) {
parts.push(escapeHtml(text.slice(lastIndex)));
}
replacements.push({ node: textNode, html: parts.join("") });
}
for (const { node, html } of replacements) {
const wrapper = document.createElement("span");
wrapper.innerHTML = html;
node.replaceWith(wrapper);
}
}
/**
* Reverse scanAndWrap remove marker spans, restoring plain text.
* Called on cleanup so subsequent remounts get a clean slate.
*/
function unwrapAll(root: Element): void {
const spans = root.querySelectorAll<HTMLElement>(`[${MARKER}]`);
// Process in reverse to avoid DOM mutation issues
for (let i = spans.length - 1; i >= 0; i--) {
const span = spans[i];
const parent = span.parentNode;
if (!parent) continue;
const text = span.getAttribute(MARKER) ?? span.textContent ?? "";
span.replaceWith(document.createTextNode(text));
}
// Normalize to merge adjacent text nodes
root.normalize();
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export default ReactiveStatManager;

View File

@ -1,121 +0,0 @@
/**
* SheetView renders a stat sheet SVG with live reactive text bindings.
*
* Parses the SVG once via DOMParser, then uses a Solid effect to
* update only <text> elements containing ${key} templates whenever
* the stats store changes.
*/
import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions";
import { fullKey } from "./stat-helpers";
import type { StatDef } from "./completions";
import { parseSheet } from "./stat-sheet";
export const SheetView: Component<{ sheetId: string }> = (props) => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats);
/** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */
const bareDefMap = createMemo(() => {
const map = new Map<string, StatDef>();
for (const def of statDefs()) {
if (!map.has(def.key)) {
map.set(def.key, def);
}
}
return map;
});
/**
* Resolve a bare key from an SVG template to its runtime value.
* Uses StatDef scoping when available, falls back to player-first, global-second.
*/
function resolveBareKey(bareKey: string): string {
const bareDef = bareDefMap().get(bareKey);
if (bareDef) {
const fk = fullKey(bareDef, stream.myName);
return values()[fk] ?? bareDef.default ?? "";
}
const pk = `${stream.myName}:${bareKey}`;
if (values()[pk] !== undefined) return values()[pk];
return values()[bareKey] ?? "";
}
const sheet = createMemo(() => {
const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId);
return s ?? null;
});
let containerRef: HTMLDivElement | undefined;
let disposeEffect: (() => void) | undefined;
createEffect(() => {
const s = sheet();
const container = containerRef;
disposeEffect?.();
disposeEffect = undefined;
if (!container || !s) return;
container.innerHTML = "";
const parsed = parseSheet(s.svg);
// Force full-width, auto-height
parsed.svgElement.setAttribute("width", "100%");
parsed.svgElement.removeAttribute("height");
parsed.svgElement.style.display = "block";
container.appendChild(parsed.svgElement);
// Center template nodes so text stays centered as values change length
for (const tpl of parsed.templates) {
const el = tpl.el;
try {
const bbox = el.getBBox();
const cx = bbox.x + bbox.width / 2;
el.setAttribute("text-anchor", "middle");
el.setAttribute("x", String(cx));
} catch {
// getBBox may fail if the node has no layout — skip silently
}
}
if (parsed.templates.length === 0) return;
const dispose = createRoot((disposer) => {
createEffect(() => {
const v = values();
for (const tpl of parsed.templates) {
let result = tpl.template;
for (const key of tpl.keys) {
result = result.replace(`\${${key}}`, resolveBareKey(key));
}
tpl.el.textContent = result;
}
});
return disposer;
});
disposeEffect = dispose;
});
onCleanup(() => disposeEffect?.());
const fallback = (
<p class="text-center text-gray-400 text-xs py-8">: {props.sheetId}</p>
);
return (
<div class="w-full h-full overflow-y-auto p-2">
{sheet() ? (
<div ref={containerRef} class="w-full" />
) : (
fallback
)}
</div>
);
};

View File

@ -1,21 +1,15 @@
/** /**
* StatsView table view of all stat key-value pairs, plus optional * StatsView table view of all stat key-value pairs.
* stat sheet rendering from *.sheet.svg files.
* *
* Groups stats by scope (global, then per-player). Shows computed values * Groups stats by scope (global, then per-player). Shows computed values
* (base + modifiers) and inline roll buttons for stats with roll/table/formula. * (base + modifiers) and inline roll buttons for stats with roll/table/formula.
*
* A floating button at bottom-right opens a sheet picker dropdown.
* Selecting a sheet delegates to SheetView.
*/ */
import { Component, For, createMemo, createSignal, Show } from "solid-js"; import { Component, For, createMemo, Show } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { useJournalStream } from "../stores/journalStream"; import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions"; import { useJournalCompletions } from "./completions";
import { fullKey, modifierLabel } from "./stat-helpers"; import { fullKey, modifierLabel } from "./stat-helpers";
import type { StatDef } from "./completions"; import type { StatDef } from "./completions";
import { SheetView } from "./SheetView";
export const StatsView: Component = () => { export const StatsView: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
@ -23,15 +17,6 @@ export const StatsView: Component = () => {
const statDefs = createMemo(() => comp.data.stats); const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats); const values = createMemo(() => stream.stats);
const sheets = createMemo(() => comp.data.statSheets);
/** Currently selected sheet id, or null for table view. Persisted in URL. */
const [searchParams, setSearchParams] = useSearchParams<{ sheet?: string }>();
const selectedSheetId = () => searchParams.sheet || null;
const setSelectedSheetId = (id: string | null) => {
setSearchParams({ sheet: id || undefined });
};
const [pickerOpen, setPickerOpen] = createSignal(false);
/** Build a lookup: fullKey -> StatDef */ /** Build a lookup: fullKey -> StatDef */
const defMap = createMemo(() => { const defMap = createMemo(() => {
@ -124,10 +109,7 @@ export const StatsView: Component = () => {
}); });
return ( return (
<div class="h-full overflow-y-auto bg-gray-50 relative"> <div class="h-full overflow-y-auto bg-gray-50">
<Show
when={selectedSheetId()}
fallback={
<Show <Show
when={statDefs().length > 0} when={statDefs().length > 0}
fallback={ fallback={
@ -247,68 +229,6 @@ export const StatsView: Component = () => {
)} )}
</For> </For>
</Show> </Show>
}
>
<SheetView sheetId={selectedSheetId()!} />
</Show>
{/* Sheet picker — only show if sheets are available */}
<Show when={sheets().length > 0}>
<div class="sticky bottom-3 flex justify-end pr-3">
{/* Dropdown trigger */}
<button
class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500"
onClick={() => setPickerOpen((v) => !v)}
title="选择属性卡"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</button>
{/* Dropdown menu */}
<Show when={pickerOpen()}>
<div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50">
<button
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700"
}`}
onClick={() => {
setSelectedSheetId(null);
setPickerOpen(false);
}}
>
📋
</button>
<div class="border-t border-gray-100 my-1" />
<For each={sheets()}>
{(sheet) => (
<button
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
selectedSheetId() === sheet.id ? "text-blue-600 font-medium" : "text-gray-700"
}`}
onClick={() => {
setSelectedSheetId(sheet.id);
setPickerOpen(false);
}}
>
{sheet.label}
</button>
)}
</For>
</div>
</Show>
{/* Backdrop to close picker */}
<Show when={pickerOpen()}>
<div
class="fixed inset-0 z-40"
onClick={() => setPickerOpen(false)}
/>
</Show>
</div>
</Show>
</div> </div>
); );
}; };

View File

@ -0,0 +1,230 @@
/**
* Command dispatcher shared logic for parsing & dispatching commands
* from text input or :cmd[] directive spans.
*
* Used by both JournalInput (manual typing) and CommandLinkManager (click).
*/
import { sendMessage } from "../stores/journalStream";
import { createSignal } from "solid-js";
import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import {
resolveStatRoll,
resolveTemplateSet,
canModifyStat,
fullKey,
findStatDef,
} from "./stat-helpers";
import type { StatDef, StatTemplate } from "./completions";
// ---------------------------------------------------------------------------
// Result
// ---------------------------------------------------------------------------
export type DispatchResult =
| { ok: true }
| { ok: false; error: string };
/**
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
* Components that show errors (JournalInput) read from here; callers that
* want errors surfaced (CommandLinkManager) write to it.
*/
export const [dispatchError, setDispatchError] =
createSignal<string | null>(null);
// ---------------------------------------------------------------------------
// Main dispatch
// ---------------------------------------------------------------------------
export interface DispatchContext {
/** The role of the current user */
role: "gm" | "player" | "observer";
/** The user's name */
myName: string;
/** The raw text to dispatch (with or without leading `/`) */
command: string;
/** Spark table lookup data (from completions) */
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
/** Current runtime stat values */
statValues: Record<string, string>;
/** Stat definitions */
statDefs: StatDef[];
/** Stat templates */
statTemplates: StatTemplate[];
}
/**
* Dispatch a command string. Works for both typed input and
* :cmd[] directive clicks.
*/
export async function dispatchCommand(
ctx: DispatchContext,
): Promise<DispatchResult> {
const raw = ctx.command.trim();
if (!raw) return { ok: false, error: "Empty command" };
// Observers can only send chat
if (ctx.role === "observer") {
const result = sendMessage("chat", { text: raw });
return finish(unwrap(result));
}
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
const parsed = parseInput(prefixed);
if (parsed.error) return finish({ ok: false, error: parsed.error });
// Players: chat + stat commands only
if (ctx.role === "player") {
if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw });
return finish(unwrap(result));
}
if (parsed.type === "stat") {
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
}
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" });
}
// GM: all commands
if (parsed.type === "roll") {
const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string },
);
const result = sendMessage("roll", p);
return finish(unwrap(result));
}
if (parsed.type === "spark") {
try {
const key = (parsed.payload as { key: string }).key;
const match = ctx.sparkTables.find((s) => s.slug === key);
const csvPath = match?.csvPath ?? "";
const remix = match?.remix ?? false;
const p = await resolveSparkPayload({ key, csvPath, remix });
const result = sendMessage("spark", p);
return finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "种子表掷骰失败",
});
}
}
if (parsed.type === "stat") {
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
}
const result = sendMessage(parsed.type, parsed.payload);
return finish(unwrap(result));
}
/** Update the shared error signal and return the result (pass-through). */
function finish(r: DispatchResult): DispatchResult {
setDispatchError(r.ok ? null : r.error);
return r;
}
// ---------------------------------------------------------------------------
// Stat dispatch
// ---------------------------------------------------------------------------
function dispatchStat(
payload: Record<string, unknown>,
ctx: DispatchContext,
): DispatchResult {
const p = payload as { action?: string; key?: string; value?: string };
if (p.action === "roll" && p.key) {
const resolved = resolveStatRoll(
p.key,
ctx.statDefs,
ctx.statValues,
ctx.myName,
ctx.statTemplates,
);
if (resolved.error) return { ok: false, error: resolved.error };
const result = sendMessage("stat", {
action: "set",
key: resolved.fullKey,
value: resolved.value,
});
const r = unwrap(result);
if (!r.ok) return r;
if (resolved.modifiers) {
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
sendMessage("stat", { action: "set", key: mk, value: mv });
}
}
return { ok: true };
}
if (!p.action || !p.key) {
return { ok: false, error: "无效的 stat 命令" };
}
const fk = resolveKey(p.key, ctx.statDefs, ctx.myName);
if (!canModifyStat(ctx.role, ctx.myName, fk, ctx.statDefs)) {
return { ok: false, error: `无权修改属性: ${p.key}` };
}
const result = sendMessage("stat", {
action: p.action,
key: fk,
value: p.value,
});
const r = unwrap(result);
if (!r.ok) return r;
if (p.action === "set" && p.value) {
const def = findStatDef(fk, ctx.statDefs, ctx.myName);
if (def) {
const modifiers = resolveTemplateSet(
def,
p.value,
ctx.statDefs,
ctx.statValues,
ctx.myName,
ctx.statTemplates,
);
if (modifiers) {
for (const [mk, mv] of Object.entries(modifiers)) {
sendMessage("stat", { action: "set", key: mk, value: mv });
}
}
}
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Resolve a bare or full key to the actual runtime key. */
function resolveKey(
inputKey: string,
statDefs: StatDef[],
playerName: string,
): string {
if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey;
const def = statDefs.find((d) => d.key === inputKey);
if (def) return fullKey(def, playerName);
return inputKey;
}
/** Unwrap a sendMessage result into DispatchResult. */
function unwrap<R>(
r: { success: true; msg: R } | { success: false; error: string },
): DispatchResult {
return r.success ? { ok: true } : { ok: false, error: r.error };
}

View File

@ -22,7 +22,6 @@ import {
parseStatModifiers, parseStatModifiers,
} from "../../cli/completions/stat-parser"; } from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
import type { StatSheet } from "../../cli/completions/types";
import { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
@ -31,7 +30,7 @@ import {
scanDirectives, scanDirectives,
} from "../../cli/completions/directive-scanner"; } from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate, StatSheet }; export type { StatDef, StatTemplate };
// ------------------- Types (mirrors CLI) ------------------- // ------------------- Types (mirrors CLI) -------------------
@ -63,7 +62,6 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export type CompletionsState = export type CompletionsState =
@ -93,7 +91,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
statTemplates: Array.isArray(data.statTemplates) statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates ? data.statTemplates
: [], : [],
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
}; };
} catch { } catch {
return null; return null;
@ -175,46 +172,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
sparkTables.push(...directiveResult.sparkTables); sparkTables.push(...directiveResult.sparkTables);
} }
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] }; return { dice, links, sparkTables, stats, statTemplates };
}
// ------------------- Stat sheet helpers -------------------
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
return m ? m[1].trim() : null;
}
function labelFromId(id: string): string {
return id
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
async function scanStatSheets(): Promise<StatSheet[]> {
const paths = await getPathsByExtension("svg");
const sheets: StatSheet[] = [];
for (const filePath of paths) {
if (!/\.sheet\.svg$/i.test(filePath)) continue;
try {
const content = await getIndexedData(filePath);
if (!content) continue;
const fileName = filePath.split("/").filter(Boolean).pop() || filePath;
const id = fileName.replace(/\.sheet\.svg$/i, "");
const title = extractSvgTitle(content);
sheets.push({
id,
label: title || labelFromId(id),
svg: content,
source: filePath,
});
} catch {
// skip unreadable files
}
}
return sheets;
} }
// ------------------- Init (runs eagerly at import time) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
@ -231,8 +189,7 @@ const _initPromise: Promise<void> = (async () => {
// 2. Fall back to client-side scan (dev/browser mode) // 2. Fall back to client-side scan (dev/browser mode)
try { try {
const data = await scanClientSide(); const data = await scanClientSide();
data.statSheets = await scanStatSheets(); if (data.dice.length > 0 || data.links.length > 0) {
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
setCompletionsState({ status: "loaded", data }); setCompletionsState({ status: "loaded", data });
} else { } else {
setCompletionsState({ status: "empty" }); setCompletionsState({ status: "empty" });
@ -283,7 +240,6 @@ export function useJournalCompletions(): {
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}, },
}; };
} }

View File

@ -49,5 +49,7 @@ export { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput"; export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm"; export { DynamicForm } from "./DynamicForm";
export { useJournalCompletions, invalidateCompletions } from "./completions"; export { useJournalCompletions, invalidateCompletions } from "./completions";
export { dispatchCommand } from "./command-dispatcher";
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
export { JournalContext, useJournalContext } from "./JournalContext"; export { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext"; export type { JournalContextValue } from "./JournalContext";

View File

@ -1,89 +0,0 @@
/**
* Stat sheet template engine parses *.sheet.svg files and extracts
* text nodes containing ${key} template patterns.
*
* Walks <text> elements. If a <text> has <tspan> children, we bind to
* each <tspan> individually (preserving their position attributes).
* If a <text> has no <tspan> children, we bind to the <text> directly.
*/
/** Regex matching ${key} template patterns (captures the key name). */
const TEMPLATE_RE = /\$\{(\w+)\}/g;
/** A single template binding targeting a specific text-bearing leaf node. */
export interface TextTemplate {
/** The DOM node to update — a <text> (no children) or a <tspan>. */
el: SVGTextContentElement;
/** Original text content with ${key} placeholders. */
template: string;
/** Bare key names extracted from the template. */
keys: string[];
}
/** The result of parsing a stat sheet SVG string. */
export interface ParsedSheet {
svgElement: SVGSVGElement;
templates: TextTemplate[];
}
/**
* Check if a text node's content has template patterns, and if so,
* register a binding.
*/
function scanNode(el: Element, templates: TextTemplate[]): void {
const content = el.textContent;
if (!content) return;
TEMPLATE_RE.lastIndex = 0;
const keys: string[] = [];
let m: RegExpExecArray | null;
while ((m = TEMPLATE_RE.exec(content)) !== null) {
keys.push(m[1]);
}
if (keys.length > 0) {
templates.push({
el: el as SVGTextContentElement,
template: content,
keys,
});
}
}
/**
* Parse an SVG string into a live DOM element and extract all template
* bindings from <text> and <tspan> leaf nodes.
*/
export function parseSheet(svgString: string): ParsedSheet {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "image/svg+xml");
const svgElement = doc.documentElement as unknown as SVGSVGElement;
if (!svgElement || svgElement.tagName !== "svg") {
return {
svgElement: document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
) as SVGSVGElement,
templates: [],
};
}
const templates: TextTemplate[] = [];
for (const textEl of svgElement.querySelectorAll("text")) {
const tspans = textEl.querySelectorAll("tspan");
if (tspans.length > 0) {
// Has children — bind to each <tspan> individually
for (const tspan of tspans) {
scanNode(tspan, templates);
}
} else {
// Leaf <text> — bind directly
scanNode(textEl, templates);
}
}
return { svgElement, templates };
}

View File

@ -1,20 +1,21 @@
/** /**
* Journal Stream Client Store * Journal Stream Client Store (Worker Proxy)
* *
* Reactive state for a single session's message stream. Manages MQTT * This is the main-thread side of the journal stream. It spawns a Shared
* connection, local message log, per-sender sequence tracking, and the * Worker that owns the MQTT connection. All state flows from the worker
* revealed-paths set (populated by the link reducer). * to this store via Comlink callbacks.
* *
* Session lifecycle (create/list/delete) is handled via MQTT retained * The public API is unchanged components still call `useJournalStream()`,
* topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session. * `sendMessage()`, etc. exactly as before.
*
* No persistence here that's the CLI server's job via JSONL append.
*/ */
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import * as Comlink from "comlink";
import type { StreamMessage } from "../journal/registry"; import type { StreamMessage } from "../journal/registry";
import { getMessageType, validatePayload } from "../journal/registry"; import { getMessageType, validatePayload } from "../journal/registry";
import type { WorkerState, WorkerPatch, SessionManifest } from "../../workers/journal.worker";
import type { JournalWorkerAPI } from "../../workers/journal.worker";
import { import {
loadPersisted, loadPersisted,
saveName, saveName,
@ -32,38 +33,17 @@ import {
export interface JournalStreamState { export interface JournalStreamState {
sessionId: string | null; sessionId: string | null;
/** Human-readable name for the current session (from manifest) */
sessionName: string | null; sessionName: string | null;
/** Full message log, oldest-first */
messages: StreamMessage[]; messages: StreamMessage[];
/** Last sequence number per sender */
senderSeq: Record<string, number>; senderSeq: Record<string, number>;
/**
* Paths and sections revealed by link messages.
* Key: normalized path (no .md). Value: set of revealed section slugs.
* An empty set means the whole article is revealed.
* Populated during hydration and live receipt via the type's reducer.
*/
revealedPaths: Record<string, Set<string>>; revealedPaths: Record<string, Set<string>>;
/** MQTT connection status */
connected: boolean; connected: boolean;
/** Granular connection state for UI indicators */
connectionStatus: "disconnected" | "connecting" | "connected" | "error"; connectionStatus: "disconnected" | "connecting" | "connected" | "error";
/** Last connection error message, if any */
connectionError: string | null; connectionError: string | null;
/** This client's identity */
myName: string; myName: string;
/** Role: gm | player | observer. Immutable while connected. */
myRole: "gm" | "player" | "observer"; myRole: "gm" | "player" | "observer";
/** Broker URL, set after connect */
brokerUrl: string | null; brokerUrl: string | null;
/** Active player list (keyed by player name) */
players: Record<string, { role: string }>; players: Record<string, { role: string }>;
/**
* Stat values set via /stat set/del/roll commands.
* Key: stat key (e.g. "strength", "alice:hp"). Value: string.
* Populated during hydration and live receipt via the stat type's reducer.
*/
stats: Record<string, string>; stats: Record<string, string>;
} }
@ -73,8 +53,24 @@ export interface SessionMeta {
players: string[]; players: string[];
} }
export interface SessionManifest { export type { SessionManifest } from "../../workers/journal.worker";
sessions: Record<string, SessionMeta>;
// ---------------------------------------------------------------------------
// Worker connection
// ---------------------------------------------------------------------------
let _workerAPI: Comlink.Remote<JournalWorkerAPI> | null = null;
let _unsubscribe: (() => void) | null = null;
function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
if (!_workerAPI) {
const worker = new SharedWorker(
new URL("../../workers/journal.worker.ts", import.meta.url),
);
_workerAPI = Comlink.wrap<JournalWorkerAPI>(worker.port);
worker.port.start();
}
return _workerAPI;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -84,7 +80,6 @@ export interface SessionManifest {
const persisted = loadPersisted(); const persisted = loadPersisted();
const urlParams = readUrlParams(); const urlParams = readUrlParams();
// URL params override localStorage if present
const initialName = urlParams.playerName ?? persisted.myName; const initialName = urlParams.playerName ?? persisted.myName;
const initialSession = urlParams.sessionId ?? persisted.lastSessionId; const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
@ -104,7 +99,6 @@ const [state, setState] = createStore<JournalStreamState>({
stats: {}, stats: {},
}); });
// Sync initial URL params if they came from localStorage (not URL)
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName); if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
if (initialSession && !urlParams.sessionId) if (initialSession && !urlParams.sessionId)
syncUrlParam("session", initialSession); syncUrlParam("session", initialSession);
@ -117,56 +111,10 @@ const [sessionList, setSessionList] = createSignal<SessionManifest>({
export { sessionList as sessions }; export { sessionList as sessions };
/**
* Change the current player's name. Persisted to localStorage so it
* survives page reloads. Also syncs to URL search param.
*/
export function setMyName(name: string): void {
setState("myName", name);
saveName(name);
syncUrlParam("player", name);
}
/**
* Change the current player's role. Persisted to localStorage.
* Only callable when disconnected.
*/
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
}
/**
* Set the active session ID and sync to URL. Also resolves the human-readable
* session name from the cached manifest.
*/
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
// Resolve session name from current manifest
const manifest = sessionList();
const name = manifest.sessions[id]?.name ?? null;
setState("sessionName", name);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
// Will hold the MQTT client instance after connect()
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Reducer runner — runs locally in each tab
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function makeMessageId(sender: string, seq: number): string {
return `${sender}-${seq}`;
}
function runReducer(msg: StreamMessage): void { function runReducer(msg: StreamMessage): void {
const def = getMessageType(msg.type); const def = getMessageType(msg.type);
if (def?.reducer) { if (def?.reducer) {
@ -174,260 +122,192 @@ function runReducer(msg: StreamMessage): void {
} }
} }
const $SESSIONS = "ttrpg/$SESSIONS"; /** Replay all messages through reducers to rebuild derived state. */
function replayReducers(): void {
// --------------------------------------------------------------------------- // Reset derived state
// Hydration (initial load from server) setState("revealedPaths", {});
// --------------------------------------------------------------------------- setState("stats", {});
for (const msg of state.messages) {
/**
* Load the full message history from the static server's JSONL file.
* The file is served from the same HTTP origin as the web app.
* Runs all reducers in order.
*/
export async function hydrateFromServer(sessionId: string): Promise<void> {
const response = await fetch(
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
);
if (!response.ok) {
if (response.status === 404) {
return; // fresh session, no file yet
}
throw new Error(`Failed to load session: ${response.statusText}`);
}
const text = await response.text();
const lines = text.split("\n").filter((l) => l.trim());
const messages: StreamMessage[] = [];
const senderSeq: Record<string, number> = {};
for (const line of lines) {
try {
const msg: StreamMessage = JSON.parse(line);
messages.push(msg);
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
runReducer(msg); runReducer(msg);
} catch {
/* skip corrupt */
} }
} }
// ---------------------------------------------------------------------------
// Worker patch handler
// ---------------------------------------------------------------------------
function handlePatch(patch: WorkerPatch): void {
switch (patch.type) {
case "fullState": {
setState( setState(
produce((s) => { produce((s) => {
s.messages = messages; s.sessionId = patch.state.sessionId;
s.senderSeq = senderSeq; s.sessionName = patch.state.sessionName;
s.sessionId = sessionId; s.messages = patch.state.messages;
s.senderSeq = patch.state.senderSeq;
s.connected = patch.state.connected;
s.connectionStatus = patch.state.connectionStatus;
s.connectionError = patch.state.connectionError;
s.myName = patch.state.myName;
s.myRole = patch.state.myRole;
s.brokerUrl = patch.state.brokerUrl;
s.players = patch.state.players;
}),
);
// Persist name/role so URL and localStorage stay in sync
if (patch.state.myName) {
saveName(patch.state.myName);
syncUrlParam("player", patch.state.myName);
}
saveRole(patch.state.myRole);
// Rebuild derived state (revealedPaths, stats) by replaying reducers
replayReducers();
break;
}
case "message": {
const msg = patch.message;
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
if (existingIdx !== -1) {
setState("messages", existingIdx, msg);
} else {
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(
s.senderSeq[msg.sender] ?? 0,
msg.seq,
);
}),
);
}
runReducer(msg);
break;
}
case "connectionStatus": {
setState("connectionStatus", patch.status);
if (patch.error !== undefined) {
setState("connectionError", patch.error);
}
if (patch.status === "connected") {
setState("connected", true);
} else if (patch.status === "disconnected") {
setState("connected", false);
}
break;
}
case "players": {
setState("players", patch.players);
break;
}
case "sessionName": {
setState("sessionName", patch.name);
break;
}
case "sessionList": {
setSessionList(patch.sessions);
break;
}
}
}
// ---------------------------------------------------------------------------
// Subscribe to worker (called once per tab)
// ---------------------------------------------------------------------------
let _subscribed = false;
async function ensureSubscribed(): Promise<void> {
if (_subscribed) return;
_subscribed = true;
const api = getWorkerAPI();
_unsubscribe = await api.subscribe(
Comlink.proxy((patch: WorkerPatch) => {
handlePatch(patch);
}), }),
); );
} }
// Start subscription eagerly
ensureSubscribed();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MQTT Connect // Public API — same signatures as before
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** export function setMyName(name: string): void {
* Connect to the MQTT broker, subscribe to the session stream, session setState("myName", name);
* list, and session meta. Must be called after `hydrateFromServer`. saveName(name);
*/ syncUrlParam("player", name);
}
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
}
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
export async function connectStream( export async function connectStream(
sessionId: string, sessionId: string,
brokerUrl: string, brokerUrl: string,
): Promise<void> { ): Promise<void> {
const { default: mqtt } = await import("mqtt"); const api = getWorkerAPI();
setState("connectionStatus", "connecting"); setState("connectionStatus", "connecting");
setState("connectionError", null); setState("connectionError", null);
return new Promise<void>((resolve, reject) => { try {
const client = mqtt.connect(brokerUrl, { await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
reconnectPeriod: 2000,
});
_mqttClient = client;
client.on("connect", () => {
_mqttConnected = true;
setState("connected", true);
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
saveBrokerUrl(brokerUrl); saveBrokerUrl(brokerUrl);
saveSessionId(sessionId); saveSessionId(sessionId);
} catch (err) {
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[stream] stream sub err:", err);
});
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[stream] sessions sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
// Presence tracking
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
const presenceData = JSON.stringify({
name: state.myName,
role: state.myRole,
});
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
presenceData,
{ qos: 1, retain: true },
);
resolve();
});
client.on("error", (err) => {
console.error("[stream] mqtt error:", err);
setState("connectionStatus", "error"); setState("connectionStatus", "error");
setState("connectionError", err.message);
reject(err);
});
client.on("close", () => {
_mqttConnected = false;
setState("connected", false);
setState("connectionStatus", "disconnected");
});
client.on("message", (topic, payload) => {
const raw = payload.toString();
const parts = topic.split("/");
if (topic === $SESSIONS) {
// Session manifest update
try {
const manifest: SessionManifest = JSON.parse(raw);
setSessionList(manifest);
// Refresh the sessionName if we're in a session now
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
setState("sessionName", manifest.sessions[currentId].name);
}
} catch (e) {
console.error("[stream] manifest parse err:", e);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
// Session meta update — manifest will be republished by server,
// handled via $SESSIONS subscription above.
return;
}
// Presence: ttrpg/{sessionId}/presence/{playerName}
if (
parts.length >= 4 &&
parts[0] === "ttrpg" &&
parts[2] === "presence"
) {
const playerName = parts[3];
if (raw) {
try {
const presence = JSON.parse(raw);
setState("players", playerName, {
role: presence.role || "player",
});
} catch {
setState("players", playerName, { role: "player" });
}
} else {
// Tombstone — player disconnected
setState( setState(
produce((s) => { "connectionError",
delete s.players[playerName]; err instanceof Error ? err.message : "Connection failed",
}),
); );
} throw err;
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
try {
const msg: StreamMessage = JSON.parse(raw);
receiveMessage(msg);
} catch (e) {
console.error("[stream] malformed message:", e);
} }
} }
});
});
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Create a new session by publishing retained metadata to its meta topic.
* The server picks it up and adds it to the $SESSIONS manifest.
*/
export function createSession(name: string, players: string[] = []): string | null {
if (!_mqttClient || !_mqttConnected) return null;
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
qos: 1,
retain: true,
});
return id;
}
/**
* Delete a session: publish tombstone (empty payload) to its meta topic.
*/
export function deleteSession(sessionId: string): void {
if (!_mqttClient || !_mqttConnected) return;
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
}
// ---------------------------------------------------------------------------
// Send
// ---------------------------------------------------------------------------
/**
* Publish a message to the stream. Validates the payload against the
* registered Zod schema before sending. Auto-increments the sender's seq.
*/
export function sendMessage<T>( export function sendMessage<T>(
type: string, type: string,
payload: T, payload: T,
): ):
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } { | { success: true; msg: StreamMessage<T> }
if (!_mqttClient || !_mqttConnected) { | { success: false; error: string } {
return { success: false, error: "Not connected to stream" }; const api = getWorkerAPI();
}
const sessionId = state.sessionId;
if (!sessionId) {
return { success: false, error: "No active session" };
}
// Validate locally first
const validation = validatePayload(type, payload); const validation = validatePayload(type, payload);
if (!validation.success) { if (!validation.success) {
return { success: false, error: validation.error }; return { success: false, error: validation.error };
} }
// Send to worker (which publishes to MQTT)
// The worker will broadcast back via the message patch
const result = api.sendMessage(type, validation.data);
// We can't await the proxy result synchronously, so we optimistically
// return success. The actual message will arrive via the patch callback.
// For the sync API compatibility, we construct a placeholder.
const sender = state.myName; const sender = state.myName;
const seq = (state.senderSeq[sender] ?? 0) + 1; const seq = (state.senderSeq[sender] ?? 0) + 1;
const id = makeMessageId(sender, seq); const id = `${sender}-${seq}`;
const msg: StreamMessage<T> = { const msg: StreamMessage<T> = {
id, id,
@ -439,116 +319,52 @@ export function sendMessage<T>(
reverted: false, reverted: false,
}; };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
if (err) console.error("[stream] publish error:", err);
});
// Optimistic local insert
receiveMessage(msg as StreamMessage);
return { success: true, msg }; return { success: true, msg };
} }
// ---------------------------------------------------------------------------
// Receive
// ---------------------------------------------------------------------------
function receiveMessage(msg: StreamMessage): void {
const existing = state.messages.find((m) => m.id === msg.id);
if (existing) {
setState(
produce((s) => {
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx !== -1) s.messages[idx] = msg;
}),
);
return;
}
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
}),
);
runReducer(msg);
}
// ---------------------------------------------------------------------------
// Revert
// ---------------------------------------------------------------------------
/**
* Revert the current sender's latest (highest seq) message.
* Only works if it's still the latest a subsequent message locks it.
*/
export function revertLatest(): export function revertLatest():
{ success: true } | { success: false; error: string } { | { success: true }
if (!_mqttClient || !_mqttConnected) { | { success: false; error: string } {
return { success: false, error: "Not connected to stream" }; const api = getWorkerAPI();
return api.revertLatest() as unknown as
| { success: true }
| { success: false; error: string };
} }
const sessionId = state.sessionId;
if (!sessionId) return { success: false, error: "No active session" };
const sender = state.myName;
const latestSeq = state.senderSeq[sender];
if (!latestSeq) return { success: false, error: "No messages to revert" };
const id = makeMessageId(sender, latestSeq);
const original = state.messages.find((m) => m.id === id);
if (!original) return { success: false, error: "Message not found" };
if (original.reverted) return { success: false, error: "Already reverted" };
const reverted: StreamMessage = { ...original, reverted: true };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
if (err) console.error("[stream] revert publish error:", err);
});
return { success: true };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
export function disconnectStream(): void { export function disconnectStream(): void {
if (_mqttClient) { const api = getWorkerAPI();
// Clear our presence before disconnecting (tombstone) api.disconnect();
const sessionId = state.sessionId;
if (sessionId) {
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
qos: 1,
retain: true,
});
}
// Force disconnect without reconnect
_mqttClient.end(true, void 0, () => {
// noop
});
_mqttClient = null;
_mqttConnected = false;
}
setState("connected", false);
setState("connectionStatus", "disconnected");
setState("players", {});
// Strip autojoin param so the dialog shows normally on next connect
removeUrlParam("autojoin"); removeUrlParam("autojoin");
} }
// --------------------------------------------------------------------------- export function createSession(
// Derived / helpers name: string,
// --------------------------------------------------------------------------- players: string[] = [],
): string | null {
const api = getWorkerAPI();
// This is sync in the worker but async over Comlink.
// We generate the ID locally using the same algorithm.
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
api.createSession(name, players);
return id;
}
export function deleteSession(sessionId: string): void {
const api = getWorkerAPI();
api.deleteSession(sessionId);
}
export function canRevert(): boolean { export function canRevert(): boolean {
const sender = state.myName; const sender = state.myName;
const seq = state.senderSeq[sender]; const seq = state.senderSeq[sender];
if (!seq) return false; if (!seq) return false;
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq)); const msg = state.messages.find(
(m) => m.id === `${sender}-${seq}`,
);
return msg !== undefined && !msg.reverted; return msg !== undefined && !msg.reverted;
} }
@ -561,3 +377,11 @@ export function useJournalStream() {
} }
export { state as journalStreamState }; 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()
}

View File

@ -1,334 +0,0 @@
import { createStore } from "solid-js/store";
import type {
CharacterStats,
CharacterSaves,
InventoryItem,
MothershipCharacter,
MothershipStoreState,
VitalValue,
StressValue,
} from "./types";
/**
*
*/
export function createDefaultCharacter(): MothershipCharacter {
return {
stats: {
strength: 50,
agility: 50,
combat: 50,
intellect: 50,
},
saves: {
fear: 50,
sanity: 50,
body: 50,
},
skills: [],
inventory: [],
status: [],
hp: { current: 0, max: 0 },
stress: { current: 0, min: 0 },
wounds: { current: 0, max: 0 },
};
}
/**
* 0-99
*/
function clampStat(value: number): number {
return Math.max(0, Math.min(99, value));
}
/**
*
*/
function clampValue(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
const [store, setStore] = createStore<MothershipStoreState>({
character: createDefaultCharacter(),
});
/**
*
*/
export function setStat<K extends keyof CharacterStats>(
key: K,
value: number
): void {
setStore("character", "stats", key, clampStat(value));
}
/**
*
*/
export function setStats(stats: Partial<CharacterStats>): void {
setStore("character", "stats", (prev) => ({
...prev,
...Object.fromEntries(
Object.entries(stats).map(([key, value]) => [key, clampStat(value)])
),
}));
}
/**
*
*/
export function setSave<K extends keyof CharacterSaves>(
key: K,
value: number
): void {
setStore("character", "saves", key, clampStat(value));
}
/**
*
*/
export function setSaves(saves: Partial<CharacterSaves>): void {
setStore("character", "saves", (prev) => ({
...prev,
...Object.fromEntries(
Object.entries(saves).map(([key, value]) => [key, clampStat(value)])
),
}));
}
/**
*
*/
export function addSkill(skill: string): void {
setStore("character", "skills", (prev) => [...prev, skill]);
}
/**
*
*/
export function removeSkill(skill: string): void {
setStore("character", "skills", (prev) =>
prev.filter((s) => s !== skill)
);
}
/**
*
*/
export function setSkills(skills: string[]): void {
setStore("character", "skills", [...skills]);
}
/**
*
*/
export function addInventoryItem(
name: string,
quantity: number = 1,
attributes?: Record<string, any>
): void {
setStore("character", "inventory", (prev) => [
...prev,
{ name, quantity: Math.max(1, quantity), attributes },
]);
}
/**
*
*/
export function removeInventoryItem(name: string): void {
setStore("character", "inventory", (prev) =>
prev.filter((item) => item.name !== name)
);
}
/**
*
*/
export function updateInventoryItemQuantity(
name: string,
quantity: number
): void {
setStore("character", "inventory", (prev) =>
prev.map((item) =>
item.name === name
? { ...item, quantity: Math.max(0, quantity) }
: item
).filter((item) => item.quantity > 0)
);
}
/**
*
*/
export function updateInventoryItemAttributes(
name: string,
attributes: Record<string, any>
): void {
setStore("character", "inventory", (prev) =>
prev.map((item) =>
item.name === name
? { ...item, attributes }
: item
)
);
}
/**
*
*/
export function addStatus(
name: string,
quantity: number = 1,
attributes?: Record<string, any>
): void {
setStore("character", "status", (prev) => [
...prev,
{ name, quantity: Math.max(1, quantity), attributes },
]);
}
/**
*
*/
export function removeStatus(name: string): void {
setStore("character", "status", (prev) =>
prev.filter((item) => item.name !== name)
);
}
/**
* HP
*/
export function setHP(value: Partial<VitalValue>): void {
setStore("character", "hp", (prev) => ({
...prev,
...value,
current: value.current !== undefined
? clampValue(value.current, 0, value.max ?? prev.max)
: prev.current,
max: value.max !== undefined
? Math.max(0, value.max)
: prev.max,
}));
}
/**
*
*/
export function takeDamage(amount: number): void {
setStore("character", "hp", (prev) => ({
...prev,
current: Math.max(0, prev.current - amount),
}));
}
/**
* HP
*/
export function healHP(amount: number): void {
setStore("character", "hp", (prev) => ({
...prev,
current: Math.min(prev.max, prev.current + amount),
}));
}
/**
*
*/
export function setStress(value: Partial<StressValue>): void {
setStore("character", "stress", (prev) => ({
...prev,
...value,
current: value.current !== undefined
? clampValue(value.current, value.min ?? prev.min, Number.MAX_SAFE_INTEGER)
: prev.current,
min: value.min !== undefined ? Math.max(0, value.min) : prev.min,
}));
}
/**
*
*/
export function addStress(amount: number): void {
setStore("character", "stress", (prev) => ({
...prev,
current: prev.current + amount,
}));
}
/**
*
*/
export function reduceStress(amount: number): void {
setStore("character", "stress", (prev) => ({
...prev,
current: Math.max(prev.min, prev.current - amount),
}));
}
/**
*
*/
export function setWounds(value: Partial<VitalValue>): void {
setStore("character", "wounds", (prev) => ({
...prev,
...value,
current: value.current !== undefined
? clampValue(value.current, 0, value.max ?? prev.max)
: prev.current,
max: value.max !== undefined
? Math.max(0, value.max)
: prev.max,
}));
}
/**
*
*/
export function addWound(amount: number = 1): void {
setStore("character", "wounds", (prev) => ({
...prev,
current: Math.min(prev.max, prev.current + amount),
}));
}
/**
*
*/
export function healWound(amount: number = 1): void {
setStore("character", "wounds", (prev) => ({
...prev,
current: Math.max(0, prev.current - amount),
}));
}
/**
*
*/
export function resetCharacter(): void {
setStore("character", createDefaultCharacter());
}
/**
*
*/
export function setCharacter(character: MothershipCharacter): void {
setStore("character", character);
}
/**
*
*/
export function getCharacter(): MothershipCharacter {
return store.character;
}
/**
* Store SolidJS
*/
export function useCharacterStore() {
return store;
}
export { store, setStore };

View File

@ -1,66 +0,0 @@
/**
* Mothership TRPG Store
*
* @module journals/mothership
*/
export type {
CharacterStats,
CharacterSaves,
InventoryItem,
VitalValue,
StressValue,
MothershipCharacter,
MothershipStoreState,
} from "./types";
export {
// Store 核心
store,
setStore,
useCharacterStore,
getCharacter,
// 初始化
createDefaultCharacter,
resetCharacter,
setCharacter,
// Stats 操作
setStat,
setStats,
// Saves 操作
setSave,
setSaves,
// Skills 操作
addSkill,
removeSkill,
setSkills,
// Inventory 操作
addInventoryItem,
removeInventoryItem,
updateInventoryItemQuantity,
updateInventoryItemAttributes,
// Status 操作
addStatus,
removeStatus,
// HP 操作
setHP,
takeDamage,
healHP,
// Stress 操作
setStress,
addStress,
reduceStress,
// Wounds 操作
setWounds,
addWound,
healWound,
} from "./characterStore";

View File

@ -1,76 +0,0 @@
/**
* Mothership TRPG
* 0-99
*/
export interface CharacterStats {
/** 力量 */
strength: number;
/** 敏捷 */
agility: number;
/** 战斗 */
combat: number;
/** 智力 */
intellect: number;
}
/**
* Mothership TRPG
* 0-99
*/
export interface CharacterSaves {
/** 恐惧豁免 */
fear: number;
/** 理智豁免 */
sanity: number;
/** 体质豁免 */
body: number;
}
/**
*
*/
export interface InventoryItem {
/** 物品名称 */
name: string;
/** 数量 */
quantity: number;
/** 自定义属性,如护甲值 { ap: 3 } */
attributes?: Record<string, any>;
}
/**
* /
*/
export interface VitalValue {
current: number;
max: number;
}
/**
*
*/
export interface StressValue {
current: number;
min: number;
}
/**
* Mothership
*/
export interface MothershipCharacter {
stats: CharacterStats;
saves: CharacterSaves;
skills: string[];
inventory: InventoryItem[];
status: InventoryItem[];
hp: VitalValue;
stress: StressValue;
wounds: VitalValue;
}
/**
* Store
*/
export type MothershipStoreState = {
character: MothershipCharacter;
};

44
src/markdown/cmd.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* marked-directive extension: :cmd[command]{label=Display text} inline syntax.
*
* Renders a clickable span that dispatches the command to the journal stream
* when clicked. The CommandLinkManager component handles the actual dispatch.
*
* Usage in markdown:
* :cmd[roll 1d20+5]{label=Roll initiative}
* :cmd[spark dungeon room-type]{label=Random room}
* :cmd[stat set strength=18]{label=Set strength}
*
* If no label is provided, the command text itself is shown.
*/
export const cmdDirective = {
level: "inline" as const,
marker: ":",
renderer(token: any) {
// Only handle :cmd[...], not other :directives
if (token.meta.name !== "cmd") return false;
const command = (token.text || "").trim();
if (!command) return "";
const label = (token.attrs?.label as string) || command;
return `<a class="cmd-link" data-cmd="${escapeAttr(command)}">${escapeHtml(label)}</a>`;
},
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View File

@ -7,6 +7,7 @@ import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns"; import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag"; import markedCodeBlockYamlTag from "./code-block-yaml-tag";
import { iconDirective, setIconBase } from "./icon"; import { iconDirective, setIconBase } from "./icon";
import { cmdDirective } from "./cmd";
// 使用 marked-directive 来支持指令语法 // 使用 marked-directive 来支持指令语法
const marked = new Marked() const marked = new Marked()
@ -27,6 +28,7 @@ const marked = new Marked()
level: "container", level: "container",
}, },
iconDirective, iconDirective,
cmdDirective,
]), ]),
{ {
extensions: [...markedColumns()], extensions: [...markedColumns()],

View File

@ -149,3 +149,14 @@ icon.big .icon-label-stroke {
.concealed .revealed { .concealed .revealed {
opacity: unset; opacity: unset;
} }
/* cmd-link — clickable command links from :cmd[] directive */
.cmd-link {
@apply text-blue-600 underline cursor-pointer select-none outline-none;
@apply hover:text-blue-800;
@apply active:text-blue-900;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}

View File

@ -0,0 +1,529 @@
/**
* Journal Shared Worker owns the single MQTT connection and message log.
*
* All browser tabs share one worker instance. The worker:
* - Connects to MQTT (one connection total)
* - Hydrates message history from the server
* - Maintains the message log and presence tracking
* - Broadcasts new messages and state changes to all connected tabs
*
* Each tab runs reducers locally to build derived state (revealedPaths, stats).
*/
import * as Comlink from "comlink";
import type { StreamMessage } from "../components/journal/registry";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SessionMeta {
name: string;
created: number;
players: string[];
}
export interface SessionManifest {
sessions: Record<string, SessionMeta>;
}
/** Lightweight state snapshot sent to tabs. */
export interface WorkerState {
sessionId: string | null;
sessionName: string | null;
messages: StreamMessage[];
senderSeq: Record<string, number>;
connected: boolean;
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
connectionError: string | null;
myName: string;
myRole: "gm" | "player" | "observer";
brokerUrl: string | null;
players: Record<string, { role: string }>;
}
/** Patch sent on incremental updates (new message, presence change, etc.). */
export type WorkerPatch =
| { type: "fullState"; state: WorkerState }
| { type: "message"; message: StreamMessage }
| { type: "connectionStatus"; status: WorkerState["connectionStatus"]; error?: string }
| { type: "players"; players: Record<string, { role: string }> }
| { type: "sessionName"; name: string | null }
| { type: "sessionList"; sessions: SessionManifest };
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS";
let state: WorkerState = {
sessionId: null,
sessionName: null,
messages: [],
senderSeq: {},
connected: false,
connectionStatus: "disconnected",
connectionError: null,
myName: "gm",
myRole: "gm",
brokerUrl: null,
players: {},
};
let sessionList: SessionManifest = { sessions: {} };
// MQTT client reference
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
// Subscribers: each tab registers a callback
type PatchCallback = (patch: WorkerPatch) => void;
const subscribers = new Set<PatchCallback>();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeMessageId(sender: string, seq: number): string {
return `${sender}-${seq}`;
}
function notifySubscribers(patch: WorkerPatch): void {
for (const cb of subscribers) {
try {
cb(patch);
} catch {
// subscriber may have disconnected
}
}
}
function broadcastFullState(): void {
notifySubscribers({ type: "fullState", state: { ...state, messages: [...state.messages], senderSeq: { ...state.senderSeq }, players: { ...state.players } } });
}
// ---------------------------------------------------------------------------
// Hydration
// ---------------------------------------------------------------------------
async function hydrateFromServer(sessionId: string): Promise<void> {
const response = await fetch(
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
);
if (!response.ok) {
if (response.status === 404) return;
throw new Error(`Failed to load session: ${response.statusText}`);
}
const text = await response.text();
const lines = text.split("\n").filter((l) => l.trim());
const messages: StreamMessage[] = [];
const senderSeq: Record<string, number> = {};
for (const line of lines) {
try {
const msg: StreamMessage = JSON.parse(line);
messages.push(msg);
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
} catch {
/* skip corrupt */
}
}
state.messages = messages;
state.senderSeq = senderSeq;
state.sessionId = sessionId;
}
// ---------------------------------------------------------------------------
// MQTT Connect
// ---------------------------------------------------------------------------
async function connectStream(
sessionId: string,
brokerUrl: string,
name: string,
role: "gm" | "player" | "observer",
): Promise<void> {
const { default: mqtt } = await import("mqtt");
state.connectionStatus = "connecting";
state.connectionError = null;
state.myName = name;
state.myRole = role;
notifySubscribers({ type: "connectionStatus", status: "connecting" });
return new Promise<void>((resolve, reject) => {
const client = mqtt.connect(brokerUrl, {
clientId: `${name}-${role}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
reconnectPeriod: 2000,
});
_mqttClient = client;
client.on("connect", () => {
_mqttConnected = true;
state.connected = true;
state.connectionStatus = "connected";
state.connectionError = null;
state.brokerUrl = brokerUrl;
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[worker] stream sub err:", err);
});
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 },
);
notifySubscribers({ type: "connectionStatus", status: "connected" });
broadcastFullState();
resolve();
});
client.on("error", (err) => {
console.error("[worker] mqtt error:", err);
state.connectionStatus = "error";
state.connectionError = err.message;
notifySubscribers({ type: "connectionStatus", status: "error", error: err.message });
reject(err);
});
client.on("close", () => {
_mqttConnected = false;
state.connected = false;
state.connectionStatus = "disconnected";
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
});
client.on("message", (topic, payload) => {
const raw = payload.toString();
const parts = topic.split("/");
if (topic === $SESSIONS) {
try {
const manifest: SessionManifest = JSON.parse(raw);
sessionList = manifest;
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
state.sessionName = manifest.sessions[currentId].name;
notifySubscribers({ type: "sessionName", name: state.sessionName });
}
notifySubscribers({ type: "sessionList", sessions: manifest });
} catch (e) {
console.error("[worker] manifest parse err:", e);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
return;
}
// Presence
if (
parts.length >= 4 &&
parts[0] === "ttrpg" &&
parts[2] === "presence"
) {
const playerName = parts[3];
if (raw) {
try {
const presence = JSON.parse(raw);
state.players = { ...state.players, [playerName]: { role: presence.role || "player" } };
} catch {
state.players = { ...state.players, [playerName]: { role: "player" } };
}
} else {
const newPlayers = { ...state.players };
delete newPlayers[playerName];
state.players = newPlayers;
}
notifySubscribers({ type: "players", players: { ...state.players } });
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
try {
const msg: StreamMessage = JSON.parse(raw);
receiveMessage(msg);
} catch (e) {
console.error("[worker] malformed message:", e);
}
}
});
});
}
// ---------------------------------------------------------------------------
// Receive
// ---------------------------------------------------------------------------
function receiveMessage(msg: StreamMessage): void {
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
if (existingIdx !== -1) {
state.messages = [
...state.messages.slice(0, existingIdx),
msg,
...state.messages.slice(existingIdx + 1),
];
} else {
state.messages = [...state.messages, msg];
state.senderSeq = {
...state.senderSeq,
[msg.sender]: Math.max(state.senderSeq[msg.sender] ?? 0, msg.seq),
};
}
notifySubscribers({ type: "message", message: msg });
}
// ---------------------------------------------------------------------------
// Send
// ---------------------------------------------------------------------------
function sendMessage(
type: string,
payload: unknown,
): { success: true; msg: StreamMessage } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) {
return { success: false, error: "No active session" };
}
// We can't import validatePayload here because it depends on the registry
// which is populated by importing types. We do basic validation.
if (!type || typeof type !== "string") {
return { success: false, error: "Invalid message type" };
}
const sender = state.myName;
const seq = (state.senderSeq[sender] ?? 0) + 1;
const id = makeMessageId(sender, seq);
const msg: StreamMessage = {
id,
sender,
seq,
type,
payload,
timestamp: Date.now(),
reverted: false,
};
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
if (err) console.error("[worker] publish error:", err);
});
// Optimistic local insert
receiveMessage(msg);
return { success: true, msg };
}
// ---------------------------------------------------------------------------
// Revert
// ---------------------------------------------------------------------------
function revertLatest():
{ success: true } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) return { success: false, error: "No active session" };
const sender = state.myName;
const latestSeq = state.senderSeq[sender];
if (!latestSeq) return { success: false, error: "No messages to revert" };
const id = makeMessageId(sender, latestSeq);
const original = state.messages.find((m) => m.id === id);
if (!original) return { success: false, error: "Message not found" };
if (original.reverted) return { success: false, error: "Already reverted" };
const reverted: StreamMessage = { ...original, reverted: true };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
if (err) console.error("[worker] revert publish error:", err);
});
return { success: true };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
function disconnectStream(): void {
if (_mqttClient) {
const sessionId = state.sessionId;
if (sessionId) {
_mqttClient.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
"",
{ qos: 1, retain: true },
);
}
_mqttClient.end(true, void 0, () => {});
_mqttClient = null;
_mqttConnected = false;
}
state.connected = false;
state.connectionStatus = "disconnected";
state.players = {};
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
notifySubscribers({ type: "players", players: {} });
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
function createSession(name: string, players: string[] = []): string | null {
if (!_mqttClient || !_mqttConnected) return null;
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
qos: 1,
retain: true,
});
return id;
}
function deleteSession(sessionId: string): void {
if (!_mqttClient || !_mqttConnected) return;
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
}
// ---------------------------------------------------------------------------
// Derived
// ---------------------------------------------------------------------------
function canRevert(): boolean {
const sender = state.myName;
const seq = state.senderSeq[sender];
if (!seq) return false;
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
return msg !== undefined && !msg.reverted;
}
function visibleMessages(): StreamMessage[] {
return state.messages.filter((m) => !m.reverted);
}
// ---------------------------------------------------------------------------
// Comlink API — exposed to main thread
// ---------------------------------------------------------------------------
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);
},
disconnect(): void {
disconnectStream();
},
sendMessage(type: string, payload: unknown) {
return sendMessage(type, payload);
},
revertLatest() {
return revertLatest();
},
createSession(name: string, players: string[] = []) {
return createSession(name, players);
},
deleteSession(sessionId: string) {
deleteSession(sessionId);
},
getState(): WorkerState {
return {
...state,
messages: [...state.messages],
senderSeq: { ...state.senderSeq },
players: { ...state.players },
};
},
getSessionList(): SessionManifest {
return sessionList;
},
canRevert(): boolean {
return canRevert();
},
visibleMessages(): StreamMessage[] {
return visibleMessages();
},
/** Register a callback for state patches. Sends full state immediately. */
subscribe(callback: Comlink.ProxyOrClone<PatchCallback>): () => void {
const cb = callback as PatchCallback;
subscribers.add(cb);
// Send initial full state
try {
cb({
type: "fullState",
state: {
...state,
messages: [...state.messages],
senderSeq: { ...state.senderSeq },
players: { ...state.players },
},
});
} catch {
// ignore
}
return () => {
subscribers.delete(cb);
};
},
};
export type JournalWorkerAPI = typeof api;
// SharedWorkers communicate via MessagePort from the 'connect' event,
// not via self.onmessage. Each connecting tab gets its own port.
(self as unknown as { onconnect: ((e: MessageEvent) => void) | null }).onconnect = (e: MessageEvent) => {
const port = e.ports[0];
Comlink.expose(api, port);
};