Compare commits
12 Commits
2068ecad10
...
86abf34c10
| Author | SHA1 | Date |
|---|---|---|
|
|
86abf34c10 | |
|
|
ffbfa65716 | |
|
|
d690d5922e | |
|
|
42e8971ff7 | |
|
|
182d7ff28d | |
|
|
736bcf4bb2 | |
|
|
722a8110e6 | |
|
|
40f2190307 | |
|
|
241c8609f1 | |
|
|
2187b7ed82 | |
|
|
fc6e37a13d | |
|
|
e46cc879ae |
|
|
@ -14,6 +14,7 @@
|
|||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"comlink": "^4.4.2",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
|
|
@ -4332,6 +4333,12 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comlink": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
|
||||
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"comlink": "^4.4.2",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
DocDialog,
|
||||
DataSourceDialog,
|
||||
RevealManager,
|
||||
ReactiveStatManager,
|
||||
CommandLinkManager,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
import { JournalPanel } from "./components/journal";
|
||||
|
|
@ -156,6 +158,8 @@ const App: Component = () => {
|
|||
src={currentPath()}
|
||||
>
|
||||
<RevealManager />
|
||||
<ReactiveStatManager />
|
||||
<CommandLinkManager />
|
||||
</Article>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
scanDirectives,
|
||||
type DirectiveScanResult,
|
||||
} from "../completions/directive-scanner.js";
|
||||
import type { StatSheet } from "../completions/types.js";
|
||||
|
||||
interface ContentIndex {
|
||||
[path: string]: string;
|
||||
|
|
@ -88,22 +87,6 @@ function getBestIP(): string {
|
|||
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 等文件,生成内容索引与块数据
|
||||
*/
|
||||
|
|
@ -116,7 +99,6 @@ export function scanDirectory(dir: string): {
|
|||
const blocks: ProcessedBlocks = {
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
const directiveResults: DirectiveScanResult[] = [];
|
||||
const mdFiles: { content: string; relPath: string }[] = [];
|
||||
|
|
@ -148,17 +130,6 @@ export function scanDirectory(dir: string): {
|
|||
blocks.stats.push(...result.blocks.stats);
|
||||
blocks.statTemplates.push(...result.blocks.statTemplates);
|
||||
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
|
||||
} else if (entry.endsWith(".sheet.svg")) {
|
||||
index[normalizedRelPath] = content;
|
||||
const id = entry.replace(/\.sheet\.svg$/i, "");
|
||||
const title = extractSvgTitle(content);
|
||||
const sheet: StatSheet = {
|
||||
id,
|
||||
label: title || labelFromId(id),
|
||||
svg: content,
|
||||
source: normalizedRelPath,
|
||||
};
|
||||
blocks.statSheets.push(sheet);
|
||||
} else {
|
||||
index[normalizedRelPath] = content;
|
||||
}
|
||||
|
|
@ -341,7 +312,6 @@ export function createContentServer(
|
|||
let collectedBlocks: ProcessedBlocks = {
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
let directiveResults: DirectiveScanResult[] = [];
|
||||
let completionsIndex: CompletionsPayload = {
|
||||
|
|
@ -350,14 +320,13 @@ export function createContentServer(
|
|||
sparkTables: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
|
||||
/** 从当前内容索引和已收集的块重新扫描补全数据 */
|
||||
function recomputeCompletions(): void {
|
||||
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
|
||||
console.log(
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`,
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -399,12 +368,6 @@ export function createContentServer(
|
|||
recomputeCompletions();
|
||||
} else {
|
||||
contentIndex[relPath] = content;
|
||||
if (relPath.endsWith(".sheet.svg")) {
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
}
|
||||
}
|
||||
console.log(`[新增] ${relPath}`);
|
||||
} catch (e) {
|
||||
|
|
@ -431,12 +394,6 @@ export function createContentServer(
|
|||
recomputeCompletions();
|
||||
} else {
|
||||
contentIndex[relPath] = content;
|
||||
if (relPath.endsWith(".sheet.svg")) {
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
recomputeCompletions();
|
||||
}
|
||||
}
|
||||
console.log(`[更新] ${relPath}`);
|
||||
} catch (e) {
|
||||
|
|
@ -454,7 +411,7 @@ export function createContentServer(
|
|||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
delete contentIndex[relPath];
|
||||
console.log(`[删除] ${relPath}`);
|
||||
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
|
||||
if (relPath.endsWith(".md")) {
|
||||
const rescan = scanDirectory(contentDir);
|
||||
collectedBlocks = rescan.blocks;
|
||||
directiveResults = rescan.directiveResults;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
parseBlockAttrs,
|
||||
resolveBlockAs,
|
||||
} from "./block-scanner.js";
|
||||
import type { StatSheet } from "./types.js";
|
||||
|
||||
// Re-export shared pieces for convenience
|
||||
export {
|
||||
|
|
@ -37,7 +36,6 @@ export {
|
|||
export interface ProcessedBlocks {
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
statSheets: StatSheet[];
|
||||
}
|
||||
|
||||
export interface BlockResult {
|
||||
|
|
@ -78,7 +76,6 @@ export function processBlocks(
|
|||
const blocks: ProcessedBlocks = {
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
};
|
||||
|
||||
const stripped = content.replace(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ export type {
|
|||
SparkTableCompletion,
|
||||
StatDef,
|
||||
StatTemplate,
|
||||
StatSheet,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
|
|
@ -43,6 +42,5 @@ export function scanCompletions(
|
|||
sparkTables,
|
||||
stats: blocks.stats,
|
||||
statTemplates: blocks.statTemplates,
|
||||
statSheets: blocks.statSheets,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,18 +6,6 @@ import type { StatDef, StatTemplate } from "./stat-parser.js";
|
|||
|
||||
export type { StatDef, StatTemplate };
|
||||
|
||||
/** A stat sheet discovered from a *.sheet.svg file */
|
||||
export interface StatSheet {
|
||||
/** Unique id — filename sans .sheet.svg */
|
||||
id: string;
|
||||
/** Display label — from <title> or derived from filename */
|
||||
label: string;
|
||||
/** Raw SVG content */
|
||||
svg: string;
|
||||
/** Source file path for attribution */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** A single dice expression found in a markdown file */
|
||||
export interface DiceCompletion {
|
||||
/** Display text shown in the dropdown */
|
||||
|
|
@ -62,7 +50,6 @@ export interface CompletionsPayload {
|
|||
sparkTables: SparkTableCompletion[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
statSheets: StatSheet[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||
* clicks on :cmd[] directive spans to dispatch them via the shared
|
||||
* command dispatcher. Errors are surfaced through the shared
|
||||
* dispatchError signal, shown by JournalInput above the textarea.
|
||||
*/
|
||||
|
||||
import { Component, onCleanup } from "solid-js";
|
||||
import { useArticleDom } from "./Article";
|
||||
import { useJournalStream } from "./stores/journalStream";
|
||||
import { useJournalCompletions } from "./journal/completions";
|
||||
import { dispatchCommand, setDispatchError } from "./journal/command-dispatcher";
|
||||
|
||||
export const CommandLinkManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
||||
if (!cmdSpan) return;
|
||||
|
||||
const command = cmdSpan.getAttribute("data-cmd");
|
||||
if (!command) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
dispatchCommand({
|
||||
role: stream.myRole as "gm" | "player" | "observer",
|
||||
myName: stream.myName,
|
||||
command,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
statValues: stream.stats,
|
||||
statDefs: comp.data.stats,
|
||||
statTemplates: comp.data.statTemplates,
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
setDispatchError(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const dom = contentDom();
|
||||
if (dom) {
|
||||
dom.addEventListener("click", onClick, true);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
const d = contentDom();
|
||||
if (d) d.removeEventListener("click", onClick, true);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CommandLinkManager;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { type FileNode, type TocNode } from "../data-loader";
|
||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||
import { useScrollContainer } from "../App";
|
||||
|
|
@ -24,6 +25,7 @@ export const FileTreeNode: Component<{
|
|||
isHidden?: (node: FileNode) => boolean;
|
||||
}> = (props) => {
|
||||
const navigate = useNavigateWithParams();
|
||||
const location = useLocation();
|
||||
const isDir = !!props.node.children;
|
||||
const isActive = createMemo(() => props.currentPath === props.node.path);
|
||||
// 默认收起,除非当前文件在该文件夹内
|
||||
|
|
@ -31,21 +33,28 @@ export const FileTreeNode: Component<{
|
|||
isDir && isPathInDir(props.currentPath, props.node.path),
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
const href = () => props.node.path + location.search;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isDir) {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded());
|
||||
} else {
|
||||
} else if (e.button === 0) {
|
||||
// Left-click: use SPA navigation
|
||||
e.preventDefault();
|
||||
navigate(props.node.path);
|
||||
props.onClose();
|
||||
}
|
||||
// Middle-click / right-click: let the browser handle the <a> natively
|
||||
};
|
||||
|
||||
const indent = props.depth * 12;
|
||||
|
||||
return (
|
||||
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||
<div
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
|
||||
<a
|
||||
href={href()}
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
|
||||
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
style={{ "padding-left": `${indent + 8}px` }}
|
||||
|
|
@ -58,7 +67,7 @@ export const FileTreeNode: Component<{
|
|||
<span class="mr-1 text-gray-400">📄</span>
|
||||
</Show>
|
||||
<span class="text-sm truncate">{props.node.name}</span>
|
||||
</div>
|
||||
</a>
|
||||
<Show when={isDir && isExpanded() && props.node.children}>
|
||||
<div>
|
||||
{props.node.children!.map((child) => (
|
||||
|
|
@ -87,8 +96,9 @@ export const HeadingNode: Component<{
|
|||
isHidden?: (node: TocNode) => boolean;
|
||||
}> = (props) => {
|
||||
const navigate = useNavigateWithParams();
|
||||
const location = useLocation();
|
||||
const anchor = props.node.id || "";
|
||||
const href = `${props.basePath}#${anchor}`;
|
||||
const href = () => `${props.basePath}${location.search}#${anchor}`;
|
||||
const hasChildren = !!props.node.children;
|
||||
// 默认收起,除非当前锚点在该节点内
|
||||
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
|
||||
|
|
@ -102,8 +112,12 @@ export const HeadingNode: Component<{
|
|||
const scrollContainer = useScrollContainer();
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
if (e.button === 0) {
|
||||
// Left-click: use SPA navigation
|
||||
e.preventDefault();
|
||||
navigate(href());
|
||||
}
|
||||
// Middle-click / right-click: let the browser handle the <a> natively
|
||||
// 滚动到目标元素,考虑导航栏高度偏移
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(anchor);
|
||||
|
|
@ -141,7 +155,7 @@ export const HeadingNode: Component<{
|
|||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={href}
|
||||
href={href()}
|
||||
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import "./md-yarn-spinner";
|
|||
export { Article } from "./Article";
|
||||
export type { ArticleProps } from "./Article";
|
||||
export { RevealManager } from "./RevealManager";
|
||||
export { CommandLinkManager } from "./CommandLinkManager";
|
||||
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
|
||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||
export type { SidebarProps } from "./Sidebar";
|
||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
|
|
|
|||
|
|
@ -7,26 +7,20 @@
|
|||
* - `/link path#section` → "link" type
|
||||
* - `/stat set key=value` → "stat" type
|
||||
* - `/` alone opens completions dropdown
|
||||
*
|
||||
* Delegates command dispatch to the shared command-dispatcher module.
|
||||
*/
|
||||
|
||||
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
|
||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||
import { resolveRollPayload } from "./types/roll";
|
||||
import { resolveSparkPayload } from "./types/spark";
|
||||
import {
|
||||
resolveStatRoll,
|
||||
resolveTemplateSet,
|
||||
canModifyStat,
|
||||
fullKey,
|
||||
findStatDef,
|
||||
} from "./stat-helpers";
|
||||
import { parseInput } from "./command-parser";
|
||||
import type { CompletionItem } from "./command-parser";
|
||||
import { buildCompletions } from "./command-completions";
|
||||
import { CompletionsDropdown } from "./CompletionsDropdown";
|
||||
import { ErrorPopup } from "./ErrorPopup";
|
||||
import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
|
||||
|
||||
// ---- Component ----
|
||||
|
||||
|
|
@ -39,7 +33,6 @@ export const JournalInput: Component = () => {
|
|||
const isGm = () => stream.myRole === "gm";
|
||||
|
||||
const [text, setText] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
||||
|
|
@ -79,201 +72,75 @@ export const JournalInput: Component = () => {
|
|||
// Observers: everything is plain chat
|
||||
if (isObserver()) {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
const r = unwrapSendResult(result);
|
||||
finish(r.ok, r.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseInput(raw);
|
||||
if (parsed.error) {
|
||||
setError(parsed.error);
|
||||
setDispatchError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setDispatchError(null);
|
||||
|
||||
// Players: chat + stat commands only
|
||||
if (isPlayer()) {
|
||||
if (parsed.type === "chat") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
const r = unwrapSendResult(result);
|
||||
finish(r.ok, r.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "stat") {
|
||||
handleStat(parsed.payload);
|
||||
if (parsed.type !== "stat") {
|
||||
setDispatchError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("玩家只能发送聊天消息或使用 /stat 命令");
|
||||
return;
|
||||
}
|
||||
|
||||
// GM: all commands
|
||||
// GM: all commands, Player: stat commands
|
||||
setSending(true);
|
||||
|
||||
if (parsed.type === "roll") {
|
||||
const p = resolveRollPayload(
|
||||
parsed.payload as { notation: string; label?: string },
|
||||
);
|
||||
const result = sendMessage("roll", p);
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "spark") {
|
||||
try {
|
||||
const key = (parsed.payload as { key: string }).key;
|
||||
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
||||
const csvPath = match?.csvPath ?? "";
|
||||
const remix = match?.remix ?? false;
|
||||
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||
const result = sendMessage("spark", p);
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "种子表掷骰失败");
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "stat") {
|
||||
handleStat(parsed.payload);
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = sendMessage(parsed.type, parsed.payload);
|
||||
const r = unwrap(result);
|
||||
finish(r.ok, r.err);
|
||||
}
|
||||
|
||||
/** Handle a /stat command payload (set/del/roll) */
|
||||
function handleStat(payload: Record<string, unknown>) {
|
||||
const p = payload as { action?: string; key?: string; value?: string };
|
||||
|
||||
if (p.action === "roll" && p.key) {
|
||||
const resolved = resolveStatRoll(
|
||||
p.key,
|
||||
comp.data.stats,
|
||||
stream.stats,
|
||||
stream.myName,
|
||||
comp.data.statTemplates,
|
||||
);
|
||||
if (resolved.error) {
|
||||
setError(resolved.error);
|
||||
} else {
|
||||
// Send the primary stat value
|
||||
const result = sendMessage("stat", {
|
||||
action: "set",
|
||||
key: resolved.fullKey,
|
||||
value: resolved.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
if (!r.ok) {
|
||||
finish(false, r.err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send any modifier overrides from template
|
||||
if (resolved.modifiers) {
|
||||
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
||||
sendMessage("stat", {
|
||||
action: "set",
|
||||
key: mk,
|
||||
value: mv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
finish(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!p.action || !p.key) {
|
||||
setError("无效的 stat 命令");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve bare key to full key for set/del
|
||||
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
|
||||
|
||||
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
|
||||
setError(`无权修改属性: ${p.key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = sendMessage("stat", {
|
||||
action: p.action,
|
||||
key: fk,
|
||||
value: p.value,
|
||||
await dispatchCommand({
|
||||
role: stream.myRole as "gm" | "player" | "observer",
|
||||
myName: stream.myName,
|
||||
command: raw,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
statValues: stream.stats,
|
||||
statDefs: comp.data.stats,
|
||||
statTemplates: comp.data.statTemplates,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
if (!r.ok) {
|
||||
finish(false, r.err);
|
||||
return;
|
||||
}
|
||||
|
||||
// If setting a template-type stat, apply its modifier overrides
|
||||
if (p.action === "set" && p.value) {
|
||||
const def = findStatDef(fk, comp.data.stats, stream.myName);
|
||||
if (def) {
|
||||
const modifiers = resolveTemplateSet(
|
||||
def,
|
||||
p.value,
|
||||
comp.data.stats,
|
||||
stream.stats,
|
||||
stream.myName,
|
||||
comp.data.statTemplates,
|
||||
);
|
||||
if (modifiers) {
|
||||
for (const [mk, mv] of Object.entries(modifiers)) {
|
||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||
}
|
||||
}
|
||||
}
|
||||
// dispatchCommand already set the shared error if it failed.
|
||||
// Only clear text on success (no error is shown).
|
||||
if (!dispatchError()) {
|
||||
setText("");
|
||||
}
|
||||
|
||||
finish(true);
|
||||
setSending(false);
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
/** Resolve a bare or full key to the actual runtime key. */
|
||||
function resolveKey(
|
||||
inputKey: string,
|
||||
statDefs: typeof comp.data.stats,
|
||||
playerName: string,
|
||||
): string {
|
||||
// Exact match
|
||||
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
|
||||
return inputKey;
|
||||
// Bare key → full key
|
||||
const def = statDefs.find((d) => d.key === inputKey);
|
||||
if (def) return fullKey(def, playerName);
|
||||
return inputKey;
|
||||
}
|
||||
|
||||
/** Clear text + error on success, or set error on failure. */
|
||||
/** Clear text or set shared dispatch error on failure. */
|
||||
function finish(success: boolean, err?: string) {
|
||||
if (success) {
|
||||
setText("");
|
||||
setDispatchError(null);
|
||||
} else if (err) {
|
||||
setError(err);
|
||||
setDispatchError(err);
|
||||
}
|
||||
setSending(false);
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
/** Unwrap a sendMessage result into (success, error?) for finish(). */
|
||||
function unwrap<R>(
|
||||
function unwrapSendResult<R>(
|
||||
r: { success: true; msg: R } | { success: false; error: string },
|
||||
) {
|
||||
return r.success
|
||||
? ({ ok: true, err: undefined } as const)
|
||||
: ({ ok: false, err: r.error } as const);
|
||||
? ({ ok: true, error: undefined } as const)
|
||||
: ({ ok: false, error: r.error } as const);
|
||||
}
|
||||
|
||||
// ---- Completions ----
|
||||
|
|
@ -404,17 +271,17 @@ export const JournalInput: Component = () => {
|
|||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
text-gray-800 placeholder-gray-400 focus:outline-none
|
||||
bg-transparent min-h-[60px]"
|
||||
bg-transparent min-h-15"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-2 pb-2">
|
||||
<Show when={error()}>
|
||||
<Show when={dispatchError()}>
|
||||
<button
|
||||
onClick={() => setShowErrorPopup((v) => !v)}
|
||||
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
|
||||
title={error() ?? undefined}
|
||||
title={dispatchError() ?? undefined}
|
||||
>
|
||||
{error()}
|
||||
{dispatchError()}
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
|
|
@ -434,7 +301,7 @@ export const JournalInput: Component = () => {
|
|||
|
||||
{/* Error popup */}
|
||||
<ErrorPopup
|
||||
message={error()}
|
||||
message={dispatchError()}
|
||||
show={showErrorPopup()}
|
||||
onClose={() => setShowErrorPopup(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* ReactiveStatManager — mounts as a child of <Article> and reactively
|
||||
* replaces ${key} template patterns in the rendered markdown DOM with
|
||||
* live stat values from the journal stream.
|
||||
*
|
||||
* On mount, walks all text nodes in the article content looking for
|
||||
* ${key} placeholders. Each match is wrapped in a <span> with a
|
||||
* data-reactive-stat attribute so that subsequent updates (driven by
|
||||
* a Solid effect) only touch those spans.
|
||||
*/
|
||||
|
||||
import { Component, createEffect, onCleanup, createMemo } from "solid-js";
|
||||
import { useArticleDom } from "../Article";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "../journal/completions";
|
||||
import { fullKey } from "../journal/stat-helpers";
|
||||
import type { StatDef } from "../journal/completions";
|
||||
|
||||
const TEMPLATE_RE = /\$\{(\w+)\}/g;
|
||||
|
||||
/** Tag name used for marker spans — must stay in sync with the scan pass. */
|
||||
const MARKER = "data-reactive-stat";
|
||||
|
||||
export const ReactiveStatManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
|
||||
/** Build a lookup: bare key -> StatDef (for scope-aware resolution) */
|
||||
const bareDefMap = createMemo(() => {
|
||||
const map = new Map<string, StatDef>();
|
||||
for (const def of statDefs()) {
|
||||
if (!map.has(def.key)) {
|
||||
map.set(def.key, def);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a bare key from markdown template to its runtime value.
|
||||
* Uses StatDef scoping when available, falls back to player-first, global-second.
|
||||
*/
|
||||
function resolveBareKey(bareKey: string): string {
|
||||
const bareDef = bareDefMap().get(bareKey);
|
||||
if (bareDef) {
|
||||
const fk = fullKey(bareDef, stream.myName);
|
||||
return values()[fk] ?? bareDef.default ?? "";
|
||||
}
|
||||
const pk = `${stream.myName}:${bareKey}`;
|
||||
if (values()[pk] !== undefined) return values()[pk];
|
||||
return values()[bareKey] ?? "";
|
||||
}
|
||||
|
||||
// ---- Initial scan: wrap ${key} text in marker spans ----
|
||||
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
if (!dom) return;
|
||||
|
||||
// Only scan once — after the first scan, the spans exist and we
|
||||
// switch to the reactive update effect below.
|
||||
if (dom.querySelector(`[${MARKER}]`)) return;
|
||||
|
||||
scanAndWrap(dom);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
const dom = contentDom();
|
||||
if (dom) unwrapAll(dom);
|
||||
});
|
||||
|
||||
// ---- Reactive updates: whenever stat values change, update all spans ----
|
||||
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
if (!dom) return;
|
||||
|
||||
const v = values();
|
||||
const spans = dom.querySelectorAll<HTMLElement>(`[${MARKER}]`);
|
||||
|
||||
for (const span of spans) {
|
||||
const template = span.getAttribute(MARKER) ?? "";
|
||||
let result = template;
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_RE.exec(template)) !== null) {
|
||||
result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1]));
|
||||
}
|
||||
if (span.textContent !== result) {
|
||||
span.textContent = result;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null; // renders nothing — works purely via DOM manipulation
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk all text nodes in a subtree and wrap `${key}` patterns in
|
||||
* `<span data-reactive-stat="originalText">` elements.
|
||||
*/
|
||||
function scanAndWrap(root: Element): void {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const replacements: { node: Text; html: string }[] = [];
|
||||
|
||||
let textNode: Text | null;
|
||||
while ((textNode = walker.nextNode() as Text | null)) {
|
||||
const text = textNode.textContent;
|
||||
if (!text) continue;
|
||||
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
if (!TEMPLATE_RE.test(text)) continue;
|
||||
|
||||
// Build replacement HTML: split on ${key} and wrap each key span
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_RE.exec(text)) !== null) {
|
||||
if (m.index > lastIndex) {
|
||||
parts.push(escapeHtml(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
parts.push(
|
||||
`<span ${MARKER}="${escapeAttr(m[0])}">${escapeHtml(m[0])}</span>`,
|
||||
);
|
||||
lastIndex = TEMPLATE_RE.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(escapeHtml(text.slice(lastIndex)));
|
||||
}
|
||||
|
||||
replacements.push({ node: textNode, html: parts.join("") });
|
||||
}
|
||||
|
||||
for (const { node, html } of replacements) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.innerHTML = html;
|
||||
node.replaceWith(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse scanAndWrap — remove marker spans, restoring plain text.
|
||||
* Called on cleanup so subsequent remounts get a clean slate.
|
||||
*/
|
||||
function unwrapAll(root: Element): void {
|
||||
const spans = root.querySelectorAll<HTMLElement>(`[${MARKER}]`);
|
||||
// Process in reverse to avoid DOM mutation issues
|
||||
for (let i = spans.length - 1; i >= 0; i--) {
|
||||
const span = spans[i];
|
||||
const parent = span.parentNode;
|
||||
if (!parent) continue;
|
||||
const text = span.getAttribute(MARKER) ?? span.textContent ?? "";
|
||||
span.replaceWith(document.createTextNode(text));
|
||||
}
|
||||
// Normalize to merge adjacent text nodes
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export default ReactiveStatManager;
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
/**
|
||||
* SheetView — renders a stat sheet SVG with live reactive text bindings.
|
||||
*
|
||||
* Parses the SVG once via DOMParser, then uses a Solid effect to
|
||||
* update only <text> elements containing ${key} templates whenever
|
||||
* the stats store changes.
|
||||
*/
|
||||
|
||||
import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { fullKey } from "./stat-helpers";
|
||||
import type { StatDef } from "./completions";
|
||||
import { parseSheet } from "./stat-sheet";
|
||||
|
||||
export const SheetView: Component<{ sheetId: string }> = (props) => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
|
||||
/** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */
|
||||
const bareDefMap = createMemo(() => {
|
||||
const map = new Map<string, StatDef>();
|
||||
for (const def of statDefs()) {
|
||||
if (!map.has(def.key)) {
|
||||
map.set(def.key, def);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a bare key from an SVG template to its runtime value.
|
||||
* Uses StatDef scoping when available, falls back to player-first, global-second.
|
||||
*/
|
||||
function resolveBareKey(bareKey: string): string {
|
||||
const bareDef = bareDefMap().get(bareKey);
|
||||
if (bareDef) {
|
||||
const fk = fullKey(bareDef, stream.myName);
|
||||
return values()[fk] ?? bareDef.default ?? "";
|
||||
}
|
||||
const pk = `${stream.myName}:${bareKey}`;
|
||||
if (values()[pk] !== undefined) return values()[pk];
|
||||
return values()[bareKey] ?? "";
|
||||
}
|
||||
|
||||
const sheet = createMemo(() => {
|
||||
const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId);
|
||||
return s ?? null;
|
||||
});
|
||||
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
let disposeEffect: (() => void) | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
const s = sheet();
|
||||
const container = containerRef;
|
||||
|
||||
disposeEffect?.();
|
||||
disposeEffect = undefined;
|
||||
|
||||
if (!container || !s) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
const parsed = parseSheet(s.svg);
|
||||
// Force full-width, auto-height
|
||||
parsed.svgElement.setAttribute("width", "100%");
|
||||
parsed.svgElement.removeAttribute("height");
|
||||
parsed.svgElement.style.display = "block";
|
||||
container.appendChild(parsed.svgElement);
|
||||
|
||||
// Center template nodes so text stays centered as values change length
|
||||
for (const tpl of parsed.templates) {
|
||||
const el = tpl.el;
|
||||
try {
|
||||
const bbox = el.getBBox();
|
||||
const cx = bbox.x + bbox.width / 2;
|
||||
el.setAttribute("text-anchor", "middle");
|
||||
el.setAttribute("x", String(cx));
|
||||
} catch {
|
||||
// getBBox may fail if the node has no layout — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.templates.length === 0) return;
|
||||
|
||||
const dispose = createRoot((disposer) => {
|
||||
createEffect(() => {
|
||||
const v = values();
|
||||
for (const tpl of parsed.templates) {
|
||||
let result = tpl.template;
|
||||
for (const key of tpl.keys) {
|
||||
result = result.replace(`\${${key}}`, resolveBareKey(key));
|
||||
}
|
||||
tpl.el.textContent = result;
|
||||
}
|
||||
});
|
||||
return disposer;
|
||||
});
|
||||
|
||||
disposeEffect = dispose;
|
||||
});
|
||||
|
||||
onCleanup(() => disposeEffect?.());
|
||||
|
||||
const fallback = (
|
||||
<p class="text-center text-gray-400 text-xs py-8">未找到属性卡: {props.sheetId}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="w-full h-full overflow-y-auto p-2">
|
||||
{sheet() ? (
|
||||
<div ref={containerRef} class="w-full" />
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,21 +1,15 @@
|
|||
/**
|
||||
* StatsView — table view of all stat key-value pairs, plus optional
|
||||
* stat sheet rendering from *.sheet.svg files.
|
||||
* StatsView — table view of all stat key-value pairs.
|
||||
*
|
||||
* Groups stats by scope (global, then per-player). Shows computed values
|
||||
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
|
||||
*
|
||||
* A floating button at bottom-right opens a sheet picker dropdown.
|
||||
* Selecting a sheet delegates to SheetView.
|
||||
*/
|
||||
|
||||
import { Component, For, createMemo, createSignal, Show } from "solid-js";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { Component, For, createMemo, Show } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions } from "./completions";
|
||||
import { fullKey, modifierLabel } from "./stat-helpers";
|
||||
import type { StatDef } from "./completions";
|
||||
import { SheetView } from "./SheetView";
|
||||
|
||||
export const StatsView: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
|
|
@ -23,15 +17,6 @@ export const StatsView: Component = () => {
|
|||
|
||||
const statDefs = createMemo(() => comp.data.stats);
|
||||
const values = createMemo(() => stream.stats);
|
||||
const sheets = createMemo(() => comp.data.statSheets);
|
||||
|
||||
/** Currently selected sheet id, or null for table view. Persisted in URL. */
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ sheet?: string }>();
|
||||
const selectedSheetId = () => searchParams.sheet || null;
|
||||
const setSelectedSheetId = (id: string | null) => {
|
||||
setSearchParams({ sheet: id || undefined });
|
||||
};
|
||||
const [pickerOpen, setPickerOpen] = createSignal(false);
|
||||
|
||||
/** Build a lookup: fullKey -> StatDef */
|
||||
const defMap = createMemo(() => {
|
||||
|
|
@ -124,190 +109,125 @@ export const StatsView: Component = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div class="h-full overflow-y-auto bg-gray-50 relative">
|
||||
<div class="h-full overflow-y-auto bg-gray-50">
|
||||
<Show
|
||||
when={selectedSheetId()}
|
||||
when={statDefs().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={statDefs().length > 0}
|
||||
fallback={
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="mb-3">
|
||||
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-600">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<For each={group.entries}>
|
||||
{({ fullKey: fk, def }) => {
|
||||
const val = () => values()[fk];
|
||||
const comp = () => computedValue(fk);
|
||||
const hasModifiers = () => {
|
||||
for (const [mfk, md] of defMap()) {
|
||||
if (
|
||||
md.type === "modifier" &&
|
||||
md.target &&
|
||||
fullKeyTarget(md.target, md, def)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const canRoll = () =>
|
||||
def.roll ||
|
||||
def.formula ||
|
||||
def.type === "enum" ||
|
||||
def.type === "template";
|
||||
|
||||
return (
|
||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
||||
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span class="text-sm text-gray-800 truncate">
|
||||
{modifierLabel(def, statDefs())}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
||||
{def.key}
|
||||
</span>
|
||||
<Show when={def.type === "modifier"}>
|
||||
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
||||
→{def.target}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show
|
||||
when={val() !== undefined}
|
||||
fallback={
|
||||
<span class="text-sm text-gray-300">
|
||||
{def.default ?? "—"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="text-sm font-mono text-gray-700">
|
||||
{val()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
hasModifiers() &&
|
||||
comp() !== (val() ?? def.default ?? "")
|
||||
}
|
||||
>
|
||||
<span class="text-xs text-gray-400">
|
||||
= {comp()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show when={canRoll()}>
|
||||
<button
|
||||
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
||||
title="掷骰"
|
||||
onClick={() => {
|
||||
const textarea =
|
||||
document.querySelector<HTMLTextAreaElement>(
|
||||
"#journal-input-textarea",
|
||||
);
|
||||
if (textarea) {
|
||||
const nativeInputValueSetter =
|
||||
Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(
|
||||
textarea,
|
||||
`/stat roll ${def.key}`,
|
||||
);
|
||||
textarea.dispatchEvent(
|
||||
new Event("input", { bubbles: true }),
|
||||
);
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<SheetView sheetId={selectedSheetId()!} />
|
||||
</Show>
|
||||
<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";
|
||||
|
||||
{/* Sheet picker — only show if sheets are available */}
|
||||
<Show when={sheets().length > 0}>
|
||||
<div class="sticky bottom-3 flex justify-end pr-3">
|
||||
{/* Dropdown trigger */}
|
||||
<button
|
||||
class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
title="选择属性卡"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
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>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<Show when={pickerOpen()}>
|
||||
<div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50">
|
||||
<button
|
||||
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
|
||||
selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSheetId(null);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
📋 表格
|
||||
</button>
|
||||
<div class="border-t border-gray-100 my-1" />
|
||||
<For each={sheets()}>
|
||||
{(sheet) => (
|
||||
<button
|
||||
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
|
||||
selectedSheetId() === sheet.id ? "text-blue-600 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSheetId(sheet.id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{sheet.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div 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>
|
||||
</Show>
|
||||
|
||||
{/* Backdrop to close picker */}
|
||||
<Show when={pickerOpen()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* Command dispatcher — shared logic for parsing & dispatching commands
|
||||
* from text input or :cmd[] directive spans.
|
||||
*
|
||||
* Used by both JournalInput (manual typing) and CommandLinkManager (click).
|
||||
*/
|
||||
|
||||
import { sendMessage } from "../stores/journalStream";
|
||||
import { createSignal } from "solid-js";
|
||||
import { parseInput } from "./command-parser";
|
||||
import { resolveRollPayload } from "./types/roll";
|
||||
import { resolveSparkPayload } from "./types/spark";
|
||||
import {
|
||||
resolveStatRoll,
|
||||
resolveTemplateSet,
|
||||
canModifyStat,
|
||||
fullKey,
|
||||
findStatDef,
|
||||
} from "./stat-helpers";
|
||||
import type { StatDef, StatTemplate } from "./completions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DispatchResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
||||
* Components that show errors (JournalInput) read from here; callers that
|
||||
* want errors surfaced (CommandLinkManager) write to it.
|
||||
*/
|
||||
export const [dispatchError, setDispatchError] =
|
||||
createSignal<string | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DispatchContext {
|
||||
/** The role of the current user */
|
||||
role: "gm" | "player" | "observer";
|
||||
/** The user's name */
|
||||
myName: string;
|
||||
/** The raw text to dispatch (with or without leading `/`) */
|
||||
command: string;
|
||||
/** Spark table lookup data (from completions) */
|
||||
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
|
||||
/** Current runtime stat values */
|
||||
statValues: Record<string, string>;
|
||||
/** Stat definitions */
|
||||
statDefs: StatDef[];
|
||||
/** Stat templates */
|
||||
statTemplates: StatTemplate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a command string. Works for both typed input and
|
||||
* :cmd[] directive clicks.
|
||||
*/
|
||||
export async function dispatchCommand(
|
||||
ctx: DispatchContext,
|
||||
): Promise<DispatchResult> {
|
||||
const raw = ctx.command.trim();
|
||||
if (!raw) return { ok: false, error: "Empty command" };
|
||||
|
||||
// Observers can only send chat
|
||||
if (ctx.role === "observer") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
|
||||
const parsed = parseInput(prefixed);
|
||||
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
||||
|
||||
// Players: chat + stat commands only
|
||||
if (ctx.role === "player") {
|
||||
if (parsed.type === "chat") {
|
||||
const result = sendMessage("chat", { text: raw });
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
if (parsed.type === "stat") {
|
||||
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
||||
}
|
||||
|
||||
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /stat 命令" });
|
||||
}
|
||||
|
||||
// GM: all commands
|
||||
if (parsed.type === "roll") {
|
||||
const p = resolveRollPayload(
|
||||
parsed.payload as { notation: string; label?: string },
|
||||
);
|
||||
const result = sendMessage("roll", p);
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
if (parsed.type === "spark") {
|
||||
try {
|
||||
const key = (parsed.payload as { key: string }).key;
|
||||
const match = ctx.sparkTables.find((s) => s.slug === key);
|
||||
const csvPath = match?.csvPath ?? "";
|
||||
const remix = match?.remix ?? false;
|
||||
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||
const result = sendMessage("spark", p);
|
||||
return finish(unwrap(result));
|
||||
} catch (e) {
|
||||
return finish({
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.type === "stat") {
|
||||
return finish(dispatchStat(parsed.payload as Record<string, unknown>, ctx));
|
||||
}
|
||||
|
||||
const result = sendMessage(parsed.type, parsed.payload);
|
||||
return finish(unwrap(result));
|
||||
}
|
||||
|
||||
/** Update the shared error signal and return the result (pass-through). */
|
||||
function finish(r: DispatchResult): DispatchResult {
|
||||
setDispatchError(r.ok ? null : r.error);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dispatchStat(
|
||||
payload: Record<string, unknown>,
|
||||
ctx: DispatchContext,
|
||||
): DispatchResult {
|
||||
const p = payload as { action?: string; key?: string; value?: string };
|
||||
|
||||
if (p.action === "roll" && p.key) {
|
||||
const resolved = resolveStatRoll(
|
||||
p.key,
|
||||
ctx.statDefs,
|
||||
ctx.statValues,
|
||||
ctx.myName,
|
||||
ctx.statTemplates,
|
||||
);
|
||||
if (resolved.error) return { ok: false, error: resolved.error };
|
||||
|
||||
const result = sendMessage("stat", {
|
||||
action: "set",
|
||||
key: resolved.fullKey,
|
||||
value: resolved.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
if (!r.ok) return r;
|
||||
|
||||
if (resolved.modifiers) {
|
||||
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!p.action || !p.key) {
|
||||
return { ok: false, error: "无效的 stat 命令" };
|
||||
}
|
||||
|
||||
const fk = resolveKey(p.key, ctx.statDefs, ctx.myName);
|
||||
|
||||
if (!canModifyStat(ctx.role, ctx.myName, fk, ctx.statDefs)) {
|
||||
return { ok: false, error: `无权修改属性: ${p.key}` };
|
||||
}
|
||||
|
||||
const result = sendMessage("stat", {
|
||||
action: p.action,
|
||||
key: fk,
|
||||
value: p.value,
|
||||
});
|
||||
const r = unwrap(result);
|
||||
if (!r.ok) return r;
|
||||
|
||||
if (p.action === "set" && p.value) {
|
||||
const def = findStatDef(fk, ctx.statDefs, ctx.myName);
|
||||
if (def) {
|
||||
const modifiers = resolveTemplateSet(
|
||||
def,
|
||||
p.value,
|
||||
ctx.statDefs,
|
||||
ctx.statValues,
|
||||
ctx.myName,
|
||||
ctx.statTemplates,
|
||||
);
|
||||
if (modifiers) {
|
||||
for (const [mk, mv] of Object.entries(modifiers)) {
|
||||
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve a bare or full key to the actual runtime key. */
|
||||
function resolveKey(
|
||||
inputKey: string,
|
||||
statDefs: StatDef[],
|
||||
playerName: string,
|
||||
): string {
|
||||
if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey;
|
||||
const def = statDefs.find((d) => d.key === inputKey);
|
||||
if (def) return fullKey(def, playerName);
|
||||
return inputKey;
|
||||
}
|
||||
|
||||
/** Unwrap a sendMessage result into DispatchResult. */
|
||||
function unwrap<R>(
|
||||
r: { success: true; msg: R } | { success: false; error: string },
|
||||
): DispatchResult {
|
||||
return r.success ? { ok: true } : { ok: false, error: r.error };
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
parseStatModifiers,
|
||||
} from "../../cli/completions/stat-parser";
|
||||
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
|
||||
import type { StatSheet } from "../../cli/completions/types";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
|
|
@ -31,7 +30,7 @@ import {
|
|||
scanDirectives,
|
||||
} from "../../cli/completions/directive-scanner";
|
||||
|
||||
export type { StatDef, StatTemplate, StatSheet };
|
||||
export type { StatDef, StatTemplate };
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
|
||||
|
|
@ -63,7 +62,6 @@ export interface JournalCompletions {
|
|||
sparkTables: SparkTableCompletion[];
|
||||
stats: StatDef[];
|
||||
statTemplates: StatTemplate[];
|
||||
statSheets: StatSheet[];
|
||||
}
|
||||
|
||||
export type CompletionsState =
|
||||
|
|
@ -93,7 +91,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
|||
statTemplates: Array.isArray(data.statTemplates)
|
||||
? data.statTemplates
|
||||
: [],
|
||||
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -175,46 +172,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||
sparkTables.push(...directiveResult.sparkTables);
|
||||
}
|
||||
|
||||
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;
|
||||
return { dice, links, sparkTables, stats, statTemplates };
|
||||
}
|
||||
|
||||
// ------------------- Init (runs eagerly at import time) -------------------
|
||||
|
|
@ -231,8 +189,7 @@ const _initPromise: Promise<void> = (async () => {
|
|||
// 2. Fall back to client-side scan (dev/browser mode)
|
||||
try {
|
||||
const data = await scanClientSide();
|
||||
data.statSheets = await scanStatSheets();
|
||||
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
|
||||
if (data.dice.length > 0 || data.links.length > 0) {
|
||||
setCompletionsState({ status: "loaded", data });
|
||||
} else {
|
||||
setCompletionsState({ status: "empty" });
|
||||
|
|
@ -283,7 +240,6 @@ export function useJournalCompletions(): {
|
|||
sparkTables: [],
|
||||
stats: [],
|
||||
statTemplates: [],
|
||||
statSheets: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,5 +49,7 @@ export { ComposePanel } from "./ComposePanel";
|
|||
export { JournalInput } from "./JournalInput";
|
||||
export { DynamicForm } from "./DynamicForm";
|
||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||
export { dispatchCommand } from "./command-dispatcher";
|
||||
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||
export type { JournalContextValue } from "./JournalContext";
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* Stat sheet template engine — parses *.sheet.svg files and extracts
|
||||
* text nodes containing ${key} template patterns.
|
||||
*
|
||||
* Walks <text> elements. If a <text> has <tspan> children, we bind to
|
||||
* each <tspan> individually (preserving their position attributes).
|
||||
* If a <text> has no <tspan> children, we bind to the <text> directly.
|
||||
*/
|
||||
|
||||
/** Regex matching ${key} template patterns (captures the key name). */
|
||||
const TEMPLATE_RE = /\$\{(\w+)\}/g;
|
||||
|
||||
/** A single template binding targeting a specific text-bearing leaf node. */
|
||||
export interface TextTemplate {
|
||||
/** The DOM node to update — a <text> (no children) or a <tspan>. */
|
||||
el: SVGTextContentElement;
|
||||
/** Original text content with ${key} placeholders. */
|
||||
template: string;
|
||||
/** Bare key names extracted from the template. */
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
/** The result of parsing a stat sheet SVG string. */
|
||||
export interface ParsedSheet {
|
||||
svgElement: SVGSVGElement;
|
||||
templates: TextTemplate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text node's content has template patterns, and if so,
|
||||
* register a binding.
|
||||
*/
|
||||
function scanNode(el: Element, templates: TextTemplate[]): void {
|
||||
const content = el.textContent;
|
||||
if (!content) return;
|
||||
|
||||
TEMPLATE_RE.lastIndex = 0;
|
||||
const keys: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_RE.exec(content)) !== null) {
|
||||
keys.push(m[1]);
|
||||
}
|
||||
|
||||
if (keys.length > 0) {
|
||||
templates.push({
|
||||
el: el as SVGTextContentElement,
|
||||
template: content,
|
||||
keys,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SVG string into a live DOM element and extract all template
|
||||
* bindings from <text> and <tspan> leaf nodes.
|
||||
*/
|
||||
export function parseSheet(svgString: string): ParsedSheet {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, "image/svg+xml");
|
||||
const svgElement = doc.documentElement as unknown as SVGSVGElement;
|
||||
|
||||
if (!svgElement || svgElement.tagName !== "svg") {
|
||||
return {
|
||||
svgElement: document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
) as SVGSVGElement,
|
||||
templates: [],
|
||||
};
|
||||
}
|
||||
|
||||
const templates: TextTemplate[] = [];
|
||||
|
||||
for (const textEl of svgElement.querySelectorAll("text")) {
|
||||
const tspans = textEl.querySelectorAll("tspan");
|
||||
|
||||
if (tspans.length > 0) {
|
||||
// Has children — bind to each <tspan> individually
|
||||
for (const tspan of tspans) {
|
||||
scanNode(tspan, templates);
|
||||
}
|
||||
} else {
|
||||
// Leaf <text> — bind directly
|
||||
scanNode(textEl, templates);
|
||||
}
|
||||
}
|
||||
|
||||
return { svgElement, templates };
|
||||
}
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
/**
|
||||
* Journal Stream — Client Store
|
||||
* Journal Stream — Client Store (Worker Proxy)
|
||||
*
|
||||
* Reactive state for a single session's message stream. Manages MQTT
|
||||
* connection, local message log, per-sender sequence tracking, and the
|
||||
* revealed-paths set (populated by the link reducer).
|
||||
* This is the main-thread side of the journal stream. It spawns a Shared
|
||||
* Worker that owns the MQTT connection. All state flows from the worker
|
||||
* to this store via Comlink callbacks.
|
||||
*
|
||||
* Session lifecycle (create/list/delete) is handled via MQTT retained
|
||||
* topics — ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
|
||||
*
|
||||
* No persistence here — that's the CLI server's job via JSONL append.
|
||||
* The public API is unchanged — components still call `useJournalStream()`,
|
||||
* `sendMessage()`, etc. exactly as before.
|
||||
*/
|
||||
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createSignal } from "solid-js";
|
||||
import * as Comlink from "comlink";
|
||||
import type { StreamMessage } from "../journal/registry";
|
||||
import { getMessageType, validatePayload } from "../journal/registry";
|
||||
import type { WorkerState, WorkerPatch, SessionManifest } from "../../workers/journal.worker";
|
||||
import type { JournalWorkerAPI } from "../../workers/journal.worker";
|
||||
import {
|
||||
loadPersisted,
|
||||
saveName,
|
||||
|
|
@ -32,38 +33,17 @@ import {
|
|||
|
||||
export interface JournalStreamState {
|
||||
sessionId: string | null;
|
||||
/** Human-readable name for the current session (from manifest) */
|
||||
sessionName: string | null;
|
||||
/** Full message log, oldest-first */
|
||||
messages: StreamMessage[];
|
||||
/** Last sequence number per sender */
|
||||
senderSeq: Record<string, number>;
|
||||
/**
|
||||
* Paths and sections revealed by link messages.
|
||||
* Key: normalized path (no .md). Value: set of revealed section slugs.
|
||||
* An empty set means the whole article is revealed.
|
||||
* Populated during hydration and live receipt via the type's reducer.
|
||||
*/
|
||||
revealedPaths: Record<string, Set<string>>;
|
||||
/** MQTT connection status */
|
||||
connected: boolean;
|
||||
/** Granular connection state for UI indicators */
|
||||
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
|
||||
/** Last connection error message, if any */
|
||||
connectionError: string | null;
|
||||
/** This client's identity */
|
||||
myName: string;
|
||||
/** Role: gm | player | observer. Immutable while connected. */
|
||||
myRole: "gm" | "player" | "observer";
|
||||
/** Broker URL, set after connect */
|
||||
brokerUrl: string | null;
|
||||
/** Active player list (keyed by player name) */
|
||||
players: Record<string, { role: string }>;
|
||||
/**
|
||||
* Stat values set via /stat set/del/roll commands.
|
||||
* Key: stat key (e.g. "strength", "alice:hp"). Value: string.
|
||||
* Populated during hydration and live receipt via the stat type's reducer.
|
||||
*/
|
||||
stats: Record<string, string>;
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +53,24 @@ export interface SessionMeta {
|
|||
players: string[];
|
||||
}
|
||||
|
||||
export interface SessionManifest {
|
||||
sessions: Record<string, SessionMeta>;
|
||||
export type { SessionManifest } from "../../workers/journal.worker";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _workerAPI: Comlink.Remote<JournalWorkerAPI> | null = null;
|
||||
let _unsubscribe: (() => void) | null = null;
|
||||
|
||||
function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
|
||||
if (!_workerAPI) {
|
||||
const worker = new SharedWorker(
|
||||
new URL("../../workers/journal.worker.ts", import.meta.url),
|
||||
);
|
||||
_workerAPI = Comlink.wrap<JournalWorkerAPI>(worker.port);
|
||||
worker.port.start();
|
||||
}
|
||||
return _workerAPI;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -84,7 +80,6 @@ export interface SessionManifest {
|
|||
const persisted = loadPersisted();
|
||||
const urlParams = readUrlParams();
|
||||
|
||||
// URL params override localStorage if present
|
||||
const initialName = urlParams.playerName ?? persisted.myName;
|
||||
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
|
||||
|
||||
|
|
@ -104,7 +99,6 @@ const [state, setState] = createStore<JournalStreamState>({
|
|||
stats: {},
|
||||
});
|
||||
|
||||
// Sync initial URL params if they came from localStorage (not URL)
|
||||
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
|
||||
if (initialSession && !urlParams.sessionId)
|
||||
syncUrlParam("session", initialSession);
|
||||
|
|
@ -117,56 +111,10 @@ const [sessionList, setSessionList] = createSignal<SessionManifest>({
|
|||
|
||||
export { sessionList as sessions };
|
||||
|
||||
/**
|
||||
* Change the current player's name. Persisted to localStorage so it
|
||||
* survives page reloads. Also syncs to URL search param.
|
||||
*/
|
||||
export function setMyName(name: string): void {
|
||||
setState("myName", name);
|
||||
saveName(name);
|
||||
syncUrlParam("player", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current player's role. Persisted to localStorage.
|
||||
* Only callable when disconnected.
|
||||
*/
|
||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||
setState("myRole", role);
|
||||
saveRole(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active session ID and sync to URL. Also resolves the human-readable
|
||||
* session name from the cached manifest.
|
||||
*/
|
||||
export function setSessionId(id: string | null): void {
|
||||
setState("sessionId", id);
|
||||
if (id) {
|
||||
saveSessionId(id);
|
||||
syncUrlParam("session", id);
|
||||
// Resolve session name from current manifest
|
||||
const manifest = sessionList();
|
||||
const name = manifest.sessions[id]?.name ?? null;
|
||||
setState("sessionName", name);
|
||||
} else {
|
||||
removeUrlParam("session");
|
||||
setState("sessionName", null);
|
||||
}
|
||||
}
|
||||
|
||||
// Will hold the MQTT client instance after connect()
|
||||
let _mqttClient: import("mqtt").MqttClient | null = null;
|
||||
let _mqttConnected = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Reducer runner — runs locally in each tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMessageId(sender: string, seq: number): string {
|
||||
return `${sender}-${seq}`;
|
||||
}
|
||||
|
||||
function runReducer(msg: StreamMessage): void {
|
||||
const def = getMessageType(msg.type);
|
||||
if (def?.reducer) {
|
||||
|
|
@ -174,260 +122,192 @@ function runReducer(msg: StreamMessage): void {
|
|||
}
|
||||
}
|
||||
|
||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hydration (initial load from server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load the full message history from the static server's JSONL file.
|
||||
* The file is served from the same HTTP origin as the web app.
|
||||
* Runs all reducers in order.
|
||||
*/
|
||||
export async function hydrateFromServer(sessionId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return; // fresh session, no file yet
|
||||
}
|
||||
throw new Error(`Failed to load session: ${response.statusText}`);
|
||||
/** Replay all messages through reducers to rebuild derived state. */
|
||||
function replayReducers(): void {
|
||||
// Reset derived state
|
||||
setState("revealedPaths", {});
|
||||
setState("stats", {});
|
||||
for (const msg of state.messages) {
|
||||
runReducer(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.split("\n").filter((l) => l.trim());
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker patch handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const messages: StreamMessage[] = [];
|
||||
const senderSeq: Record<string, number> = {};
|
||||
function handlePatch(patch: WorkerPatch): void {
|
||||
switch (patch.type) {
|
||||
case "fullState": {
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.sessionId = patch.state.sessionId;
|
||||
s.sessionName = patch.state.sessionName;
|
||||
s.messages = patch.state.messages;
|
||||
s.senderSeq = patch.state.senderSeq;
|
||||
s.connected = patch.state.connected;
|
||||
s.connectionStatus = patch.state.connectionStatus;
|
||||
s.connectionError = patch.state.connectionError;
|
||||
s.myName = patch.state.myName;
|
||||
s.myRole = patch.state.myRole;
|
||||
s.brokerUrl = patch.state.brokerUrl;
|
||||
s.players = patch.state.players;
|
||||
}),
|
||||
);
|
||||
// Persist name/role so URL and localStorage stay in sync
|
||||
if (patch.state.myName) {
|
||||
saveName(patch.state.myName);
|
||||
syncUrlParam("player", patch.state.myName);
|
||||
}
|
||||
saveRole(patch.state.myRole);
|
||||
// Rebuild derived state (revealedPaths, stats) by replaying reducers
|
||||
replayReducers();
|
||||
break;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(line);
|
||||
messages.push(msg);
|
||||
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
|
||||
case "message": {
|
||||
const msg = patch.message;
|
||||
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
|
||||
if (existingIdx !== -1) {
|
||||
setState("messages", existingIdx, msg);
|
||||
} else {
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.messages.push(msg);
|
||||
s.senderSeq[msg.sender] = Math.max(
|
||||
s.senderSeq[msg.sender] ?? 0,
|
||||
msg.seq,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
runReducer(msg);
|
||||
} catch {
|
||||
/* skip corrupt */
|
||||
break;
|
||||
}
|
||||
|
||||
case "connectionStatus": {
|
||||
setState("connectionStatus", patch.status);
|
||||
if (patch.error !== undefined) {
|
||||
setState("connectionError", patch.error);
|
||||
}
|
||||
if (patch.status === "connected") {
|
||||
setState("connected", true);
|
||||
} else if (patch.status === "disconnected") {
|
||||
setState("connected", false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "players": {
|
||||
setState("players", patch.players);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessionName": {
|
||||
setState("sessionName", patch.name);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessionList": {
|
||||
setSessionList(patch.sessions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.messages = messages;
|
||||
s.senderSeq = senderSeq;
|
||||
s.sessionId = sessionId;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscribe to worker (called once per tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _subscribed = false;
|
||||
|
||||
async function ensureSubscribed(): Promise<void> {
|
||||
if (_subscribed) return;
|
||||
_subscribed = true;
|
||||
|
||||
const api = getWorkerAPI();
|
||||
_unsubscribe = await api.subscribe(
|
||||
Comlink.proxy((patch: WorkerPatch) => {
|
||||
handlePatch(patch);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Start subscription eagerly
|
||||
ensureSubscribed();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT Connect
|
||||
// Public API — same signatures as before
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Connect to the MQTT broker, subscribe to the session stream, session
|
||||
* list, and session meta. Must be called after `hydrateFromServer`.
|
||||
*/
|
||||
export function setMyName(name: string): void {
|
||||
setState("myName", name);
|
||||
saveName(name);
|
||||
syncUrlParam("player", name);
|
||||
}
|
||||
|
||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||
setState("myRole", role);
|
||||
saveRole(role);
|
||||
}
|
||||
|
||||
export function setSessionId(id: string | null): void {
|
||||
setState("sessionId", id);
|
||||
if (id) {
|
||||
saveSessionId(id);
|
||||
syncUrlParam("session", id);
|
||||
} else {
|
||||
removeUrlParam("session");
|
||||
setState("sessionName", null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectStream(
|
||||
sessionId: string,
|
||||
brokerUrl: string,
|
||||
): Promise<void> {
|
||||
const { default: mqtt } = await import("mqtt");
|
||||
|
||||
const api = getWorkerAPI();
|
||||
setState("connectionStatus", "connecting");
|
||||
setState("connectionError", null);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = mqtt.connect(brokerUrl, {
|
||||
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
|
||||
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
|
||||
reconnectPeriod: 2000,
|
||||
});
|
||||
|
||||
_mqttClient = client;
|
||||
|
||||
client.on("connect", () => {
|
||||
_mqttConnected = true;
|
||||
setState("connected", true);
|
||||
setState("connectionStatus", "connected");
|
||||
setState("connectionError", null);
|
||||
setState("brokerUrl", brokerUrl);
|
||||
|
||||
// Persist connection info for next time
|
||||
saveBrokerUrl(brokerUrl);
|
||||
saveSessionId(sessionId);
|
||||
|
||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] stream sub err:", err);
|
||||
});
|
||||
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] sessions sub err:", err);
|
||||
});
|
||||
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
||||
// Presence tracking
|
||||
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
||||
|
||||
// Publish own presence (retained)
|
||||
const presenceData = JSON.stringify({
|
||||
name: state.myName,
|
||||
role: state.myRole,
|
||||
});
|
||||
client.publish(
|
||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||
presenceData,
|
||||
{ qos: 1, retain: true },
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
console.error("[stream] mqtt error:", err);
|
||||
setState("connectionStatus", "error");
|
||||
setState("connectionError", err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
_mqttConnected = false;
|
||||
setState("connected", false);
|
||||
setState("connectionStatus", "disconnected");
|
||||
});
|
||||
|
||||
client.on("message", (topic, payload) => {
|
||||
const raw = payload.toString();
|
||||
const parts = topic.split("/");
|
||||
|
||||
if (topic === $SESSIONS) {
|
||||
// Session manifest update
|
||||
try {
|
||||
const manifest: SessionManifest = JSON.parse(raw);
|
||||
setSessionList(manifest);
|
||||
// Refresh the sessionName if we're in a session now
|
||||
const currentId = state.sessionId;
|
||||
if (currentId && manifest.sessions[currentId]) {
|
||||
setState("sessionName", manifest.sessions[currentId].name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[stream] manifest parse err:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
||||
// Session meta update — manifest will be republished by server,
|
||||
// handled via $SESSIONS subscription above.
|
||||
return;
|
||||
}
|
||||
|
||||
// Presence: ttrpg/{sessionId}/presence/{playerName}
|
||||
if (
|
||||
parts.length >= 4 &&
|
||||
parts[0] === "ttrpg" &&
|
||||
parts[2] === "presence"
|
||||
) {
|
||||
const playerName = parts[3];
|
||||
if (raw) {
|
||||
try {
|
||||
const presence = JSON.parse(raw);
|
||||
setState("players", playerName, {
|
||||
role: presence.role || "player",
|
||||
});
|
||||
} catch {
|
||||
setState("players", playerName, { role: "player" });
|
||||
}
|
||||
} else {
|
||||
// Tombstone — player disconnected
|
||||
setState(
|
||||
produce((s) => {
|
||||
delete s.players[playerName];
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(raw);
|
||||
receiveMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[stream] malformed message:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
|
||||
saveBrokerUrl(brokerUrl);
|
||||
saveSessionId(sessionId);
|
||||
} catch (err) {
|
||||
setState("connectionStatus", "error");
|
||||
setState(
|
||||
"connectionError",
|
||||
err instanceof Error ? err.message : "Connection failed",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new session by publishing retained metadata to its meta topic.
|
||||
* The server picks it up and adds it to the $SESSIONS manifest.
|
||||
*/
|
||||
export function createSession(name: string, players: string[] = []): string | null {
|
||||
if (!_mqttClient || !_mqttConnected) return null;
|
||||
|
||||
const id =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "session";
|
||||
|
||||
const meta: SessionMeta = { name, created: Date.now(), players };
|
||||
|
||||
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session: publish tombstone (empty payload) to its meta topic.
|
||||
*/
|
||||
export function deleteSession(sessionId: string): void {
|
||||
if (!_mqttClient || !_mqttConnected) return;
|
||||
|
||||
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Publish a message to the stream. Validates the payload against the
|
||||
* registered Zod schema before sending. Auto-increments the sender's seq.
|
||||
*/
|
||||
export function sendMessage<T>(
|
||||
type: string,
|
||||
payload: T,
|
||||
):
|
||||
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } {
|
||||
if (!_mqttClient || !_mqttConnected) {
|
||||
return { success: false, error: "Not connected to stream" };
|
||||
}
|
||||
|
||||
const sessionId = state.sessionId;
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "No active session" };
|
||||
}
|
||||
| { success: true; msg: StreamMessage<T> }
|
||||
| { success: false; error: string } {
|
||||
const api = getWorkerAPI();
|
||||
|
||||
// Validate locally first
|
||||
const validation = validatePayload(type, payload);
|
||||
if (!validation.success) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Send to worker (which publishes to MQTT)
|
||||
// The worker will broadcast back via the message patch
|
||||
const result = api.sendMessage(type, validation.data);
|
||||
|
||||
// We can't await the proxy result synchronously, so we optimistically
|
||||
// return success. The actual message will arrive via the patch callback.
|
||||
// For the sync API compatibility, we construct a placeholder.
|
||||
const sender = state.myName;
|
||||
const seq = (state.senderSeq[sender] ?? 0) + 1;
|
||||
const id = makeMessageId(sender, seq);
|
||||
const id = `${sender}-${seq}`;
|
||||
|
||||
const msg: StreamMessage<T> = {
|
||||
id,
|
||||
|
|
@ -439,116 +319,52 @@ export function sendMessage<T>(
|
|||
reverted: false,
|
||||
};
|
||||
|
||||
const topic = `ttrpg/${sessionId}/stream`;
|
||||
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] publish error:", err);
|
||||
});
|
||||
|
||||
// Optimistic local insert
|
||||
receiveMessage(msg as StreamMessage);
|
||||
|
||||
return { success: true, msg };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function receiveMessage(msg: StreamMessage): void {
|
||||
const existing = state.messages.find((m) => m.id === msg.id);
|
||||
if (existing) {
|
||||
setState(
|
||||
produce((s) => {
|
||||
const idx = s.messages.findIndex((m) => m.id === msg.id);
|
||||
if (idx !== -1) s.messages[idx] = msg;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(
|
||||
produce((s) => {
|
||||
s.messages.push(msg);
|
||||
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
|
||||
}),
|
||||
);
|
||||
|
||||
runReducer(msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Revert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Revert the current sender's latest (highest seq) message.
|
||||
* Only works if it's still the latest — a subsequent message locks it.
|
||||
*/
|
||||
export function revertLatest():
|
||||
{ success: true } | { success: false; error: string } {
|
||||
if (!_mqttClient || !_mqttConnected) {
|
||||
return { success: false, error: "Not connected to stream" };
|
||||
}
|
||||
|
||||
const sessionId = state.sessionId;
|
||||
if (!sessionId) return { success: false, error: "No active session" };
|
||||
|
||||
const sender = state.myName;
|
||||
const latestSeq = state.senderSeq[sender];
|
||||
if (!latestSeq) return { success: false, error: "No messages to revert" };
|
||||
|
||||
const id = makeMessageId(sender, latestSeq);
|
||||
const original = state.messages.find((m) => m.id === id);
|
||||
if (!original) return { success: false, error: "Message not found" };
|
||||
if (original.reverted) return { success: false, error: "Already reverted" };
|
||||
|
||||
const reverted: StreamMessage = { ...original, reverted: true };
|
||||
const topic = `ttrpg/${sessionId}/stream`;
|
||||
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
|
||||
if (err) console.error("[stream] revert publish error:", err);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
| { success: true }
|
||||
| { success: false; error: string } {
|
||||
const api = getWorkerAPI();
|
||||
return api.revertLatest() as unknown as
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function disconnectStream(): void {
|
||||
if (_mqttClient) {
|
||||
// Clear our presence before disconnecting (tombstone)
|
||||
const sessionId = state.sessionId;
|
||||
if (sessionId) {
|
||||
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
}
|
||||
// Force disconnect without reconnect
|
||||
_mqttClient.end(true, void 0, () => {
|
||||
// noop
|
||||
});
|
||||
_mqttClient = null;
|
||||
_mqttConnected = false;
|
||||
}
|
||||
setState("connected", false);
|
||||
setState("connectionStatus", "disconnected");
|
||||
setState("players", {});
|
||||
|
||||
// Strip autojoin param so the dialog shows normally on next connect
|
||||
const api = getWorkerAPI();
|
||||
api.disconnect();
|
||||
removeUrlParam("autojoin");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived / helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export function createSession(
|
||||
name: string,
|
||||
players: string[] = [],
|
||||
): string | null {
|
||||
const api = getWorkerAPI();
|
||||
// This is sync in the worker but async over Comlink.
|
||||
// We generate the ID locally using the same algorithm.
|
||||
const id =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "session";
|
||||
api.createSession(name, players);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string): void {
|
||||
const api = getWorkerAPI();
|
||||
api.deleteSession(sessionId);
|
||||
}
|
||||
|
||||
export function canRevert(): boolean {
|
||||
const sender = state.myName;
|
||||
const seq = state.senderSeq[sender];
|
||||
if (!seq) return false;
|
||||
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
|
||||
const msg = state.messages.find(
|
||||
(m) => m.id === `${sender}-${seq}`,
|
||||
);
|
||||
return msg !== undefined && !msg.reverted;
|
||||
}
|
||||
|
||||
|
|
@ -561,3 +377,11 @@ export function useJournalStream() {
|
|||
}
|
||||
|
||||
export { state as journalStreamState };
|
||||
|
||||
/**
|
||||
* Hydrate from server — now handled by the worker during connect().
|
||||
* Kept for API compatibility; does nothing.
|
||||
*/
|
||||
export async function hydrateFromServer(_sessionId: string): Promise<void> {
|
||||
// Hydration is now handled by the worker during connect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,334 +0,0 @@
|
|||
import { createStore } from "solid-js/store";
|
||||
import type {
|
||||
CharacterStats,
|
||||
CharacterSaves,
|
||||
InventoryItem,
|
||||
MothershipCharacter,
|
||||
MothershipStoreState,
|
||||
VitalValue,
|
||||
StressValue,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 创建默认角色数据
|
||||
*/
|
||||
export function createDefaultCharacter(): MothershipCharacter {
|
||||
return {
|
||||
stats: {
|
||||
strength: 50,
|
||||
agility: 50,
|
||||
combat: 50,
|
||||
intellect: 50,
|
||||
},
|
||||
saves: {
|
||||
fear: 50,
|
||||
sanity: 50,
|
||||
body: 50,
|
||||
},
|
||||
skills: [],
|
||||
inventory: [],
|
||||
status: [],
|
||||
hp: { current: 0, max: 0 },
|
||||
stress: { current: 0, min: 0 },
|
||||
wounds: { current: 0, max: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制数值在 0-99 范围内
|
||||
*/
|
||||
function clampStat(value: number): number {
|
||||
return Math.max(0, Math.min(99, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制数值在指定范围内
|
||||
*/
|
||||
function clampValue(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<MothershipStoreState>({
|
||||
character: createDefaultCharacter(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新单个统计值
|
||||
*/
|
||||
export function setStat<K extends keyof CharacterStats>(
|
||||
key: K,
|
||||
value: number
|
||||
): void {
|
||||
setStore("character", "stats", key, clampStat(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新统计值
|
||||
*/
|
||||
export function setStats(stats: Partial<CharacterStats>): void {
|
||||
setStore("character", "stats", (prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
Object.entries(stats).map(([key, value]) => [key, clampStat(value)])
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个豁免值
|
||||
*/
|
||||
export function setSave<K extends keyof CharacterSaves>(
|
||||
key: K,
|
||||
value: number
|
||||
): void {
|
||||
setStore("character", "saves", key, clampStat(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新豁免值
|
||||
*/
|
||||
export function setSaves(saves: Partial<CharacterSaves>): void {
|
||||
setStore("character", "saves", (prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
Object.entries(saves).map(([key, value]) => [key, clampStat(value)])
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加技能
|
||||
*/
|
||||
export function addSkill(skill: string): void {
|
||||
setStore("character", "skills", (prev) => [...prev, skill]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除技能
|
||||
*/
|
||||
export function removeSkill(skill: string): void {
|
||||
setStore("character", "skills", (prev) =>
|
||||
prev.filter((s) => s !== skill)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置技能列表
|
||||
*/
|
||||
export function setSkills(skills: string[]): void {
|
||||
setStore("character", "skills", [...skills]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加物品到物品栏
|
||||
*/
|
||||
export function addInventoryItem(
|
||||
name: string,
|
||||
quantity: number = 1,
|
||||
attributes?: Record<string, any>
|
||||
): void {
|
||||
setStore("character", "inventory", (prev) => [
|
||||
...prev,
|
||||
{ name, quantity: Math.max(1, quantity), attributes },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从物品栏移除物品(通过名称)
|
||||
*/
|
||||
export function removeInventoryItem(name: string): void {
|
||||
setStore("character", "inventory", (prev) =>
|
||||
prev.filter((item) => item.name !== name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物品数量
|
||||
*/
|
||||
export function updateInventoryItemQuantity(
|
||||
name: string,
|
||||
quantity: number
|
||||
): void {
|
||||
setStore("character", "inventory", (prev) =>
|
||||
prev.map((item) =>
|
||||
item.name === name
|
||||
? { ...item, quantity: Math.max(0, quantity) }
|
||||
: item
|
||||
).filter((item) => item.quantity > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物品属性
|
||||
*/
|
||||
export function updateInventoryItemAttributes(
|
||||
name: string,
|
||||
attributes: Record<string, any>
|
||||
): void {
|
||||
setStore("character", "inventory", (prev) =>
|
||||
prev.map((item) =>
|
||||
item.name === name
|
||||
? { ...item, attributes }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加状态效果
|
||||
*/
|
||||
export function addStatus(
|
||||
name: string,
|
||||
quantity: number = 1,
|
||||
attributes?: Record<string, any>
|
||||
): void {
|
||||
setStore("character", "status", (prev) => [
|
||||
...prev,
|
||||
{ name, quantity: Math.max(1, quantity), attributes },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除状态效果
|
||||
*/
|
||||
export function removeStatus(name: string): void {
|
||||
setStore("character", "status", (prev) =>
|
||||
prev.filter((item) => item.name !== name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 HP
|
||||
*/
|
||||
export function setHP(value: Partial<VitalValue>): void {
|
||||
setStore("character", "hp", (prev) => ({
|
||||
...prev,
|
||||
...value,
|
||||
current: value.current !== undefined
|
||||
? clampValue(value.current, 0, value.max ?? prev.max)
|
||||
: prev.current,
|
||||
max: value.max !== undefined
|
||||
? Math.max(0, value.max)
|
||||
: prev.max,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 受到伤害
|
||||
*/
|
||||
export function takeDamage(amount: number): void {
|
||||
setStore("character", "hp", (prev) => ({
|
||||
...prev,
|
||||
current: Math.max(0, prev.current - amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 治疗 HP
|
||||
*/
|
||||
export function healHP(amount: number): void {
|
||||
setStore("character", "hp", (prev) => ({
|
||||
...prev,
|
||||
current: Math.min(prev.max, prev.current + amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置压力值
|
||||
*/
|
||||
export function setStress(value: Partial<StressValue>): void {
|
||||
setStore("character", "stress", (prev) => ({
|
||||
...prev,
|
||||
...value,
|
||||
current: value.current !== undefined
|
||||
? clampValue(value.current, value.min ?? prev.min, Number.MAX_SAFE_INTEGER)
|
||||
: prev.current,
|
||||
min: value.min !== undefined ? Math.max(0, value.min) : prev.min,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加压力
|
||||
*/
|
||||
export function addStress(amount: number): void {
|
||||
setStore("character", "stress", (prev) => ({
|
||||
...prev,
|
||||
current: prev.current + amount,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少压力
|
||||
*/
|
||||
export function reduceStress(amount: number): void {
|
||||
setStore("character", "stress", (prev) => ({
|
||||
...prev,
|
||||
current: Math.max(prev.min, prev.current - amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置伤口值
|
||||
*/
|
||||
export function setWounds(value: Partial<VitalValue>): void {
|
||||
setStore("character", "wounds", (prev) => ({
|
||||
...prev,
|
||||
...value,
|
||||
current: value.current !== undefined
|
||||
? clampValue(value.current, 0, value.max ?? prev.max)
|
||||
: prev.current,
|
||||
max: value.max !== undefined
|
||||
? Math.max(0, value.max)
|
||||
: prev.max,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加伤口
|
||||
*/
|
||||
export function addWound(amount: number = 1): void {
|
||||
setStore("character", "wounds", (prev) => ({
|
||||
...prev,
|
||||
current: Math.min(prev.max, prev.current + amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 治疗伤口
|
||||
*/
|
||||
export function healWound(amount: number = 1): void {
|
||||
setStore("character", "wounds", (prev) => ({
|
||||
...prev,
|
||||
current: Math.max(0, prev.current - amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置角色到默认状态
|
||||
*/
|
||||
export function resetCharacter(): void {
|
||||
setStore("character", createDefaultCharacter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置完整角色数据
|
||||
*/
|
||||
export function setCharacter(character: MothershipCharacter): void {
|
||||
setStore("character", character);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前角色数据
|
||||
*/
|
||||
export function getCharacter(): MothershipCharacter {
|
||||
return store.character;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Store 订阅(用于 SolidJS 组件)
|
||||
*/
|
||||
export function useCharacterStore() {
|
||||
return store;
|
||||
}
|
||||
|
||||
export { store, setStore };
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* Mothership TRPG 角色表 Store
|
||||
*
|
||||
* @module journals/mothership
|
||||
*/
|
||||
|
||||
export type {
|
||||
CharacterStats,
|
||||
CharacterSaves,
|
||||
InventoryItem,
|
||||
VitalValue,
|
||||
StressValue,
|
||||
MothershipCharacter,
|
||||
MothershipStoreState,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
// Store 核心
|
||||
store,
|
||||
setStore,
|
||||
useCharacterStore,
|
||||
getCharacter,
|
||||
|
||||
// 初始化
|
||||
createDefaultCharacter,
|
||||
resetCharacter,
|
||||
setCharacter,
|
||||
|
||||
// Stats 操作
|
||||
setStat,
|
||||
setStats,
|
||||
|
||||
// Saves 操作
|
||||
setSave,
|
||||
setSaves,
|
||||
|
||||
// Skills 操作
|
||||
addSkill,
|
||||
removeSkill,
|
||||
setSkills,
|
||||
|
||||
// Inventory 操作
|
||||
addInventoryItem,
|
||||
removeInventoryItem,
|
||||
updateInventoryItemQuantity,
|
||||
updateInventoryItemAttributes,
|
||||
|
||||
// Status 操作
|
||||
addStatus,
|
||||
removeStatus,
|
||||
|
||||
// HP 操作
|
||||
setHP,
|
||||
takeDamage,
|
||||
healHP,
|
||||
|
||||
// Stress 操作
|
||||
setStress,
|
||||
addStress,
|
||||
reduceStress,
|
||||
|
||||
// Wounds 操作
|
||||
setWounds,
|
||||
addWound,
|
||||
healWound,
|
||||
} from "./characterStore";
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
/**
|
||||
* Mothership TRPG 角色表统计值
|
||||
* 所有值范围为 0-99
|
||||
*/
|
||||
export interface CharacterStats {
|
||||
/** 力量 */
|
||||
strength: number;
|
||||
/** 敏捷 */
|
||||
agility: number;
|
||||
/** 战斗 */
|
||||
combat: number;
|
||||
/** 智力 */
|
||||
intellect: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mothership TRPG 角色表豁免值
|
||||
* 所有值范围为 0-99
|
||||
*/
|
||||
export interface CharacterSaves {
|
||||
/** 恐惧豁免 */
|
||||
fear: number;
|
||||
/** 理智豁免 */
|
||||
sanity: number;
|
||||
/** 体质豁免 */
|
||||
body: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品栏物品
|
||||
*/
|
||||
export interface InventoryItem {
|
||||
/** 物品名称 */
|
||||
name: string;
|
||||
/** 数量 */
|
||||
quantity: number;
|
||||
/** 自定义属性,如护甲值 { ap: 3 } */
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命值/伤口等有最大值和当前值的属性
|
||||
*/
|
||||
export interface VitalValue {
|
||||
current: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压力值(有最小值和当前值)
|
||||
*/
|
||||
export interface StressValue {
|
||||
current: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mothership 角色表完整数据结构
|
||||
*/
|
||||
export interface MothershipCharacter {
|
||||
stats: CharacterStats;
|
||||
saves: CharacterSaves;
|
||||
skills: string[];
|
||||
inventory: InventoryItem[];
|
||||
status: InventoryItem[];
|
||||
hp: VitalValue;
|
||||
stress: StressValue;
|
||||
wounds: VitalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store 状态类型
|
||||
*/
|
||||
export type MothershipStoreState = {
|
||||
character: MothershipCharacter;
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* marked-directive extension: :cmd[command]{label=Display text} inline syntax.
|
||||
*
|
||||
* Renders a clickable span that dispatches the command to the journal stream
|
||||
* when clicked. The CommandLinkManager component handles the actual dispatch.
|
||||
*
|
||||
* Usage in markdown:
|
||||
* :cmd[roll 1d20+5]{label=Roll initiative}
|
||||
* :cmd[spark dungeon room-type]{label=Random room}
|
||||
* :cmd[stat set strength=18]{label=Set strength}
|
||||
*
|
||||
* If no label is provided, the command text itself is shown.
|
||||
*/
|
||||
|
||||
export const cmdDirective = {
|
||||
level: "inline" as const,
|
||||
marker: ":",
|
||||
renderer(token: any) {
|
||||
// Only handle :cmd[...], not other :directives
|
||||
if (token.meta.name !== "cmd") return false;
|
||||
|
||||
const command = (token.text || "").trim();
|
||||
if (!command) return "";
|
||||
|
||||
const label = (token.attrs?.label as string) || command;
|
||||
|
||||
return `<a class="cmd-link" data-cmd="${escapeAttr(command)}">${escapeHtml(label)}</a>`;
|
||||
},
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { gfmHeadingId } from "marked-gfm-heading-id";
|
|||
import markedColumns from "./columns";
|
||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
||||
import { iconDirective, setIconBase } from "./icon";
|
||||
import { cmdDirective } from "./cmd";
|
||||
|
||||
// 使用 marked-directive 来支持指令语法
|
||||
const marked = new Marked()
|
||||
|
|
@ -27,6 +28,7 @@ const marked = new Marked()
|
|||
level: "container",
|
||||
},
|
||||
iconDirective,
|
||||
cmdDirective,
|
||||
]),
|
||||
{
|
||||
extensions: [...markedColumns()],
|
||||
|
|
|
|||
|
|
@ -149,3 +149,14 @@ icon.big .icon-label-stroke {
|
|||
.concealed .revealed {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,529 @@
|
|||
/**
|
||||
* Journal Shared Worker — owns the single MQTT connection and message log.
|
||||
*
|
||||
* All browser tabs share one worker instance. The worker:
|
||||
* - Connects to MQTT (one connection total)
|
||||
* - Hydrates message history from the server
|
||||
* - Maintains the message log and presence tracking
|
||||
* - Broadcasts new messages and state changes to all connected tabs
|
||||
*
|
||||
* Each tab runs reducers locally to build derived state (revealedPaths, stats).
|
||||
*/
|
||||
|
||||
import * as Comlink from "comlink";
|
||||
import type { StreamMessage } from "../components/journal/registry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionMeta {
|
||||
name: string;
|
||||
created: number;
|
||||
players: string[];
|
||||
}
|
||||
|
||||
export interface SessionManifest {
|
||||
sessions: Record<string, SessionMeta>;
|
||||
}
|
||||
|
||||
/** Lightweight state snapshot sent to tabs. */
|
||||
export interface WorkerState {
|
||||
sessionId: string | null;
|
||||
sessionName: string | null;
|
||||
messages: StreamMessage[];
|
||||
senderSeq: Record<string, number>;
|
||||
connected: boolean;
|
||||
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
|
||||
connectionError: string | null;
|
||||
myName: string;
|
||||
myRole: "gm" | "player" | "observer";
|
||||
brokerUrl: string | null;
|
||||
players: Record<string, { role: string }>;
|
||||
}
|
||||
|
||||
/** Patch sent on incremental updates (new message, presence change, etc.). */
|
||||
export type WorkerPatch =
|
||||
| { type: "fullState"; state: WorkerState }
|
||||
| { type: "message"; message: StreamMessage }
|
||||
| { type: "connectionStatus"; status: WorkerState["connectionStatus"]; error?: string }
|
||||
| { type: "players"; players: Record<string, { role: string }> }
|
||||
| { type: "sessionName"; name: string | null }
|
||||
| { type: "sessionList"; sessions: SessionManifest };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||
|
||||
let state: WorkerState = {
|
||||
sessionId: null,
|
||||
sessionName: null,
|
||||
messages: [],
|
||||
senderSeq: {},
|
||||
connected: false,
|
||||
connectionStatus: "disconnected",
|
||||
connectionError: null,
|
||||
myName: "gm",
|
||||
myRole: "gm",
|
||||
brokerUrl: null,
|
||||
players: {},
|
||||
};
|
||||
|
||||
let sessionList: SessionManifest = { sessions: {} };
|
||||
|
||||
// MQTT client reference
|
||||
let _mqttClient: import("mqtt").MqttClient | null = null;
|
||||
let _mqttConnected = false;
|
||||
|
||||
// Subscribers: each tab registers a callback
|
||||
type PatchCallback = (patch: WorkerPatch) => void;
|
||||
const subscribers = new Set<PatchCallback>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMessageId(sender: string, seq: number): string {
|
||||
return `${sender}-${seq}`;
|
||||
}
|
||||
|
||||
function notifySubscribers(patch: WorkerPatch): void {
|
||||
for (const cb of subscribers) {
|
||||
try {
|
||||
cb(patch);
|
||||
} catch {
|
||||
// subscriber may have disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastFullState(): void {
|
||||
notifySubscribers({ type: "fullState", state: { ...state, messages: [...state.messages], senderSeq: { ...state.senderSeq }, players: { ...state.players } } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hydration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function hydrateFromServer(sessionId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return;
|
||||
throw new Error(`Failed to load session: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.split("\n").filter((l) => l.trim());
|
||||
|
||||
const messages: StreamMessage[] = [];
|
||||
const senderSeq: Record<string, number> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(line);
|
||||
messages.push(msg);
|
||||
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
|
||||
} catch {
|
||||
/* skip corrupt */
|
||||
}
|
||||
}
|
||||
|
||||
state.messages = messages;
|
||||
state.senderSeq = senderSeq;
|
||||
state.sessionId = sessionId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MQTT Connect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function connectStream(
|
||||
sessionId: string,
|
||||
brokerUrl: string,
|
||||
name: string,
|
||||
role: "gm" | "player" | "observer",
|
||||
): Promise<void> {
|
||||
const { default: mqtt } = await import("mqtt");
|
||||
|
||||
state.connectionStatus = "connecting";
|
||||
state.connectionError = null;
|
||||
state.myName = name;
|
||||
state.myRole = role;
|
||||
notifySubscribers({ type: "connectionStatus", status: "connecting" });
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = mqtt.connect(brokerUrl, {
|
||||
clientId: `${name}-${role}-${Date.now()}`,
|
||||
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
|
||||
reconnectPeriod: 2000,
|
||||
});
|
||||
|
||||
_mqttClient = client;
|
||||
|
||||
client.on("connect", () => {
|
||||
_mqttConnected = true;
|
||||
state.connected = true;
|
||||
state.connectionStatus = "connected";
|
||||
state.connectionError = null;
|
||||
state.brokerUrl = brokerUrl;
|
||||
|
||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
||||
if (err) console.error("[worker] stream sub err:", err);
|
||||
});
|
||||
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
||||
if (err) console.error("[worker] sessions sub err:", err);
|
||||
});
|
||||
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
||||
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
||||
|
||||
// Publish own presence (retained)
|
||||
const presenceData = JSON.stringify({ name, role });
|
||||
client.publish(
|
||||
`ttrpg/${sessionId}/presence/${name}`,
|
||||
presenceData,
|
||||
{ qos: 1, retain: true },
|
||||
);
|
||||
|
||||
notifySubscribers({ type: "connectionStatus", status: "connected" });
|
||||
broadcastFullState();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
console.error("[worker] mqtt error:", err);
|
||||
state.connectionStatus = "error";
|
||||
state.connectionError = err.message;
|
||||
notifySubscribers({ type: "connectionStatus", status: "error", error: err.message });
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
_mqttConnected = false;
|
||||
state.connected = false;
|
||||
state.connectionStatus = "disconnected";
|
||||
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
|
||||
});
|
||||
|
||||
client.on("message", (topic, payload) => {
|
||||
const raw = payload.toString();
|
||||
const parts = topic.split("/");
|
||||
|
||||
if (topic === $SESSIONS) {
|
||||
try {
|
||||
const manifest: SessionManifest = JSON.parse(raw);
|
||||
sessionList = manifest;
|
||||
const currentId = state.sessionId;
|
||||
if (currentId && manifest.sessions[currentId]) {
|
||||
state.sessionName = manifest.sessions[currentId].name;
|
||||
notifySubscribers({ type: "sessionName", name: state.sessionName });
|
||||
}
|
||||
notifySubscribers({ type: "sessionList", sessions: manifest });
|
||||
} catch (e) {
|
||||
console.error("[worker] manifest parse err:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Presence
|
||||
if (
|
||||
parts.length >= 4 &&
|
||||
parts[0] === "ttrpg" &&
|
||||
parts[2] === "presence"
|
||||
) {
|
||||
const playerName = parts[3];
|
||||
if (raw) {
|
||||
try {
|
||||
const presence = JSON.parse(raw);
|
||||
state.players = { ...state.players, [playerName]: { role: presence.role || "player" } };
|
||||
} catch {
|
||||
state.players = { ...state.players, [playerName]: { role: "player" } };
|
||||
}
|
||||
} else {
|
||||
const newPlayers = { ...state.players };
|
||||
delete newPlayers[playerName];
|
||||
state.players = newPlayers;
|
||||
}
|
||||
notifySubscribers({ type: "players", players: { ...state.players } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(raw);
|
||||
receiveMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[worker] malformed message:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function receiveMessage(msg: StreamMessage): void {
|
||||
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
|
||||
if (existingIdx !== -1) {
|
||||
state.messages = [
|
||||
...state.messages.slice(0, existingIdx),
|
||||
msg,
|
||||
...state.messages.slice(existingIdx + 1),
|
||||
];
|
||||
} else {
|
||||
state.messages = [...state.messages, msg];
|
||||
state.senderSeq = {
|
||||
...state.senderSeq,
|
||||
[msg.sender]: Math.max(state.senderSeq[msg.sender] ?? 0, msg.seq),
|
||||
};
|
||||
}
|
||||
|
||||
notifySubscribers({ type: "message", message: msg });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sendMessage(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
): { success: true; msg: StreamMessage } | { success: false; error: string } {
|
||||
if (!_mqttClient || !_mqttConnected) {
|
||||
return { success: false, error: "Not connected to stream" };
|
||||
}
|
||||
|
||||
const sessionId = state.sessionId;
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "No active session" };
|
||||
}
|
||||
|
||||
// We can't import validatePayload here because it depends on the registry
|
||||
// which is populated by importing types. We do basic validation.
|
||||
if (!type || typeof type !== "string") {
|
||||
return { success: false, error: "Invalid message type" };
|
||||
}
|
||||
|
||||
const sender = state.myName;
|
||||
const seq = (state.senderSeq[sender] ?? 0) + 1;
|
||||
const id = makeMessageId(sender, seq);
|
||||
|
||||
const msg: StreamMessage = {
|
||||
id,
|
||||
sender,
|
||||
seq,
|
||||
type,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
reverted: false,
|
||||
};
|
||||
|
||||
const topic = `ttrpg/${sessionId}/stream`;
|
||||
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
|
||||
if (err) console.error("[worker] publish error:", err);
|
||||
});
|
||||
|
||||
// Optimistic local insert
|
||||
receiveMessage(msg);
|
||||
|
||||
return { success: true, msg };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Revert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function revertLatest():
|
||||
{ success: true } | { success: false; error: string } {
|
||||
if (!_mqttClient || !_mqttConnected) {
|
||||
return { success: false, error: "Not connected to stream" };
|
||||
}
|
||||
|
||||
const sessionId = state.sessionId;
|
||||
if (!sessionId) return { success: false, error: "No active session" };
|
||||
|
||||
const sender = state.myName;
|
||||
const latestSeq = state.senderSeq[sender];
|
||||
if (!latestSeq) return { success: false, error: "No messages to revert" };
|
||||
|
||||
const id = makeMessageId(sender, latestSeq);
|
||||
const original = state.messages.find((m) => m.id === id);
|
||||
if (!original) return { success: false, error: "Message not found" };
|
||||
if (original.reverted) return { success: false, error: "Already reverted" };
|
||||
|
||||
const reverted: StreamMessage = { ...original, reverted: true };
|
||||
const topic = `ttrpg/${sessionId}/stream`;
|
||||
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
|
||||
if (err) console.error("[worker] revert publish error:", err);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function disconnectStream(): void {
|
||||
if (_mqttClient) {
|
||||
const sessionId = state.sessionId;
|
||||
if (sessionId) {
|
||||
_mqttClient.publish(
|
||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||
"",
|
||||
{ qos: 1, retain: true },
|
||||
);
|
||||
}
|
||||
_mqttClient.end(true, void 0, () => {});
|
||||
_mqttClient = null;
|
||||
_mqttConnected = false;
|
||||
}
|
||||
state.connected = false;
|
||||
state.connectionStatus = "disconnected";
|
||||
state.players = {};
|
||||
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
|
||||
notifySubscribers({ type: "players", players: {} });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSession(name: string, players: string[] = []): string | null {
|
||||
if (!_mqttClient || !_mqttConnected) return null;
|
||||
|
||||
const id =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "session";
|
||||
|
||||
const meta: SessionMeta = { name, created: Date.now(), players };
|
||||
|
||||
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function deleteSession(sessionId: string): void {
|
||||
if (!_mqttClient || !_mqttConnected) return;
|
||||
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function canRevert(): boolean {
|
||||
const sender = state.myName;
|
||||
const seq = state.senderSeq[sender];
|
||||
if (!seq) return false;
|
||||
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
|
||||
return msg !== undefined && !msg.reverted;
|
||||
}
|
||||
|
||||
function visibleMessages(): StreamMessage[] {
|
||||
return state.messages.filter((m) => !m.reverted);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comlink API — exposed to main thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const api = {
|
||||
async connect(
|
||||
sessionId: string,
|
||||
brokerUrl: string,
|
||||
name: string,
|
||||
role: "gm" | "player" | "observer",
|
||||
): Promise<void> {
|
||||
await hydrateFromServer(sessionId);
|
||||
await connectStream(sessionId, brokerUrl, name, role);
|
||||
},
|
||||
|
||||
disconnect(): void {
|
||||
disconnectStream();
|
||||
},
|
||||
|
||||
sendMessage(type: string, payload: unknown) {
|
||||
return sendMessage(type, payload);
|
||||
},
|
||||
|
||||
revertLatest() {
|
||||
return revertLatest();
|
||||
},
|
||||
|
||||
createSession(name: string, players: string[] = []) {
|
||||
return createSession(name, players);
|
||||
},
|
||||
|
||||
deleteSession(sessionId: string) {
|
||||
deleteSession(sessionId);
|
||||
},
|
||||
|
||||
getState(): WorkerState {
|
||||
return {
|
||||
...state,
|
||||
messages: [...state.messages],
|
||||
senderSeq: { ...state.senderSeq },
|
||||
players: { ...state.players },
|
||||
};
|
||||
},
|
||||
|
||||
getSessionList(): SessionManifest {
|
||||
return sessionList;
|
||||
},
|
||||
|
||||
canRevert(): boolean {
|
||||
return canRevert();
|
||||
},
|
||||
|
||||
visibleMessages(): StreamMessage[] {
|
||||
return visibleMessages();
|
||||
},
|
||||
|
||||
/** Register a callback for state patches. Sends full state immediately. */
|
||||
subscribe(callback: Comlink.ProxyOrClone<PatchCallback>): () => void {
|
||||
const cb = callback as PatchCallback;
|
||||
subscribers.add(cb);
|
||||
// Send initial full state
|
||||
try {
|
||||
cb({
|
||||
type: "fullState",
|
||||
state: {
|
||||
...state,
|
||||
messages: [...state.messages],
|
||||
senderSeq: { ...state.senderSeq },
|
||||
players: { ...state.players },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export type JournalWorkerAPI = typeof api;
|
||||
|
||||
// SharedWorkers communicate via MessagePort from the 'connect' event,
|
||||
// not via self.onmessage. Each connecting tab gets its own port.
|
||||
(self as unknown as { onconnect: ((e: MessageEvent) => void) | null }).onconnect = (e: MessageEvent) => {
|
||||
const port = e.ports[0];
|
||||
Comlink.expose(api, port);
|
||||
};
|
||||
Loading…
Reference in New Issue