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 (
+ {/* #326: a can't-miss in-browser play-entry. When a live/resumable session exists, this is + the FIRST thing the player sees — clicking it drops straight into the live table (no app, + no bridge needed; the session + DM already exist). This is the affordance the #324 newbie + was missing when they instead clicked "Begin a new chronicle" and hit the app-only wall. */} + {playableCampaign && ( + enterPlayable(playableCampaign)} + /> + )} +
{/* LEFT: Hero with title plate */} @@ -168,6 +201,19 @@ function ScreenLauncher({ onNavigate, state, setState }) { Begin a new chronicle + {/* #326: be honest, not silent. Starting a BRAND-NEW chronicle mints a fresh DM + provider session — only the desktop app can do that (it has the supervisor + bridge). In a plain browser there is no DM to attach, so picking a hero through + here can't begin play (the roster says so too). Say it up-front rather than + letting a newbie walk into the dead-end. (If a live/resumable session exists, + the Continue banner above is their way in.) */} + {!hasBridge && ( +
+ {playableCampaign + ? "Browsing heroes here is fine — to actually start a NEW chronicle you'll need the WorldOS desktop app. To keep playing now, use Continue above." + : "Starting a new chronicle needs the WorldOS desktop app (it spins up the Dungeon Master). You can still browse the roster here."} +
+ )}
@@ -272,18 +318,32 @@ function ScreenLauncher({ onNavigate, state, setState }) {

- {/* CTA bar */} -
- - {summoning ? "Summoning the Dungeon Master…" : (c.canResume ? "Resume Chronicle" : "View Chronicle")} - - onNavigate("character")}>Roster - onNavigate("journal")}>Journal -
+ {/* CTA bar. #326: in a plain browser a resumable session is played by entering + the live table directly (enterPlayable) — no provider to "summon", so don't + mislabel it. In the native app, Resume mints/attaches a provider via the + bridge (onResume → startProviderSession). A non-resumable card is read-only. */} + {(() => { + const browserPlayable = !hasBridge && c.canResume && c.live; + const onCta = browserPlayable ? () => enterPlayable(c) : onResume; + const label = summoning + ? "Summoning the Dungeon Master…" + : browserPlayable + ? "Continue Chronicle" + : (c.canResume ? "Resume Chronicle" : "View Chronicle"); + return ( +
+ + {label} + + onNavigate("character")}>Roster + onNavigate("journal")}>Journal +
+ ); + })()} {summonError && (
{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}
- - - - + + + +
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && sendAction()} - placeholder={canAct ? "Describe what your hero does..." : `Read-only: ${readOnlyReason}`} - style={{ ...inkInput, fontFamily: "var(--f-body)", fontSize: 16 }} + disabled={Boolean(pending)} + placeholder={pending ? "The Dungeon Master is narrating…" : (canAct ? "Describe what your hero does..." : `Read-only: ${readOnlyReason}`)} + style={{ ...inkInput, fontFamily: "var(--f-body)", fontSize: 16, opacity: pending ? 0.6 : 1 }} /> - Declare + {pending ? "Narrating…" : "Declare"}
@@ -384,7 +417,7 @@ function ScreenTable({ onNavigate, state, setState }) { label={a.label} detail={a.available ? a.groupLabel : a.disabled_reason} tone={a.available ? (a.group === "combat" ? "royal" : "") : "crimson"} - disabled={!a.available} + disabled={!a.available || Boolean(pending)} onClick={() => invokeAction(a)} /> ))} @@ -537,7 +570,43 @@ function LogEntry({ entry }) { ); } -Object.assign(window, { ScreenTable, PartyRow, ConditionRow, LogEntry }); +// #327: the persistent "DM is narrating…" beat shown in the chronicle while a submitted move is +// being resolved. The DM's turn is long (minutes), so this is the difference between "the world +// is thinking" and "the app froze". Mirrors the narration LogEntry's gilt-rule styling, with a +// gentle pulsing trio of dots (honored only when reduced-motion is off). aria-live so a screen +// reader announces the wait too. +function DmNarratingBeat() { + return ( +
+
+
+ The Dungeon Master is narrating +
+
+ ); +} + +// Keyframes + reduced-motion fallback, injected once (the OpenWorlds bundle is in-browser Babel, +// so a tiny self-contained