Skip to content

fix(play): DM resolver always writes non-empty player-facing narration per beat (Addresses #357) - #360

Merged
100yenadmin merged 1 commit into
mainfrom
fix/357-empty-narration
May 30, 2026
Merged

fix(play): DM resolver always writes non-empty player-facing narration per beat (Addresses #357)#360
100yenadmin merged 1 commit into
mainfrom
fix/357-empty-narration

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 30, 2026

Copy link
Copy Markdown
Member

Root cause (file:line)

The play/QA resolver records the DM turn's final reply text to the player-facing /chat panel. That reply text is extracted as jq -rs 'map(select(.type=="result"))[-1].result // ""':

  • scripts/play.sh:191 (dm_turn) → written at scripts/play.sh:319 via chatlog dm "$DMSG" (beat loop) and :274 (opener).
  • scripts/play_party.sh:224 (turn dm) → written at :342 (opener), :380 (after intros), :442 (beat loop).
  • Same pattern in qa/run_duo.sh:101:163/:212, and qa/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 final result is empty/whitespace. The engine work still succeeded and the DM logged real 2nd-person prose via the engine's log_event(kind="narration"/"dialogue") tool (servers/engine/server.py:6665_log_session_entrystore.append_log, written to campaigns/<id>/sessions/<sid>.jsonl). That prose renders in the viewer's recentEvents (viewer/server.py:_session_event_tail_from_dir_session_recent_events), but never reaches /chat — so chatlog 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_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, 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, else session_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 into scripts/play.sh + scripts/play_party.sh (the product play path the baseline used) and qa/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 standalone qa/dm_narration_fallback.py because the macOS system bash (3.2.57) mis-parses a quoted heredoc nested in $(...).

2. Prompt nudge (secondary). skills/dungeon-master/SKILL.md and the beat-resolution prompts in play.sh/play_party.sh 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.

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 .app baseline: 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

    • Dungeon Master narration/dialogue now automatically recovers from engine logs when direct replies are empty, preventing silent or blank outputs during tool calls or system operations.
  • Bug Fixes

    • DM turns no longer appear silent when resolving on tool calls or non-prose operations.
  • Documentation

    • Updated DM skill instructions to require player-facing narration at the end of each turn.
  • Tests

    • Added comprehensive test coverage for narration recovery logic, edge cases, and boundary conditions.

Review Change Stack

…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).
@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 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.

Changes

DM Narration Fallback Recovery

Layer / File(s) Summary
Narration recovery script and test suite
qa/dm_narration_fallback.py, servers/engine/tests/test_dm_narration_fallback.py
Implements dm_narration_fallback.py to read snapshot.json, safely resolve session log paths, scan JSONL logs for trailing prose blocks (narration/dialogue), cap to 6 rows, and return formatted output. Seven tests validate trailing-block recovery, non-prose-only logs, fallback to last session id, missing logs, path-traversal rejection, output bounding, and malformed snapshots.
Shell integration helper function
qa/lib_beat_driver.sh
Introduces clawdnd_dm_narration_or_fallback() that returns the DM reply verbatim if non-empty; otherwise calls the Python fallback script to recover player-facing narration from the session log, returning recovered prose if available or the original reply as fallback.
QA script integrations
qa/run_duo.sh, qa/run_party.sh
Post-process the DM's initial message and each beat's response with clawdnd_dm_narration_or_fallback, ensuring tool-final-but-logged-prose turns are not misclassified as silent DM output.
Production script integrations
scripts/play.sh, scripts/play_party.sh
Apply narration fallback after the DM's initial turn and per-move/per-beat turns. play_party.sh sources lib_beat_driver.sh for the helper function and applies fallback at scene opening, companion intro, and beat resolution.
DM skill output contract documentation
skills/dungeon-master/SKILL.md
Documents the non-negotiable contract that DM turns must end with 2nd-person player-facing narration, forbidding tool invocations and meta/status lines, and requiring mirroring of any log_event-recorded prose in the reply text.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • electricsheephq/WorldOS#357: Addresses the same root cause—empty DM replies when tool/status calls conclude beats—by implementing the narration recovery fallback mechanism from session logs.

Possibly related PRs

  • electricsheephq/WorldOS#338: Both PRs enforce anti-internal-prose expectations in skills/dungeon-master/SKILL.md; this PR provides the QA/CLI recovery mechanism while the retrieved PR implements viewer-side sanitization.

Poem

🐰 A fallback for narration so fine,
When tools end turns with nary a line,
The logs hold the prose, both spoken and dear,
So players hear stories, loud and clear!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: implementing a fallback mechanism to ensure non-empty player-facing narration is written per beat when the DM resolver encounters empty final reply text.
Description check ✅ Passed The description is comprehensive and addresses the template requirements: it explains what changed (the root cause, the fix with two components, tests, and verification), includes the required CLA checkbox section (though unchecked), and lists validation checks.
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 427051e into main May 30, 2026
7 of 8 checks passed
@100yenadmin
100yenadmin deleted the fix/357-empty-narration branch May 30, 2026 10:14
100yenadmin added a commit that referenced this pull request May 30, 2026
…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>
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.

[playtest][CRITICAL] DM turn returns empty player-facing narration on engine-heavy beats

1 participant