diff --git a/viewer/openworlds/screen-launcher.jsx b/viewer/openworlds/screen-launcher.jsx
index c287d56c..be34b2db 100644
--- a/viewer/openworlds/screen-launcher.jsx
+++ b/viewer/openworlds/screen-launcher.jsx
@@ -8,6 +8,17 @@ function ScreenLauncher({ onNavigate, state, setState }) {
const [summonError, setSummonError] = React.useState("");
const toast = window.useToast ? window.useToast() : (() => {});
const active = campaigns.find((c) => c.id === selected) || campaigns[0] || null;
+ const hasBridge = Boolean(window.OpenWorldsNative?.hasBridge?.());
+ // #326: the in-browser play-entry. The catalog marks a session the engine can take moves for as
+ // `live` (its move sink is writable) and `canResume` (it is the attached, current run). When such
+ // a session exists — the #324 harness pre-mints exactly this, and a resumable save is the same —
+ // the player can step straight into the live table and play; the table's own /move + /chat loop
+ // carries it from there. We surface that as an unmistakable primary so a newbie is never left
+ // hunting (the old launcher led with "Begin a new chronicle", which dead-ends without the app).
+ const playableCampaign =
+ campaigns.find((c) => c.live && c.canResume) ||
+ campaigns.find((c) => c.canResume) ||
+ null;
React.useEffect(() => {
if (campaigns.some((c) => c.id === selected)) return;
@@ -69,8 +80,30 @@ function ScreenLauncher({ onNavigate, state, setState }) {
startPlay(c?.world);
};
+ // #326: drop straight into the live table for an already-playable session. Unlike startPlay
+ // (which, in the native app, mints a NEW provider session), this binds the table to an EXISTING
+ // live/resumable chronicle and navigates there — the only step a browser player needs, since the
+ // session and its DM already exist. Used by the in-browser "Continue / Resume → play" primary.
+ const enterPlayable = (c) => {
+ const target = c || playableCampaign;
+ if (!target) return;
+ setState((s) => ({ ...s, activeCampaign: target.id }));
+ onNavigate("table");
+ };
+
return (
{summonError}
@@ -305,6 +365,43 @@ function ScreenLauncher({ onNavigate, state, setState }) {
);
}
+// #326: the unmistakable in-browser "Continue / Resume → play" primary. Shown at the very top of
+// the launcher whenever a live/resumable session exists (the #324 harness, or any resumable save).
+// Clicking it binds the table to this chronicle and drops the player straight into the live loop —
+// the single click the browser player needs, since the session + DM already exist. A live session
+// gets the brighter "Continue" treatment; a resumable-but-idle save says "Resume".
+function ContinueBanner({ campaign, onEnter }) {
+ const isLive = Boolean(campaign?.live);
+ const region = campaignRegion(campaign);
+ return (
+
+
![]()
+
+
+
{isLive ? "Live now" : "Ready to resume"}
+
Your chronicle awaits
+
+
+ {campaign?.title || "Open Worlds"}
+
+
+ {campaign?.subtitle || region}
+
+
+
+ {isLive ? "Continue → play" : "Resume → play"}
+
+
+ );
+}
+
function normalizeCampaignParty(party) {
if (!Array.isArray(party)) return [];
return party.map((p) => {
@@ -514,4 +611,4 @@ function SegRadio({ value, onChange, options }) {
);
}
-Object.assign(window, { ScreenLauncher, Stat, CampaignRow, PartyPortrait, NewCampaignModal, FormField, SegRadio, inkInput });
+Object.assign(window, { ScreenLauncher, ContinueBanner, Stat, CampaignRow, PartyPortrait, NewCampaignModal, FormField, SegRadio, inkInput });
diff --git a/viewer/openworlds/screen-roster.jsx b/viewer/openworlds/screen-roster.jsx
index 6320a449..dca98e98 100644
--- a/viewer/openworlds/screen-roster.jsx
+++ b/viewer/openworlds/screen-roster.jsx
@@ -140,6 +140,14 @@ function ScreenRoster({ onNavigate, state, setState }) {
const campaignId = campaigns.some((c) => c.id === state?.activeCampaign)
? state.activeCampaign
: (campaigns[0]?.id || "");
+ const hasBridge = Boolean(window.OpenWorldsNative?.hasBridge?.());
+ // #326: an already-playable session (live + resumable) the browser player can enter directly,
+ // mirroring the launcher. Without the desktop bridge a NEW hero bind can't mint a DM session,
+ // so if such a session exists we redirect the player to CONTINUE it rather than dead-ending.
+ const playableCampaign =
+ campaigns.find((c) => c.live && c.canResume) ||
+ campaigns.find((c) => c.canResume) ||
+ null;
const [race, setRace] = React.useState("");
const [klass, setKlass] = React.useState("");
@@ -204,14 +212,23 @@ function ScreenRoster({ onNavigate, state, setState }) {
const playAs = async (npc) => {
if (summoningName) return;
setBindNote("");
- if (!window.OpenWorldsNative?.hasBridge?.()) {
- // FOLLOW-UP (flagged): a browser-only preview has no supervisor to mint the session. The
- // native path is the supported bind; here we surface the chosen hero so the flow is honest.
+ if (!hasBridge) {
+ // #326: a browser-only preview has no supervisor to mint a NEW session for a freshly-picked
+ // hero. Don't dead-end. If a live/resumable chronicle already exists (the #324 harness case),
+ // send the player there to actually PLAY; otherwise be honest that a new chronicle needs the
+ // desktop app — never a silent nothing.
+ if (playableCampaign) {
+ setState((s) => ({ ...s, activeCampaign: playableCampaign.id }));
+ toast({ kind: "info", title: "Continuing your live chronicle", body: "A session is already in progress — dropping you into the table." });
+ onNavigate("table");
+ return;
+ }
setBindNote(
- `Selected ${npc.name} as your hero. Live play starts from the WorldOS app — ` +
- `open this world there to begin the chronicle as ${npc.name}.`
+ `Picking ${npc.name} as a brand-new hero starts a fresh chronicle, which needs the ` +
+ `WorldOS desktop app (it spins up the Dungeon Master). In this browser preview you can ` +
+ `browse the roster, but you can't begin a new chronicle here.`
);
- toast({ kind: "info", title: `Chosen: ${npc.name}`, body: "Start live play from the WorldOS app to embody this hero." });
+ toast({ kind: "info", title: `Chosen: ${npc.name}`, body: "Starting a new chronicle needs the WorldOS desktop app." });
return;
}
setSummoningName(npc.name);
diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 656868f9..bf3ef184 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -15,6 +15,14 @@ function ScreenTable({ onNavigate, state, setState }) {
const [chatBeats, setChatBeats] = React.useState([]);
const chatCursor = React.useRef(0);
const [input, setInput] = React.useState("");
+ // #327: a visible "DM is narrating…" affordance. The unchanged DM's opening / resolving turn
+ // can take many minutes; without this the play loop reads as frozen the moment a move is sent.
+ // `pending` holds the submitted line + when it was sent; it is set the instant /move succeeds
+ // and cleared when a NEW DM narration beat lands via /chat (tracked by dmBeatCount below) — or
+ // by a long safety timeout so a dropped beat can never wedge the bar shut forever.
+ const [pending, setPending] = React.useState(null);
+ const dmBeatCountRef = React.useRef(0);
+ const pendingTimer = React.useRef(null);
const logRef = React.useRef(null);
const inputRef = React.useRef(null);
const toast = window.useToast ? window.useToast() : (() => {});
@@ -86,6 +94,13 @@ function ScreenTable({ onNavigate, state, setState }) {
? { kind: "dialog", who: "You", text: it.text }
: { kind: "narration", text: it.text });
setChatBeats((prev) => [...prev, ...beats]);
+ // #327: a fresh DM narration beat means the turn resolved — clear the pending indicator
+ // so the action bar re-opens and the spinner stops. (Player echoes don't count.)
+ const dmArrived = beats.some((b) => b.kind === "narration");
+ if (dmArrived) {
+ dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length;
+ setPending(null);
+ }
}
if (!isCancelled() && typeof cPayload.next === "number") chatCursor.current = cPayload.next;
}
@@ -137,10 +152,20 @@ function ScreenTable({ onNavigate, state, setState }) {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [visibleLog]);
+ // #327: arm the "DM is narrating…" pending state when a move is accepted, and a safety
+ // auto-clear so a missed /chat beat can't lock the bar forever (the DM turn is long but
+ // bounded). The /chat poll clears `pending` the instant a new DM narration beat arrives.
+ const armPending = (text) => {
+ setPending({ text, since: Date.now() });
+ if (pendingTimer.current) window.clearTimeout(pendingTimer.current);
+ pendingTimer.current = window.setTimeout(() => setPending(null), 12 * 60 * 1000);
+ };
+ React.useEffect(() => () => { if (pendingTimer.current) window.clearTimeout(pendingTimer.current); }, []);
+
const postMove = async (move, label, actionId) => {
const enabledAction = actionId ? enabledActionById(actionId) : null;
- if (!move || !canAct || (actionId && !enabledAction)) {
- toast({ kind: "danger", title: "Action unavailable", body: readOnlyReason });
+ if (!move || !canAct || pending || (actionId && !enabledAction)) {
+ toast({ kind: "danger", title: "Action unavailable", body: pending ? "The Dungeon Master is still narrating — one move at a time." : readOnlyReason });
return;
}
const text = label || move.text || move.name || "declares an action";
@@ -155,6 +180,7 @@ function ScreenTable({ onNavigate, state, setState }) {
throw new Error(payload.reason || `move ${response.status}`);
}
setLog((l) => [...l, { kind: "action", who: hero.name, text }]);
+ armPending(text);
loadSurface();
} catch (error) {
toast({ kind: "danger", title: "Move not sent", body: error?.message || `The viewer could not reach ${writeLane.endpoint || "/move"}.` });
@@ -162,6 +188,7 @@ function ScreenTable({ onNavigate, state, setState }) {
};
const sendAction = async () => {
+ if (pending) return;
const text = input.trim();
if (!text) return;
const action = actionById("do");
@@ -183,6 +210,10 @@ function ScreenTable({ onNavigate, state, setState }) {
};
const invokeAction = (action) => {
+ if (pending) {
+ toast({ kind: "danger", title: "Action unavailable", body: "The Dungeon Master is still narrating — one move at a time." });
+ return;
+ }
if (!action?.available) {
toast({ kind: "danger", title: action?.label || "Action unavailable", body: action?.disabled_reason || readOnlyReason });
return;
@@ -276,6 +307,7 @@ function ScreenTable({ onNavigate, state, setState }) {
{visibleLog.length ? visibleLog.map((entry, i) => (
)) :
No moves yet
}
+ {pending &&
}
{/* Action bar */}
@@ -286,10 +318,10 @@ function ScreenTable({ onNavigate, state, setState }) {
{hero.name}