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
22 changes: 21 additions & 1 deletion viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,26 @@ function useLiveSession(state) {
setPendingState(null);
}, [clearTimers, setPendingState]);

// #826: authoritatively ROLL BACK the optimistic in-flight arm when the /move POST itself is
// REJECTED by the server (the move never started, so there is no DM turn to wait for). postMove now
// arms the narrating gate the INSTANT the player commits — BEFORE the network round-trip — so the
// one-move-at-a-time gate SURVIVES a navigation during the in-flight window (the App-level pending
// state outlives ScreenTable's unmount, where a re-mounted submittingRef would otherwise re-open
// the bar and let a second move double-fire the lane = the #826 state corruption). When that POST
// comes back an ERROR, the gate must clear NOW — but a plain clearPending would be SWALLOWED by the
// #648 arm-grace (it deliberately ignores a spurious clear inside the first PENDING_ARM_GRACE_MS).
// This is NOT spurious: it is the server's authoritative rejection, so it bypasses the grace. It is
// surgical — it only clears the move WE optimistically armed (text match against the still-pending,
// not-yet-streaming turn) so it can never clobber a newer live turn (e.g. a fast retry).
const abandonPending = React.useCallback((text) => {
const p = pendingRef.current;
if (!p || p.streaming) return; // a streaming turn is real — never abandon it
const want = String(text == null ? "" : text);
if (want && String(p.text == null ? "" : p.text) !== want) return; // not the move we armed — no-op
clearTimers();
setPendingState(null);
}, [clearTimers, setPendingState]);

// #342 + #348: arm the narrating indicator + a recovery timeout. If a DM beat doesn't arrive within
// the recovery window the turn is flagged `stuck` (the bar re-enables with a "try again" hint)
// instead of staying frozen until the 12-minute backstop. A real beat (below) clears it outright.
Expand Down Expand Up @@ -757,7 +777,7 @@ function useLiveSession(state) {
// (consistent with armPending/clearPending; the /events poll calls the same ref). Purely additive —
// existing consumers destructure named fields, so nothing breaks; it also makes the mid-stream stall
// ceiling unit-testable without reaching into the hook's internals.
return { chatBeats, log, pending, armPending, clearPending, recordPlayerEcho, notePendingProgress };
return { chatBeats, log, pending, armPending, clearPending, abandonPending, recordPlayerEcho, notePendingProgress };
}
window.useLiveSession = useLiveSession;
// #348: expose the recovery-timing contract for tests (and devtools introspection). Purely
Expand Down
50 changes: 47 additions & 3 deletions viewer/openworlds/chrome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,23 +269,67 @@ function PortraitSilhouette() {
);
}

// #826: how many times Img RETRIES a scope whose /image 404s, and the backoff between tries. The
// scene image is fire-and-forget (#399): the engine returns a "pending" descriptor immediately and a
// daemon worker writes the real art OFF the turn path, so /image legitimately 404s for a window after
// a new scope appears. The OLD Img latched `failed` on the first onError and only ever cleared it on
// a SCOPE CHANGE — so a same-scope image that became servable LATER stayed frozen on the placeholder
// forever (a dead handle). That froze the scene art when a player navigated away mid-narration and
// back (the surface re-projects the same scope, but the latched component never re-attempts). These
// retries let the component recover when the pending art lands, while a bounded budget + backoff
// keeps a genuinely-missing image from hammering the endpoint.
const _IMG_MAX_RETRIES = 8;
const _IMG_RETRY_MS = 4000;

function Img({ scope, label, w, h, framed, style, className, fit = "cover" }) {
// `attempt` doubles as the cache-buster + the retry counter; `failed` is the per-attempt error
// latch (placeholder while we wait), NOT a permanent freeze.
const [attempt, setAttempt] = React.useState(0);
const [failed, setFailed] = React.useState(false);
React.useEffect(() => { setFailed(false); }, [scope]);
const retryRef = React.useRef(null);
const clearRetry = () => {
if (retryRef.current != null) { window.clearTimeout(retryRef.current); retryRef.current = null; }
};
// A new scope is a fresh subject — reset the retry budget + error latch and cancel any pending retry.
React.useEffect(() => {
setFailed(false);
setAttempt(0);
return clearRetry;
}, [scope]);
const isPortrait = /(^|[-:/])(portrait|pc|npc|char)/i.test(scope || "");
const onError = () => {
// #826: do NOT permanently latch. Show the placeholder for THIS attempt, then — if we still have
// retry budget for this scope (the #399 pending-art window) — schedule another try so a scope
// whose image becomes servable later RECOVERS instead of freezing on a dead handle. Past the
// budget we stop (a genuinely-missing image), still on the graceful placeholder.
setFailed(true);
if (attempt >= _IMG_MAX_RETRIES) return;
clearRetry();
retryRef.current = window.setTimeout(() => {
retryRef.current = null;
setFailed(false); // clear the per-attempt latch …
setAttempt((a) => a + 1); // … and re-mount the <img> (cache-busted) to re-probe /image.
}, _IMG_RETRY_MS);
};
if (!scope || failed) {
return (
<Placeholder label={isPortrait ? "" : label} w={w} h={h} framed={framed} style={style} className={className}>
{isPortrait ? <PortraitSilhouette /> : undefined}
</Placeholder>
);
}
// The cache-buster (`v=attempt`) forces the browser to actually re-request the scope on a retry
// rather than re-serve the cached 404; attempt 0 keeps the original URL shape (no change for the
// happy path / existing tests that assert the `/image?scope=` prefix).
const src = attempt > 0
? `/image?scope=${encodeURIComponent(scope)}&v=${attempt}`
: `/image?scope=${encodeURIComponent(scope)}`;
return (
<img
src={`/image?scope=${encodeURIComponent(scope)}`}
src={src}
alt={label || ""}
loading="lazy"
onError={() => setFailed(true)}
onError={onError}
className={`ow-img ${framed ? "framed" : ""} ${className || ""}`}
style={{ width: w, height: h, objectFit: fit, display: "block", ...(style || {}) }}
/>
Expand Down
29 changes: 23 additions & 6 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
// LOCKOUT P0 play gate); a turn the recovery timeout flagged `stuck` is NO LONGER pending — the bar
// re-opens (and `stuckRetryUnblocked` lets the retry probe the server even through an app-status latch).
const armPending = session.armPending;
// #826: the authoritative rollback for an OPTIMISTIC arm — postMove arms the narrating gate the
// instant the player commits (before the /move round-trip) so the one-move gate survives a nav
// during the in-flight window; abandonPending rolls it back if the POST is rejected.
const abandonPending = session.abandonPending;
const recordPlayerEcho = session.recordPlayerEcho;
// #385: the COLD-OPEN (first beat) gets action-bar copy that reads as "the DM is taking its turn"
// (alive) rather than the generic "Narrating…" — so the locked bar matches the obviously-alive
Expand Down Expand Up @@ -837,6 +841,19 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
// #344: capture the (already-neutralized) move + label + actionId so a later "Try again"
// recovery can re-POST this exact turn if the DM stalls on it.
lastMoveRef.current = { move: cleanMove, label: text, actionId };
// #826: arm the narrating gate OPTIMISTICALLY — the INSTANT the player commits, BEFORE the /move
// round-trip resolves. The arm + the echo live in the app-level useLiveSession hook, so they
// SURVIVE this screen unmounting: if the player navigates away (Table→Map) DURING the in-flight
// POST and back, the App still holds `pending` (the one-move gate) and the chronicle echo. The old
// order armed only AFTER the await, leaving a window where a nav-away/nav-back remounted ScreenTable
// with a fresh submittingRef=false AND pending=null — the bar re-opened and a SECOND move could
// double-fire the one-move-at-a-time lane (the #826 state corruption). recordPlayerEcho is #399-
// idempotent, so re-POST/retry paths don't duplicate the echo.
recordPlayerEcho(hero.name, text, cleanMove);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back optimistic echoes when /move is rejected

This now appends the player's action to the Chronicle before the /move POST is accepted, but the rejection path only clears the optimistic pending state and never removes that echo. When the server rejects a move (for example a stale action, dead move sink, or payload.ok === false), the player gets a "Move not sent" toast while the Chronicle still shows the action as if it happened, and a later retry can leave duplicate/false history in the visible session.

Useful? React with 👍 / 👎.

armPending(text);
// #402: a new turn was just submitted — force the chronicle back to the bottom on the next content
// change even if the player had scrolled up, so they always see their move land + the DM reply begin.
snapNextRef.current = true;
try {
const response = await fetch(writeLane.endpoint || "/move", {
method: "POST",
Expand All @@ -847,14 +864,14 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
if (!response.ok || payload.ok === false) {
throw new Error(payload.reason || `move ${response.status}`);
}
recordPlayerEcho(hero.name, text, cleanMove);
armPending(text);
// #402: a new turn was just submitted — force the chronicle back to the bottom on the next
// content change even if the player had scrolled up, so they always see their move land and
// the DM's reply begin. The auto-follow effect honors this one-shot, then re-arms stickiness.
snapNextRef.current = true;
loadSurface();
} catch (error) {
// #826: the POST was REJECTED — the move never started, so authoritatively roll back the
// optimistic arm (abandonPending bypasses the #648 arm-grace; it's surgical — clears ONLY the
// move we just armed, never a newer live turn). Then surface the honest failure toast. Falls back
// to clearPending on an older bundle that predates abandonPending.
if (typeof abandonPending === "function") abandonPending(text);
else if (typeof session.clearPending === "function") session.clearPending();
toast({ kind: "danger", title: "Move not sent", body: error?.message || `The viewer could not reach ${writeLane.endpoint || "/move"}.` });
} finally {
submittingRef.current = false;
Expand Down
30 changes: 29 additions & 1 deletion viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,24 @@ def _session_action_context(snapshot: dict, location: dict, summary: str, quests
}


# #825: a generous DoS-only ceiling for a single chronicle history row's text. Set FAR above any real
# multi-paragraph DM beat (a long Act-opening beat is a few KB), so it can never cut a genuine turn;
# it exists only to bound a pathological row. _bounded_chronicle_text cuts on a WORD boundary when it
# trips, so even that case is never sliced mid-word (the #825 complaint).
_CHRONICLE_ROW_TEXT_MAX = 64 * 1024


def _bounded_chronicle_text(text: str) -> str:
"""Return the FULL beat text (the #825 fix — no fixed mid-word ceiling), trimming only a
pathological row past _CHRONICLE_ROW_TEXT_MAX and, even then, on the last whitespace boundary so
the cut never lands inside a word."""
if len(text) <= _CHRONICLE_ROW_TEXT_MAX:
return text
head = text[:_CHRONICLE_ROW_TEXT_MAX]
cut = head.rfind(" ")
return (head[:cut] if cut > 0 else head).rstrip()


def _session_recent_events(raw_events: list[dict] | None) -> list[dict]:
out: list[dict] = []
for row in raw_events or []:
Expand All @@ -1660,7 +1678,17 @@ def _session_recent_events(raw_events: list[dict] | None) -> list[dict]:
text = _text(row.get("text") or row.get("detail") or row.get("summary"))
if not text:
continue
item = {"kind": kind, "text": text[:1000]}
# #825: the chronicle's leading history band must carry the FULL DM beat. The old fixed
# `text[:1000]` ceiling cut a long narration MID-WORD with no ellipsis/expand — three
# personas (rc2 adversarial+narrative, rc1 veteran) reported the remainder unreadable. The
# chronicle render region is already a scrollable role="log" (screen-table.jsx), and the #752
# a11y bound is the ROW cap (CHRONICLE_RENDER_CAP / MAX_LIVE_BEATS) — NOT a per-row char cut —
# so removing this ceiling does not reintroduce the #752 a11y-tree flood. We keep only a
# generous DoS guard at _CHRONICLE_ROW_TEXT_MAX, set far above any real multi-paragraph beat
# so it can never slice a genuine DM turn (and we cut on a WORD boundary if it ever trips, so
# even a pathological row is never cut mid-word). Engine stays sole writer — this is the
# read-only viewer bridge projecting state the engine already wrote.
item = {"kind": kind, "text": _bounded_chronicle_text(text)}
if label:
item["label"] = label[:120]
# Carry the stable session-log line index (`seq`) through to the surface when present, so the
Expand Down
Loading
Loading