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
231 lines
8.4 KiB
Python
231 lines
8.4 KiB
Python
"""Deleting a turn puts the shared state back.
|
|
|
|
`world_state` belongs to the adventure, not to the node
|
|
that changed them. Undo, retry, a take and a branch switch all restore them;
|
|
the delete endpoint did not. Deleting an AI turn removed the text and left
|
|
everything the turn did to the numbers standing.
|
|
|
|
The visible symptom was the cooldown clock. It lives in
|
|
`world_state._meta.last_changed` and it holds a depth. A deleted turn left
|
|
its depth there, and the turn played in its place is played at that same
|
|
depth, so the referee refused the change as one that had happened this very
|
|
turn — on a turn the story no longer contains. Delete the AI reply because
|
|
you did not like the stat change it proposed, press Continue, and the same
|
|
change comes back refused as "changed too recently".
|
|
|
|
python -m pytest tests/test_delete_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
|
|
|
|
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
|
|
# a refusal rather than as a number that is merely off.
|
|
SCHEMA = {
|
|
"player": {
|
|
"hp": {"min": 0, "max": 100, "initial": 100},
|
|
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
|
|
# The per-turn counter these tests measure rollbacks with. Unbounded and
|
|
# uncapped on purpose, so every turn's +10 lands in full. See
|
|
# `fakes.gold_reply`.
|
|
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
|
}
|
|
}
|
|
|
|
# Ten gold a turn. A total that only ever climbs makes a missing rollback
|
|
# obvious: it is off by exactly one turn's worth.
|
|
|
|
# The instrument is a typed narrative fact with an absolute value (M5,
|
|
# ADR 010). It was an RPG mana drain plus a gold counter; what these tests
|
|
# measure — that deleting a turn puts the state back to what the position
|
|
# before it left behind — is unchanged, and is now measured through the
|
|
# production state path rather than through a removed game system.
|
|
DRAIN = tally_reply("Drained.", 10)
|
|
DRAIN_AND_GOLD = DRAIN
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="delstate@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="Tower", scenario_id=scenario.id,
|
|
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
|
|
)
|
|
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 = [DRAIN_AND_GOLD]
|
|
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)
|
|
|
|
|
|
# ------------------------------------------------------------------ helpers
|
|
|
|
def _play(client, text="look around"):
|
|
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
|
json={"type": "do", "text": text})
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def _continue(client):
|
|
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
|
json={"type": "continue", "text": ""})
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def _delete(client, action_id):
|
|
return client.delete(f"/api/adventures/{client.adv_id}/actions/{action_id}")
|
|
|
|
|
|
def _state(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)
|
|
state = adv.narrative_state or {}
|
|
return {"gold": tally_of(state)}, state
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _ai_rows(adv_id):
|
|
db = SessionLocal()
|
|
try:
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == adv_id, models.Action.type == "ai")
|
|
.order_by(models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _last_proposal(adv_id):
|
|
"""The newest state proposal, which is how a refusal is now visible."""
|
|
db = SessionLocal()
|
|
try:
|
|
return (
|
|
db.query(models.StateProposal)
|
|
.filter_by(adventure_id=adv_id)
|
|
.order_by(models.StateProposal.id.desc())
|
|
.first()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------------- the reported bug
|
|
|
|
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
|
|
_play(client)
|
|
assert _state(client.adv_id)[0]["gold"] == 10
|
|
|
|
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
|
|
|
assert _state(client.adv_id)[0]["gold"] == 0, "the change went with the turn"
|
|
|
|
|
|
def test_the_next_turn_is_not_refused_for_what_a_deleted_turn_established(client):
|
|
"""The bug as a player meets it: delete the reply, press Continue, and the
|
|
turn that replaces it lands cleanly.
|
|
|
|
Under M5 this is a statement about the *state document* rather than about a
|
|
cooldown clock — the RPG cooldown machinery the original bug surfaced
|
|
through is no longer in the turn path — but the failure it guards is the
|
|
same one: a deleted turn leaving something behind that makes the next turn
|
|
behave as though it had already happened.
|
|
"""
|
|
_play(client)
|
|
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
|
|
|
_continue(client)
|
|
|
|
assert _state(client.adv_id)[0]["gold"] == 10, "the replacement turn landed"
|
|
proposal = _last_proposal(client.adv_id)
|
|
assert proposal.status == "accepted", "the replacement's state was refused"
|
|
|
|
|
|
def test_deleting_the_ai_turn_rewinds_the_counter(client):
|
|
"""The same restore, seen through a value that only ever climbs. Without it
|
|
a replayed turn stacks its effects on top of the deleted one's."""
|
|
_play(client)
|
|
assert _state(client.adv_id)[0]["gold"] == 10
|
|
|
|
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
|
assert _state(client.adv_id)[0]["gold"] == 0
|
|
|
|
_continue(client)
|
|
assert _state(client.adv_id)[0]["gold"] == 10, "one turn of gold, not two"
|
|
|
|
|
|
# ------------------------------------------------- deleting further back
|
|
|
|
def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
|
|
"""A restore reads the tip's own outcome, not the deleted node's
|
|
neighbour, so removing a turn from the middle of the story does not roll
|
|
the numbers back to that point. The text goes; the state stays."""
|
|
_play(client)
|
|
ScriptedProvider.replies = [tally_reply("Drained again.", 20)]
|
|
_play(client, "press on")
|
|
before = _state(client.adv_id)
|
|
assert before[0]["gold"] == 20
|
|
|
|
first_ai = _ai_rows(client.adv_id)[0]
|
|
assert _delete(client, first_ai.id).status_code == 204
|
|
|
|
assert _state(client.adv_id) == before
|
|
|
|
|
|
def test_delete_is_blocked_while_a_turn_is_generating(client):
|
|
"""The endpoint writes the shared state now, so it takes the same lock
|
|
undo takes rather than racing the turn that is about to write it."""
|
|
_play(client)
|
|
action_id = _ai_rows(client.adv_id)[-1].id
|
|
|
|
adventures.turns.acquire_turn_lock(client.adv_id) # a turn is "generating"
|
|
try:
|
|
assert _delete(client, action_id).status_code == 409
|
|
# The refused delete must not have released someone else's lock.
|
|
assert client.adv_id in adventures.turns._active_turns
|
|
finally:
|
|
adventures.turns._active_turns.discard(client.adv_id)
|
|
assert len(_ai_rows(client.adv_id)) == 1, "and the turn is still there"
|