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/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 92be66e9..0630006a 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,30 @@ 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 = !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");
@@ -983,7 +1013,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..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")
@@ -609,7 +617,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 +708,15 @@ 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)
+ 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