From adc6b3d71e88db0ce119c4ba35ad98076ba3e95d Mon Sep 17 00:00:00 2001
From: Eva
Date: Sat, 30 May 2026 03:36:39 +0700
Subject: [PATCH] fix(openworlds): in-browser play-entry + DM-narrating
affordance (Closes #326, #327)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two P0 play-loop entrance blockers from the #324 AI playtester (play1, newbie).
#326 — the browser dead-end. With no native bridge the launcher led with
"Begin a new chronicle" → roster → playAs(), whose no-bridge branch only set a
note ("Live play starts from the WorldOS app") and returned: a silent dead-end.
"Resume Chronicle" already DID drop a browser player into the table (startPlay's
no-bridge path navigates there), but it was a small secondary CTA the newbie
never found. Engine/wiring is correct: a harness-pre-minted (or resumable)
session reports live+canResume in campaigns.json, /session-surface returns
can_act:true (auto-followed campaign), and POST /move + /chat both work in a
plain browser — verified empirically.
Fix: when a playable session exists (live+canResume, or any resumable save),
the launcher now leads with an unmistakable ContinueBanner ("Continue → play")
that binds the table to that chronicle and drops the player straight into the
live loop. The right-panel CTA routes the same way in-browser (no false
"Summoning…"), the roster's no-bridge branch redirects into the live session
instead of dead-ending, and "Begin a new chronicle" now honestly says a NEW
chronicle needs the desktop app rather than leading to a wall.
#327 — the loop looked frozen. After a submit, screen-table only echoed the
player's own line; with no pending affordance the unchanged DM's 5–8 min turn
read as broken. (/move did reach the DM and /chat did render the reply — the
gap was purely the missing indicator.)
Fix: a "DM is narrating…" beat + disabled action bar (input, Declare→Narrating,
dice, encounter actions) the instant a move posts, cleared the moment a new DM
narration beat lands via /chat, with a 12-min safety auto-clear so a dropped
beat can't wedge the bar. aria-live + reduced-motion aware.
Viewer stays a pure reader (reads surfaces, POSTs /move); engine remains the
sole writer. No wire-contract, asset, or _private changes.
Validated headless (Playwright, isolated port, seeded harness-style session):
launcher Continue → table; submit → narrating banner + disabled bar; DM beat →
banner clears + bar re-enables; roster Play-as redirects to the live table.
---
viewer/openworlds/screen-launcher.jsx | 123 +++++++++++++++++++++++---
viewer/openworlds/screen-roster.jsx | 29 ++++--
viewer/openworlds/screen-table.jsx | 91 ++++++++++++++++---
3 files changed, 213 insertions(+), 30 deletions(-)
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."}
+
{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
+
+ );
+}
+
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) => (
)) :
@@ -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
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ );
+}
+
+// Keyframes + reduced-motion fallback, injected once (the OpenWorlds bundle is in-browser Babel,
+// so a tiny self-contained