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:
co-authored by
Claude Opus 5
parent
62a997f364
commit
b7005e6fdd
@@ -18,7 +18,7 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import tally_reply, GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_replies
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ def client(monkeypatch):
|
||||
setup.close()
|
||||
|
||||
# Force a real, non-demo turn that uses the fake provider.
|
||||
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
|
||||
# Each turn states its own running total, absolutely (ADR 010). One
|
||||
# repeated reply would set the same total every turn, which is exactly
|
||||
# what a delta protocol could not distinguish from adding.
|
||||
ScriptedProvider.replies = tally_replies(AI_REPLY, 20)
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
@@ -66,12 +69,15 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
def _gold(adv_id) -> int:
|
||||
"""The banked total. Reading one stat rather than the whole section keeps
|
||||
these assertions about the rollback, not about the schema's shape."""
|
||||
"""The banked total, read out of the authoritative narrative state.
|
||||
|
||||
M5 moved the instrument from an RPG stat to a typed narrative fact. What
|
||||
these assertions are about is unchanged: rollback, and which position's
|
||||
state is current.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
|
||||
return player.get("gold", 0)
|
||||
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -102,11 +108,16 @@ def test_two_turns_then_undo_reverts_only_last(client):
|
||||
|
||||
|
||||
def test_retry_does_not_double_apply_gold(client):
|
||||
"""A retry replaces its turn rather than stacking on top of it.
|
||||
|
||||
The retake states the same total the turn it replaces did, so a state that
|
||||
had stacked would read as a value no position ever established. Before the
|
||||
fix this file was written for, the turn's effects ran twice.
|
||||
"""
|
||||
_play(client)
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
# Before the fix, this produced 20 because the turn's effects ran twice.
|
||||
# Now it stays 10.
|
||||
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _gold(client.adv_id) == 10
|
||||
@@ -114,6 +125,7 @@ def test_retry_does_not_double_apply_gold(client):
|
||||
|
||||
def test_retry_then_undo_still_clean(client):
|
||||
_play(client)
|
||||
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert _gold(client.adv_id) == 10
|
||||
client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
|
||||
Reference in New Issue
Block a user