fix(dm): restore real mid-turn narration streaming (slow DM turn shows prose flowing, not a dead counter) - #401
Conversation
…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.
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThis PR clarifies how player-facing narration flows through the Dungeon Master skill and ChangesNarration streaming and de-duplication
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
Do NOT close on merge — verify on the next full-arc playtest.
Problem
A "DM turn" (player submits a move → backend
claude -pDM narrates the result) runs 60–160s. The viewer has a live-narration stream — it polls/events, which tails the engine session log (_read_events→campaigns/<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-END —
persist_beat(events=[…]), SKILL.md beat-cycle step 7. So nothing was written to the session log mid-turn for/eventsto surface. The player saw a ticking "the DM is narrating…" counter for ~2 minutes, then all the prose appeared at once. The viewer comments atviewer/openworlds/app.jsx:86-105literally 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_entry→store.append_log) — exactly what/eventstails. The viewer's #393 machinery (live/eventspoll + text-keyed dedup + stall-clock reset vianotePendingProgress) 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.mdbeat cycle is now:scene_context) — unchanged.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_eventonce the dice are in — the result streams too.persist_beatpersists 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 thepersist_beatdocstring 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/chatreply), andlog(player echoes, never deduped). Relevant existing machinery I traced:claimNarration(app.jsx) — first-seen wins; the/chatcopy of an already-streamed paragraph is dropped, but the DM-line arrival still resolves the turn (clears the indicator).dedupedRecent(screen-table.jsx) — dropsrecentEventsnarration already in the live tail, keyed identically.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/chatline.The cleanest design (and what this PR ships):
log_eventis the SOLE writer of player-facing narration to the session log. The DM's reply text mirrors that same prose (so/chatresolves the turn and the #357 fallback is a clean no-op; the viewer dedups the copy by text).persist_beat.eventsis 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 thatpersist_beatcarries state, not that prose.Sole-writer invariant:
log_eventappends only to the append-only session log (a derived narration surface); it does not write narration into the campaignsnapshot.jsonstate. Verified below.How I verified incremental streaming (ACTUAL behavior, not claims)
A real heavy
claude -pDM run was not safe to add (a full-arc playtest with twoclaude -pagents was already running on the host), so I verified the real pipeline directly — booting the actual viewer and probing the real/eventsHTTP endpoint across a simulated slow turn (isolated state dir + port 8873, zero impact on the running playtest):log_event(opening)→GET /events?since=0returns only the opening paragraph (next=2).log_event(outcome)→GET /events?since=2returns only the new outcome (next=3) — incremental, not bundled.persist_beat(state-only)→GET /events?since=3returns nothing new — no duplicate narration at the source.snapshot.jsoncontains no narration prose (sole-writer intact); state (decision + memory) did persist.Tests:
viewer/tests/test_live_narration_stream.py— 14 pass (transpiles the realapp.jsx+screen-table.jsx): mid-turn/eventsnarration streams live; the turn-END/chatcopy is deduped (incl. whitespace/case-normalized); an un-streamed/chatparagraph still renders; fresh-run dedup reset.servers/engine/tests/test_beat_roundtrip.py— 8 pass (persist_beat/log_eventengine behavior unchanged).scripts/license_check.py— pass.Scope / constraints honored
clawdnd-*/CLAWDND_*,dev.clawdnd.app); no--resumemodel change;persist_beatbatching unchanged (only narration routing + docs)./eventsprobe described above.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