From e48105508a2f9e05d87509de64267034455478bb Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 06:57:05 +0700 Subject: [PATCH 1/5] Polish Chronicle move rows --- viewer/openworlds/app.jsx | 46 ++++++++--- viewer/openworlds/screen-journal.jsx | 42 ++++++---- viewer/openworlds/screen-merchant.jsx | 90 ++++++++++++++-------- viewer/openworlds/screen-table.jsx | 45 ++++++++++- viewer/tests/test_live_narration_stream.py | 69 ++++++++++++++++- viewer/tests/test_openworlds_static.py | 56 ++++++++++++++ 6 files changed, 290 insertions(+), 58 deletions(-) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 7752bb2c..83047436 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -11,6 +11,20 @@ window.stripRoutingTag = window.stripRoutingTag || function stripRoutingTag(text .replace(/^\s*\[(say|do|check|save|continue|attack|cast|use_item|clarify)\]\s*/i, ""); }; +window.playerReplayBeat = window.playerReplayBeat || function playerReplayBeat(text) { + const raw = String(text == null ? "" : text); + const match = raw.match(/^\s*\[(say|do|check|save|continue|attack|cast|use_item|clarify)\]\s*/i); + const route = (match?.[1] || "").toLowerCase(); + const displayText = window.stripRoutingTag(raw).replace(/\s+/g, " ").trim(); + if (!displayText) return null; + const quickLabels = { + continue: "Continue", + "look around": "Look", + }; + if (route === "say" || !route) return { kind: "dialog", who: "You", text: displayText }; + return { kind: "action", who: "You", text: quickLabels[displayText.toLowerCase()] || displayText }; +}; + const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "palette": "warm", "ornaments": true, @@ -389,11 +403,13 @@ function useLiveSession(state) { .map((it) => { // #274: stamp each beat with the shared monotonic counter at ingest time so it // time-merges correctly against local player echoes (which share the same counter). - // #410: the engine logs the player's line WITH its routing tag ("[do] …") for move - // classification, and /chat replays it verbatim. Strip the tag for DISPLAY so the - // replayed dialog row shows the player's words, not "[do] …" (matches the optimistic - // echo above, which already strips via the same helper). - if (it.role === "player") return { kind: "dialog", who: "You", text: window.stripRoutingTag(it.text), at: nextLogSeq(), eventAt: it.at }; + // #410/#548: the engine logs the player's line WITH its routing tag ("[do] …") for + // move classification, and /chat replays it verbatim. Parse the tag for DISPLAY so + // resumed/reloaded sessions keep actions as action rows while speech stays dialogue. + if (it.role === "player") { + const beat = window.playerReplayBeat(it.text); + return beat ? { ...beat, at: nextLogSeq(), eventAt: it.at } : null; + } dmLineArrived = true; // #405: a /chat DM line is the turn-RESOLUTION signal (it clears the pending indicator // below). It is NOT a second narration row when this run is streaming its prose via the @@ -778,15 +794,25 @@ function App() { "forge", "relations", "journal", "bestiary", "acts", "merchant", "create", "seed", "settings", ]); - const ALIAS = { battle: "combat", parley: "dialogue", chronicles: "launcher", market: "merchant", stash: "inventory", heroes: "character", pick: "roster", picker: "roster" }; + const ALIAS = { battle: "combat", parley: "dialogue", chronicles: "launcher", market: "merchant", stash: "inventory", heroes: "character", pick: "roster", picker: "roster", camp: "map", rest: "map" }; const fromHash = () => { const raw = (window.location.hash || "").replace(/^#\/?/, "").trim().toLowerCase(); if (!raw) return null; - return VALID.has(raw) ? raw : (ALIAS[raw] || null); + const id = VALID.has(raw) ? raw : (ALIAS[raw] || null); + if (!id) return null; + return { id, campMode: raw === "camp" || raw === "rest" ? true : (id === "map" ? false : undefined) }; }; const initial = fromHash(); - if (initial) setScreen(initial); - const onHash = () => { const id = fromHash(); if (id) setScreen(id); }; + if (initial) { + if (typeof initial.campMode === "boolean") setCampMode(initial.campMode); + setScreen(initial.id); + } + const onHash = () => { + const route = fromHash(); + if (!route) return; + if (typeof route.campMode === "boolean") setCampMode(route.campMode); + setScreen(route.id); + }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); @@ -808,7 +834,7 @@ function App() {
quests.filter((q) => journalQuestInTab(q, tab)), [quests, tab]); // Bookmarks (J-03): a player can pin quests; the set persists in localStorage so a // bookmark survives reload. Pinned quests show a marker in the chronicle list. No engine @@ -88,17 +95,30 @@ function ScreenJournal({ onNavigate, state, setState }) { }, [loadSurface]); React.useEffect(() => { - if (quests.length && !quests.some((q) => q.id === activeQuest)) { - setActiveQuest(quests[0]?.id || ""); + if (visibleQuests.length && !visibleQuests.some((q) => q.id === activeQuest)) { + setActiveQuest(visibleQuests[0]?.id || ""); + } else if (!visibleQuests.length && activeQuest) { + setActiveQuest(""); } - }, [quests, activeQuest]); + }, [visibleQuests, activeQuest]); - const quest = quests.find((q) => q.id === activeQuest) || quests[0] || { - label: "Empty", - title: "No quest selected", - entry: "No quests have been recorded yet.", + const emptyQuest = tab === "active" ? { + label: "Active", + title: "No active quests", + entry: "No quest has been committed to the chronicle yet. Rumors and hooks stay in the Rumors tab until the party pursues them.", + objectives: [], + } : tab === "complete" ? { + label: "Past", + title: "No past quests", + entry: "Nothing has been resolved or failed yet.", + objectives: [], + } : { + label: "Rumors", + title: "No rumors", + entry: "No rumors or untracked hooks have reached the party yet.", objectives: [], }; + const quest = visibleQuests.find((q) => q.id === activeQuest) || visibleQuests[0] || emptyQuest; return (
@@ -124,11 +144,7 @@ function ScreenJournal({ onNavigate, state, setState }) {
- {quests.filter((q) => { - if (tab === "active") return q.status === "active"; - if (tab === "complete") return q.status === "complete"; - return q.status === "rumor"; - }).map((q) => ( + {visibleQuests.map((q) => ( ))} - {!quests.filter((q) => (tab === "active" ? q.status === "active" : tab === "complete" ? q.status === "complete" : q.status === "rumor")).length && ( + {!visibleQuests.length && (
{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 */}
- {tab === "buy" ? "Wares of " + merchant.name.split(" ")[0] : "Your Stash"} + {tab === "buy" ? "Wares of " + merchantWaresName : "Your Stash"}
@@ -372,35 +386,51 @@ const tdStyle = { verticalAlign: "middle", }; +const GATE_MARKET_STOCK = [ + { id: "m1", name: "Crossbow bolts", type: "weapon", glyph: "bolts", qty: 30, weight: "3 lb", price: 6, desc: "Standard. Iron-tipped. The fletching is reused." }, + { id: "m2", name: "Travel rations", type: "common", glyph: "rations", qty: 12, weight: "12 lb", price: 24, desc: "Hardtack, salted pork, hard cheese, dried apple." }, + { id: "m3", name: "Iron lantern", type: "common", glyph: "lantern", qty: 1, weight: "2 lb", price: 7, desc: "Wick included. Oil sold separately, by the stall two rows over." }, + { id: "m4", name: "Lantern oil", type: "common", glyph: "oil flask", qty: 4, weight: "1 lb", price: 1, desc: "One pint. Burns six hours, four in the river wind off the Chionthar." }, + { id: "m5", name: "Studded leather", type: "armor", glyph: "leather armor", qty: 1, weight: "20 lb", price: 25, desc: "Sized for a medium frame. Belt may need a hole punched." }, + { id: "m6", name: "Handaxes", type: "weapon", glyph: "axe pair", qty: 6, weight: "4 lb", price: 8, desc: "A set of three, light and balanced for throwing. Forged upriver, edged here at the Gate." }, + { id: "m7", name: "Bandage roll", type: "common", glyph: "bandage", qty: 8, weight: "0.5 lb", price: 1, desc: "Linen. Clean. Mostly clean." }, + { id: "m8", name: "Potion of Healing", type: "spell", glyph: "red potion", qty: 3, weight: "0.5 lb", price: 50, desc: "Restores 2d4+2 HP. Tastes of iron and elderberry." }, + { id: "m9", name: "Antitoxin", type: "spell", glyph: "green vial", qty: 2, weight: "0.5 lb", price: 50, desc: "Advantage on saving throws against poison for 1 hour." }, + { id: "m10", name: "Climbing kit", type: "common", glyph: "rope & pitons", qty: 2, weight: "10 lb", price: 80, desc: "Rope, pitons, hammer. Used. The hammer is new." }, + { id: "m11", name: "Compass", type: "common", glyph: "brass compass", qty: 1, weight: "0.5 lb", price: 25, desc: "Brass. The needle drifts twelve degrees east of true. Dell knows this and has not said so." }, + { id: "m12", name: "Heavy crossbow", type: "weapon", glyph: "heavy crossbow", qty: 1, weight: "8 lb", price: 50, desc: "Reliable. Slow. The kind of weapon you have time to be sorry about firing." }, + { id: "m13", name: "Iron chain (10ft)", type: "common", glyph: "iron chain", qty: 3, weight: "10 lb", price: 30, desc: "Forged upriver. Tested at Wyrm's Crossing, by a man no longer with us." }, + { id: "m14", name: "Spellbook (blank)", type: "spell", glyph: "blank book", qty: 1, weight: "3 lb", price: 15, desc: "Quality paper, oxblood binding. She rarely stocks them — Sorcerous Sundries keeps the good paper." }, + { id: "m15", name: "Salt", type: "rare", glyph: "salt pouch", qty: 4, weight: "1 lb", price: 12, desc: "Coarse. Hauled up the salt-roads south. Useful against more things than you think." }, + { id: "m16", name: "Wax candle (×6)", type: "common", glyph: "candles", qty: 4, weight: "1 lb", price: 4, desc: "Beeswax. Burns long. Useful for vigils and for less wholesome purposes." }, +]; + const MERCHANTS = [ + { + id: "old-troutman", + name: "Old Troutman", + short: "Old Troutman", + subtitle: "A shield dwarven trader working the docks east of Philgrave's Mansion.", + location: "Baldur's Gate — Lower City", + waresName: "Old Troutman", + greeting: "Aye, you found the right crate. Bolts, rations, rope, oil, and a few things the Watch forgot to inventory. Keep your purse where I can see it and your questions shorter than the tide.", + repLabel: "Wary but open", + rep: 28, + disposition: "dockside trade · open while the tide holds", + stock: GATE_MARKET_STOCK, + }, { id: "talli", name: "Quartermaster Talli", short: "Q·portrait", subtitle: "The Harpers' quartermaster, and the woman the road found.", location: "the Last Light Inn", + waresName: "Talli", greeting: "Come in, then. Mind the curse outside — the lantern's covenant ends a step past the threshold. The bolts are sharp, the rations dry, the draughts honest. Coin first, then the catalogue. Harpers don't quibble, but we don't subsidize the careless either.", repLabel: "Cautiously fond", rep: 42, disposition: "open until dusk · shuttered when the Watch patrols", - stock: [ - { id: "m1", name: "Crossbow bolts", type: "weapon", glyph: "bolts", qty: 30, weight: "3 lb", price: 6, desc: "Standard. Iron-tipped. The fletching is reused." }, - { id: "m2", name: "Travel rations", type: "common", glyph: "rations", qty: 12, weight: "12 lb", price: 24, desc: "Hardtack, salted pork, hard cheese, dried apple." }, - { id: "m3", name: "Iron lantern", type: "common", glyph: "lantern", qty: 1, weight: "2 lb", price: 7, desc: "Wick included. Oil sold separately, by the stall two rows over." }, - { id: "m4", name: "Lantern oil", type: "common", glyph: "oil flask", qty: 4, weight: "1 lb", price: 1, desc: "One pint. Burns six hours, four in the river wind off the Chionthar." }, - { id: "m5", name: "Studded leather", type: "armor", glyph: "leather armor", qty: 1, weight: "20 lb", price: 25, desc: "Sized for a medium frame. Belt may need a hole punched." }, - { id: "m6", name: "Handaxes", type: "weapon", glyph: "axe pair", qty: 6, weight: "4 lb", price: 8, desc: "A set of three, light and balanced for throwing. Forged upriver, edged here at the Gate." }, - { id: "m7", name: "Bandage roll", type: "common", glyph: "bandage", qty: 8, weight: "0.5 lb", price: 1, desc: "Linen. Clean. Mostly clean." }, - { id: "m8", name: "Potion of Healing", type: "spell", glyph: "red potion", qty: 3, weight: "0.5 lb", price: 50, desc: "Restores 2d4+2 HP. Tastes of iron and elderberry." }, - { id: "m9", name: "Antitoxin", type: "spell", glyph: "green vial", qty: 2, weight: "0.5 lb", price: 50, desc: "Advantage on saving throws against poison for 1 hour." }, - { id: "m10", name: "Climbing kit", type: "common", glyph: "rope & pitons", qty: 2, weight: "10 lb", price: 80, desc: "Rope, pitons, hammer. Used. The hammer is new." }, - { id: "m11", name: "Compass", type: "common", glyph: "brass compass", qty: 1, weight: "0.5 lb", price: 25, desc: "Brass. The needle drifts twelve degrees east of true. Dell knows this and has not said so." }, - { id: "m12", name: "Heavy crossbow", type: "weapon", glyph: "heavy crossbow", qty: 1, weight: "8 lb", price: 50, desc: "Reliable. Slow. The kind of weapon you have time to be sorry about firing." }, - { id: "m13", name: "Iron chain (10ft)", type: "common", glyph: "iron chain", qty: 3, weight: "10 lb", price: 30, desc: "Forged upriver. Tested at Wyrm's Crossing, by a man no longer with us." }, - { id: "m14", name: "Spellbook (blank)", type: "spell", glyph: "blank book", qty: 1, weight: "3 lb", price: 15, desc: "Quality paper, oxblood binding. She rarely stocks them — Sorcerous Sundries keeps the good paper." }, - { id: "m15", name: "Salt", type: "rare", glyph: "salt pouch", qty: 4, weight: "1 lb", price: 12, desc: "Coarse. Hauled up the salt-roads south. Useful against more things than you think." }, - { id: "m16", name: "Wax candle (×6)", type: "common", glyph: "candles", qty: 4, weight: "1 lb", price: 4, desc: "Beeswax. Burns long. Useful for vigils and for less wholesome purposes." }, - ], + stock: GATE_MARKET_STOCK, }, ]; diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 0024c45d..8937d0fe 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -259,6 +259,35 @@ function buildChronicleLog(recentEvents, chatBeats, log) { const sanitize = (t) => (typeof window !== "undefined" && typeof window.sanitizeNarration === "function") ? window.sanitizeNarration(t || "") : (t || ""); const narrationKey = (t) => sanitize(t || "").replace(/\s+/g, " ").trim().toLowerCase(); + const QUICK_ACTION_REPLAY_ALIASES = { + look: ["look around"], + "look around": ["look"], + }; + const QUICK_ACTION_REPLAY_LABELS = { + continue: "Continue", + "look around": "Look", + }; + const playerEchoKeys = (entry) => { + const kind = entry && (entry.kind || entry.type); + const who = String(entry?.who || "").trim().toLowerCase(); + if (kind !== "action" && !(kind === "dialog" && who === "you")) return []; + const key = String(entry?.text || "") + .replace(/^\s*(?:say|do|check|save)\s*:\s*/i, "") + .replace(/^[`'"“”]+|[`'"“”]+$/g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + if (!key) return []; + return [key, ...(QUICK_ACTION_REPLAY_ALIASES[key] || [])]; + }; + const projectPlayerReplay = (entry) => { + const kind = entry && (entry.kind || entry.type); + const who = String(entry?.who || "").trim().toLowerCase(); + if (kind !== "dialog" || who !== "you") return entry; + const key = playerEchoKeys(entry)[0] || ""; + const label = QUICK_ACTION_REPLAY_LABELS[key]; + return label ? { ...entry, kind: "action", text: label } : entry; + }; const orderOf = (e) => { if (e && typeof e.orderSeq === "number") return e.orderSeq; if (e && typeof e.seq === "number") return e.seq; @@ -272,7 +301,18 @@ function buildChronicleLog(recentEvents, chatBeats, log) { if (sa !== null && sb !== null && sa !== sb) return sa - sb; return (a?.at || 0) - (b?.at || 0); }; - const mergedTail = [...beats, ...echoes].sort((a, b) => { + // The live tail can carry the player's move twice: once as the optimistic local echo + // (immediate feedback after /move accepts) and once replayed from /chat as a "You" row. + // Prefer the optimistic echo when present so the Chronicle reads as one clean turn: + // You continue → DM reply + // not + // Abby — Continue → You "continue" → DM reply. + const optimisticPlayerKeys = new Set(echoes.flatMap(playerEchoKeys).filter(Boolean)); + const dedupedBeats = beats.filter((b) => { + const keys = playerEchoKeys(b); + return !keys.length || !keys.some((key) => optimisticPlayerKeys.has(key)); + }); + const mergedTail = [...dedupedBeats.map(projectPlayerReplay), ...echoes].sort((a, b) => { return compareChronicle(a, b); }); const liveSeqs = new Set( @@ -1022,8 +1062,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_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py index edf1cbad..4c87476c 100644 --- a/viewer/tests/test_live_narration_stream.py +++ b/viewer/tests/test_live_narration_stream.py @@ -684,10 +684,75 @@ 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 }" + "], next: 2 });" + "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"}, + ], + "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..077c0076 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -161,6 +161,58 @@ 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('id === "map" ? false', source) + self.assertIn('setCampMode(route.campMode)', 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) + + 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( @@ -634,6 +686,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 +696,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.assertNotIn('>Chronicle\n {text}', source) def test_openworlds_app_bounds_the_live_session_tail(self): # #402: the live tail (chatBeats + player echoes) is bounded in useLiveSession so a long From d70c5befc59a8720583379f9c63a9fedc409b655 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 08:14:32 +0700 Subject: [PATCH 2/5] Polish Forge art and route replay --- viewer/openworlds/app.jsx | 11 ++++++----- viewer/openworlds/chrome.jsx | 1 + viewer/openworlds/screen-forge.jsx | 9 +++++---- viewer/openworlds/screen-table.jsx | 2 ++ viewer/tests/test_item_icons.py | 8 ++++++++ viewer/tests/test_live_narration_stream.py | 6 ++++-- viewer/tests/test_openworlds_static.py | 9 +++++++-- 7 files changed, 33 insertions(+), 13 deletions(-) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 83047436..558ea243 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -21,8 +21,8 @@ window.playerReplayBeat = window.playerReplayBeat || function playerReplayBeat(t continue: "Continue", "look around": "Look", }; - if (route === "say" || !route) return { kind: "dialog", who: "You", text: displayText }; - return { kind: "action", who: "You", text: quickLabels[displayText.toLowerCase()] || displayText }; + if (route === "say" || !route) return { kind: "dialog", who: "You", text: displayText, route: route || "" }; + return { kind: "action", who: "You", text: quickLabels[displayText.toLowerCase()] || displayText, route }; }; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ @@ -800,17 +800,17 @@ function App() { 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 : (id === "map" ? false : undefined) }; + return { id, campMode: raw === "camp" || raw === "rest" ? true : false }; }; const initial = fromHash(); if (initial) { - if (typeof initial.campMode === "boolean") setCampMode(initial.campMode); + setCampMode(initial.campMode); setScreen(initial.id); } const onHash = () => { const route = fromHash(); if (!route) return; - if (typeof route.campMode === "boolean") setCampMode(route.campMode); + setCampMode(route.campMode); setScreen(route.id); }; window.addEventListener("hashchange", onHash); @@ -819,6 +819,7 @@ function App() { const navigate = (id, opts) => { if (opts?.openCamp) setCampMode(true); + else if (id !== "map") setCampMode(false); setScreen(id); }; diff --git a/viewer/openworlds/chrome.jsx b/viewer/openworlds/chrome.jsx index 0388ee7f..6112fb88 100644 --- a/viewer/openworlds/chrome.jsx +++ b/viewer/openworlds/chrome.jsx @@ -20,6 +20,7 @@ const ITEM_ART_ALIASES = { "wax-candle-6": "candle", "wax-candles-6": "candle", "candles": "candle", + "sharpened-greataxe-edge": "greataxe", }; window.itemArtScope = function itemArtScope(itemOrName) { diff --git a/viewer/openworlds/screen-forge.jsx b/viewer/openworlds/screen-forge.jsx index 50f128c8..15362c69 100644 --- a/viewer/openworlds/screen-forge.jsx +++ b/viewer/openworlds/screen-forge.jsx @@ -1,10 +1,11 @@ /* Screen: Forge — item & spell crafting */ -/* W2d: item-icon scope helper — mirrors screen-inventory's slug()/itemScope(). Recipes craft - a real item (its `name`) and components are named reagents; build "item-" so - wiki icons resolve, with graceful 404 → fallback inside . Obscure - crafting reagents with no wiki page simply fall back to the placeholder glyph. */ +/* W2d: item-icon scope helper — mirrors screen-inventory's slug()/itemScope(). Recipes often + name an outcome ("Sharpened greataxe edge") while the ingested art cache stores the reusable + base prop ("item-greataxe"), so ask the shared alias helper first. Obscure crafting reagents + with no wiki page simply fall back to the placeholder glyph. */ function fItemScope(name) { + if (window.itemArtScope) return window.itemArtScope(name); const s = (window.slug ? window.slug(name) : ""); return s ? "item-" + s : ""; } diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 8937d0fe..3b2193d0 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -270,6 +270,7 @@ function buildChronicleLog(recentEvents, chatBeats, log) { const playerEchoKeys = (entry) => { const kind = entry && (entry.kind || entry.type); const who = String(entry?.who || "").trim().toLowerCase(); + if (entry?.route === "say" || entry?.mode === "say" || entry?.routing?.type === "say") return []; if (kind !== "action" && !(kind === "dialog" && who === "you")) return []; const key = String(entry?.text || "") .replace(/^\s*(?:say|do|check|save)\s*:\s*/i, "") @@ -284,6 +285,7 @@ function buildChronicleLog(recentEvents, chatBeats, log) { const kind = entry && (entry.kind || entry.type); const who = String(entry?.who || "").trim().toLowerCase(); if (kind !== "dialog" || who !== "you") return entry; + if (entry?.route === "say" || entry?.mode === "say" || entry?.routing?.type === "say") return entry; const key = playerEchoKeys(entry)[0] || ""; const label = QUICK_ACTION_REPLAY_LABELS[key]; return label ? { ...entry, kind: "action", text: label } : entry; diff --git a/viewer/tests/test_item_icons.py b/viewer/tests/test_item_icons.py index 5cc753ad..c55e7774 100644 --- a/viewer/tests/test_item_icons.py +++ b/viewer/tests/test_item_icons.py @@ -112,6 +112,7 @@ def test_shared_item_art_alias_helper_exists(self): self.assertIn('"travel-rations": "rations"', src) self.assertIn('"iron-lantern": "lantern"', src) self.assertIn('"wax-candle-6": "candle"', src) + self.assertIn('"sharpened-greataxe-edge": "greataxe"', src) def test_merchant_uses_shared_item_art_scope(self): """Merchant item icons must use the shared alias helper before falling back.""" @@ -120,6 +121,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 4c87476c..06986ec2 100644 --- a/viewer/tests/test_live_narration_stream.py +++ b/viewer/tests/test_live_narration_stream.py @@ -741,8 +741,9 @@ 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 }" - "], next: 2 });" + "{ 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() });" ) @@ -751,6 +752,7 @@ def test_routed_player_replay_preserves_action_vs_speech_after_reload(self): [ {"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", ) diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 077c0076..f179dde6 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -170,8 +170,11 @@ def test_openworlds_camp_deep_link_opens_map_camp_mode(self): self.assertIn('camp: "map"', source) self.assertIn('rest: "map"', source) self.assertIn('raw === "camp" || raw === "rest" ? true', source) - self.assertIn('id === "map" ? false', 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): @@ -199,6 +202,8 @@ def test_merchant_waits_for_live_action_lane_before_purchase(self): 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") @@ -697,7 +702,7 @@ def test_openworlds_table_chronicle_preserves_paragraph_breaks(self): # …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.assertNotIn('>Chronicle\n {text}', 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 From f11cdba43422f13255b11f8823fe3cea5562dc44 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 08:27:19 +0700 Subject: [PATCH 3/5] Guard table actions when app status is not playable --- viewer/openworlds/screen-table.jsx | 65 +++++++++++++++++++++----- viewer/tests/test_openworlds_static.py | 23 ++++++++- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 3b2193d0..aafcd6b7 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -346,6 +346,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const campaignId = activeCampaign.campaign_id || state?.activeCampaign || activeCampaign.id || ""; const [surface, setSurface] = React.useState(null); const [surfaceStatus, setSurfaceStatus] = React.useState("loading"); + const [appStatus, setAppStatus] = React.useState(null); const demoLog = []; const [input, setInput] = React.useState(""); const [composerModeId, setComposerModeId] = React.useState("do"); @@ -419,6 +420,20 @@ 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 appReadiness = appStatus?.readiness || {}; + const appHealth = appStatus?.health || {}; + const appFailureBucket = appReadiness.failure_bucket || appHealth.failure_bucket || ""; + const appFailureDetail = appReadiness.failure_detail || appHealth.failure_detail || ""; + const appStatusBlocksPlay = Boolean( + appStatus && + appReadiness.ready_for_play === false && + ["no_provider", "no_launcher", "move_rejected"].includes(appFailureBucket), + ); + const appStatusBlockReason = appFailureDetail || ( + appFailureBucket === "no_provider" + ? "No Dungeon Master provider is connected." + : "The live move lane is not ready." + ); const loadSurface = React.useCallback(async (isCancelled = () => false) => { const params = new URLSearchParams(); @@ -430,12 +445,22 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const response = await fetch(`/session-surface${query}`, { cache: "no-store" }); if (!response.ok) throw new Error(`session surface ${response.status}`); const payload = await response.json(); + let statusPayload = null; + try { + const statusResponse = await fetch(`/app-status${query}`, { cache: "no-store" }); + if (!statusResponse.ok) throw new Error(`app status ${statusResponse.status}`); + statusPayload = await statusResponse.json(); + } catch (_statusError) { + statusPayload = null; + } if (isCancelled()) return; setSurface(payload); + setAppStatus(statusPayload); setSurfaceStatus("ready"); } catch (error) { if (isCancelled()) return; setSurfaceStatus(error?.message || "unavailable"); + setAppStatus(null); } // #357 (nb3): the GM Advisory (Campaign Director #72) fetch was removed here — its only // consumer was the GM-bookkeeping panel that leaked into the player's live-play sidebar @@ -550,8 +575,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const postMove = async (move, label, actionId) => { const enabledAction = actionId ? enabledActionById(actionId) : null; - if (!move || !canAct || pendingActive || (actionId && !enabledAction)) { - toast({ kind: "danger", title: "Action unavailable", body: pendingActive ? "The Dungeon Master is still narrating — one move at a time." : readOnlyReason }); + 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 }); return; } // #342: neutralize any markup in a free-text move (kind "do"/"say"/etc. carry the player's words @@ -712,6 +737,24 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { {surfaceStatus === "loading" ? "Loading session surface." : `Session surface unavailable: ${surfaceStatus}`}
)} + {appStatusBlocksPlay && ( +
+ {appStatusBlockReason} Start or resume a provider-backed session from Chronicles before sending moves. +
+ )} {/* Scene plate */}
invokeAction(a)} /> ))} @@ -836,7 +879,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { hint={ACTION_HINTS[a.id]} actionId={a.id} tone={a.available ? "royal" : "crimson"} - disabled={!a.available || pendingActive} + disabled={!a.available || pendingActive || appStatusBlocksPlay} onClick={() => invokeAction(a)} /> ))} @@ -864,10 +907,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. */}
@@ -881,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"}
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index f179dde6..bdc9e458 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -576,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 @@ -644,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 From 008770f404e3ee406ccfacb3281e46c6c7267840 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 08:41:43 +0700 Subject: [PATCH 4/5] Polish Market item art fallbacks --- viewer/openworlds/chrome.jsx | 7 ++++++- viewer/tests/test_item_icons.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/viewer/openworlds/chrome.jsx b/viewer/openworlds/chrome.jsx index 6112fb88..84534c06 100644 --- a/viewer/openworlds/chrome.jsx +++ b/viewer/openworlds/chrome.jsx @@ -17,16 +17,21 @@ const ITEM_ART_ALIASES = { "travel-ration": "rations", "travel-rations": "rations", "iron-lantern": "lantern", + "climbing-kit": "rope", "wax-candle-6": "candle", "wax-candles-6": "candle", "candles": "candle", "sharpened-greataxe-edge": "greataxe", + "bandage-roll": "", + "compass": "", + "iron-chain-10ft": "", + "spellbook-blank": "", }; window.itemArtScope = function itemArtScope(itemOrName) { const name = typeof itemOrName === "string" ? itemOrName : itemOrName?.name; const s = window.slug(name); - const aliased = ITEM_ART_ALIASES[s] || s; + const aliased = Object.prototype.hasOwnProperty.call(ITEM_ART_ALIASES, s) ? ITEM_ART_ALIASES[s] : s; return aliased ? "item-" + aliased : ""; }; diff --git a/viewer/tests/test_item_icons.py b/viewer/tests/test_item_icons.py index c55e7774..1c268dfe 100644 --- a/viewer/tests/test_item_icons.py +++ b/viewer/tests/test_item_icons.py @@ -111,8 +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.""" From 191107604d02a991cb11a3e2f7f7a94ae8b5edd0 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 08:52:02 +0700 Subject: [PATCH 5/5] Fix camp rest feedback --- viewer/openworlds/camp-sidebar.jsx | 2 -- viewer/tests/test_openworlds_static.py | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/viewer/openworlds/camp-sidebar.jsx b/viewer/openworlds/camp-sidebar.jsx index 3b4d6838..6df06d38 100644 --- a/viewer/openworlds/camp-sidebar.jsx +++ b/viewer/openworlds/camp-sidebar.jsx @@ -135,8 +135,6 @@ function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner, dmBusy } throw new Error(payload.reason || `move ${response.status}`); } toast({ kind: "rest", eyebrow: "Camp", title: "Resting", body: "Move relayed to the DM — the engine resolves the long rest, refreshes the party, and advances the clock to morning." }); - // Keep the existing screen-map nicety working if the parent supplied one. - if (typeof onBeginRest === "function") { try { onBeginRest(); } catch (_) { /* non-fatal */ } } } catch (error) { toast({ kind: "danger", eyebrow: "Camp", title: "Rest not sent", body: error?.message || "The viewer could not reach /move." }); } finally { diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index bdc9e458..8e663e88 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -773,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")