Skip to content

fix(dm): restore real mid-turn narration streaming (slow DM turn shows prose flowing, not a dead counter) - #401

Merged
100yenadmin merged 1 commit into
mainfrom
feat/restore-midturn-streaming
May 30, 2026
Merged

fix(dm): restore real mid-turn narration streaming (slow DM turn shows prose flowing, not a dead counter)#401
100yenadmin merged 1 commit into
mainfrom
feat/restore-midturn-streaming

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 30, 2026

Copy link
Copy Markdown
Member

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

Problem

A "DM turn" (player submits a move → backend claude -p DM narrates the result) runs 60–160s. The viewer has a live-narration stream — it polls /events, which tails the engine session log (_read_eventscampaigns/<id>/sessions/<sid>.jsonl), with a stall-clock that resets when new narration arrives. This stream was INERT.

Root cause (confirmed in code, not guessed): a latency optimization made every shipped DM path persist a turn's narration in one batched write at turn-ENDpersist_beat(events=[…]), SKILL.md beat-cycle step 7. So nothing was written to the session log mid-turn for /events to surface. The player saw a ticking "the DM is narrating…" counter for ~2 minutes, then all the prose appeared at once. The viewer comments at viewer/openworlds/app.jsx:86-105 literally describe this gap ("every shipped DM path persists a turn's narration in ONE batched write at turn-END … so the /events stream ALSO surfaces a beat only at turn-end").

The fix (skill-focused — the mechanism already existed)

The engine already exposes log_event(campaign_id, kind, text, …) (servers/engine/server.py), which appends one entry to the session log immediately (_log_session_entrystore.append_log) — exactly what /events tails. The viewer's #393 machinery (live /events poll + text-keyed dedup + stall-clock reset via notePendingProgress) was already fully built and tested — it was just never fed mid-turn.

So this PR is prose-routing only: make the DM emit player-facing narration via log_event(kind="narration") during the turn.

skills/dungeon-master/SKILL.md beat cycle is now:

  1. Re-ground (scene_context) — unchanged.
  2. Emit the opening/setup narration via log_event(kind="narration") immediately — scene forms on the dashboard within ~10–20s.
    3–6. Resolve mechanics (rolls/combat) — unchanged.
    6a. Emit the outcome narration via log_event once the dice are in — the result streams too.
  3. persist_beat persists STATE only (memories / decision / advance) — the batching latency optimization is unchanged; it simply no longer re-logs the prose already streamed.

For roll-dependent beats ([check]/[attack]/[cast]) you can't narrate the outcome before rolling, so the skill says: stream the setup/tension (step 2), roll (step 6), then stream the outcome (6a) — the scene builds, then the result lands.

Also updated reference/quest-generation.md (stream the cold-open Arrival — the slowest turn, benefits most) and added a behavior-preserving note to the persist_beat docstring so the tool guidance doesn't contradict the skill.

Double-render investigation (the key risk) + how I avoided it

The chronicle is fed by THREE sources, all deduped by normalized text (whitespace-collapsed + lowercased): recentEvents (snapshot session history), chatBeats (the live /events + the turn-END /chat reply), and log (player echoes, never deduped). Relevant existing machinery I traced:

  • claimNarration (app.jsx) — first-seen wins; the /chat copy of an already-streamed paragraph is dropped, but the DM-line arrival still resolves the turn (clears the indicator).
  • dedupedRecent (screen-table.jsx) — drops recentEvents narration already in the live tail, keyed identically.
  • The [playtest][CRITICAL] DM turn returns empty player-facing narration on engine-heavy beats #357 harness fallback clawdnd_dm_narration_or_fallback — when a DM turn ends on a tool call (empty reply), it recovers the trailing session-log narration as the /chat line.

The cleanest design (and what this PR ships): log_event is the SOLE writer of player-facing narration to the session log. The DM's reply text mirrors that same prose (so /chat resolves the turn and the #357 fallback is a clean no-op; the viewer dedups the copy by text). persist_beat.events is left empty for streamed prose, so the same paragraph is never written to the session log twice. The skill is explicit that the reply must be the same prose (a reworded reply would defeat the text-keyed dedup), and that persist_beat carries state, not that prose.

Sole-writer invariant: log_event appends only to the append-only session log (a derived narration surface); it does not write narration into the campaign snapshot.json state. Verified below.

How I verified incremental streaming (ACTUAL behavior, not claims)

A real heavy claude -p DM run was not safe to add (a full-arc playtest with two claude -p agents was already running on the host), so I verified the real pipeline directly — booting the actual viewer and probing the real /events HTTP endpoint across a simulated slow turn (isolated state dir + port 8873, zero impact on the running playtest):

  • After step-2 log_event(opening)GET /events?since=0 returns only the opening paragraph (next=2).
  • After step-6a log_event(outcome)GET /events?since=2 returns only the new outcome (next=3) — incremental, not bundled.
  • After step-7 persist_beat(state-only)GET /events?since=3 returns nothing newno duplicate narration at the source.
  • snapshot.json contains no narration prose (sole-writer intact); state (decision + memory) did persist.

Tests:

  • viewer/tests/test_live_narration_stream.py14 pass (transpiles the real app.jsx+screen-table.jsx): mid-turn /events narration streams live; the turn-END /chat copy is deduped (incl. whitespace/case-normalized); an un-streamed /chat paragraph still renders; fresh-run dedup reset.
  • servers/engine/tests/test_beat_roundtrip.py8 pass (persist_beat/log_event engine behavior unchanged).
  • scripts/license_check.py — pass.

Scope / constraints honored

Acceptance check for the playtest reviewer

Run one move on a slow beat and watch the chronicle: opening prose should appear within ~10–20s, the outcome after resolution, the stall-clock should keep resetting (never trips "DM seems stuck"), and no paragraph should appear twice.

Summary by CodeRabbit

  • Documentation
    • Improved narration streaming guidelines to eliminate duplicate content in session logs.
    • Enhanced quest generation documentation for better cold open delivery.
    • Clarified beat procedure to ensure consistent, non-duplicated player-facing prose output.

Review Change Stack

…ows prose flowing

A DM turn runs 60-160s. The viewer's #393 live-narration stream (the /events
tail + text-keyed dedup + stall-clock reset) was built to surface narration as
it's written to the session log — but every shipped DM path batched a turn's
prose into ONE persist_beat write at turn-END (SKILL.md step 7), so nothing hit
the session log mid-turn and /events stayed INERT: the player watched a ticking
'DM is narrating…' counter for ~2 min, then all prose appeared at once.

Fix is skill-only (the mechanism already exists): make the DM author
player-facing narration via log_event(kind=narration) DURING the turn —
opening/setup prose first (beat-cycle step 2), then the outcome after resolving
mechanics (new step 6a) — so the scene visibly builds. persist_beat (step 7) now
saves STATE only (memories/decision/advance) and does NOT re-log that prose.

Double-render avoidance: log_event is the SOLE writer of player-facing narration
to the session log; the DM's reply text mirrors that SAME prose (so /chat resolves
the turn and the #357 empty-reply fallback is a clean no-op), and the viewer's
claimNarration dedups the /chat + recentEvents copies by normalized text — each
paragraph shows exactly once. persist_beat.events left empty for streamed prose
avoids a second session-log write of the same paragraph.

No engine behavior change (persist_beat batching untouched); SKILL/reference prose
+ a behavior-preserving persist_beat docstring note only.
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR clarifies how player-facing narration flows through the Dungeon Master skill and persist_beat tool: narration must be streamed live via log_event as it is composed, not batched into persist_beat calls, and the final chat reply must exactly match the streamed prose to avoid duplication in the player-facing session log.

Changes

Narration streaming and de-duplication

Layer / File(s) Summary
persist_beat parameter and de-duplication guidance
servers/engine/server.py
Docstring refinement clarifies that the persist_beat tool's events parameter should not contain already-streamed narration/dialogue; events should remain empty unless there are unlogged beat log rows, to prevent double-logging and de-duplication issues.
Dungeon Master beat streaming procedure
skills/dungeon-master/SKILL.md
Skill guidance updates establish that the Dungeon Master must stream setup narration via log_event before resolving mechanics, stream roll-dependent outcome narration via log_event immediately after resolution, and ensure the final chat reply matches the exact prose already streamed—with persist_beat constrained to state-only and events left empty for narration that was logged live.
Quest generation cold-open streaming
skills/dungeon-master/reference/quest-generation.md
Reference guidance adds explicit instructions for quest generation cold-open: compose the Arrival scene as player-facing prose, stream it immediately via log_event(kind="narration", …) with the exact same text, and return that identical prose as the final reply—avoiding scratchpad briefs and preventing duplicate or mismatched outputs.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Possibly related PRs

  • electricsheephq/WorldOS#349: Both PRs update skills/dungeon-master/SKILL.md to constrain player-facing narration (main PR for de-duplicated log_event/persist_beat streaming, related PR for preventing DM scaffolding from leaking into the player Chronicle).
  • electricsheephq/WorldOS#360: Both PRs align on the requirement that the DM's final chat reply must exactly match player-facing narration already streamed via log_event to prevent duplication.

Poem

🐰 Events flow live, no double takes,
Stream your prose, then chat repeats—
Once narrated in the log so bright,
Let persist hold state, not light.
Beat and quest now sing as one!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: restoring mid-turn narration streaming so players see prose flowing during long DM turns rather than a static counter.
Description check ✅ Passed The PR description comprehensively covers the problem, root cause, fix, verification, and scope constraints. All key sections addressing the issue and implementation are present and detailed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

@100yenadmin
100yenadmin merged commit 23271c0 into main May 30, 2026
6 of 7 checks passed
@100yenadmin
100yenadmin deleted the feat/restore-midturn-streaming branch May 30, 2026 15:53
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.

1 participant