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:
2026-09-13 13:23:50 +08:00
parent 59f87bffda
commit 30ec8a43cf
9 changed files with 350 additions and 293 deletions
+119 -49
View File
@@ -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 {
connectStream,
hydrateFromServer,
useJournalStream,
connectBroker,
createSession,
joinSession,
resolveBrokerUrl,
sessions,
setMyName,
setMyRole,
setSessionId,
useJournalStream,
} from "../stores/journalStream";
export const ConnectDialog: Component = () => {
const stream = useJournalStream();
const manifest = sessions;
const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole,
);
const [connecting, setConnecting] = createSignal(false);
const [newSessionName, setNewSessionName] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [autoJoined, setAutoJoined] = createSignal(false);
// Auto-join when URL has both session and player params
const ensureBroker = async () => {
if (stream.connected) return;
await connectBroker(resolveBrokerUrl());
};
const identity = () => ({
name: playerName().trim() || stream.myName,
role: role(),
});
const applyIdentity = () => {
const { name, role: r } = identity();
setMyName(name);
setMyRole(r);
};
const handleJoin = async (sessionId: string) => {
setBusy(true);
setError(null);
try {
applyIdentity();
await ensureBroker();
await joinSession(sessionId, identity());
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setBusy(false);
}
};
const handleCreate = async () => {
const name = newSessionName().trim();
if (!name) return;
setBusy(true);
setError(null);
try {
applyIdentity();
await ensureBroker();
const id = await createSession(name);
if (id) await joinSession(id, identity());
setNewSessionName("");
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setBusy(false);
}
};
// Auto-join when the URL carries session + player + autojoin params;
// otherwise enter the lobby so the session list is available up front.
onMount(() => {
const params = new URL(window.location.href).searchParams;
if (
@@ -32,51 +89,26 @@ export const ConnectDialog: Component = () => {
setPlayerName(params.get("player") || "");
setRole("player");
setAutoJoined(true);
handleConnect();
handleJoin(params.get("session")!);
return;
}
ensureBroker().catch((e) => {
setError(e instanceof Error ? e.message : "Connection failed");
});
});
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
// rsbuild dev server. In production, the CLI serves everything itself.
const brokerUrl =
process.env.NODE_ENV === "development"
? `ws://localhost:3000`
: `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return (
<Show
when={!autoJoined() || error()}
fallback={
<p class="text-sm text-gray-500 text-center">
{connecting() ? "正在加入会话…" : "已加入!"}
{busy() ? "正在加入会话…" : "已加入!"}
</p>
}
>
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
请输入你的名字和角色来加入会话。
输入你的名字和角色,选择或新建一个会话。
<br />
<span class="text-xs text-gray-400">
连接到 {window.location.host}
@@ -86,7 +118,7 @@ export const ConnectDialog: Component = () => {
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="你的名字"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
@@ -106,14 +138,52 @@ export const ConnectDialog: Component = () => {
</button>
))}
</div>
<button
onClick={handleConnect}
disabled={connecting() || !playerName().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "连接中..." : "加入"}
</button>
{/* Session list */}
<div class="space-y-1">
<p class="text-xs text-gray-500">会话</p>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<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()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>