M5: genre-neutral authoritative narrative state, with review corrections

Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
JesseMarkowitz
2026-09-05 07:01:50 -04:00
co-authored by Claude Opus 5
parent 62a997f364
commit b7005e6fdd
57 changed files with 7257 additions and 474 deletions
+49 -23
View File
@@ -14,7 +14,7 @@ from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -64,10 +64,17 @@ def client(monkeypatch):
def _adv(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -163,16 +170,17 @@ def test_three_attempts_all_kept_in_order(client):
# ---------------------------------------------------------------- switching
def test_switching_back_restores_that_attempt_state(client):
"""Each attempt records its own total, so switching between them shows
whether the state followed the narration back."""
ScriptedProvider.replies = [
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
tally_reply("You take a scratch.", 95),
tally_reply("You take a beating.", 60),
]
_play(client)
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
assert _adv(client.adv_id)[0]["gold"] == 95
_retry(client)
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 60
assert player["gold"] == 10 # rolled back, not stacked to 20
player, _document = _adv(client.adv_id)
assert player["gold"] == 60, "the retake's own state, not the one it replaced"
last = _actions(client)[-1]
r = client.post(
@@ -180,30 +188,36 @@ def test_switching_back_restores_that_attempt_state(client):
assert r.status_code == 200, r.text
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["take_index"] == 0
# The stats follow the narration back.
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
assert player["gold"] == 10
# The state follows the narration back.
assert _adv(client.adv_id)[0]["gold"] == 95
# And forward again.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 1})
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
def test_switching_updates_the_world_change_chips(client):
def test_switching_updates_the_state_summary_chips(client):
"""The chip under a message describes the take on screen.
M5 replaced the RPG world-change chips with the narrative-state summary;
what this test guards is unchanged — switching takes must change what the
chip says, or the reader is shown one attempt's prose beside another's
consequences.
"""
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
tally_reply("A scratch.", 5),
tally_reply("A beating.", 40),
]
_play(client)
_retry(client)
last = _actions(client)[-1]
assert last["world_changes"][0]["delta"] == -40
assert any("40" in line for line in last["state_summary"]), last["state_summary"]
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
switched = _actions(client)[-1]["state_summary"]
assert any("5" in line for line in switched), switched
assert not any("40" in line for line in switched)
def test_cannot_switch_a_turn_the_story_moved_past(client):
@@ -260,7 +274,13 @@ def test_undo_removes_the_action_and_its_history(client):
assert _adv(client.adv_id)[0]["gold"] == 0
def test_editing_the_text_updates_the_live_variant(client):
def test_editing_a_narrator_take_adds_a_take_and_keeps_the_original(client):
"""M5 corrective pass: a narrator correction is a new take, not a rewrite.
§15.5 requires the original narration to be retained. At the tip that means
the correction joins the turn's attempts rather than replacing the words of
one, so the pager still reaches what was there before.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
@@ -268,11 +288,17 @@ def test_editing_the_text_updates_the_live_variant(client):
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
json={"text": "Two, but better."})
# Page away and back: the edit must survive, not be reverted by the switch.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
live = _actions(client)[-1]
assert live["text"] == "Two, but better."
assert live["take_count"] == 3, "the correction is a third attempt"
assert live["take_index"] == 2
# The original is still reachable, and the edit survives paging away.
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 1})
assert _actions(client)[-1]["text"] == "Two."
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 2})
assert _actions(client)[-1]["text"] == "Two, but better."