Skip to content

fix(openworlds): slow-but-working DM turn no longer reads as broken (#399) - #400

Merged
100yenadmin merged 1 commit into
mainfrom
fix/viewer-slow-turn-ux
May 30, 2026
Merged

fix(openworlds): slow-but-working DM turn no longer reads as broken (#399)#400
100yenadmin merged 1 commit into
mainfrom
fix/viewer-slow-turn-ux

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 30, 2026

Copy link
Copy Markdown
Member

The bug

A full-arc playtest abandoned the session on beats 2–4: the DM backend completed the turn (runbooks fired, the in-game clock advanced — proven by the backend log) but the turn ran longer than the viewer's 90s "stuck" window. The player saw "The Dungeon Master seems stuck — No reply came back in time", the action bar re-opened with Try Again, and clicking it duplicated the player's action in the chronicle. Two MAJOR timeout bugs + one MINOR duplication bug — the single blocker preventing a fresh player from finishing an 8-beat session.

Root cause (verified against the engine + DM skill, not just the static viewer code)

useLiveSession (app.jsx) already had, from #393, a live /events narration stream and an adaptive stall-clock reset (notePendingProgress) that on paper means a long-but-streaming turn never trips stuck. So fixes "raise the timeout adaptively" and "stream on turns 2+" looked already-done.

But the streaming is inert on every shipped DM path. The DM beat cycle (skills/dungeon-master/SKILL.md step 7) prescribes: write the player-facing prose FIRST, then persist the whole beat in ONE persist_beat call LAST (a latency optimization — servers/engine/server.py:persist_beat batches log_event+remember+decision+advance into a single atomic write). The duo/human/native runners all go through this. So nothing is written to sessions/<sid>.jsonl mid-turn/events surfaces a beat only at turn-END (same instant as the /chat turn-end line) → notePendingProgress() never fires during the turn → later beats run under the cold 90s wall-clock with no resets. The 90s was tuned for the ~35–60s norm; a content-rich beat 2–4 runs 90–120s and trips it on a working turn.

The viewer streaming logic itself is correct for turns 2+ (the /events cursor + dmBeatCountRef reset per-run, not per-turn, so later turns keep tailing). It simply has nothing to stream today. So the honest fix is to size the wall-clock window for the real worst case and stop the retry from duplicating the action — both viewer-only.

Changes (viewer-only — no Swift rebuild; engine sole-writer + wire contracts untouched)

  1. Raise the later-beat stuck window 90s → 180s (PENDING_RECOVERY_MS, app.jsx) so a worst-case ~120s turn completes without a false stuck. First-beat window (4 min) + 12-min hard backstop unchanged. notePendingProgress() reset is kept (it's correct, and starts helping the day a DM path logs beats incrementally).
  2. Idempotent player echo (recordPlayerEcho, app.jsx): the [playtest][P1] VALIDATE #343 completeness — veteran hit dead 'Try again' button + Table nav still blocked from Map/Journal #344 "Try again" re-POSTs the exact stalled move, which used to append a second identical chronicle action — the duplicated entry the playtester saw. Now a back-to-back identical (who, text) is suppressed; distinct moves (a rephrase, or a later turn separated by DM narration) are preserved.
  3. Honest later-beat wait copy ("a minute or two", screen-table.jsx) matching the 180s window. DmNarratingBeat already shows a live ticking elapsed counter (0:07 → 0:42, updates every 1s) + a polite aria-live status — not a dead spinner.

On streaming (task fix #2 — being explicit)

The live /events stream is wired and verified correct on turns 2+ viewer-side (new test below). The reason it doesn't currently make a 120s turn visibly fill in is upstream: the DM persists its narration in one batched write at turn-end. Making the DM stream per-beat would mean splitting persist_beat (log narration before the heavy persist) + adjusting SKILL.md — a backend/skill change, out of this viewer-only PR's scope. Flagged as an open item. The 180s window is the real protection until then.

Verification

Real-JSX harness tests (viewer/tests/, transpile the actual .jsx with the bundled Babel-standalone + a deterministic React/clock/fetch stub — they track shipped behavior, not a reimplementation), run single-process:

  • test_recovery_timing.py — later window now 180s; no false stuck at 120s; first-beat + backstop + clearPending contracts preserved. (7/7)
  • test_live_narration_stream.pya resolved beat flips the next turn to a LATER beat (so the 180s window genuinely governs turns 2+, not the cold-open window); later window == 180s; player echo idempotent on a Try-again re-POST; distinct actions preserved; all [playtest][P0] Per-beat DM latency (~60-90s) with no streaming → impatient players give up mid-session #393 streaming/dedup behavior intact. (14/14, 4 new)
  • test_cold_open_progress.py — stale later-beat comment refreshed; later-beat structure assertions unchanged. (6/6)

Local full viewer suite: 195/196 pass. The 1 failure (test_portrait_gen::test_null_default_returns_placeholder_no_network) is a pre-existing ambient-env issue — it spawns a bare-python3 subprocess that needs pydantic (present under uv, absent in ambient python); unrelated to this change (my diff touches only app.jsx + screen-table.jsx + 3 test files). python3 scripts/license_check.py green. Both JSX files transpile clean.

Heads-up for reviewers/orchestrator: the existing CI (.github/workflows/ci.yml) runs only the engine / rules / voice pytest + license-check — it does not run viewer/tests/. So the viewer-behavior tests above are validated locally (node-transpile harness), not by CI. Consider adding a viewer-tests CI step (out of scope here).

Do NOT close on merge — verify on the next full-arc playtest.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed duplicate action entries when retrying a failed turn submission.
  • Documentation

    • Updated wait message for narration to indicate longer preparation times ("a minute or two" instead of "up to a minute").
  • Tests

    • Enhanced test coverage for recovery behavior and action retry handling.

Review Change Stack

…399)

A full-arc playtest gave up on beats 2–4: the DM turn COMPLETED (runbooks
fired, the in-game clock advanced) but exceeded the viewer's 90s "stuck"
window, so the player saw "The Dungeon Master seems stuck — No reply came
back in time", the bar re-opened with "Try again", and retrying DUPLICATED
the player's action in the chronicle. Two MAJOR timeout bugs + one MINOR
dup bug — the single blocker to a fresh player finishing an 8-beat session.

Root cause (verified against the engine + DM skill, not just the static
code): #393 shipped a viewer-side live /events stream + a stall-clock reset
(notePendingProgress), which on paper makes a long turn never trip 'stuck'.
But EVERY shipped DM path (duo / human / native) persists a turn's narration
in ONE batched write at turn-END (SKILL.md step 7 → persist_beat — prose
FIRST, persist LAST, for latency). So nothing is written to the per-session
log mid-turn → /events surfaces a beat only at turn-end → the reset never
fires DURING the turn → later beats run under the COLD 90s wall-clock. The
90s window was tuned for the ~35–60s norm; a content-rich beat 2–4 runs
90–120s and tripped it on a working turn. The streaming code is correct
viewer-side (turns 2+ tail fine); it just has nothing to stream today.

Viewer-only fixes (no swift rebuild; engine sole-writer + wire contracts
untouched):
- Raise the later-beat 'stuck' window 90s → 180s (PENDING_RECOVERY_MS) so a
  worst-case ~120s turn finishes without a false 'stuck'. First-beat (4 min)
  + 12-min backstop unchanged. The notePendingProgress reset is KEPT (correct,
  and starts helping the day a DM path logs beats incrementally).
- Make the player echo idempotent (recordPlayerEcho): a 'Try again' re-POST of
  the exact stalled move no longer appends a duplicate chronicle action. Only a
  back-to-back identical (who, text) is suppressed; distinct moves are kept.
- Honest later-beat wait copy ("a minute or two") matching the 180s window.

Tests (viewer/tests, real-JSX Babel-transpile harness, single-process):
- test_recovery_timing.py: later window now 180s; no false 'stuck' at 120s.
- test_live_narration_stream.py: a resolved beat flips the next turn to a
  LATER beat (so the 180s window governs turns 2+); later window == 180s;
  player echo idempotent on retry; distinct actions preserved.
- test_cold_open_progress.py: stale later-beat comment refreshed.
Local: 195/196 viewer tests pass (the 1 failure is a pre-existing ambient-env
pydantic import in test_portrait_gen, unrelated to this change). license_check
green.
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR updates the live-session recovery logic for later beats (90s → 180s timeout), makes player echo logging idempotent to prevent duplicate retries, aligns user-facing wait-hint text with the longer content-rich beat timing, and adds comprehensive tests validating recovery window transitions and echo deduplication behavior.

Changes

Adaptive Session Recovery and Echo Idempotency

Layer / File(s) Summary
Core implementation: Recovery timeout and echo idempotency
viewer/openworlds/app.jsx
Later-beat recovery timeout increased from 90s to 180s with expanded inline documentation. recordPlayerEcho(who, text) now deduplicates consecutive identical actions by checking the most recent log row and suppressing append when who and trimmed text match, preventing duplicates on retries.
User-facing timing updates
viewer/openworlds/screen-table.jsx
Wait-hint text in DmNarratingBeat updated from "take up to a minute" to "take a minute or two" to reflect content-rich beat timing. Inline comments describing adaptive recovery window adjusted to match updated guidance.
Test harness setup and comprehensive behavior tests
viewer/tests/test_live_narration_stream.py
Test harness extended to expose recordPlayerEcho, accumulated log entries, and recoveryWindowMs(firstBeat) helper. Four new behavior tests verify recovery window transition to later beat (180s), echo idempotency on identical action retry (deduplicates), and deduplication does not suppress distinct actions.
Updated timing constant and selector tests
viewer/tests/test_recovery_timing.py
Test assertions updated to expect 180s later-beat recovery window (previously 90s); first-beat (240s) and backstop (720s) remain unchanged. New test verifies pending is not marked stuck after 120s elapsed.
Test module documentation clarification
viewer/tests/test_cold_open_progress.py
Module docstring updated to specify tests focus on first-beat cold-open behavior; later-beat changes from #399 are not covered by these assertions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • electricsheephq/WorldOS#343: Establishes the live-session recovery and session.recordPlayerEcho logic that this PR refines with 180s timeout and idempotency behavior.
  • electricsheephq/WorldOS#346: Implements the "Try again" UI/action to re-post stalled moves; this PR makes the retry path idempotent so duplicate echo entries are suppressed.
  • electricsheephq/WorldOS#351: Concurrent modifications to the same "stuck" recovery/pending timing logic and "DM is narrating…" UI flow in app.jsx and screen-table.jsx.

Poem

🐰 A timeout grows from ninety to one-eighty,
Echo logs now heed the "try again" treaty,
Duplicates fade when the same moves retry,
Tests assert the beat window—not stuck, but nigh!
Recovery blooms through deduplication's might. 🌙

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Linked issue #2 is an Epic about implementing a D&D dice engine with seeded rolls. This PR fixes a viewer UI timeout for slow DM turns—unrelated to dice mechanics, parsing, or roll ledger work. This PR appears mislabeled. Verify the correct issue numbers (#399, #344, #393, #385, #336) are linked instead of the unrelated dice epic #2.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately describes the main fix: addressing false 'stuck' UI for slow DM turns by increasing the recovery window timeout from 90s to 180s.
Description check ✅ Passed Description comprehensively covers the bug, root cause, all three changes, verification steps, and deployment notes. All key sections match template expectations.
Out of Scope Changes check ✅ Passed All changes (90s→180s timeout, idempotent echo, wait-copy update, test updates) directly address the stated bug. No unrelated refactoring or scope creep detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@viewer/openworlds/app.jsx`:
- Around line 235-244: recordPlayerEcho currently dedups by only comparing
against the very last entry in log, which ignores intervening narration stored
in chatBeats and therefore can suppress identical player actions that occur
across a narration beat; update recordPlayerEcho to scope deduping to the most
recent action entry since the last narration (or equivalent turn boundary)
rather than the absolute last log item: locate recordPlayerEcho and inspect
chatBeats/log to find the last narration beat (or maintain a turn/pending
marker), then compare the incoming (who, trimmed text) against the last action
entry after that narration boundary (using nextLogSeq as before) so identical
actions on a later turn are not suppressed; add a unit test that posts an
action, inserts a narration beat into chatBeats, then posts the same action
again and asserts it is appended (or alternatively correct the function comment
if you intentionally want current behavior).

In `@viewer/tests/test_recovery_timing.py`:
- Around line 207-210: Update the module docstring references that still say
"90s" to match the new later-beat constant of 180s: change the phrases
mentioning the snappy PENDING_RECOVERY_MS (~90s) and the LATER-beat branch
(snappy 90s) to 180s so the docstring aligns with the test assertions (see
PENDING_RECOVERY_MS and the assertions for recoveryMs/recoveryFirstMs/backstopMs
in test_recovery_timing.py).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d2f35cc-d026-4112-8735-d2ecd5c09c17

📥 Commits

Reviewing files that changed from the base of the PR and between 6583f4b and 41f7324.

📒 Files selected for processing (5)
  • viewer/openworlds/app.jsx
  • viewer/openworlds/screen-table.jsx
  • viewer/tests/test_cold_open_progress.py
  • viewer/tests/test_live_narration_stream.py
  • viewer/tests/test_recovery_timing.py

Comment thread viewer/openworlds/app.jsx
Comment on lines 235 to 244
const recordPlayerEcho = React.useCallback((who, text) => {
setLog((l) => [...l, { kind: "action", who, text, at: nextLogSeq() }]); // #274: creation-order stamp
setLog((l) => {
const last = l[l.length - 1];
if (last && last.kind === "action" && last.who === who
&& 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
});
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if tests verify that repeated actions separated by DM narration are preserved.

# Search for test assertions involving recordPlayerEcho, log state, and narration
rg -nC5 'recordPlayerEcho|idempotent.*echo' viewer/tests/test_live_narration_stream.py

# Check if tests assert distinct actions are preserved
rg -nC3 'distinct|legitimate.*repeat|separated.*narration' viewer/tests/

Repository: electricsheephq/WorldOS

Length of output: 5678


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"

python3 - <<'PY'
import itertools
path="viewer/tests/test_live_narration_stream.py"
start=430
end=490
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 3032


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"

rg -n "later turn|rephrase|different action|keep(s)? distinct|idempotent|Try again|chatBeats|narrat" "$FILE"

# Also locate all tests that call h.echo (player echo)
rg -n "h\.echo\(" "$FILE" | head -n 50

Repository: electricsheephq/WorldOS

Length of output: 4531


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"
python3 - <<'PY'
path="viewer/tests/test_live_narration_stream.py"
for start,end in [(360,430),(370,410),(380,420)]:
    print("\n# --- lines",start,"-",end,"---")
    with open(path,'r',encoding='utf-8') as f:
        for i,line in enumerate(f,1):
            if start<=i<=end:
                print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 10256


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"

rg -n "h\.echo\(" "$FILE"

# Show a wider context around each h.echo call
rg -n -C8 "h\.echo\(" "$FILE"

Repository: electricsheephq/WorldOS

Length of output: 2334


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/openworlds/app.jsx"

python3 - <<'PY'
path="viewer/openworlds/app.jsx"
# Print the region around the snippet and the nearby comment that mentions narration beats.
for start,end in [(200,270),(270,340)]:
    print(f"\n# --- {start}-{end} ---")
    with open(path,'r',encoding='utf-8') as f:
        for i,line in enumerate(f,1):
            if start<=i<=end:
                print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 9166


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "recordPlayerEcho|setLog\(" viewer/openworlds/app.jsx

Repository: electricsheephq/WorldOS

Length of output: 366


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "recordPlayerEcho\(" viewer/openworlds

Repository: electricsheephq/WorldOS

Length of output: 147


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/openworlds/screen-table.jsx"
start=360
end=430
python3 - <<PY
path="$FILE"
s=$start;e=$end
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if s<=i<=e:
            print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 3984


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "setLog\\(" viewer/openworlds/app.jsx

Repository: electricsheephq/WorldOS

Length of output: 111


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "setLog\\(|log\\s*:\\s*\\(|\\.log\\(" viewer/openworlds
rg -n "setLog\\(" viewer

Repository: electricsheephq/WorldOS

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"
python3 - <<'PY'
path="viewer/tests/test_live_narration_stream.py"
start=180
end=260
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 4664


Dedup for recordPlayerEcho ignores narration beats; identical actions on a later turn can be suppressed.

  • viewer/openworlds/app.jsx recordPlayerEcho only checks the last entry in log (same who + same trimmed text); DM narration goes to chatBeats, not log, so narration does not “separate” two identical actions for this dedup.
  • This contradicts the in-code claim that “two turns apart … is NOT deduped” based on a narration beat between them.
  • Existing tests only cover: (1) immediate Try again exact re-post idempotence and (2) different actions; there’s no test for “same action again on the next turn after narration.”

Suggested fix: either correct the comment to match current behavior, or change the dedup to be scoped to the same retry/pending turn (and add a test for the next-turn-after-narration repeat).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/openworlds/app.jsx` around lines 235 - 244, recordPlayerEcho currently
dedups by only comparing against the very last entry in log, which ignores
intervening narration stored in chatBeats and therefore can suppress identical
player actions that occur across a narration beat; update recordPlayerEcho to
scope deduping to the most recent action entry since the last narration (or
equivalent turn boundary) rather than the absolute last log item: locate
recordPlayerEcho and inspect chatBeats/log to find the last narration beat (or
maintain a turn/pending marker), then compare the incoming (who, trimmed text)
against the last action entry after that narration boundary (using nextLogSeq as
before) so identical actions on a later turn are not suppressed; add a unit test
that posts an action, inserts a narration beat into chatBeats, then posts the
same action again and asserts it is appended (or alternatively correct the
function comment if you intentionally want current behavior).

Comment on lines +207 to 210
# Concretely: later = 180s (#399, was 90s), first = 4 min, backstop = 12 min.
self.assertEqual(c["recoveryMs"], 180 * 1000)
self.assertEqual(c["recoveryFirstMs"], 4 * 60 * 1000)
self.assertEqual(c["backstopMs"], 12 * 60 * 1000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Module docstring still documents the old 90s later-beat window.

The constant is now asserted at 180s here, but the docstring (Line 13: "the snappy PENDING_RECOVERY_MS (~90s)" and Line 24: "the LATER-beat branch (snappy 90s)") still says 90s, contradicting these assertions in the same file. Refresh both lines to 180s for consistency with #399.

📝 Suggested docstring updates (outside the changed range)
-  • LATER beats (the ~35–60s norm): the snappy PENDING_RECOVERY_MS (~90s).
+  • LATER beats (the ~35–60s norm, but content-rich beats can run ~90–120s):
+    PENDING_RECOVERY_MS (~180s, `#399`; was 90s).
-  • the LATER-beat branch (snappy 90s) — preserved for a genuine mid-session stall;
+  • the LATER-beat branch (180s, `#399`) — preserved for a genuine mid-session stall;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/tests/test_recovery_timing.py` around lines 207 - 210, Update the
module docstring references that still say "90s" to match the new later-beat
constant of 180s: change the phrases mentioning the snappy PENDING_RECOVERY_MS
(~90s) and the LATER-beat branch (snappy 90s) to 180s so the docstring aligns
with the test assertions (see PENDING_RECOVERY_MS and the assertions for
recoveryMs/recoveryFirstMs/backstopMs in test_recovery_timing.py).

@100yenadmin
100yenadmin merged commit b8c8cd9 into main May 30, 2026
7 checks passed
@100yenadmin
100yenadmin deleted the fix/viewer-slow-turn-ux branch May 30, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epic 1: Dice engine

1 participant