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
452 lines
17 KiB
Python
452 lines
17 KiB
Python
"""Tests for undo and retry rolling back the state a position left behind.
|
|
|
|
M5 made `narrative_state` the authoritative document and `world_state` the
|
|
legacy one, and the corrective pass added the narrative column to this file. The
|
|
review found it covering only the legacy column (Finding 8) — which is how the
|
|
NULL-snapshot rule that broke restoring to a migrated position (Finding 3) came
|
|
to live in the very code this module exists to pin down, untested.
|
|
|
|
The two columns follow deliberately different NULL rules, and both are asserted
|
|
below: a missing narrative snapshot restores the empty document, a missing
|
|
legacy snapshot is left alone.
|
|
|
|
The state being rolled back was the scripting engine's `script_state` until
|
|
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
|
|
`roll_back_before`, and the per-node outcome snapshot — is unchanged; only the
|
|
column it moves has. `world_state`/`world_state_after` is now the shared state
|
|
an adventure carries, so that is what these tests exercise.
|
|
|
|
Phase 14 SP4 reversed the snapshots. An action used to carry the state as
|
|
it stood before it ran, and rolling back read the snapshot off the action
|
|
being removed. Now it carries the state it left behind, and rolling back
|
|
reads that state off the node in front of it. This is the same value
|
|
reached from the other direction, and it is the only version a retry can
|
|
use, because attempts at one turn share a starting position and differ
|
|
only in their outcome.
|
|
|
|
Run from the backend dir: python -m pytest tests/test_state_revert.py -v
|
|
"""
|
|
|
|
# Point the app at a throwaway SQLite file before importing anything that
|
|
# binds the engine at import time. `app.database` reads `AIDND_DB_PATH`
|
|
# on import.
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app import attempts, memorybank, models
|
|
from app.narrative import model as narrative_model
|
|
from app.database import Base, SessionLocal, engine
|
|
from app.routers import adventures
|
|
|
|
|
|
@pytest.fixture()
|
|
def db():
|
|
Base.metadata.create_all(bind=engine)
|
|
session = SessionLocal()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
Base.metadata.drop_all(bind=engine)
|
|
adventures.turns._active_turns.clear()
|
|
|
|
|
|
def _make_adventure(db, world_state):
|
|
user = models.User(is_guest=False)
|
|
db.add(user)
|
|
db.flush()
|
|
adv = models.Adventure(user_id=user.id, title="T", world_state=world_state)
|
|
db.add(adv)
|
|
db.flush()
|
|
return user, adv
|
|
|
|
|
|
def _add(db, adv, index, type_, text="x", state_after=None):
|
|
a = models.Action(
|
|
adventure_id=adv.id, type=type_, text=text,
|
|
world_state_after=state_after,
|
|
)
|
|
db.add(a)
|
|
db.flush()
|
|
return a
|
|
|
|
|
|
def _row_count(db, adv):
|
|
"""Every action row the adventure holds, live or not, head or no head."""
|
|
return db.query(models.Action).filter_by(adventure_id=adv.id).count()
|
|
|
|
|
|
def _all_types(db, adv):
|
|
"""The retained story in depth order, which is not the same as the story
|
|
being told once the head has moved back behind the tip."""
|
|
rows = (
|
|
db.query(models.Action)
|
|
.filter_by(adventure_id=adv.id)
|
|
.order_by(models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
return [a.type for a in rows]
|
|
|
|
|
|
def _retrievable(db, adv):
|
|
"""The memories the story can currently reach, read through the same clause
|
|
`memorybank` retrieves with — which is capped at the active head."""
|
|
from app.context import lineage
|
|
|
|
rows = (
|
|
db.query(models.Memory)
|
|
.filter(
|
|
models.Memory.adventure_id == adv.id,
|
|
lineage.path_of(db, adv).clause(models.Memory),
|
|
)
|
|
.all()
|
|
)
|
|
return {m.text for m in rows}
|
|
|
|
|
|
def _forget_snapshots(db, adv):
|
|
"""Blank every outcome, the way a row written before SP4 looks.
|
|
|
|
Straight SQL, because `tree.stamp_outcome` runs on every flush precisely so
|
|
that a node written through the ORM cannot end up without one.
|
|
"""
|
|
db.query(models.Action).filter_by(adventure_id=adv.id).update(
|
|
{"state_after": None, "world_state_after": None}, synchronize_session=False
|
|
)
|
|
db.commit()
|
|
db.expire_all()
|
|
|
|
|
|
# ---------------------------------------------------------------- undo
|
|
|
|
def test_undo_reverts_state_to_before_the_turn(db):
|
|
# A turn moved world_state from {gold:0} to {gold:10}. The node in
|
|
# front of the turn records where it started. The current state is
|
|
# the mutated one.
|
|
#
|
|
# M3 rewrote what the second half of this test asserts. Undo used to delete
|
|
# the turn, so the story was short afterwards because the rows were gone.
|
|
# It now moves the head, so the story is short because it is being read
|
|
# from somewhere earlier — and the rows are all still there. The state
|
|
# assertion is unchanged, because `attempts.restore_state` is unchanged:
|
|
# the state still comes off the node the story now ends on.
|
|
user, adv = _make_adventure(db, {"gold": 10})
|
|
_add(db, adv, 0, "start", state_after={"gold": 0})
|
|
_add(db, adv, 1, "do", state_after={"gold": 0})
|
|
_add(db, adv, 2, "ai", state_after={"gold": 10})
|
|
db.commit()
|
|
before = _row_count(db, adv)
|
|
|
|
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
|
|
|
|
assert adv.world_state == {"gold": 0}
|
|
# What the story now tells.
|
|
assert [a.type for a in page.actions] == ["start"]
|
|
assert adv.head_depth == 0
|
|
# What it still holds. Zero accepted turns deleted, which is the M3
|
|
# invariant this file is the closest test to.
|
|
assert _row_count(db, adv) == before
|
|
assert _all_types(db, adv) == ["start", "do", "ai"]
|
|
assert page.can_redo is True
|
|
|
|
|
|
def test_undo_of_bare_continue_uses_the_node_in_front(db):
|
|
# A "continue" turn has no player action, so the opening is what the story
|
|
# falls back to.
|
|
user, adv = _make_adventure(db, {"gold": 5})
|
|
_add(db, adv, 0, "start", state_after={"gold": 0})
|
|
_add(db, adv, 1, "ai", state_after={"gold": 5})
|
|
db.commit()
|
|
|
|
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
|
|
|
|
assert adv.world_state == {"gold": 0}
|
|
assert [a.type for a in page.actions] == ["start"]
|
|
assert _all_types(db, adv) == ["start", "ai"]
|
|
|
|
|
|
def test_redo_puts_back_the_state_the_turn_left_behind(db):
|
|
"""The other half of the same mechanism: undo and redo restore the same
|
|
snapshot from opposite directions, because it belongs to the node rather
|
|
than to the direction of travel."""
|
|
user, adv = _make_adventure(db, {"gold": 10})
|
|
_add(db, adv, 0, "start", state_after={"gold": 0})
|
|
_add(db, adv, 1, "do", state_after={"gold": 0})
|
|
_add(db, adv, 2, "ai", state_after={"gold": 10})
|
|
db.commit()
|
|
|
|
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
|
page = adventures.redo_turn(adv.id, db=db, adventure=adv)
|
|
|
|
assert adv.world_state == {"gold": 10}
|
|
assert [a.type for a in page.actions] == ["start", "do", "ai"]
|
|
assert adv.head_depth == 2
|
|
assert page.can_redo is False
|
|
|
|
|
|
def test_undo_leaves_state_untouched_when_snapshot_missing(db):
|
|
# A row the SP4 migration could not derive an outcome for: leave the live
|
|
# state alone rather than resetting it to nothing.
|
|
user, adv = _make_adventure(db, {"gold": 10})
|
|
_add(db, adv, 0, "start")
|
|
_add(db, adv, 1, "do")
|
|
_add(db, adv, 2, "ai")
|
|
_forget_snapshots(db, adv)
|
|
|
|
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
|
|
|
assert adv.world_state == {"gold": 10}
|
|
|
|
|
|
def test_undo_raises_when_nothing_to_undo(db):
|
|
user, adv = _make_adventure(db, {})
|
|
_add(db, adv, 0, "start")
|
|
db.commit()
|
|
with pytest.raises(HTTPException) as exc:
|
|
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_undo_blocked_by_active_turn_lock(db):
|
|
user, adv = _make_adventure(db, {})
|
|
_add(db, adv, 0, "start")
|
|
_add(db, adv, 1, "ai", state_after={})
|
|
db.commit()
|
|
|
|
adventures.turns.acquire_turn_lock(adv.id) # a turn is "generating"
|
|
try:
|
|
with pytest.raises(HTTPException) as exc:
|
|
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
|
assert exc.value.status_code == 409
|
|
# The failed undo must not have released someone else's lock.
|
|
assert adv.id in adventures.turns._active_turns
|
|
finally:
|
|
adventures.turns._active_turns.discard(adv.id)
|
|
|
|
|
|
def test_undo_stops_retrieving_a_memory_without_deleting_it(db):
|
|
"""M3 rewrote this test. Undo used to prune the memories covering the turns
|
|
it deleted, because those turns were gone and a summary of them described
|
|
story the adventure no longer had.
|
|
|
|
Nothing is deleted now, and nothing needs pruning either. A memory carries
|
|
the coordinate of the node its block ends on, so one derived from a turn
|
|
that is now past the head falls outside the head-capped path clause and
|
|
stops being retrievable — and becomes eligible again on Redo, without having
|
|
been deleted and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33
|
|
holding as a consequence of the head rather than as its own mechanism.
|
|
"""
|
|
user, adv = _make_adventure(db, {})
|
|
for i in range(4):
|
|
_add(db, adv, i, "ai" if i % 2 else "do", state_after={})
|
|
db.commit()
|
|
# A memory ending on the turn undo will step behind, and one ending before
|
|
# it. The coordinate is what the clause reads; `source_*` only says which
|
|
# stretch the summarizer covered.
|
|
covering = models.Memory(
|
|
adventure_id=adv.id, text="m", source_start=0, source_end=3,
|
|
branch_id=adv.head_branch_id, depth=3,
|
|
)
|
|
keep = models.Memory(
|
|
adventure_id=adv.id, text="k", source_start=0, source_end=1,
|
|
branch_id=adv.head_branch_id, depth=1,
|
|
)
|
|
db.add_all([covering, keep])
|
|
db.commit()
|
|
|
|
adventures.undo_turn(adv.id, db=db, adventure=adv) # head moves to depth 1
|
|
|
|
assert _retrievable(db, adv) == {"k"}
|
|
# Still on disk, still embedded, still attached to the adventure.
|
|
assert {m.text for m in adv.memories} == {"k", "m"}
|
|
|
|
adventures.redo_turn(adv.id, db=db, adventure=adv)
|
|
|
|
assert _retrievable(db, adv) == {"k", "m"}
|
|
|
|
|
|
# -------------------------------------------------------- withdrawing a node
|
|
|
|
def test_forget_node_withdraws_only_what_that_node_produced(db):
|
|
"""Phase 14 SP3: a memory attaches to the node where its block ends, so
|
|
removing a node is a lookup rather than a scan for memories that
|
|
reference actions the story no longer has."""
|
|
user, adv = _make_adventure(db, {})
|
|
_add(db, adv, 0, "do")
|
|
second = _add(db, adv, 1, "ai")
|
|
db.add_all([
|
|
models.Memory(adventure_id=adv.id, text="hangs off node 1",
|
|
source_start=0, source_end=1),
|
|
models.Memory(adventure_id=adv.id, text="hangs off node 0",
|
|
source_start=0, source_end=0),
|
|
])
|
|
db.commit()
|
|
|
|
removed = memorybank.forget_node(db, adv, second)
|
|
db.commit()
|
|
db.refresh(adv) # expire_on_commit=False: reload the memories collection
|
|
|
|
assert removed == 1
|
|
assert {m.text for m in adv.memories} == {"hangs off node 0"}
|
|
|
|
|
|
# ---------------------------------------------------------------- snapshot
|
|
|
|
def test_snapshot_outcome_is_an_independent_deep_copy(db):
|
|
_, adv = _make_adventure(db, {"nested": {"n": 1}})
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
|
attempts.snapshot_outcome(adv, node)
|
|
adv.world_state["nested"]["n"] = 99
|
|
assert node.world_state_after == {"nested": {"n": 1}} # unaffected by later mutation
|
|
|
|
|
|
def test_snapshot_outcome_handles_non_dict(db):
|
|
_, adv = _make_adventure(db, {})
|
|
adv.world_state = None
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
|
attempts.snapshot_outcome(adv, node)
|
|
assert node.world_state_after == {}
|
|
|
|
|
|
def test_restore_state_ignores_a_node_with_no_outcome(db):
|
|
_, adv = _make_adventure(db, {"gold": 7})
|
|
attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai"))
|
|
assert adv.world_state == {"gold": 7}
|
|
attempts.restore_state(adv, None)
|
|
assert adv.world_state == {"gold": 7}
|
|
|
|
|
|
# ---------------------------------------------------------------- retry
|
|
|
|
def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
|
|
# Retry must roll script_state back to what the node in front of the AI
|
|
# action left behind, so regeneration does not stack output mutations
|
|
# on top of the attempt being replaced.
|
|
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
|
|
_add(db, adv, 0, "start", state_after={"gold": 0})
|
|
_add(db, adv, 1, "do", state_after={"gold": 10})
|
|
_add(db, adv, 2, "ai", state_after={"gold": 20})
|
|
db.commit()
|
|
|
|
|
|
async def _noop(*a, **k):
|
|
if False:
|
|
yield # make it an async generator
|
|
monkeypatch.setattr(adventures.turns, "generate_turn", _noop)
|
|
|
|
adventures.retry_action(adv.id, request=None, db=db, user=user, adventure=adv)
|
|
|
|
assert adv.world_state == {"gold": 10}
|
|
# Nothing is written until a replacement actually arrives: the attempt on
|
|
# screen is left exactly as it was, and stays the live one.
|
|
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
|
|
last = adv.actions[-1]
|
|
assert last.live is True
|
|
assert len(attempts.group(db, last)) == 1 # no sibling was filed
|
|
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
|
|
adventures.turns._active_turns.discard(adv.id)
|
|
|
|
|
|
# --------------------------------------------- the narrative document (M5)
|
|
#
|
|
# `narrative_state` is what the reader is shown and what the narrator is told,
|
|
# so these are the assertions that matter most. They were missing until the M5
|
|
# corrective pass.
|
|
|
|
|
|
def _document(**entities) -> dict:
|
|
"""A minimal but real narrative document."""
|
|
state = narrative_model.empty()
|
|
for key, name in entities.items():
|
|
state["entities"][key] = {
|
|
"name": name, "type": "character", "status": "active",
|
|
"aliases": [], "attributes": {}, "conditions": [],
|
|
"description": "", "location": None,
|
|
}
|
|
return state
|
|
|
|
|
|
def test_snapshot_outcome_records_the_narrative_document(db):
|
|
_, adv = _make_adventure(db, {})
|
|
adv.narrative_state = _document(mara="Mara")
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
|
|
|
attempts.snapshot_outcome(adv, node)
|
|
|
|
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
|
|
|
|
|
|
def test_the_narrative_snapshot_is_an_independent_deep_copy(db):
|
|
_, adv = _make_adventure(db, {})
|
|
adv.narrative_state = _document(mara="Mara")
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
|
attempts.snapshot_outcome(adv, node)
|
|
|
|
adv.narrative_state["entities"]["mara"]["name"] = "Someone else"
|
|
|
|
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
|
|
|
|
|
|
def test_snapshot_outcome_writes_an_empty_document_when_there_is_none(db):
|
|
_, adv = _make_adventure(db, {})
|
|
adv.narrative_state = None
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
|
|
|
attempts.snapshot_outcome(adv, node)
|
|
|
|
assert node.narrative_state_after == narrative_model.empty()
|
|
|
|
|
|
def test_restore_state_puts_back_the_narrative_document(db):
|
|
_, adv = _make_adventure(db, {})
|
|
adv.narrative_state = _document(aldric="Aldric")
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x",
|
|
narrative_state_after=_document(mara="Mara"))
|
|
|
|
attempts.restore_state(adv, node)
|
|
|
|
assert list(adv.narrative_state["entities"]) == ["mara"]
|
|
|
|
|
|
def test_restoring_the_narrative_document_does_not_alias_the_snapshot(db):
|
|
_, adv = _make_adventure(db, {})
|
|
node = models.Action(adventure_id=adv.id, type="ai", text="x",
|
|
narrative_state_after=_document(mara="Mara"))
|
|
attempts.restore_state(adv, node)
|
|
|
|
adv.narrative_state["entities"]["mara"]["name"] = "Changed live"
|
|
|
|
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
|
|
|
|
|
|
def test_a_node_with_no_narrative_snapshot_restores_the_empty_document(db):
|
|
"""M5 review, Finding 3 — the rule this file failed to pin down.
|
|
|
|
A pre-M5 position established nothing, and arriving there has to say so.
|
|
Leaving the live document alone instead left a *later* position's entities
|
|
and facts standing while the reader was somewhere earlier, which is the one
|
|
thing the head/state invariant forbids.
|
|
"""
|
|
_, adv = _make_adventure(db, {"gold": 7})
|
|
adv.narrative_state = _document(mara="Mara")
|
|
pre_m5 = models.Action(adventure_id=adv.id, type="ai", text="x")
|
|
assert pre_m5.narrative_state_after is None
|
|
|
|
attempts.restore_state(adv, pre_m5)
|
|
|
|
assert adv.narrative_state == narrative_model.empty()
|
|
# The legacy column keeps the opposite rule, deliberately: nothing consults
|
|
# it, and blanking a running campaign's numbers would help no one.
|
|
assert adv.world_state == {"gold": 7}
|
|
|
|
|
|
def test_restore_state_of_nothing_changes_neither_column(db):
|
|
_, adv = _make_adventure(db, {"gold": 7})
|
|
adv.narrative_state = _document(mara="Mara")
|
|
|
|
attempts.restore_state(adv, None)
|
|
|
|
assert list(adv.narrative_state["entities"]) == ["mara"]
|
|
assert adv.world_state == {"gold": 7}
|