fix(journal): decouple broker from session
The broker connection now persists across session changes. Joining or switching a session swaps subscriptions instead of reconnecting, and a lobby lets sessions be listed and created before joining one. Fixes session switching being a no-op (the store only updated local state) and session creation being unreachable before connecting. Also removes the dead ConnectionBar and the no-op hydrateFromServer/setSessionId store calls.
This commit is contained in:
@@ -1,27 +1,84 @@
|
|||||||
/**
|
/**
|
||||||
* ConnectDialog — name + role picker to join a session
|
* ConnectDialog — broker connection + session lobby (list / create / join)
|
||||||
|
*
|
||||||
|
* The broker connection and the active session are decoupled: this dialog
|
||||||
|
* connects to the broker, which makes the session list available, and joining
|
||||||
|
* or creating a session is a separate step that reuses that connection.
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, onMount, Show } from "solid-js";
|
import { Component, createSignal, For, onMount, Show } from "solid-js";
|
||||||
import {
|
import {
|
||||||
connectStream,
|
connectBroker,
|
||||||
hydrateFromServer,
|
createSession,
|
||||||
useJournalStream,
|
joinSession,
|
||||||
|
resolveBrokerUrl,
|
||||||
|
sessions,
|
||||||
setMyName,
|
setMyName,
|
||||||
setMyRole,
|
setMyRole,
|
||||||
setSessionId,
|
useJournalStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
export const ConnectDialog: Component = () => {
|
export const ConnectDialog: Component = () => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
const manifest = sessions;
|
||||||
const [playerName, setPlayerName] = createSignal(stream.myName);
|
const [playerName, setPlayerName] = createSignal(stream.myName);
|
||||||
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
||||||
stream.myRole,
|
stream.myRole,
|
||||||
);
|
);
|
||||||
const [connecting, setConnecting] = createSignal(false);
|
const [newSessionName, setNewSessionName] = createSignal("");
|
||||||
|
const [busy, setBusy] = createSignal(false);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [autoJoined, setAutoJoined] = createSignal(false);
|
const [autoJoined, setAutoJoined] = createSignal(false);
|
||||||
|
|
||||||
// Auto-join when URL has both session and player params
|
const ensureBroker = async () => {
|
||||||
|
if (stream.connected) return;
|
||||||
|
await connectBroker(resolveBrokerUrl());
|
||||||
|
};
|
||||||
|
|
||||||
|
const identity = () => ({
|
||||||
|
name: playerName().trim() || stream.myName,
|
||||||
|
role: role(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyIdentity = () => {
|
||||||
|
const { name, role: r } = identity();
|
||||||
|
setMyName(name);
|
||||||
|
setMyRole(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (sessionId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
applyIdentity();
|
||||||
|
await ensureBroker();
|
||||||
|
await joinSession(sessionId, identity());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newSessionName().trim();
|
||||||
|
if (!name) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
applyIdentity();
|
||||||
|
await ensureBroker();
|
||||||
|
const id = await createSession(name);
|
||||||
|
if (id) await joinSession(id, identity());
|
||||||
|
setNewSessionName("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join when the URL carries session + player + autojoin params;
|
||||||
|
// otherwise enter the lobby so the session list is available up front.
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const params = new URL(window.location.href).searchParams;
|
const params = new URL(window.location.href).searchParams;
|
||||||
if (
|
if (
|
||||||
@@ -32,51 +89,26 @@ export const ConnectDialog: Component = () => {
|
|||||||
setPlayerName(params.get("player") || "");
|
setPlayerName(params.get("player") || "");
|
||||||
setRole("player");
|
setRole("player");
|
||||||
setAutoJoined(true);
|
setAutoJoined(true);
|
||||||
handleConnect();
|
handleJoin(params.get("session")!);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
ensureBroker().catch((e) => {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
const name = playerName().trim() || stream.myName;
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
|
|
||||||
// rsbuild dev server. In production, the CLI serves everything itself.
|
|
||||||
const brokerUrl =
|
|
||||||
process.env.NODE_ENV === "development"
|
|
||||||
? `ws://localhost:3000`
|
|
||||||
: `ws://${window.location.host}`;
|
|
||||||
|
|
||||||
setConnecting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setMyName(name);
|
|
||||||
setMyRole(role());
|
|
||||||
const sessionId = stream.sessionId || "default";
|
|
||||||
setSessionId(sessionId);
|
|
||||||
await hydrateFromServer(sessionId);
|
|
||||||
await connectStream(sessionId, brokerUrl);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : "Connection failed";
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={!autoJoined() || error()}
|
when={!autoJoined() || error()}
|
||||||
fallback={
|
fallback={
|
||||||
<p class="text-sm text-gray-500 text-center">
|
<p class="text-sm text-gray-500 text-center">
|
||||||
{connecting() ? "正在加入会话…" : "已加入!"}
|
{busy() ? "正在加入会话…" : "已加入!"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-sm space-y-3">
|
<div class="w-full max-w-sm space-y-3">
|
||||||
<p class="text-sm text-gray-600 text-center">
|
<p class="text-sm text-gray-600 text-center">
|
||||||
请输入你的名字和角色来加入会话。
|
输入你的名字和角色,选择或新建一个会话。
|
||||||
<br />
|
<br />
|
||||||
<span class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">
|
||||||
连接到 {window.location.host}
|
连接到 {window.location.host}
|
||||||
@@ -86,7 +118,7 @@ export const ConnectDialog: Component = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={playerName()}
|
value={playerName()}
|
||||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
placeholder="你的名字"
|
placeholder="你的名字"
|
||||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -106,14 +138,52 @@ export const ConnectDialog: Component = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
{/* Session list */}
|
||||||
disabled={connecting() || !playerName().trim()}
|
<div class="space-y-1">
|
||||||
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
<p class="text-xs text-gray-500">会话</p>
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
|
||||||
>
|
<For each={Object.entries(manifest().sessions)}>
|
||||||
{connecting() ? "连接中..." : "加入"}
|
{([id, meta]) => (
|
||||||
</button>
|
<button
|
||||||
|
onClick={() => handleJoin(id)}
|
||||||
|
disabled={busy()}
|
||||||
|
class="w-full text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{meta.name || id}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={Object.keys(manifest().sessions).length === 0}>
|
||||||
|
<p class="px-2 py-2 text-xs text-gray-400">
|
||||||
|
{stream.connected ? "还没有会话" : "正在加载会话…"}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create a new session and join it (GM only) */}
|
||||||
|
<Show when={role() === "gm"}>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSessionName()}
|
||||||
|
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
|
placeholder="新会话名称"
|
||||||
|
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={busy() || !newSessionName().trim()}
|
||||||
|
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
|
||||||
|
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
新建并加入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConnectionBar — session picker, player info (connected state only)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, createSignal, Show, For } from "solid-js";
|
|
||||||
import {
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
deleteSession,
|
|
||||||
disconnectStream,
|
|
||||||
setSessionId,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { journalSetState } from "../stores/journalStream";
|
|
||||||
|
|
||||||
export const ConnectionBar: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const [newSessionName, setNewSessionName] = createSignal("");
|
|
||||||
const [showNewSession, setShowNewSession] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const manifest = sessions;
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
|
||||||
const name = newSessionName().trim();
|
|
||||||
if (!name) return;
|
|
||||||
const id = createSession(name);
|
|
||||||
if (id) {
|
|
||||||
setSessionId(id);
|
|
||||||
hydrateFromServer(id).catch(() => {});
|
|
||||||
}
|
|
||||||
setNewSessionName("");
|
|
||||||
setShowNewSession(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSession = (id: string) => {
|
|
||||||
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
|
|
||||||
deleteSession(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSessionSelect = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await hydrateFromServer(id);
|
|
||||||
setSessionId(id);
|
|
||||||
} catch (e) {
|
|
||||||
setError("无法加载会话");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
disconnectStream();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
|
||||||
{/* Status row */}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<span class="text-xs text-gray-500">已连接</span>
|
|
||||||
<Show when={stream.sessionId}>
|
|
||||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
|
||||||
{stream.sessionName || stream.sessionId}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1" />
|
|
||||||
<Show when={stream.myName !== "gm"}>
|
|
||||||
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500"
|
|
||||||
title="断开连接"
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GM session management */}
|
|
||||||
<Show when={stream.myName === "gm"}>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="text-xs text-gray-500">会话列表:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewSession((v) => !v)}
|
|
||||||
class="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
+ 新建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showNewSession()}>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSessionName()}
|
|
||||||
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
|
||||||
placeholder="会话名称"
|
|
||||||
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateSession}
|
|
||||||
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Create Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
|
||||||
{([id, meta]) => (
|
|
||||||
<div
|
|
||||||
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
|
|
||||||
id === stream.sessionId
|
|
||||||
? "bg-blue-100 text-blue-800"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex-1 truncate"
|
|
||||||
onClick={() => handleSessionSelect(id)}
|
|
||||||
>
|
|
||||||
{meta.name || id}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteSession(id);
|
|
||||||
}}
|
|
||||||
class="text-gray-400 hover:text-red-500 text-xs"
|
|
||||||
title="删除会话"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* CreateSessionDialog — modal for naming a new session
|
* CreateSessionDialog — modal for naming a new session
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, onMount } from "solid-js";
|
import { Component, createSignal, onMount } from "solid-js";
|
||||||
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
|
import { createSession, joinSession } from "../stores/journalStream";
|
||||||
|
|
||||||
interface CreateSessionDialogProps {
|
interface CreateSessionDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -19,15 +19,16 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
|
|||||||
const trimmed = name().trim();
|
const trimmed = name().trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const id = createSession(trimmed);
|
try {
|
||||||
if (id) {
|
const id = await createSession(trimmed);
|
||||||
setSessionId(id);
|
if (id) await joinSession(id);
|
||||||
// Hydrate the new (empty) session so the UI reflects it immediately
|
} catch (e) {
|
||||||
await hydrateFromServer(id).catch(() => {});
|
console.error("[journal] failed to create session:", e);
|
||||||
|
} finally {
|
||||||
|
setName("");
|
||||||
|
setCreating(false);
|
||||||
|
props.onClose();
|
||||||
}
|
}
|
||||||
setName("");
|
|
||||||
setCreating(false);
|
|
||||||
props.onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => inputRef?.focus());
|
onMount(() => inputRef?.focus());
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* JournalHeader — status dot, player name, session dropdown, disconnect & close
|
* JournalHeader — status dot, player name, session dropdown, leave & close
|
||||||
*/
|
*/
|
||||||
import { Component, Show } from "solid-js";
|
import { Component, Show } from "solid-js";
|
||||||
import { useJournalStream, disconnectStream } from "../stores/journalStream";
|
import { useJournalStream, leaveSession } from "../stores/journalStream";
|
||||||
import { SessionDropdown } from "./SessionDropdown";
|
import { SessionDropdown } from "./SessionDropdown";
|
||||||
|
|
||||||
interface JournalHeaderProps {
|
interface JournalHeaderProps {
|
||||||
@@ -13,7 +13,7 @@ interface JournalHeaderProps {
|
|||||||
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
|
||||||
const handleDisconnect = () => disconnectStream();
|
const handleLeave = () => leaveSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
||||||
@@ -32,7 +32,9 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
<Show
|
<Show
|
||||||
when={stream.connected && stream.sessionId}
|
when={stream.connected && stream.sessionId}
|
||||||
fallback={
|
fallback={
|
||||||
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
<h2 class="text-sm font-semibold text-gray-700 truncate">
|
||||||
|
{stream.connected ? "选择会话" : "未连接"}
|
||||||
|
</h2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -58,13 +60,13 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.sessionId}>
|
||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
onClick={handleLeave}
|
||||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
class="text-xs text-gray-400 hover:text-blue-500 px-1"
|
||||||
title="断开连接"
|
title="返回会话列表"
|
||||||
>
|
>
|
||||||
⏻
|
←
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Player list tabs */}
|
{/* Player list tabs */}
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.connected && stream.sessionId}>
|
||||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
||||||
<For each={playerEntries()}>
|
<For each={playerEntries()}>
|
||||||
{([name]) => (
|
{([name]) => (
|
||||||
@@ -79,7 +79,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={stream.connected}
|
when={stream.connected && stream.sessionId}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
<ConnectDialog />
|
<ConnectDialog />
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
import { Component, createSignal, For, Show } from "solid-js";
|
import { Component, createSignal, For, Show } from "solid-js";
|
||||||
import {
|
import {
|
||||||
sessions,
|
sessions,
|
||||||
setSessionId,
|
joinSession,
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
useJournalStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
@@ -25,8 +24,9 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
setSessionId(id);
|
joinSession(id).catch((e) => {
|
||||||
hydrateFromServer(id).catch(() => {});
|
console.error("[journal] failed to switch session:", e);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ export {
|
|||||||
} from "./registry";
|
} from "./registry";
|
||||||
export type { MessageTypeDef, StreamMessage } from "./registry";
|
export type { MessageTypeDef, StreamMessage } from "./registry";
|
||||||
export {
|
export {
|
||||||
connectStream,
|
connectBroker,
|
||||||
|
joinSession,
|
||||||
|
switchSession,
|
||||||
|
leaveSession,
|
||||||
disconnectStream,
|
disconnectStream,
|
||||||
hydrateFromServer,
|
resolveBrokerUrl,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
revertLatest,
|
revertLatest,
|
||||||
canRevert,
|
canRevert,
|
||||||
@@ -42,7 +45,6 @@ export { ConnectDialog } from "./ConnectDialog";
|
|||||||
export { InviteDialog } from "./InviteDialog";
|
export { InviteDialog } from "./InviteDialog";
|
||||||
export { SessionDropdown } from "./SessionDropdown";
|
export { SessionDropdown } from "./SessionDropdown";
|
||||||
export { CreateSessionDialog } from "./CreateSessionDialog";
|
export { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
export { ConnectionBar } from "./ConnectionBar";
|
|
||||||
export { StreamView } from "./StreamView";
|
export { StreamView } from "./StreamView";
|
||||||
export { StreamMessageCard } from "./StreamMessage";
|
export { StreamMessageCard } from "./StreamMessage";
|
||||||
export { ComposePanel } from "./ComposePanel";
|
export { ComposePanel } from "./ComposePanel";
|
||||||
|
|||||||
@@ -282,29 +282,25 @@ export function setMyRole(role: "gm" | "player" | "observer"): void {
|
|||||||
saveRole(role);
|
saveRole(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionId(id: string | null): void {
|
/**
|
||||||
setState("sessionId", id);
|
* Resolve the broker URL for the current environment. In dev the MQTT broker
|
||||||
if (id) {
|
* lives on the CLI server (port 3000); in production the CLI serves everything.
|
||||||
saveSessionId(id);
|
*/
|
||||||
syncUrlParam("session", id);
|
export function resolveBrokerUrl(): string {
|
||||||
} else {
|
return process.env.NODE_ENV === "development"
|
||||||
removeUrlParam("session");
|
? "ws://localhost:3000"
|
||||||
setState("sessionName", null);
|
: `ws://${window.location.host}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectStream(
|
/** Connect to the broker and enter the lobby (no active session). */
|
||||||
sessionId: string,
|
export async function connectBroker(brokerUrl: string): Promise<void> {
|
||||||
brokerUrl: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const api = getWorkerAPI();
|
const api = getWorkerAPI();
|
||||||
setState("connectionStatus", "connecting");
|
setState("connectionStatus", "connecting");
|
||||||
setState("connectionError", null);
|
setState("connectionError", null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
|
await api.connect(brokerUrl, state.myName, state.myRole);
|
||||||
saveBrokerUrl(brokerUrl);
|
saveBrokerUrl(brokerUrl);
|
||||||
saveSessionId(sessionId);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState("connectionStatus", "error");
|
setState("connectionStatus", "error");
|
||||||
setState(
|
setState(
|
||||||
@@ -315,6 +311,32 @@ export async function connectStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join (or switch to) a session. The broker connection is reused, so switching
|
||||||
|
* sessions is a subscription swap rather than a reconnect.
|
||||||
|
*/
|
||||||
|
export async function joinSession(
|
||||||
|
sessionId: string,
|
||||||
|
identity?: { name?: string; role?: "gm" | "player" | "observer" },
|
||||||
|
): Promise<void> {
|
||||||
|
const api = getWorkerAPI();
|
||||||
|
await api.joinSession(sessionId, identity?.name, identity?.role);
|
||||||
|
saveSessionId(sessionId);
|
||||||
|
syncUrlParam("session", sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alias for `joinSession` — reading `switchSession` at call sites is clearer. */
|
||||||
|
export const switchSession = joinSession;
|
||||||
|
|
||||||
|
/** Leave the active session, returning to the lobby (broker stays connected). */
|
||||||
|
export function leaveSession(): void {
|
||||||
|
const api = getWorkerAPI();
|
||||||
|
api.leaveSession();
|
||||||
|
removeUrlParam("session");
|
||||||
|
setState("sessionId", null);
|
||||||
|
setState("sessionName", null);
|
||||||
|
}
|
||||||
|
|
||||||
export function sendMessage<T>(
|
export function sendMessage<T>(
|
||||||
type: string,
|
type: string,
|
||||||
payload: T,
|
payload: T,
|
||||||
@@ -365,28 +387,21 @@ export function revertLatest():
|
|||||||
export function disconnectStream(): void {
|
export function disconnectStream(): void {
|
||||||
const api = getWorkerAPI();
|
const api = getWorkerAPI();
|
||||||
api.disconnect();
|
api.disconnect();
|
||||||
|
removeUrlParam("session");
|
||||||
removeUrlParam("autojoin");
|
removeUrlParam("autojoin");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSession(
|
export async function createSession(
|
||||||
name: string,
|
name: string,
|
||||||
players: string[] = [],
|
players: string[] = [],
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
const api = getWorkerAPI();
|
return getWorkerAPI().createSession(name, players);
|
||||||
// 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 {
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
const api = getWorkerAPI();
|
await getWorkerAPI().deleteSession(sessionId);
|
||||||
api.deleteSession(sessionId);
|
// Deleting the active session drops you back to the lobby.
|
||||||
|
if (state.sessionId === sessionId) leaveSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canRevert(): boolean {
|
export function canRevert(): boolean {
|
||||||
@@ -408,11 +423,3 @@ export function useJournalStream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { state as journalStreamState };
|
export { state as journalStreamState };
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate from server — now handled by the worker during connect().
|
|
||||||
* Kept for API compatibility; does nothing.
|
|
||||||
*/
|
|
||||||
export async function hydrateFromServer(_sessionId: string): Promise<void> {
|
|
||||||
// Hydration is now handled by the worker during connect()
|
|
||||||
}
|
|
||||||
|
|||||||
+154
-31
@@ -139,11 +139,50 @@ async function hydrateFromServer(sessionId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MQTT Connect
|
// MQTT Connect — the broker connection is independent of the active session
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function connectStream(
|
function subscribeSession(
|
||||||
|
client: import("mqtt").MqttClient,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
): void {
|
||||||
|
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
||||||
|
if (err) console.error("[worker] stream sub err:", err);
|
||||||
|
});
|
||||||
|
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
||||||
|
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
||||||
|
|
||||||
|
// Publish own presence (retained)
|
||||||
|
client.publish(
|
||||||
|
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||||
|
JSON.stringify({ name: state.myName, role: state.myRole }),
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeSession(
|
||||||
|
client: import("mqtt").MqttClient,
|
||||||
|
sessionId: string,
|
||||||
|
): void {
|
||||||
|
// Clear own retained presence before dropping the subscription.
|
||||||
|
client.publish(
|
||||||
|
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||||
|
"",
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
);
|
||||||
|
client.unsubscribe(
|
||||||
|
[
|
||||||
|
`ttrpg/${sessionId}/stream`,
|
||||||
|
`ttrpg/${sessionId}/meta`,
|
||||||
|
`ttrpg/${sessionId}/presence/+`,
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error("[worker] unsubscribe err:", err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectBroker(
|
||||||
brokerUrl: string,
|
brokerUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
role: "gm" | "player" | "observer",
|
role: "gm" | "player" | "observer",
|
||||||
@@ -172,22 +211,13 @@ async function connectStream(
|
|||||||
state.connectionError = null;
|
state.connectionError = null;
|
||||||
state.brokerUrl = brokerUrl;
|
state.brokerUrl = brokerUrl;
|
||||||
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
// Broker-level subscription: the session list is available in the lobby.
|
||||||
if (err) console.error("[worker] stream sub err:", err);
|
|
||||||
});
|
|
||||||
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
||||||
if (err) console.error("[worker] sessions sub err:", 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)
|
// Restore the active session's subscriptions after an auto-reconnect.
|
||||||
const presenceData = JSON.stringify({ name, role });
|
if (state.sessionId) subscribeSession(client, state.sessionId);
|
||||||
client.publish(
|
|
||||||
`ttrpg/${sessionId}/presence/${name}`,
|
|
||||||
presenceData,
|
|
||||||
{ qos: 1, retain: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
notifySubscribers({ type: "connectionStatus", status: "connected" });
|
notifySubscribers({ type: "connectionStatus", status: "connected" });
|
||||||
broadcastFullState();
|
broadcastFullState();
|
||||||
@@ -229,6 +259,10 @@ async function connectStream(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ignore session-scoped traffic that isn't for the active session, so a
|
||||||
|
// late message from the previous session can't leak into the new one.
|
||||||
|
if (parts[1] !== state.sessionId) return;
|
||||||
|
|
||||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,6 +302,63 @@ async function connectStream(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session lifecycle — join/leave without reconnecting the broker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function joinSession(
|
||||||
|
sessionId: string,
|
||||||
|
name?: string,
|
||||||
|
role?: "gm" | "player" | "observer",
|
||||||
|
): Promise<void> {
|
||||||
|
const client = _mqttClient;
|
||||||
|
if (!client || !_mqttConnected) {
|
||||||
|
throw new Error("Not connected to broker");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId === state.sessionId) {
|
||||||
|
if (name) state.myName = name;
|
||||||
|
if (role) state.myRole = role;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the old session under the old identity before applying any change.
|
||||||
|
if (state.sessionId) unsubscribeSession(client, state.sessionId);
|
||||||
|
|
||||||
|
if (name) state.myName = name;
|
||||||
|
if (role) state.myRole = role;
|
||||||
|
|
||||||
|
// Reset session-scoped state before hydrating (a 404 leaves it empty).
|
||||||
|
state.sessionId = sessionId;
|
||||||
|
state.sessionName = sessionList.sessions[sessionId]?.name ?? null;
|
||||||
|
state.messages = [];
|
||||||
|
state.senderSeq = {};
|
||||||
|
state.players = {};
|
||||||
|
|
||||||
|
await hydrateFromServer(sessionId);
|
||||||
|
|
||||||
|
subscribeSession(client, sessionId);
|
||||||
|
|
||||||
|
notifySubscribers({ type: "sessionName", name: state.sessionName });
|
||||||
|
notifySubscribers({ type: "players", players: {} });
|
||||||
|
broadcastFullState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveSession(): void {
|
||||||
|
const client = _mqttClient;
|
||||||
|
if (client && _mqttConnected && state.sessionId) {
|
||||||
|
unsubscribeSession(client, state.sessionId);
|
||||||
|
}
|
||||||
|
state.sessionId = null;
|
||||||
|
state.sessionName = null;
|
||||||
|
state.messages = [];
|
||||||
|
state.senderSeq = {};
|
||||||
|
state.players = {};
|
||||||
|
notifySubscribers({ type: "sessionName", name: null });
|
||||||
|
notifySubscribers({ type: "players", players: {} });
|
||||||
|
broadcastFullState();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Receive
|
// Receive
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -376,10 +467,9 @@ function revertLatest():
|
|||||||
|
|
||||||
function disconnectStream(): void {
|
function disconnectStream(): void {
|
||||||
if (_mqttClient) {
|
if (_mqttClient) {
|
||||||
const sessionId = state.sessionId;
|
if (state.sessionId) {
|
||||||
if (sessionId) {
|
|
||||||
_mqttClient.publish(
|
_mqttClient.publish(
|
||||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
`ttrpg/${state.sessionId}/presence/${state.myName}`,
|
||||||
"",
|
"",
|
||||||
{ qos: 1, retain: true },
|
{ qos: 1, retain: true },
|
||||||
);
|
);
|
||||||
@@ -390,6 +480,10 @@ function disconnectStream(): void {
|
|||||||
}
|
}
|
||||||
state.connected = false;
|
state.connected = false;
|
||||||
state.connectionStatus = "disconnected";
|
state.connectionStatus = "disconnected";
|
||||||
|
state.sessionId = null;
|
||||||
|
state.sessionName = null;
|
||||||
|
state.messages = [];
|
||||||
|
state.senderSeq = {};
|
||||||
state.players = {};
|
state.players = {};
|
||||||
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
|
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
|
||||||
notifySubscribers({ type: "players", players: {} });
|
notifySubscribers({ type: "players", players: {} });
|
||||||
@@ -399,8 +493,11 @@ function disconnectStream(): void {
|
|||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function createSession(name: string, players: string[] = []): string | null {
|
function createSession(
|
||||||
if (!_mqttClient || !_mqttConnected) return null;
|
name: string,
|
||||||
|
players: string[] = [],
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!_mqttClient || !_mqttConnected) return Promise.resolve(null);
|
||||||
|
|
||||||
const id =
|
const id =
|
||||||
name
|
name
|
||||||
@@ -410,17 +507,33 @@ function createSession(name: string, players: string[] = []): string | null {
|
|||||||
|
|
||||||
const meta: SessionMeta = { name, created: Date.now(), players };
|
const meta: SessionMeta = { name, created: Date.now(), players };
|
||||||
|
|
||||||
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
|
return new Promise((resolve) => {
|
||||||
qos: 1,
|
_mqttClient!.publish(
|
||||||
retain: true,
|
`ttrpg/${id}/meta`,
|
||||||
|
JSON.stringify(meta),
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("[worker] create session publish error:", err);
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
resolve(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSession(sessionId: string): void {
|
function deleteSession(sessionId: string): Promise<void> {
|
||||||
if (!_mqttClient || !_mqttConnected) return;
|
if (!_mqttClient || !_mqttConnected) return Promise.resolve();
|
||||||
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
|
return new Promise((resolve) => {
|
||||||
|
_mqttClient!.publish(
|
||||||
|
`ttrpg/${sessionId}/meta`,
|
||||||
|
"",
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
() => resolve(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -445,13 +558,23 @@ function visibleMessages(): StreamMessage[] {
|
|||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
async connect(
|
async connect(
|
||||||
sessionId: string,
|
|
||||||
brokerUrl: string,
|
brokerUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
role: "gm" | "player" | "observer",
|
role: "gm" | "player" | "observer",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await hydrateFromServer(sessionId);
|
await connectBroker(brokerUrl, name, role);
|
||||||
await connectStream(sessionId, brokerUrl, name, role);
|
},
|
||||||
|
|
||||||
|
async joinSession(
|
||||||
|
sessionId: string,
|
||||||
|
name?: string,
|
||||||
|
role?: "gm" | "player" | "observer",
|
||||||
|
): Promise<void> {
|
||||||
|
await joinSession(sessionId, name, role);
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveSession(): void {
|
||||||
|
leaveSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
@@ -471,7 +594,7 @@ const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteSession(sessionId: string) {
|
deleteSession(sessionId: string) {
|
||||||
deleteSession(sessionId);
|
return deleteSession(sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
getState(): WorkerState {
|
getState(): WorkerState {
|
||||||
|
|||||||
Reference in New Issue
Block a user