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
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
"""Phase 14 SP9: what a take does to the shared state.
|
|
|
|
A turn does not only write text. A script mutates `script_state`, and the
|
|
referee mutates `world_state`. Both are shared: they belong to the
|
|
adventure, not to the node. Playing a turn again must put them back to
|
|
where they were before that turn ran. Otherwise the new take stacks its
|
|
mutations on top of the one it replaces, and the numbers drift every time
|
|
the player asks for another take.
|
|
|
|
`retry` has provided this guarantee since SP4 (`attempts.roll_back_before`).
|
|
These tests confirm the same guarantee for the two roads SP9 opened: a take
|
|
of a turn the story moved past, and a take of the player's own turn. Both
|
|
create a branch, which matters because the rollback must survive leaving
|
|
the line it was on.
|
|
|
|
python -m pytest tests/test_take_state.py -v
|
|
"""
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import auth, limits, models
|
|
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, tally_of,
|
|
tally_reply,
|
|
)
|
|
|
|
SCHEMA = GOLD_SCHEMA
|
|
|
|
# A running tally, ten a turn, recorded through the narrative-state engine as an
|
|
# absolute total. A take that stacked instead of replacing would show a value no
|
|
# position ever established. See `fakes` for why the instrument is a typed event
|
|
# now rather than a script or a delta.
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="takestate@example.com")
|
|
setup.add(user)
|
|
setup.flush()
|
|
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
|
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
|
|
setup.add(scenario)
|
|
setup.flush()
|
|
adv = models.Adventure(
|
|
user_id=user.id, title="Vault", scenario_id=scenario.id,
|
|
)
|
|
setup.add(adv)
|
|
setup.flush()
|
|
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
|
|
setup.commit()
|
|
adv_id, user_id = adv.id, user.id
|
|
setup.close()
|
|
|
|
ScriptedProvider.replies = gold_replies("Take", 39)
|
|
ScriptedProvider.calls = 0
|
|
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
|
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
|
|
|
def _current_user(db=Depends(get_db)):
|
|
return db.get(models.User, user_id)
|
|
|
|
app.dependency_overrides[auth.get_current_user] = _current_user
|
|
c = TestClient(app)
|
|
c.adv_id = adv_id
|
|
try:
|
|
yield c
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
adventures.turns._active_turns.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def _play(client, text="look around", after_id=None):
|
|
body = {"type": "do", "text": text}
|
|
if after_id is not None:
|
|
body["after_id"] = after_id
|
|
r = client.post(f"/api/adventures/{client.adv_id}/actions", json=body)
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def _take(client, action_id, text=""):
|
|
r = client.post(
|
|
f"/api/adventures/{client.adv_id}/actions/{action_id}/takes",
|
|
json={"text": text},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r
|
|
|
|
|
|
def _gold(adv_id) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _rows(adv_id, type_):
|
|
db = SessionLocal()
|
|
try:
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == adv_id, models.Action.type == type_)
|
|
.order_by(models.Action.id)
|
|
.all()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_the_script_runs_once_a_turn(client):
|
|
"""This test establishes the baseline the rest of the file depends on."""
|
|
_play(client)
|
|
assert _gold(client.adv_id) == 10
|
|
_play(client, "press on")
|
|
assert _gold(client.adv_id) == 20
|
|
|
|
|
|
def test_a_take_of_a_past_ai_turn_does_not_stack_its_script(client):
|
|
"""Two turns played, then the first one taken again.
|
|
|
|
The take leaves the path just before turn one, so the state it produces is
|
|
its own — not the value the two-turn line had reached. Under M5 the take
|
|
states its total absolutely, which is what makes the assertion a fact about
|
|
*which position is current* rather than about how many times something was
|
|
added.
|
|
"""
|
|
_play(client)
|
|
_play(client, "press on")
|
|
assert _gold(client.adv_id) == 20
|
|
|
|
first_ai = _rows(client.adv_id, "ai")[0]
|
|
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
|
_take(client, first_ai.id)
|
|
|
|
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
|
|
|
|
|
|
def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
|
|
_play(client)
|
|
_play(client, "press on")
|
|
assert _gold(client.adv_id) == 20
|
|
|
|
first_player = _rows(client.adv_id, "do")[0]
|
|
ScriptedProvider.replies = [tally_reply("A different opening.", 10)]
|
|
_take(client, first_player.id, "> You do something else.")
|
|
|
|
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
|
|
|
|
|
|
def test_writing_below_a_passed_take_starts_from_that_take_s_state(client):
|
|
"""The `after_id` path, which forks while writing.
|
|
|
|
Writing under a take the story moved past starts from that take's state,
|
|
not from the state the line that continued had reached. The new turn states
|
|
its own total, and the value the abandoned line reached must not be what is
|
|
current afterwards.
|
|
"""
|
|
_play(client)
|
|
ScriptedProvider.replies = [tally_reply("A second telling.", 10)]
|
|
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert r.status_code == 200, r.text
|
|
ScriptedProvider.replies = [tally_reply("Onward.", 20)]
|
|
_play(client, "press on")
|
|
assert _gold(client.adv_id) == 20
|
|
|
|
discarded = [a for a in _rows(client.adv_id, "ai") if not a.live][0]
|
|
ScriptedProvider.replies = [tally_reply("A different way.", 55)]
|
|
_play(client, "a different way", after_id=discarded.id)
|
|
|
|
assert _gold(client.adv_id) == 55, "this branch's own state"
|
|
|
|
|
|
def test_the_line_left_behind_keeps_the_state_it_reached(client):
|
|
"""Switching back finds the abandoned line's numbers where it left them."""
|
|
_play(client)
|
|
_play(client, "press on")
|
|
first_ai = _rows(client.adv_id, "ai")[0]
|
|
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
|
_take(client, first_ai.id)
|
|
assert _gold(client.adv_id) == 10
|
|
|
|
branches = client.get(f"/api/adventures/{client.adv_id}/branches").json()
|
|
root = [b for b in branches if b["parent_branch_id"] is None][0]
|
|
r = client.post(f"/api/adventures/{client.adv_id}/branches/{root['id']}/switch")
|
|
assert r.status_code == 200, r.text
|
|
|
|
assert _gold(client.adv_id) == 20, "the first telling still has its two turns"
|