fix(play): DM resolver always writes non-empty player-facing narration per beat (Addresses #357) - #360
Conversation
…n per beat (Addresses #357) A DM turn can end on a tool call (its last act was log_event/roll/advance_time) or a bare 3rd-person status line, so its FINAL reply text — which the resolver records to the chat panel via `chatlog dm "$DMSG"` — is empty. The engine work succeeded and the DM logged real 2nd-person prose via log_event(kind="narration"/"dialogue"), but that prose lands only in the engine session log (rendered as the viewer's `recentEvents`), never the player-facing /chat. A newbie sees "I acted and the game replied with nothing." Intermittent: it depends on whether the DM's last output block was prose or a tool call. Belt + suspenders: 1. RESOLVER FALLBACK (primary): new shared helper clawdnd_dm_narration_or_fallback in qa/lib_beat_driver.sh — when the DM turn's final reply is empty/whitespace, recover the most recent contiguous block of player-facing prose (narration|dialogue) the engine logged this beat, from the active session log (campaigns/<id>/sessions/<sid>.jsonl), mirroring the viewer's session-log resolution. Read-only on engine state (engine stays the sole writer); graceful no-op (original text) when there's nothing to recover. Wired into scripts/play.sh + scripts/play_party.sh (the product play path) and qa/run_duo.sh + qa/run_party.sh (the QA path, where the same empty reply also feeds the behavioral gate's silent-DM check). The recovery logic is a standalone qa/dm_narration_fallback.py because the macOS system bash 3.2 mis-parses a quoted heredoc nested in $(...). 2. PROMPT NUDGE (secondary): the dungeon-master SKILL.md and the beat-resolution prompts now instruct the DM to ALWAYS end its turn on 2nd-person player-facing narration, never a tool call or a 3rd-person status line, and to also speak any prose it logged via log_event as its reply text. Test: servers/engine/tests/test_dm_narration_fallback.py (runs in engine CI) writes the session log via the engine's own store.append_log + SessionLogEntry and exercises the real fallback script (recovers trailing prose, passes non-empty replies through, no-ops on missing/bookkeeping-only/corrupt logs, bounds a fat block, rejects path-traversal sids).
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThis PR prevents empty DM chat output when the LLM resolves player moves via tool calls without prose replies. It adds a narration recovery script that reads session event logs, a shell helper that wraps the script, integration into QA and production play runners, comprehensive test coverage validating trailing-block recovery and edge cases, and updated DM skill documentation of the output contract. ChangesDM Narration Fallback Recovery
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
…ot the setup brief (#388) Root cause (nb3, bb1870d): the DM never wrote the 2nd-person opening scene as its reply text (final result text = 0 chars). It logged only a 3rd-person game-system setup brief via log_event(kind=narration) ('COLD OPEN — ARRIVAL: Rolan (tiefling wizard, PC) walks toward...'), then ended its turn. The #360 empty-reply fallback recovered that 3rd-person brief and pushed it to /chat, so a first-timer saw developer/GM notation instead of an opening scene. A GM Advisory bookkeeping panel also leaked to the player's live-play sidebar. Fixes both ends + the leak (engine stays sole writer — prompt/skill/resolver/ sanitizer only): - qa/dm_narration_fallback.py: never recover a 3rd-person setup brief / game-system notation (high-confidence shapes: a leading ALLCAPS label header or a (... PC)/(... NPC)/(level N ...) sheet tag) — blank beats notation. - scripts/play.sh (both opener branches) + scripts/play_party.sh: the cold-open opener prompt now carries the same 'FINAL reply text MUST be the 2nd-person scene, never a tool call / 3rd-person brief' contract the beat loop has. - skills/dungeon-master/reference/quest-generation.md: explicit cold-open reply-text contract. - viewer/openworlds/screen-table.jsx: sanitizeNarration strips the leaked scene-debt KIND label line ('npc introduced silent ...'); the GM Advisory panel is removed from the player live-play table screen (still on the journal). - tests: +4 resolver tests, +1 sanitizer test. Addresses #357 Co-authored-by: Eva <arncalso@gmail.com>
Root cause (file:line)
The play/QA resolver records the DM turn's final reply text to the player-facing
/chatpanel. That reply text is extracted asjq -rs 'map(select(.type=="result"))[-1].result // ""':scripts/play.sh:191(dm_turn) → written atscripts/play.sh:319viachatlog dm "$DMSG"(beat loop) and:274(opener).scripts/play_party.sh:224(turn dm) → written at:342(opener),:380(after intros),:442(beat loop).qa/run_duo.sh:101→:163/:212, andqa/run_party.sh:180/187→:297/:318.When a DM turn ends on a tool call (its last act was
log_event/roll/advance_time) or a bare 3rd-person status line, that finalresultis empty/whitespace. The engine work still succeeded and the DM logged real 2nd-person prose via the engine'slog_event(kind="narration"/"dialogue")tool (servers/engine/server.py:6665→_log_session_entry→store.append_log, written tocampaigns/<id>/sessions/<sid>.jsonl). That prose renders in the viewer'srecentEvents(viewer/server.py:_session_event_tail_from_dir→_session_recent_events), but never reaches/chat— sochatlog dm ""writes a len=0 chat entry. A newbie sees "I acted and the game replied with nothing." It's intermittent because it depends on whether the DM's final output block was prose or a tool call (beats 0–1 wrote full prose; the[say]and[check]beats in the baseline ended on a tool/status line).The fix (belt + suspenders)
1. Resolver fallback (primary). New shared helper
clawdnd_dm_narration_or_fallbackinqa/lib_beat_driver.sh: when the DM turn's final reply is empty/whitespace, recover the most recent contiguous block of player-facing prose (narration|dialogue, with dialogue keeping its speaker tag) the engine logged this beat, from the active session log — mirroring the viewer's session-log resolution (active_session_id, elsesession_ids[-1], same bare-filename safety). It is read-only on engine state (the engine stays the sole writer; this only reads its JSONL) and a graceful no-op (returns the original text) when there's nothing to recover — so a non-empty reply passes through verbatim and today's behavior is unchanged. Wired intoscripts/play.sh+scripts/play_party.sh(the product play path the baseline used) andqa/run_duo.sh+qa/run_party.sh(the QA path — the same empty reply also feeds the behavioral gate's silent-DM check, so a tool-final-but-narrated turn is no longer mis-flagged as silence). The recovery logic lives in a standaloneqa/dm_narration_fallback.pybecause the macOS system bash (3.2.57) mis-parses a quoted heredoc nested in$(...).2. Prompt nudge (secondary).
skills/dungeon-master/SKILL.mdand the beat-resolution prompts inplay.sh/play_party.shnow instruct the DM to ALWAYS end its turn on 2nd-person player-facing narration, never a tool call or a 3rd-person status line, and to also speak any prose it logged vialog_eventas its reply text.Tests
servers/engine/tests/test_dm_narration_fallback.py(runs in the existing engine CI lane) writes the session log via the engine's own writer (store.append_log+models.SessionLogEntry, so it validates the exact on-disk format) and exercises the real fallback script: recovers the trailing prose block (skipping older beats + roll/system/combat bookkeeping), passes non-empty replies through, no-ops on missing/bookkeeping-only/corrupt logs, bounds a fat multi-paragraph block, and rejects a path-traversal session id. 7/7 pass locally (single-process, 0.55s).Verification (do NOT close on merge)
Do NOT close #357 on merge — verify on the NEXT build's
.appbaseline: a[say]beat and a[check]beat must both produce non-empty 2nd-person narration in/chat.Constraints honored: isolated worktree off
origin/main; engine (servers/engine) is the sole writer — this is a harness/resolver/skill fix only; additive + invariant-safe.Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests