- Enter your name to join the session.
+ Enter your name and role to join the session.
Connecting to {window.location.host}
@@ -284,10 +312,25 @@ const ConnectDialog: Component = () => {
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
- placeholder="Your name (GM or player)"
+ placeholder="Your name"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
/>
+ {/* Role selector */}
+
+ {(["gm", "player", "observer"] as const).map((r) => (
+ setRole(r)}
+ class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
+ role() === r
+ ? "bg-blue-600 text-white border-blue-600"
+ : "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
+ }`}
+ >
+ {r === "gm" ? "GM" : r === "player" ? "Player" : "Observer"}
+
+ ))}
+
{
type: string;
/** Human-readable label shown in the compose panel dropdown */
label: string;
- /** Which roles can emit this type. Empty array = all roles allowed. */
- emitters?: ("gm" | "player")[];
+ /** Which roles can emit this type. Empty array = all roles (including observer) allowed. */
+ emitters?: ("gm" | "player" | "observer")[];
/** Zod schema used to validate payload before publish */
schema: ZodType;
/** Returns a default payload for compose panel initialization */
@@ -110,7 +110,9 @@ export function validatePayload
(
}
return {
success: false,
- error: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
+ error: result.error.issues
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
+ .join("; "),
};
}
@@ -122,5 +124,5 @@ export function canEmit(type: string, role: string): boolean {
if (!def) return false;
const emitters = def.emitters;
if (!emitters || emitters.length === 0) return true;
- return emitters.includes(role as "gm" | "player");
+ return emitters.includes(role as "gm" | "player" | "observer");
}
diff --git a/src/components/journal/types/article.tsx b/src/components/journal/types/article.tsx
index dc8e79a..8555f05 100644
--- a/src/components/journal/types/article.tsx
+++ b/src/components/journal/types/article.tsx
@@ -42,9 +42,7 @@ const RevealLink: Component = (p) => {
const displayLabel = () => {
if (p.label) return p.label;
- const file = fileNameFromPath(p.path);
- if (p.section) return `${file} § ${slugToTitle(p.section)}`;
- return file;
+ return fileNameFromPath(p.path);
};
const handleClick = (e: MouseEvent) => {
diff --git a/src/components/journal/types/narrative.tsx b/src/components/journal/types/narrative.tsx
index 67f0e01..aaf203c 100644
--- a/src/components/journal/types/narrative.tsx
+++ b/src/components/journal/types/narrative.tsx
@@ -17,6 +17,7 @@ export type NarrativePayload = z.infer;
registerMessageType({
type: "narrative",
label: "Narrative",
+ emitters: ["gm", "player"],
schema,
defaultPayload: () => ({ text: "" }),
render: (p) => {p.text}
,
diff --git a/src/components/stores/journalStream.ts b/src/components/stores/journalStream.ts
index 328c0b3..800ef01 100644
--- a/src/components/stores/journalStream.ts
+++ b/src/components/stores/journalStream.ts
@@ -41,8 +41,12 @@ export interface JournalStreamState {
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;
}
export interface SessionMeta {
@@ -62,19 +66,22 @@ export interface SessionManifest {
const LS_PLAYER_NAME = "ttrpg.playerName";
const LS_BROKER_URL = "ttrpg.brokerUrl";
const LS_LAST_SESSION = "ttrpg.lastSessionId";
+const LS_PLAYER_ROLE = "ttrpg.playerRole";
function loadPersisted(): {
myName: string;
brokerUrl: string | null;
lastSessionId: string | null;
+ myRole: string;
} {
if (typeof localStorage === "undefined") {
- return { myName: "gm", brokerUrl: null, lastSessionId: null };
+ return { myName: "gm", brokerUrl: null, lastSessionId: null, myRole: "gm" };
}
return {
myName: localStorage.getItem(LS_PLAYER_NAME) || "gm",
brokerUrl: localStorage.getItem(LS_BROKER_URL),
lastSessionId: localStorage.getItem(LS_LAST_SESSION),
+ myRole: localStorage.getItem(LS_PLAYER_ROLE) || "gm",
};
}
@@ -99,7 +106,9 @@ const [state, setState] = createStore({
connectionStatus: "disconnected",
connectionError: null,
myName: initialName,
+ myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
brokerUrl: persisted.brokerUrl,
+ players: {},
});
// Sync initial URL params if they came from localStorage (not URL)
@@ -127,6 +136,17 @@ export function setMyName(name: string): void {
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);
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(LS_PLAYER_ROLE, role);
+ }
+}
+
/**
* Set the active session ID and sync to URL. Also resolves the human-readable
* session name from the cached manifest.
@@ -270,7 +290,7 @@ export async function connectStream(
return new Promise((resolve, reject) => {
const client = mqtt.connect(brokerUrl, {
- clientId: `${state.myName}-${Date.now()}`,
+ clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
reconnectPeriod: 2000,
});
@@ -297,6 +317,19 @@ export async function connectStream(
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();
});
@@ -340,6 +373,33 @@ export async function connectStream(
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);
@@ -505,12 +565,24 @@ export function revertLatest():
export function disconnectStream(): void {
if (_mqttClient) {
- _mqttClient.end(true);
+ // 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", {});
}
// ---------------------------------------------------------------------------