diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index ed82bad6..bb3f1878 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -518,6 +518,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const appHealth = appStatus?.health || {}; const appFailureBucket = appReadiness.failure_bucket || appHealth.failure_bucket || ""; const appFailureDetail = appReadiness.failure_detail || appHealth.failure_detail || ""; + const surfaceStatusBlocksPlay = surfaceStatus !== "ready"; + const surfaceStatusBlockReason = surfaceStatus === "loading" + ? "The live session surface is still loading." + : `Session surface unavailable: ${surfaceStatus}`; const appStatusBlocksPlay = Boolean( appStatus && appReadiness.ready_for_play === false && @@ -528,6 +532,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { ? "No Dungeon Master provider is connected." : "The live move lane is not ready." ); + const livePlayBlocked = surfaceStatusBlocksPlay || appStatusBlocksPlay; + const livePlayBlockReason = surfaceStatusBlocksPlay ? surfaceStatusBlockReason : appStatusBlockReason; const loadSurface = React.useCallback(async (isCancelled = () => false) => { const params = new URLSearchParams(); @@ -690,8 +696,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const postMove = async (move, label, actionId) => { const enabledAction = actionId ? enabledActionById(actionId) : null; - if (!move || !canAct || appStatusBlocksPlay || pendingActive || (actionId && !enabledAction)) { - toast({ kind: "danger", title: "Action unavailable", body: pendingActive ? "The Dungeon Master is still narrating — one move at a time." : appStatusBlocksPlay ? appStatusBlockReason : readOnlyReason }); + if (!move || !canAct || livePlayBlocked || pendingActive || (actionId && !enabledAction)) { + toast({ kind: "danger", title: "Action unavailable", body: pendingActive ? "The Dungeon Master is still narrating — one move at a time." : livePlayBlocked ? livePlayBlockReason : readOnlyReason }); return; } if (submittingRef.current) return; // already submitting this turn — drop the rapid double-fire @@ -776,13 +782,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { // Route the click to the right handler so a stuck turn actually retries instead of no-op'ing. const onDeclareClick = () => (pendingStuck ? retryStuck() : sendAction()); const declareNeedsDraft = !pendingStuck && !draftText; - const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft; + const declareDisabled = !composerAction?.available || pendingActive || livePlayBlocked || declareNeedsDraft; const declareTitle = !composerAction?.available ? `${composerMode.label} is unavailable: ${composerAction?.disabled_reason || readOnlyReason}` : pendingActive ? (pendingFirstBeat ? "The Dungeon Master is composing your opening scene." : "The Dungeon Master is still narrating.") - : appStatusBlocksPlay - ? appStatusBlockReason + : livePlayBlocked + ? livePlayBlockReason : pendingStuck ? "Re-send your last action to the Dungeon Master, or type a new one first." : declareNeedsDraft @@ -792,8 +798,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { ? `${composerMode.label} unavailable` : pendingActive ? "Wait for the Dungeon Master before declaring" - : appStatusBlocksPlay - ? "Start or resume provider before declaring" + : livePlayBlocked + ? "Reconnect live session before declaring" : pendingStuck ? "Try action again" : declareNeedsDraft @@ -1026,7 +1032,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { actionId={a.id} selected={COMPOSER_MODE_BY_UI[a.ui] === composerModeId} tone={a.available ? "" : "crimson"} - disabled={!a.available || pendingActive || appStatusBlocksPlay} + disabled={!a.available || pendingActive || livePlayBlocked} onClick={() => invokeAction(a)} /> ))} @@ -1047,7 +1053,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { hint={ACTION_HINTS[a.id]} actionId={a.id} tone={a.available ? "royal" : "crimson"} - disabled={!a.available || pendingActive || appStatusBlocksPlay} + disabled={!a.available || pendingActive || livePlayBlocked} onClick={() => invokeAction(a)} /> ))} @@ -1075,10 +1081,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
{/* #337: dice buttons explain themselves on hover — a newbie didn't know d20/d12/d8/d6 ask the DM for a check. */} - - - - + + + +
{/* #337: one-line hint under the action bar so a first-timer knows free-text + Declare is the core loop, distinct from the quick-action buttons. */}
@@ -1092,9 +1098,9 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onDeclareClick()} - disabled={pendingActive || appStatusBlocksPlay} + disabled={pendingActive || livePlayBlocked} title={composerMode.inputTitle || DECLARE_HINT} - placeholder={pendingFirstBeat ? "The Dungeon Master is composing your opening scene…" : pendingActive ? "The Dungeon Master is narrating…" : pendingStuck ? "The DM seemed stuck — try again." : appStatusBlocksPlay ? "Start or resume a DM provider to play…" : (canAct ? composerMode.placeholder : `Read-only: ${readOnlyReason}`)} + placeholder={pendingFirstBeat ? "The Dungeon Master is composing your opening scene…" : pendingActive ? "The Dungeon Master is narrating…" : pendingStuck ? "The DM seemed stuck — try again." : livePlayBlocked ? "Reconnect the live session to play…" : (canAct ? composerMode.placeholder : `Read-only: ${readOnlyReason}`)} style={{ ...inkInput, fontFamily: "var(--f-body)", fontSize: 16, opacity: pendingActive ? 0.6 : 1 }} /> {pendingFirstBeat ? "Composing…" : pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"} diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 4cc44de5..f4324376 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -788,10 +788,13 @@ def test_openworlds_table_blocks_moves_when_app_status_play_lane_not_ready(self) self.assertIn("appReadiness.ready_for_play === false", source) self.assertIn("Start or resume a provider-backed session from Chronicles", source) self.assertIn("data-worldos-status-scope=\"app-status\"", source) - self.assertIn("appStatusBlocksPlay ? appStatusBlockReason : readOnlyReason", source) - self.assertIn("disabled={!a.available || pendingActive || appStatusBlocksPlay}", source) - self.assertIn("disabled={pendingActive || appStatusBlocksPlay}", source) - self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft", source) + self.assertIn("const surfaceStatusBlocksPlay = surfaceStatus !== \"ready\"", source) + self.assertIn("const livePlayBlocked = surfaceStatusBlocksPlay || appStatusBlocksPlay", source) + self.assertIn("livePlayBlocked ? livePlayBlockReason : readOnlyReason", source) + self.assertIn("disabled={!a.available || pendingActive || livePlayBlocked}", source) + self.assertIn("disabled={pendingActive || livePlayBlocked}", source) + self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || livePlayBlocked || declareNeedsDraft", source) + self.assertIn('"Reconnect live session before declaring"', source) self.assertIn("disabled={declareDisabled}", source) def test_openworlds_table_bounds_and_anchors_the_chronicle(self): @@ -927,14 +930,15 @@ def test_openworlds_table_action_buttons_select_declare_mode(self): self.assertIn("kind: composerMode.kind", source) self.assertIn("composerMode.placeholder", source) self.assertIn("const declareNeedsDraft = !pendingStuck && !draftText", source) - self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft", source) + self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || livePlayBlocked || declareNeedsDraft", source) self.assertIn('title={declareTitle}', source) self.assertIn('ariaLabel={declareAriaLabel}', source) self.assertIn('!composerAction?.available', source) self.assertIn('pendingActive', source) self.assertIn('appStatusBlocksPlay', source) + self.assertIn('livePlayBlocked', source) self.assertIn('"Wait for the Dungeon Master before declaring"', source) - self.assertIn('"Start or resume provider before declaring"', source) + self.assertIn('"Reconnect live session before declaring"', source) def test_openworlds_table_immediate_actions_reset_stale_composer_mode(self): # Fresh-player blocker: if Say was selected, clicking an immediate action like Continue