Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 76 additions & 40 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<script>…</script>", "{{ }}", or
// "<b>…</b>" sent the raw markup straight to the DM (it stalled 35s+) and rode along in the local
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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 =
Expand Down
34 changes: 32 additions & 2 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 }}
/>
<BrassButton onClick={onDeclareClick} title={pendingStuck ? "Re-send your last action to the Dungeon Master, or type a new one first." : DECLARE_HINT} disabled={!actionById(composerMode.actionId)?.available || pendingActive || appStatusBlocksPlay} testId="move-submit" ariaLabel={pendingStuck ? "Try action again" : "Declare move"}>{pendingFirstBeat ? "Composing…" : pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"}</BrassButton>
<BrassButton onClick={onDeclareClick} title={declareTitle} disabled={declareDisabled} testId="move-submit" ariaLabel={declareAriaLabel}>{pendingFirstBeat ? "Composing…" : pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"}</BrassButton>
</div>
</div>
</Panel>
Expand Down
21 changes: 19 additions & 2 deletions viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading