From 11a6088569a2806c5067134b9466360de1e67e77 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 11:05:51 +0700 Subject: [PATCH 1/3] Keep empty Declare moves disabled --- viewer/openworlds/screen-table.jsx | 22 ++++++++++++++++++++-- viewer/tests/test_openworlds_static.py | 8 ++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 92be66e9..c2dfce5d 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -438,6 +438,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const actionById = (id) => actions.find((a) => a.id === id); const enabledActionById = (id) => enabledActions.find((a) => a.id === id); const composerMode = COMPOSER_MODES[composerModeId] || COMPOSER_MODES.do; + const composerAction = actionById(composerMode.actionId); + const draftText = input.trim(); const appReadiness = appStatus?.readiness || {}; const appHealth = appStatus?.health || {}; const appFailureBucket = appReadiness.failure_bucket || appHealth.failure_bucket || ""; @@ -654,7 +656,11 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const sendAction = async () => { if (pendingActive) return; const text = input.trim(); - if (!text) return; + if (!text) { + toast({ kind: "danger", title: "Type a move first", body: `Add ${composerMode.label.toLowerCase()} details, then press Declare.` }); + inputRef.current?.focus(); + return; + } const action = actionById(composerMode.actionId); if (!action?.available) { toast({ kind: "danger", title: `${composerMode.label} is unavailable`, body: action?.disabled_reason || readOnlyReason }); @@ -691,6 +697,18 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { // #344: the Declare button doubles as the stuck-recovery "Try again" button (same slot, relabeled). // 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 declareTitle = pendingStuck + ? "Re-send your last action to the Dungeon Master, or type a new one first." + : declareNeedsDraft + ? `Type ${composerMode.label.toLowerCase()} details before declaring.` + : DECLARE_HINT; + const declareAriaLabel = pendingStuck + ? "Try action again" + : declareNeedsDraft + ? "Type a move before declaring" + : "Declare move"; const requestRoll = (sides = 20) => { const action = actionById("check"); @@ -983,7 +1001,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { 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}`)} style={{ ...inkInput, fontFamily: "var(--f-body)", fontSize: 16, opacity: pendingActive ? 0.6 : 1 }} /> - {pendingFirstBeat ? "Composing…" : pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"} + {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 3def034f..5275d6f0 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -609,7 +609,8 @@ def test_openworlds_table_blocks_moves_when_app_status_play_lane_not_ready(self) self.assertIn("appStatusBlocksPlay ? appStatusBlockReason : readOnlyReason", source) self.assertIn("disabled={!a.available || pendingActive || appStatusBlocksPlay}", source) self.assertIn("disabled={pendingActive || appStatusBlocksPlay}", source) - self.assertIn("disabled={!actionById(composerMode.actionId)?.available || pendingActive || appStatusBlocksPlay}", source) + self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft", source) + self.assertIn("disabled={declareDisabled}", source) def test_openworlds_table_bounds_and_anchors_the_chronicle(self): # #402: the chronicle must stay navigable across a long session — the rendered row count is @@ -699,7 +700,10 @@ def test_openworlds_table_action_buttons_select_declare_mode(self): self.assertIn("data-worldos-selected", source) self.assertIn("kind: composerMode.kind", source) self.assertIn("composerMode.placeholder", source) - self.assertIn("disabled={!actionById(composerMode.actionId)?.available || pendingActive || appStatusBlocksPlay}", source) + self.assertIn("const declareNeedsDraft = !pendingStuck && !draftText", source) + self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft", source) + self.assertIn('title={declareTitle}', source) + self.assertIn('ariaLabel={declareAriaLabel}', 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 From bf5b202dee53e1701d4526705176ee2be948ea16 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 11:11:33 +0700 Subject: [PATCH 2/3] Clarify disabled Declare reasons --- viewer/openworlds/screen-table.jsx | 32 ++++++++++++++++++-------- viewer/tests/test_openworlds_static.py | 5 ++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index c2dfce5d..0630006a 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -699,16 +699,28 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const onDeclareClick = () => (pendingStuck ? retryStuck() : sendAction()); const declareNeedsDraft = !pendingStuck && !draftText; const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft; - const declareTitle = pendingStuck - ? "Re-send your last action to the Dungeon Master, or type a new one first." - : declareNeedsDraft - ? `Type ${composerMode.label.toLowerCase()} details before declaring.` - : DECLARE_HINT; - const declareAriaLabel = pendingStuck - ? "Try action again" - : declareNeedsDraft - ? "Type a move before declaring" - : "Declare move"; + 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 + : pendingStuck + ? "Re-send your last action to the Dungeon Master, or type a new one first." + : declareNeedsDraft + ? `Type ${composerMode.label.toLowerCase()} details before declaring.` + : DECLARE_HINT; + const declareAriaLabel = !composerAction?.available + ? `${composerMode.label} unavailable` + : pendingActive + ? "Wait for the Dungeon Master before declaring" + : appStatusBlocksPlay + ? "Start or resume provider before declaring" + : pendingStuck + ? "Try action again" + : declareNeedsDraft + ? "Type a move before declaring" + : "Declare move"; const requestRoll = (sides = 20) => { const action = actionById("check"); diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 5275d6f0..1598af1c 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -704,6 +704,11 @@ def test_openworlds_table_action_buttons_select_declare_mode(self): self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || 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('"Wait for the Dungeon Master before declaring"', source) + self.assertIn('"Start or resume provider 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 From 61f39777640eacfd4547dd2a36bc828776bc1376 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 11:20:06 +0700 Subject: [PATCH 3/3] Sync OpenWorlds nav hashes --- viewer/openworlds/app.jsx | 116 ++++++++++++++++--------- viewer/tests/test_openworlds_static.py | 8 ++ 2 files changed, 84 insertions(+), 40 deletions(-) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 77c67ccb..0f051a4f 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -80,6 +80,67 @@ function openWorldsPlayerChronicle(c) { return Boolean(c?.canResume || c?.current); } +const OPENWORLDS_VALID_SCREENS = new Set([ + "launcher", "roster", "table", "combat", "dialogue", "map", "character", "inventory", + "forge", "relations", "journal", "bestiary", "acts", "merchant", "create", + "seed", "settings", +]); +const OPENWORLDS_HASH_ALIASES = { + battle: "combat", + parley: "dialogue", + party: "character", + heroes: "character", + chronicles: "launcher", + worlds: "launcher", + market: "merchant", + stash: "inventory", + pick: "roster", + picker: "roster", + camp: "map", + rest: "map", +}; +const OPENWORLDS_SCREEN_HASHES = { + launcher: "worlds", + roster: "roster", + table: "table", + combat: "battle", + dialogue: "parley", + map: "map", + character: "party", + inventory: "stash", + forge: "forge", + relations: "relations", + journal: "journal", + bestiary: "bestiary", + acts: "acts", + merchant: "market", + create: "create", + seed: "seed", + settings: "settings", +}; +function openWorldsRouteFromHash() { + const raw = (window.location.hash || "").replace(/^#\/?/, "").trim().toLowerCase(); + if (!raw) return null; + const id = OPENWORLDS_VALID_SCREENS.has(raw) ? raw : (OPENWORLDS_HASH_ALIASES[raw] || null); + if (!id) return null; + return { id, campMode: raw === "camp" || raw === "rest" ? true : false }; +} +function openWorldsHashForScreen(id, opts) { + if (id === "map" && opts?.openCamp) return "camp"; + return OPENWORLDS_SCREEN_HASHES[id] || id; +} +function openWorldsSyncHashForScreen(id, opts) { + const hash = openWorldsHashForScreen(id, opts); + if (!hash) return; + const nextHash = `#${hash}`; + if (window.location.hash === nextHash) return; + if (opts?.replaceHash && window.history?.replaceState) { + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${nextHash}`); + } else { + window.location.hash = nextHash; + } +} + // #342: neutralize markup in player free-text BEFORE it is sent to the engine or echoed into the // chronicle. The adversarial run (#324 v2) found that submitting "", "{{ }}", or // "" sent the raw markup straight to the DM (it stalled 35s+) and rode along in the local @@ -700,6 +761,13 @@ function App() { }; }, [refreshNative]); + const navigate = React.useCallback((id, opts = {}) => { + if (opts?.openCamp) setCampMode(true); + else if (id !== "map") setCampMode(false); + setScreen(id); + openWorldsSyncHashForScreen(id, opts); + }, []); + // Auto-land in the session when a live DM (provider) is attached. The launcher's "Resume / // Begin" calls the native startProviderSession bridge, which repoints the WebView at the // live, move-sink-wired viewer on a fresh port — the page reloads here at the launcher, and @@ -710,9 +778,9 @@ function App() { if (didAutoRoute.current) return; if (nativeState?.appStatus?.runningProvider && screen === "launcher") { didAutoRoute.current = true; - setScreen("table"); + navigate("table", { replaceHash: true }); } - }, [nativeState, screen]); + }, [nativeState, screen, navigate]); // Building→table handoff. The "building your universe" overlay clears (active → inactive) the // moment the first DM narration beat lands — that beat is already in the chronicle, so land the @@ -724,10 +792,10 @@ function App() { React.useEffect(() => { if (building.active) { wasBuilding.current = true; return; } if (wasBuilding.current && screen === "launcher") { - setScreen("table"); + navigate("table", { replaceHash: true }); } wasBuilding.current = false; - }, [building.active, screen]); + }, [building.active, screen, navigate]); // During a live play session (a DM provider is attached), keep the active campaign bound to // the viewer's CURRENT (live) campaign. The DM mints this run's campaign a few seconds after @@ -778,50 +846,24 @@ function App() { const id = map[e.key.toLowerCase()]; if (id) { e.preventDefault(); - setScreen(id); + navigate(id); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, []); + }, [navigate]); // Deep-link the active screen via the URL hash (e.g. #character, #battle→combat). // Lets a screen be linked/bookmarked directly and makes headless QA captures of a // specific screen possible. On mount we honor the hash; hashchange re-routes live. React.useEffect(() => { - const VALID = new Set([ - "launcher", "roster", "table", "combat", "dialogue", "map", "character", "inventory", - "forge", "relations", "journal", "bestiary", "acts", "merchant", "create", - "seed", "settings", - ]); - const ALIAS = { - battle: "combat", - parley: "dialogue", - party: "character", - heroes: "character", - chronicles: "launcher", - worlds: "launcher", - market: "merchant", - stash: "inventory", - pick: "roster", - picker: "roster", - camp: "map", - rest: "map", - }; - const fromHash = () => { - const raw = (window.location.hash || "").replace(/^#\/?/, "").trim().toLowerCase(); - if (!raw) return null; - const id = VALID.has(raw) ? raw : (ALIAS[raw] || null); - if (!id) return null; - return { id, campMode: raw === "camp" || raw === "rest" ? true : false }; - }; - const initial = fromHash(); + const initial = openWorldsRouteFromHash(); if (initial) { setCampMode(initial.campMode); setScreen(initial.id); } const onHash = () => { - const route = fromHash(); + const route = openWorldsRouteFromHash(); if (!route) return; setCampMode(route.campMode); setScreen(route.id); @@ -830,12 +872,6 @@ function App() { return () => window.removeEventListener("hashchange", onHash); }, []); - const navigate = (id, opts) => { - if (opts?.openCamp) setCampMode(true); - else if (id !== "map") setCampMode(false); - setScreen(id); - }; - const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const playerChronicles = campaigns.filter(openWorldsPlayerChronicle); const current = diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 1598af1c..afd99efa 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -190,6 +190,14 @@ def test_openworlds_hash_aliases_match_primary_nav_labels(self): self.assertIn('id: "g_worlds", label: "Worlds"', chrome) self.assertIn('party: "character"', source) self.assertIn('worlds: "launcher"', source) + self.assertIn("OPENWORLDS_SCREEN_HASHES", source) + self.assertIn('character: "party"', source) + self.assertIn('merchant: "market"', source) + self.assertIn('launcher: "worlds"', source) + self.assertIn('if (id === "map" && opts?.openCamp) return "camp";', source) + self.assertIn("openWorldsSyncHashForScreen(id, opts)", source) + self.assertIn("window.location.hash = nextHash", source) + self.assertIn("navigate(id);", source) def test_merchant_defaults_to_baldurs_gate_lower_city_vendor(self): status, ctype, body = self._get("/openworlds/screen-merchant.jsx")