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
47 changes: 37 additions & 10 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, route: route || "" };
return { kind: "action", who: "You", text: quickLabels[displayText.toLowerCase()] || displayText, route };
};

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"palette": "warm",
"ornaments": true,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -778,21 +794,32 @@ 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 : false };
};
const initial = fromHash();
if (initial) setScreen(initial);
const onHash = () => { const id = fromHash(); if (id) setScreen(id); };
if (initial) {
setCampMode(initial.campMode);
setScreen(initial.id);
}
const onHash = () => {
const route = fromHash();
if (!route) return;
setCampMode(route.campMode);
setScreen(route.id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);

const navigate = (id, opts) => {
if (opts?.openCamp) setCampMode(true);
else if (id !== "map") setCampMode(false);
setScreen(id);
};

Expand All @@ -808,7 +835,7 @@ function App() {
<div className="window">
<TitleBar
campaign={current.title}
location={SCREEN_TITLES[screen]}
location={screen === "map" && campMode ? "Camp" : SCREEN_TITLES[screen]}
day={current.day}
capability={capabilityForScreen(screen, nativeState)}
nativeStatus={nativeState}
Expand Down
2 changes: 0 additions & 2 deletions viewer/openworlds/camp-sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion viewer/openworlds/chrome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +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 : "";
};

Expand Down
9 changes: 5 additions & 4 deletions viewer/openworlds/screen-forge.jsx
Original file line number Diff line number Diff line change
@@ -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-<slug(name)>" so
wiki icons resolve, with graceful 404 → <Placeholder> fallback inside <Img>. 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 : "";
}
Expand Down
42 changes: 29 additions & 13 deletions viewer/openworlds/screen-journal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function jNpcScope(n) {
return (n && n.id) ? "portrait-" + n.id : "";
}

function journalQuestInTab(q, tab) {
if (tab === "active") return q.status === "active";
if (tab === "complete") return q.status === "complete";
return q.status === "rumor";
}

function ScreenJournal({ onNavigate, state, setState }) {
const surfaceQuery = window.combatSurfaceFromCampaign
? window.combatSurfaceFromCampaign(
Expand All @@ -38,6 +44,7 @@ function ScreenJournal({ onNavigate, state, setState }) {
const threads = Array.isArray(surface?.threads) ? surface.threads : [];
const [activeQuest, setActiveQuest] = React.useState("");
const [tab, setTab] = React.useState("active");
const visibleQuests = React.useMemo(() => 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
Expand Down Expand Up @@ -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 (
<div className="screen" style={{ height: "100%", display: "grid", gridTemplateColumns: "300px 1fr", gap: 14, padding: 14 }}>
Expand All @@ -124,11 +144,7 @@ function ScreenJournal({ onNavigate, state, setState }) {
</div>

<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
{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) => (
<button key={q.id} onClick={() => setActiveQuest(q.id)} style={{
textAlign: "left",
padding: "10px 12px",
Expand All @@ -148,7 +164,7 @@ function ScreenJournal({ onNavigate, state, setState }) {
<div className="hand" style={{ fontSize: 13, color: "var(--ink-600)", marginTop: 4 }}>{q.objective}</div>
</button>
))}
{!quests.filter((q) => (tab === "active" ? q.status === "active" : tab === "complete" ? q.status === "complete" : q.status === "rumor")).length && (
{!visibleQuests.length && (
<div className="body-sm muted" style={{ padding: "8px 4px" }}>
{tab === "active" ? "No active quests in the chronicle yet." : tab === "complete" ? "Nothing has been resolved or failed yet." : "No rumors or untracked hooks."}
</div>
Expand Down
Loading
Loading