{tab === "active" ? "No active quests in the chronicle yet." : tab === "complete" ? "Nothing has been resolved or failed yet." : "No rumors or untracked hooks."}
diff --git a/viewer/openworlds/screen-merchant.jsx b/viewer/openworlds/screen-merchant.jsx
index b6410d03..cfb93a01 100644
--- a/viewer/openworlds/screen-merchant.jsx
+++ b/viewer/openworlds/screen-merchant.jsx
@@ -12,10 +12,9 @@ function mItemScope(item) {
function ScreenMerchant({ onNavigate, state, setState }) {
const [tab, setTab] = React.useState("buy");
- // MK-02: the initial id MUST match a MERCHANTS entry. It previously read "gate-sundries"
- // while the only merchant is id:"talli", so the find() silently fell back to MERCHANTS[0] —
- // masking the mismatch and breaking any id-keyed lookup (e.g. the portrait scope).
- const [merchantId, setMerchantId] = React.useState("talli");
+ // MK-02/#548: the initial id MUST match a MERCHANTS entry and the first playable BG session
+ // should not open on an Act Two Last Light Inn merchant while the party is in the Lower City.
+ const [merchantId, setMerchantId] = React.useState("old-troutman");
const [hoverItem, setHoverItem] = React.useState(null);
const [coins, setCoins] = React.useState({ gp: 232, sp: 68, cp: 14 });
const [cart, setCart] = React.useState([]);
@@ -38,16 +37,27 @@ function ScreenMerchant({ onNavigate, state, setState }) {
)
: "";
const [surface, setSurface] = React.useState(null);
+ const [surfaceStatus, setSurfaceStatus] = React.useState("loading");
React.useEffect(() => {
let cancelled = false;
+ setSurfaceStatus("loading");
fetch("/character-surface" + surfaceQuery, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
- .then((d) => { if (!cancelled) setSurface(d); })
- .catch(() => { if (!cancelled) setSurface(null); });
+ .then((d) => {
+ if (cancelled) return;
+ setSurface(d);
+ setSurfaceStatus(d ? "ready" : "preview");
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setSurface(null);
+ setSurfaceStatus("preview");
+ });
return () => { cancelled = true; };
}, [surfaceQuery]);
const canAct = Boolean(surface?.can_act);
const campaignId = surface?.campaign_id || "";
+ const surfaceLoading = surfaceStatus === "loading";
const toast = window.useToast ? window.useToast() : (() => {});
// Sell-tab inventory. The Market is a display-only prototype and has NO live shop/stash
@@ -60,6 +70,7 @@ function ScreenMerchant({ onNavigate, state, setState }) {
const adjustedBuyTotal = Math.round(buyTotal * (1 - haggle / 100));
const balanceDelta = sellTotal - adjustedBuyTotal;
const displayedTotal = Math.abs(balanceDelta);
+ const merchantWaresName = merchant.waresName || merchant.name;
const baseInv = tab === "buy" ? merchant.stock : stash.filter((i) => i.type !== "quest");
// MK-06: the kinds actually present on the table, so the filter only offers real options.
@@ -136,7 +147,7 @@ function ScreenMerchant({ onNavigate, state, setState }) {
{/* CENTER — split inventory */}
{/* #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. */}
@@ -839,12 +924,12 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onDeclareClick()}
- disabled={pendingActive}
+ disabled={pendingActive || appStatusBlocksPlay}
title={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." : (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." : 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"}
@@ -1022,8 +1107,7 @@ function LogEntry({ entry }) {
(the sibling skill PR emits \n\n) so a multi-paragraph beat renders as
separated paragraphs instead of one run-on block. sanitizeNarration is
still applied above, untouched. */}
-
- Chronicle
+
{text}
diff --git a/viewer/tests/test_item_icons.py b/viewer/tests/test_item_icons.py
index 5cc753ad..1c268dfe 100644
--- a/viewer/tests/test_item_icons.py
+++ b/viewer/tests/test_item_icons.py
@@ -111,7 +111,11 @@ def test_shared_item_art_alias_helper_exists(self):
self.assertIn("window.itemArtScope", src)
self.assertIn('"travel-rations": "rations"', src)
self.assertIn('"iron-lantern": "lantern"', src)
+ self.assertIn('"climbing-kit": "rope"', src)
self.assertIn('"wax-candle-6": "candle"', src)
+ self.assertIn('"sharpened-greataxe-edge": "greataxe"', src)
+ self.assertIn('"bandage-roll": ""', src)
+ self.assertIn("Object.prototype.hasOwnProperty.call(ITEM_ART_ALIASES, s)", src)
def test_merchant_uses_shared_item_art_scope(self):
"""Merchant item icons must use the shared alias helper before falling back."""
@@ -120,6 +124,13 @@ def test_merchant_uses_shared_item_art_scope(self):
self.assertIn("window.itemArtScope", src)
self.assertIn("return window.itemArtScope(item)", src)
+ def test_forge_uses_shared_item_art_scope(self):
+ """Forge recipe icons must use shared item-art aliases before falling back."""
+ _status, _ctype, body = self._get("/openworlds/screen-forge.jsx")
+ src = body.decode("utf-8")
+ self.assertIn("window.itemArtScope", src)
+ self.assertIn("return window.itemArtScope(name)", src)
+
if __name__ == "__main__":
unittest.main()
diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py
index edf1cbad..06986ec2 100644
--- a/viewer/tests/test_live_narration_stream.py
+++ b/viewer/tests/test_live_narration_stream.py
@@ -684,10 +684,77 @@ def test_chat_player_row_interleaves_between_recent_events_by_event_time(self):
out["chronicle"],
[
{"kind": "narration", "text": "The lantern steadies."},
- {"kind": "dialog", "text": "Ask what changed tonight.", "who": "You"},
+ {"kind": "action", "text": "Ask what changed tonight.", "who": "You"},
{"kind": "narration", "text": "A nearby voice answers."},
],
- "system bookkeeping stays hidden and the player move renders before the DM reply when /chat timestamps place it there (#503)",
+ "system bookkeeping stays hidden and the player action renders before the DM reply when /chat timestamps place it there (#503)",
+ )
+
+ # The optimistic local echo is the row the player sees immediately after /move accepts.
+ # When /chat later replays the same player move, the Chronicle must not render a second
+ # "You ..." row for the same turn.
+ def test_chat_player_replay_is_deduped_against_optimistic_echo(self):
+ out = self._run(
+ "h.echo('Abby', 'Continue');"
+ "h.enqueue('/chat', { items: [{ role: 'player', text: '[do] continue', at: 20 }], next: 1 });"
+ "await h.tick();"
+ "return ({ chronicle: h.chronicle() });"
+ )
+ self.assertEqual(
+ out["chronicle"],
+ [{"kind": "action", "text": "Continue", "who": "Abby"}],
+ "a /chat player replay matching the optimistic echo must not add a duplicate player row",
+ )
+
+ def test_quick_action_alias_replay_is_deduped_against_optimistic_echo(self):
+ out = self._run(
+ "h.echo('Abby', 'Look');"
+ "h.enqueue('/chat', { items: [{ role: 'player', text: '[do] look around', at: 20 }], next: 1 });"
+ "await h.tick();"
+ "return ({ chronicle: h.chronicle() });"
+ )
+ self.assertEqual(
+ out["chronicle"],
+ [{"kind": "action", "text": "Look", "who": "Abby"}],
+ "quick action payload aliases like Look/look around must not render as a second You row",
+ )
+
+ def test_quick_action_replay_renders_as_clean_action_after_reload(self):
+ out = self._run(
+ "h.enqueue('/chat', { items: ["
+ "{ role: 'player', text: '[do] continue', at: 20 },"
+ "{ role: 'player', text: '[do] look around', at: 21 }"
+ "], next: 2 });"
+ "await h.tick();"
+ "return ({ chronicle: h.chronicle() });"
+ )
+ self.assertEqual(
+ out["chronicle"],
+ [
+ {"kind": "action", "text": "Continue", "who": "You"},
+ {"kind": "action", "text": "Look", "who": "You"},
+ ],
+ "reloaded quick-action chat rows should render as clean player actions, not quoted dialogue",
+ )
+
+ def test_routed_player_replay_preserves_action_vs_speech_after_reload(self):
+ out = self._run(
+ "h.enqueue('/chat', { items: ["
+ "{ role: 'player', text: '[do] ask the guard what changed overnight', at: 20 },"
+ "{ role: 'player', text: '[say] Any trouble on the wall?', at: 21 },"
+ "{ role: 'player', text: '[say] continue', at: 22 }"
+ "], next: 3 });"
+ "await h.tick();"
+ "return ({ chronicle: h.chronicle() });"
+ )
+ self.assertEqual(
+ out["chronicle"],
+ [
+ {"kind": "action", "text": "ask the guard what changed overnight", "who": "You"},
+ {"kind": "dialog", "text": "Any trouble on the wall?", "who": "You"},
+ {"kind": "dialog", "text": "continue", "who": "You"},
+ ],
+ "routing tags should keep resumed action rows distinct from in-character speech rows",
)
# --- #479: provider wrappers may write the final DM reply to /chat only as a turn-resolution
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py
index 266c559e..8e663e88 100644
--- a/viewer/tests/test_openworlds_static.py
+++ b/viewer/tests/test_openworlds_static.py
@@ -161,6 +161,63 @@ def test_openworlds_config_is_browser_safe_metadata(self):
self.assertFalse(config["demo_data"])
self.assertTrue(config["demo_data_fallback"])
+ def test_openworlds_camp_deep_link_opens_map_camp_mode(self):
+ status, ctype, body = self._get("/openworlds/app.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ self.assertIn('camp: "map"', source)
+ self.assertIn('rest: "map"', source)
+ self.assertIn('raw === "camp" || raw === "rest" ? true', source)
+ self.assertIn(': false', source)
+ self.assertIn('setCampMode(route.campMode)', source)
+ self.assertNotIn("typeof route.campMode", source)
+ self.assertNotIn("typeof initial.campMode", source)
+ self.assertIn('else if (id !== "map") setCampMode(false)', source)
+ self.assertIn('location={screen === "map" && campMode ? "Camp"', source)
+
+ def test_merchant_defaults_to_baldurs_gate_lower_city_vendor(self):
+ status, ctype, body = self._get("/openworlds/screen-merchant.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ self.assertIn('React.useState("old-troutman")', source)
+ self.assertIn('id: "old-troutman"', source)
+ self.assertIn('waresName: "Old Troutman"', source)
+ self.assertIn('location: "Baldur\'s Gate — Lower City"', source)
+ self.assertIn('id: "talli"', source)
+
+ def test_merchant_waits_for_live_action_lane_before_purchase(self):
+ status, ctype, body = self._get("/openworlds/screen-merchant.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ self.assertIn('React.useState("loading")', source)
+ self.assertIn('setSurfaceStatus("loading")', source)
+ self.assertIn('const surfaceLoading = surfaceStatus === "loading"', source)
+ self.assertIn("if (surfaceLoading) return;", source)
+ self.assertIn("disabled={cart.length === 0 || surfaceLoading", source)
+ self.assertIn("Checking the counter", source)
+ self.assertIn("if (!response.ok) throw new Error", source)
+ self.assertIn('.catch((e) => toast({ kind: "danger"', source)
+ self.assertIn('title: "Move not sent"', source)
+
+ def test_journal_detail_matches_selected_tab_not_first_rumor(self):
+ status, ctype, body = self._get("/openworlds/screen-journal.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ self.assertIn("function journalQuestInTab(q, tab)", source)
+ self.assertIn("const visibleQuests = React.useMemo", source)
+ self.assertIn("visibleQuests.find((q) => q.id === activeQuest)", source)
+ self.assertIn("visibleQuests[0] || emptyQuest", source)
+ self.assertIn("title: \"No active quests\"", source)
+ self.assertNotIn("|| quests[0] ||", source)
+
def test_app_status_route_exposes_agent_probe_contract(self):
campaign_dir = self._tmp / "campaigns" / "camp_live"
self._write_snapshot(
@@ -519,6 +576,27 @@ def test_openworlds_table_posts_only_enabled_session_actions(self):
self.assertNotIn("snapshot.json", source)
self.assertNotIn("writeSnapshot", source)
+ def test_openworlds_table_blocks_moves_when_app_status_play_lane_not_ready(self):
+ # A static/no-provider viewer can still expose a writable /move file and a can_act surface.
+ # The player-facing table must trust same-port /app-status too, otherwise a click lands in
+ # "DM composing" forever with no resolver behind it.
+ status, ctype, body = self._get("/openworlds/screen-table.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ self.assertIn("const [appStatus, setAppStatus] = React.useState(null);", source)
+ self.assertIn('fetch(`/app-status${query}`', source)
+ self.assertIn("const appStatusBlocksPlay = Boolean", source)
+ self.assertIn('"no_provider", "no_launcher", "move_rejected"', source)
+ 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("disabled={!actionById(composerMode.actionId)?.available || pendingActive || appStatusBlocksPlay}", 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
# CAPPED (DOM + a11y tree bounded so the latest beat isn't truncated), the scroll region is
@@ -587,7 +665,7 @@ 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}", source)
+ self.assertIn("disabled={!actionById(composerMode.actionId)?.available || pendingActive || appStatusBlocksPlay}", 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
@@ -634,6 +712,8 @@ def test_openworlds_table_chronicle_preserves_paragraph_breaks(self):
# block. The narration branch renders sanitized {text} in a `div.body`; with the default
# white-space the embedded blank-line paragraph breaks the DM emits collapse. The render
# honors them via whiteSpace:"pre-line" (and sanitizeNarration is still applied first).
+ # Each narration row should not repeat the region title inline; the surrounding SectionTitle
+ # and role="log" label already name the Chronicle.
status, _ctype, body = self._get("/openworlds/screen-table.jsx")
self.assertEqual(status, 200)
@@ -642,6 +722,8 @@ def test_openworlds_table_chronicle_preserves_paragraph_breaks(self):
self.assertRegex(source, r'whiteSpace:\s*"pre-line"')
# …and the GM-advisory strip is still in the narration path (not removed by this change).
self.assertIn("sanitizeNarration(entry.text)", source)
+ self.assertIn('data-worldos-testid="chronicle-narration"', source)
+ self.assertNotRegex(source, r'data-worldos-testid="chronicle-narration"[\s\S]*?>Chronicle')
def test_openworlds_app_bounds_the_live_session_tail(self):
# #402: the live tail (chatBeats + player echoes) is bounded in useLiveSession so a long
@@ -691,6 +773,11 @@ def test_openworlds_camp_rest_gives_feedback_when_dm_is_busy(self):
# The button is disabled while busy, and the early-return path toasts instead of no-op'ing.
self.assertIn("!canAct || dmBusy", camp_source)
self.assertIn("still narrating", camp_source)
+ # A successful live rest already toasts "Resting"; do not call ScreenMap's atlas-only
+ # onBeginRest handler afterward, because that can emit a contradictory "Camp unavailable"
+ # toast when the current atlas location is not tagged as a rest point.
+ self.assertIn('title: "Resting"', camp_source)
+ self.assertNotIn("onBeginRest();", camp_source)
# And the app actually passes liveSession to the map screen (so dmBusy is real, not always false).
_s_app, _c_app, app_body = self._get("/openworlds/app.jsx")