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
33 changes: 29 additions & 4 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,25 @@ function recoveryWindowMs(firstBeat) {
return firstBeat ? PENDING_RECOVERY_FIRST_MS : PENDING_RECOVERY_MS;
}

// #402: BOUND the live tail. `chatBeats` (every streamed/turn-end DM narration + dialogue beat) and
// `log` (every optimistic player echo) accumulated for the WHOLE session with no cap — so a long
// playtest grew them without limit. The chronicle rendered ALL of it (screen-table visibleLog.map),
// so the DOM + the accessibility tree grew unbounded: after a few beats an a11y reader (and a real
// screen reader) truncated BEFORE reaching the newest narration, and the latest beat + action box
// were buried under an ever-taller scroll region — the player couldn't see the DM's reply and the
// run stalled. We keep only the most-recent MAX_LIVE_* entries in each array; older prose still
// lives in the server's recentEvents history band (screen-table's leading window), so nothing is
// truly lost — the live tail just stops growing. The caps are generous so a single multi-paragraph
// DM turn (several /events beats in one turn) is NEVER clipped mid-beat. Pure + exported for tests.
const MAX_LIVE_BEATS = 60; // DM narration/dialogue beats kept in the live tail (≫ one turn's paragraphs).
const MAX_LIVE_ECHOES = 40; // optimistic player-action echoes kept in the live tail.
// Trim an append-only array to its last `max` entries WITHOUT copying when already within bound
// (so a steady-state turn doesn't reallocate the array every poll). Returns the same ref when no
// trim is needed — React's setState bails out on an identical ref, avoiding a needless re-render.
function boundTail(arr, max) {
return (Array.isArray(arr) && arr.length > max) ? arr.slice(arr.length - max) : arr;
}

// #274: a monotonic, client-side sequence stamped on every chronicle entry created here (player
// echoes + each ingested chat beat) as `.at`. The session log in screen-table.jsx concatenates
// three sources (recentEvents → chatBeats → log); because the player's optimistic echo (`log`) and
Expand Down Expand Up @@ -239,7 +258,9 @@ function useLiveSession(state) {
&& String(last.text || "").trim() === String(text || "").trim()) {
return l; // identical to the row already showing (a Try-again re-POST) — no duplicate.
}
return [...l, { kind: "action", who, text, at: nextLogSeq() }]; // #274: creation-order stamp
// #402: bound the echo tail so a long session doesn't grow `log` (and the rendered DOM /
// a11y tree) without limit. Keep the most-recent MAX_LIVE_ECHOES.
return boundTail([...l, { kind: "action", who, text, at: nextLogSeq() }], MAX_LIVE_ECHOES); // #274: creation-order stamp
});
}, []);

Expand Down Expand Up @@ -292,7 +313,7 @@ function useLiveSession(state) {
return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq() } : null;
})
.filter(Boolean);
if (beats.length) setChatBeats((prev) => [...prev, ...beats]);
if (beats.length) setChatBeats((prev) => boundTail([...prev, ...beats], MAX_LIVE_BEATS)); // #402: cap the live tail
// The arrival of the DM's turn-END line means the turn RESOLVED → clear the narrating
// indicator + its timers. This fires even when the prose was wholly deduped (a turn whose
// entire beat streamed live via /events), so a fully-streamed turn still re-opens the bar.
Expand Down Expand Up @@ -353,7 +374,7 @@ function useLiveSession(state) {
})
.filter(Boolean);
if (beats.length) {
setChatBeats((prev) => [...prev, ...beats]);
setChatBeats((prev) => boundTail([...prev, ...beats], MAX_LIVE_BEATS)); // #402: cap the live tail
// The scene is visibly building → the turn is plainly alive. Count the streamed prose as
// real DM beats (so the NEXT turn isn't mis-treated as a cold-open 'firstBeat') and reset
// the stall clock so a long-but-healthy streaming turn is never falsely declared 'stuck'.
Expand Down Expand Up @@ -391,6 +412,10 @@ window.__PENDING_TIMING__ = {
recoveryFirstMs: PENDING_RECOVERY_FIRST_MS,
backstopMs: PENDING_BACKSTOP_MS,
};
// #402: expose the live-tail bound for tests/devtools introspection (purely additive — the hook
// closes over the consts directly; nothing in the running app reads these off window).
window.boundTail = boundTail;
window.__LIVE_TAIL_CAPS__ = { maxBeats: MAX_LIVE_BEATS, maxEchoes: MAX_LIVE_ECHOES };

function App() {
const [state, setState] = React.useState(window.INITIAL_STATE || {});
Expand Down Expand Up @@ -763,7 +788,7 @@ function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMo
case "acts": return <ScreenActs state={state} setState={setState} onNavigate={onNavigate} />;
case "seed": return <ScreenSeed state={state} setState={setState} onNavigate={onNavigate} />;
case "inventory": return <ScreenInventory state={state} setState={setState} onNavigate={onNavigate} />;
case "map": return <ScreenMap state={state} setState={setState} onNavigate={onNavigate} campMode={campMode} setCampMode={setCampMode} />;
case "map": return <ScreenMap state={state} setState={setState} onNavigate={onNavigate} campMode={campMode} setCampMode={setCampMode} liveSession={liveSession} />;
case "journal": return <ScreenJournal state={state} setState={setState} onNavigate={onNavigate} />;
case "bestiary": return <ScreenBestiary state={state} setState={setState} onNavigate={onNavigate} />;
case "merchant": return <ScreenMerchant state={state} setState={setState} onNavigate={onNavigate} />;
Expand Down
37 changes: 27 additions & 10 deletions viewer/openworlds/camp-sidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Camp Sidebar — D&D 5e party role assignment during a long rest */

function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) {
function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner, dmBusy }) {
// LIVE party for the active campaign. The camp sidebar has no dedicated surface route, so it
// reuses the same /character-surface read-model screen-character.jsx polls (it carries `.party`).
// We never fall back to `state.party` (the non-canonical demo party).
Expand Down Expand Up @@ -103,6 +103,15 @@ function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) {
// morning, and firing companion camp beats). The constrained /move palette accepts `do`
// free-text intents, so the watch/cook/recipe/healing choices ride in the sentence.
const beginRest = async () => {
// #402: the DM is mid-narration — a long rest is a new action the player can't take yet. Don't
// silently no-op (the old behavior: the button stayed enabled because `can_act` is still true,
// so a click POSTed a move that just queued behind the in-flight turn and nothing advanced —
// it read as an identical reload). Give clear feedback, mirroring ScreenTable's "one move at a
// time" gate. The button is also disabled while busy (below), so this is the keyboard/edge path.
if (dmBusy) {
toast({ kind: "danger", eyebrow: "Camp", title: "The Dungeon Master is still narrating", body: "Resolve the current beat first — then make camp and rest." });
return;
}
if (!canAct || resting) return;
const watch = [heroName(roles.watch1), heroName(roles.watch2)].filter(Boolean);
const clauses = [];
Expand Down Expand Up @@ -346,30 +355,38 @@ function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) {
{talkHero && <TalkPanel hero={talkHero} onClose={() => onTalk(null)} />}

{/* Begin resting — wired to the engine via /move (CS-01). Enabled + functional when a
live session is attached (can_act); honestly disabled + explained when the chronicle
is read-only, or with no party to rest. */}
live session is attached (can_act) AND the DM isn't mid-turn (#402); honestly disabled +
explained when the chronicle is read-only, the DM is narrating, or there's no party. */}
<div style={{ flex: "0 0 auto" }}>
<div style={{ display: "flex", gap: 6 }}>
<BrassButton tone="ghost" size="sm" onClick={onExit}>Leave camp</BrassButton>
<BrassButton
tone="dark"
disabled={!canAct || party.length === 0 || resting}
disabled={!canAct || dmBusy || party.length === 0 || resting}
onClick={beginRest}
style={{ flex: 1 }}
title={
canAct
? (party.length === 0 ? "No party in camp to rest" : "Relays a long rest to the DM via /move — the engine refreshes the party and advances the clock to morning")
: "The chronicle is read-only — start a session to rest"
!canAct
? "The chronicle is read-only — start a session to rest"
: dmBusy
? "The Dungeon Master is still narrating — resolve the current beat first"
: (party.length === 0 ? "No party in camp to rest" : "Relays a long rest to the DM via /move — the engine refreshes the party and advances the clock to morning")
}
>
{resting ? "✺ Resting…" : "✺ Begin Resting"}
{/* #402: the label reflects the DM-busy state so the disabled button isn't a mystery. */}
{resting ? "✺ Resting…" : dmBusy ? "✺ DM is narrating…" : "✺ Begin Resting"}
</BrassButton>
</div>
{!canAct && (
{!canAct ? (
<div className="hand muted" style={{ fontSize: 11, marginTop: 6, textAlign: "center" }}>
The chronicle is read-only. Start a session to make camp and rest.
</div>
)}
) : dmBusy ? (
// #402: explain WHY the rest CTA is disabled mid-turn (the silent no-op was the bug).
<div className="hand muted" style={{ fontSize: 11, marginTop: 6, textAlign: "center" }}>
Resolve the current beat first — the Dungeon Master is narrating.
</div>
) : null}
</div>
</div>
);
Expand Down
11 changes: 10 additions & 1 deletion viewer/openworlds/screen-map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function atlasShortLabel(name) {
return base.length <= 16 ? base : base.slice(0, 15) + "…";
}

function ScreenMap({ onNavigate, state, campMode, setCampMode }) {
function ScreenMap({ onNavigate, state, campMode, setCampMode, liveSession }) {
const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : [];
const activeCampaign =
campaigns.find((c) => c.id === state?.activeCampaign) ||
Expand Down Expand Up @@ -116,6 +116,14 @@ function ScreenMap({ onNavigate, state, campMode, setCampMode }) {
const selectedTravel = selected ? travelOptions.find((t) => t.to === selected.id) : null;
const canCamp = Boolean(surface?.camp_available);
const canAct = Boolean(surface?.can_act);
// #402: is the DM mid-turn? The /move sink always accepts (it just appends an intent), and
// `can_act` stays true while the DM narrates — so a camp "Begin Resting" click during a beat used
// to POST a move that silently queued behind the in-flight turn (no advance, a misleading success
// toast). Mirror ScreenTable's gate: `pending` (present + not flagged stuck) ⇒ the DM is narrating
// and the player can't take a new action yet. Threaded down to CampSidebar so the rest CTA can
// disable + explain instead of no-op'ing. (`pending` lives on the app-level liveSession hook.)
const dmPending = liveSession?.pending || null;
const dmBusy = Boolean(dmPending && !dmPending.stuck);
// Day/night is CLOCK-DRIVEN: read the engine's normalized phase off the surface (falling
// back to a sniff of the legacy day label for older builds). There is no manual toggle —
// the indicator always reflects the live campaign clock.
Expand Down Expand Up @@ -230,6 +238,7 @@ function ScreenMap({ onNavigate, state, campMode, setCampMode }) {
onBeginRest={beginRest}
onTalk={setTalkPartner}
talkPartner={talkPartner}
dmBusy={dmBusy}
/>
</div>
) : (
Expand Down
Loading
Loading