Compare commits

..

No commits in common. "86abf34c10bb4984d6d56e9aec726b349048e63a" and "2068ecad10054c7be3b2a35ec89670069215101e" have entirely different histories.

26 changed files with 1564 additions and 1471 deletions

7
package-lock.json generated
View File

@ -14,7 +14,6 @@
"@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",
@ -4333,12 +4332,6 @@
"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,7 +47,6 @@
"@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,8 +18,6 @@ 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";
@ -158,8 +156,6 @@ const App: Component = () => {
src={currentPath()} src={currentPath()}
> >
<RevealManager /> <RevealManager />
<ReactiveStatManager />
<CommandLinkManager />
</Article> </Article>
</div> </div>
</main> </main>

View File

@ -19,6 +19,7 @@ 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;
@ -87,6 +88,22 @@ 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
*/ */
@ -99,6 +116,7 @@ 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 }[] = [];
@ -130,6 +148,17 @@ 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;
} }
@ -312,6 +341,7 @@ 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 = {
@ -320,13 +350,14 @@ 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}`, `[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}`,
); );
} }
@ -368,6 +399,12 @@ 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) {
@ -394,6 +431,12 @@ 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) {
@ -411,7 +454,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")) { if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults; directiveResults = rescan.directiveResults;

View File

@ -20,6 +20,7 @@ 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 {
@ -36,6 +37,7 @@ export {
export interface ProcessedBlocks { export interface ProcessedBlocks {
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export interface BlockResult { export interface BlockResult {
@ -76,6 +78,7 @@ 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,6 +14,7 @@ export type {
SparkTableCompletion, SparkTableCompletion,
StatDef, StatDef,
StatTemplate, StatTemplate,
StatSheet,
} from "./types.js"; } from "./types.js";
/** /**
@ -42,5 +43,6 @@ export function scanCompletions(
sparkTables, sparkTables,
stats: blocks.stats, stats: blocks.stats,
statTemplates: blocks.statTemplates, statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,
}; };
} }

View File

@ -6,6 +6,18 @@ 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 */
@ -50,6 +62,7 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
/** /**

View File

@ -1,58 +0,0 @@
/**
* 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,5 +1,4 @@
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";
@ -25,7 +24,6 @@ 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);
// 默认收起,除非当前文件在该文件夹内 // 默认收起,除非当前文件在该文件夹内
@ -33,28 +31,21 @@ export const FileTreeNode: Component<{
isDir && isPathInDir(props.currentPath, props.node.path), isDir && isPathInDir(props.currentPath, props.node.path),
); );
const href = () => props.node.path + location.search; const handleClick = () => {
const handleClick = (e: MouseEvent) => {
if (isDir) { if (isDir) {
e.preventDefault();
setIsExpanded(!isExpanded()); setIsExpanded(!isExpanded());
} else if (e.button === 0) { } else {
// 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" : ""}>
<a <div
href={href()} class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
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` }}
@ -67,7 +58,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>
</a> </div>
<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) => (
@ -96,9 +87,8 @@ 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}${location.search}#${anchor}`; const href = `${props.basePath}#${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);
@ -112,12 +102,8 @@ export const HeadingNode: Component<{
const scrollContainer = useScrollContainer(); const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (e.button === 0) { e.preventDefault();
// Left-click: use SPA navigation navigate(href);
e.preventDefault();
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);
@ -155,7 +141,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,8 +15,6 @@ 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,20 +7,26 @@
* - `/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 ----
@ -33,6 +39,7 @@ 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);
@ -72,75 +79,201 @@ 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 = unwrapSendResult(result); const r = unwrap(result);
finish(r.ok, r.error); finish(r.ok, r.err);
return; return;
} }
const parsed = parseInput(raw); const parsed = parseInput(raw);
if (parsed.error) { if (parsed.error) {
setDispatchError(parsed.error); setError(parsed.error);
return; return;
} }
setDispatchError(null); setError(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 = unwrapSendResult(result); const r = unwrap(result);
finish(r.ok, r.error); finish(r.ok, r.err);
return; return;
} }
if (parsed.type !== "stat") { if (parsed.type === "stat") {
setDispatchError("玩家只能发送聊天消息或使用 /stat 命令"); handleStat(parsed.payload);
return; return;
} }
setError("玩家只能发送聊天消息或使用 /stat 命令");
return;
} }
// GM: all commands, Player: stat commands // GM: all commands
setSending(true); setSending(true);
await dispatchCommand({ if (parsed.type === "roll") {
role: stream.myRole as "gm" | "player" | "observer", const p = resolveRollPayload(
myName: stream.myName, parsed.payload as { notation: string; label?: string },
command: raw, );
sparkTables: comp.data.sparkTables, const result = sendMessage("roll", p);
statValues: stream.stats, const r = unwrap(result);
statDefs: comp.data.stats, finish(r.ok, r.err);
statTemplates: comp.data.statTemplates, return;
});
// dispatchCommand already set the shared error if it failed.
// Only clear text on success (no error is shown).
if (!dispatchError()) {
setText("");
} }
setSending(false);
textareaRef?.focus(); if (parsed.type === "spark") {
try {
const key = (parsed.payload as { key: string }).key;
const match = comp.data.sparkTables.find((s) => s.slug === key);
const csvPath = match?.csvPath ?? "";
const remix = match?.remix ?? false;
const p = await resolveSparkPayload({ key, csvPath, remix });
const result = sendMessage("spark", p);
const r = unwrap(result);
finish(r.ok, r.err);
} catch (e) {
setError(e instanceof Error ? e.message : "种子表掷骰失败");
setSending(false);
}
return;
}
if (parsed.type === "stat") {
handleStat(parsed.payload);
setSending(false);
return;
}
const result = sendMessage(parsed.type, parsed.payload);
const r = unwrap(result);
finish(r.ok, r.err);
} }
/** Clear text or set shared dispatch error on failure. */ /** 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) {
setDispatchError(err); setError(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 unwrapSendResult<R>( function unwrap<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, error: undefined } as const) ? ({ ok: true, err: undefined } as const)
: ({ ok: false, error: r.error } as const); : ({ ok: false, err: r.error } as const);
} }
// ---- Completions ---- // ---- Completions ----
@ -271,17 +404,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-15" bg-transparent min-h-[60px]"
/> />
<div class="flex items-center justify-between px-2 pb-2"> <div class="flex items-center justify-between px-2 pb-2">
<Show when={dispatchError()}> <Show when={error()}>
<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={dispatchError() ?? undefined} title={error() ?? undefined}
> >
{dispatchError()} {error()}
</button> </button>
</Show> </Show>
<div class="flex items-center gap-1 ml-auto"> <div class="flex items-center gap-1 ml-auto">
@ -301,7 +434,7 @@ export const JournalInput: Component = () => {
{/* Error popup */} {/* Error popup */}
<ErrorPopup <ErrorPopup
message={dispatchError()} message={error()}
show={showErrorPopup()} show={showErrorPopup()}
onClose={() => setShowErrorPopup(false)} onClose={() => setShowErrorPopup(false)}
/> />

View File

@ -1,183 +0,0 @@
/**
* 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

@ -0,0 +1,121 @@
/**
* 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,15 +1,21 @@
/** /**
* StatsView table view of all stat key-value pairs. * StatsView table view of all stat key-value pairs, plus optional
* stat sheet rendering from *.sheet.svg files.
* *
* Groups stats by scope (global, then per-player). Shows computed values * 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, Show } from "solid-js"; import { Component, For, createMemo, createSignal, 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();
@ -17,6 +23,15 @@ 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(() => {
@ -109,126 +124,191 @@ export const StatsView: Component = () => {
}); });
return ( return (
<div class="h-full overflow-y-auto bg-gray-50"> <div class="h-full overflow-y-auto bg-gray-50 relative">
<Show <Show
when={statDefs().length > 0} when={selectedSheetId()}
fallback={ fallback={
<p class="text-center text-gray-400 text-xs py-8"> <Show
使 ```yaml role=stat 代码块定义属性。 when={statDefs().length > 0}
</p> fallback={
<p class="text-center text-gray-400 text-xs py-8">
使 ```yaml role=stat 代码块定义属性。
</p>
}
>
<For each={groups()}>
{(group) => (
<div class="mb-3">
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-600">
{group.label}
</span>
</div>
<div class="divide-y divide-gray-100">
<For each={group.entries}>
{({ fullKey: fk, def }) => {
const val = () => values()[fk];
const comp = () => computedValue(fk);
const hasModifiers = () => {
for (const [mfk, md] of defMap()) {
if (
md.type === "modifier" &&
md.target &&
fullKeyTarget(md.target, md, def)
) {
return true;
}
}
return false;
};
const canRoll = () =>
def.roll ||
def.formula ||
def.type === "enum" ||
def.type === "template";
return (
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-sm text-gray-800 truncate">
{modifierLabel(def, statDefs())}
</span>
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
{def.key}
</span>
<Show when={def.type === "modifier"}>
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
{def.target}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show
when={val() !== undefined}
fallback={
<span class="text-sm text-gray-300">
{def.default ?? "—"}
</span>
}
>
<span class="text-sm font-mono text-gray-700">
{val()}
</span>
</Show>
<Show
when={
hasModifiers() &&
comp() !== (val() ?? def.default ?? "")
}
>
<span class="text-xs text-gray-400">
= {comp()}
</span>
</Show>
<Show when={canRoll()}>
<button
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
title="掷骰"
onClick={() => {
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${def.key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</Show>
} }
> >
<For each={groups()}> <SheetView sheetId={selectedSheetId()!} />
{(group) => ( </Show>
<div class="mb-3">
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-600">
{group.label}
</span>
</div>
<div class="divide-y divide-gray-100">
<For each={group.entries}>
{({ fullKey: fk, def }) => {
const val = () => values()[fk];
const comp = () => computedValue(fk);
const hasModifiers = () => {
for (const [mfk, md] of defMap()) {
if (
md.type === "modifier" &&
md.target &&
fullKeyTarget(md.target, md, def)
) {
return true;
}
}
return false;
};
const canRoll = () =>
def.roll ||
def.formula ||
def.type === "enum" ||
def.type === "template";
return ( {/* Sheet picker — only show if sheets are available */}
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"> <Show when={sheets().length > 0}>
<div class="flex-1 min-w-0 flex items-center gap-1.5"> <div class="sticky bottom-3 flex justify-end pr-3">
<span class="text-sm text-gray-800 truncate"> {/* Dropdown trigger */}
{modifierLabel(def, statDefs())} <button
</span> 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"
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0"> onClick={() => setPickerOpen((v) => !v)}
{def.key} title="选择属性卡"
</span> >
<Show when={def.type === "modifier"}> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0"> <rect x="3" y="3" width="18" height="18" rx="2" />
{def.target} <line x1="9" y1="3" x2="9" y2="21" />
</span> </svg>
</Show> </button>
</div>
<div class="flex items-center gap-2 shrink-0"> {/* Dropdown menu */}
<Show <Show when={pickerOpen()}>
when={val() !== undefined} <div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50">
fallback={ <button
<span class="text-sm text-gray-300"> class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
{def.default ?? "—"} selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700"
</span> }`}
} onClick={() => {
> setSelectedSheetId(null);
<span class="text-sm font-mono text-gray-700"> setPickerOpen(false);
{val()} }}
</span> >
</Show> 📋
</button>
<Show <div class="border-t border-gray-100 my-1" />
when={ <For each={sheets()}>
hasModifiers() && {(sheet) => (
comp() !== (val() ?? def.default ?? "") <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"
<span class="text-xs text-gray-400"> }`}
= {comp()} onClick={() => {
</span> setSelectedSheetId(sheet.id);
</Show> setPickerOpen(false);
}}
<Show when={canRoll()}> >
<button {sheet.label}
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors" </button>
title="掷骰" )}
onClick={() => { </For>
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${def.key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div> </div>
)} </Show>
</For>
{/* Backdrop to close picker */}
<Show when={pickerOpen()}>
<div
class="fixed inset-0 z-40"
onClick={() => setPickerOpen(false)}
/>
</Show>
</div>
</Show> </Show>
</div> </div>
); );
}; };

View File

@ -1,230 +0,0 @@
/**
* 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,6 +22,7 @@ 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,
@ -30,7 +31,7 @@ import {
scanDirectives, scanDirectives,
} from "../../cli/completions/directive-scanner"; } from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate }; export type { StatDef, StatTemplate, StatSheet };
// ------------------- Types (mirrors CLI) ------------------- // ------------------- Types (mirrors CLI) -------------------
@ -62,6 +63,7 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export type CompletionsState = export type CompletionsState =
@ -91,6 +93,7 @@ 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;
@ -172,7 +175,46 @@ async function scanClientSide(): Promise<JournalCompletions> {
sparkTables.push(...directiveResult.sparkTables); sparkTables.push(...directiveResult.sparkTables);
} }
return { dice, links, sparkTables, stats, statTemplates }; return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
}
// ------------------- 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) -------------------
@ -189,7 +231,8 @@ 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();
if (data.dice.length > 0 || data.links.length > 0) { data.statSheets = await scanStatSheets();
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" });
@ -240,6 +283,7 @@ export function useJournalCompletions(): {
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}, },
}; };
} }

View File

@ -49,7 +49,5 @@ 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

@ -0,0 +1,89 @@
/**
* 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,21 +1,20 @@
/** /**
* Journal Stream Client Store (Worker Proxy) * Journal Stream Client Store
* *
* This is the main-thread side of the journal stream. It spawns a Shared * Reactive state for a single session's message stream. Manages MQTT
* Worker that owns the MQTT connection. All state flows from the worker * connection, local message log, per-sender sequence tracking, and the
* to this store via Comlink callbacks. * revealed-paths set (populated by the link reducer).
* *
* The public API is unchanged components still call `useJournalStream()`, * Session lifecycle (create/list/delete) is handled via MQTT retained
* `sendMessage()`, etc. exactly as before. * topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
*
* No persistence here that's the CLI server's job via JSONL append.
*/ */
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,
@ -33,17 +32,38 @@ 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>;
} }
@ -53,24 +73,8 @@ export interface SessionMeta {
players: string[]; players: string[];
} }
export type { SessionManifest } from "../../workers/journal.worker"; export interface SessionManifest {
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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -80,6 +84,7 @@ function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
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;
@ -99,6 +104,7 @@ 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);
@ -111,10 +117,56 @@ 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;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reducer runner — runs locally in each tab // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
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) {
@ -122,192 +174,260 @@ function runReducer(msg: StreamMessage): void {
} }
} }
/** Replay all messages through reducers to rebuild derived state. */ const $SESSIONS = "ttrpg/$SESSIONS";
function replayReducers(): void {
// Reset derived state
setState("revealedPaths", {});
setState("stats", {});
for (const msg of state.messages) {
runReducer(msg);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Worker patch handler // Hydration (initial load from server)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function handlePatch(patch: WorkerPatch): void { /**
switch (patch.type) { * Load the full message history from the static server's JSONL file.
case "fullState": { * The file is served from the same HTTP origin as the web app.
setState( * Runs all reducers in order.
produce((s) => { */
s.sessionId = patch.state.sessionId; export async function hydrateFromServer(sessionId: string): Promise<void> {
s.sessionName = patch.state.sessionName; const response = await fetch(
s.messages = patch.state.messages; `/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
s.senderSeq = patch.state.senderSeq; );
s.connected = patch.state.connected;
s.connectionStatus = patch.state.connectionStatus; if (!response.ok) {
s.connectionError = patch.state.connectionError; if (response.status === 404) {
s.myName = patch.state.myName; return; // fresh session, no file yet
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;
} }
throw new Error(`Failed to load session: ${response.statusText}`);
}
case "message": { const text = await response.text();
const msg = patch.message; const lines = text.split("\n").filter((l) => l.trim());
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
if (existingIdx !== -1) { const messages: StreamMessage[] = [];
setState("messages", existingIdx, msg); const senderSeq: Record<string, number> = {};
} else {
setState( for (const line of lines) {
produce((s) => { try {
s.messages.push(msg); const msg: StreamMessage = JSON.parse(line);
s.senderSeq[msg.sender] = Math.max( messages.push(msg);
s.senderSeq[msg.sender] ?? 0, senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
msg.seq,
);
}),
);
}
runReducer(msg); runReducer(msg);
break; } catch {
} /* skip corrupt */
case "connectionStatus": {
setState("connectionStatus", patch.status);
if (patch.error !== undefined) {
setState("connectionError", patch.error);
}
if (patch.status === "connected") {
setState("connected", true);
} else if (patch.status === "disconnected") {
setState("connected", false);
}
break;
}
case "players": {
setState("players", patch.players);
break;
}
case "sessionName": {
setState("sessionName", patch.name);
break;
}
case "sessionList": {
setSessionList(patch.sessions);
break;
} }
} }
}
// --------------------------------------------------------------------------- setState(
// Subscribe to worker (called once per tab) produce((s) => {
// --------------------------------------------------------------------------- s.messages = messages;
s.senderSeq = senderSeq;
let _subscribed = false; s.sessionId = sessionId;
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();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API — same signatures as before // MQTT Connect
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function setMyName(name: string): void { /**
setState("myName", name); * Connect to the MQTT broker, subscribe to the session stream, session
saveName(name); * list, and session meta. Must be called after `hydrateFromServer`.
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 api = getWorkerAPI(); const { default: mqtt } = await import("mqtt");
setState("connectionStatus", "connecting"); setState("connectionStatus", "connecting");
setState("connectionError", null); setState("connectionError", null);
try { return new Promise<void>((resolve, reject) => {
await api.connect(sessionId, brokerUrl, state.myName, state.myRole); const client = mqtt.connect(brokerUrl, {
saveBrokerUrl(brokerUrl); clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
saveSessionId(sessionId); protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
} catch (err) { reconnectPeriod: 2000,
setState("connectionStatus", "error"); });
setState(
"connectionError", _mqttClient = client;
err instanceof Error ? err.message : "Connection failed",
); client.on("connect", () => {
throw err; _mqttConnected = true;
} setState("connected", true);
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[stream] stream sub err:", err);
});
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[stream] sessions sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
// Presence tracking
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
const presenceData = JSON.stringify({
name: state.myName,
role: state.myRole,
});
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
presenceData,
{ qos: 1, retain: true },
);
resolve();
});
client.on("error", (err) => {
console.error("[stream] mqtt error:", err);
setState("connectionStatus", "error");
setState("connectionError", err.message);
reject(err);
});
client.on("close", () => {
_mqttConnected = false;
setState("connected", false);
setState("connectionStatus", "disconnected");
});
client.on("message", (topic, payload) => {
const raw = payload.toString();
const parts = topic.split("/");
if (topic === $SESSIONS) {
// Session manifest update
try {
const manifest: SessionManifest = JSON.parse(raw);
setSessionList(manifest);
// Refresh the sessionName if we're in a session now
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
setState("sessionName", manifest.sessions[currentId].name);
}
} catch (e) {
console.error("[stream] manifest parse err:", e);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
// Session meta update — manifest will be republished by server,
// handled via $SESSIONS subscription above.
return;
}
// Presence: ttrpg/{sessionId}/presence/{playerName}
if (
parts.length >= 4 &&
parts[0] === "ttrpg" &&
parts[2] === "presence"
) {
const playerName = parts[3];
if (raw) {
try {
const presence = JSON.parse(raw);
setState("players", playerName, {
role: presence.role || "player",
});
} catch {
setState("players", playerName, { role: "player" });
}
} else {
// Tombstone — player disconnected
setState(
produce((s) => {
delete s.players[playerName];
}),
);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
try {
const msg: StreamMessage = JSON.parse(raw);
receiveMessage(msg);
} catch (e) {
console.error("[stream] malformed message:", e);
}
}
});
});
} }
// ---------------------------------------------------------------------------
// 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: true; msg: StreamMessage<T> } | { success: false; error: string } {
| { success: false; error: string } { if (!_mqttClient || !_mqttConnected) {
const api = getWorkerAPI(); return { success: false, error: "Not connected to stream" };
}
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 = `${sender}-${seq}`; const id = makeMessageId(sender, seq);
const msg: StreamMessage<T> = { const msg: StreamMessage<T> = {
id, id,
@ -319,52 +439,116 @@ 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 };
} }
export function revertLatest(): // ---------------------------------------------------------------------------
| { success: true } // Receive
| { success: false; error: string } { // ---------------------------------------------------------------------------
const api = getWorkerAPI();
return api.revertLatest() as unknown as function receiveMessage(msg: StreamMessage): void {
| { success: true } const existing = state.messages.find((m) => m.id === msg.id);
| { success: false; error: string }; if (existing) {
setState(
produce((s) => {
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx !== -1) s.messages[idx] = msg;
}),
);
return;
}
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
}),
);
runReducer(msg);
} }
// ---------------------------------------------------------------------------
// Revert
// ---------------------------------------------------------------------------
/**
* Revert the current sender's latest (highest seq) message.
* Only works if it's still the latest a subsequent message locks it.
*/
export function revertLatest():
{ success: true } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) return { success: false, error: "No active session" };
const sender = state.myName;
const latestSeq = state.senderSeq[sender];
if (!latestSeq) return { success: false, error: "No messages to revert" };
const id = makeMessageId(sender, latestSeq);
const original = state.messages.find((m) => m.id === id);
if (!original) return { success: false, error: "Message not found" };
if (original.reverted) return { success: false, error: "Already reverted" };
const reverted: StreamMessage = { ...original, reverted: true };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
if (err) console.error("[stream] revert publish error:", err);
});
return { success: true };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
export function disconnectStream(): void { export function disconnectStream(): void {
const api = getWorkerAPI(); if (_mqttClient) {
api.disconnect(); // Clear our presence before disconnecting (tombstone)
const sessionId = state.sessionId;
if (sessionId) {
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
qos: 1,
retain: true,
});
}
// Force disconnect without reconnect
_mqttClient.end(true, void 0, () => {
// noop
});
_mqttClient = null;
_mqttConnected = false;
}
setState("connected", false);
setState("connectionStatus", "disconnected");
setState("players", {});
// Strip autojoin param so the dialog shows normally on next connect
removeUrlParam("autojoin"); removeUrlParam("autojoin");
} }
export function createSession( // ---------------------------------------------------------------------------
name: string, // Derived / helpers
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( const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
(m) => m.id === `${sender}-${seq}`,
);
return msg !== undefined && !msg.reverted; return msg !== undefined && !msg.reverted;
} }
@ -377,11 +561,3 @@ 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

@ -0,0 +1,334 @@
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

@ -0,0 +1,66 @@
/**
* 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

@ -0,0 +1,76 @@
/**
* 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;
};

View File

@ -1,44 +0,0 @@
/**
* 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,7 +7,6 @@ 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()
@ -28,7 +27,6 @@ const marked = new Marked()
level: "container", level: "container",
}, },
iconDirective, iconDirective,
cmdDirective,
]), ]),
{ {
extensions: [...markedColumns()], extensions: [...markedColumns()],

View File

@ -149,14 +149,3 @@ 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

@ -1,529 +0,0 @@
/**
* 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);
};