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
896 lines
32 KiB
Python
896 lines
32 KiB
Python
"""M3: the active head, and what moving it costs.
|
|
|
|
This file is the acceptance contract for the milestone that stopped Undo from
|
|
deleting. Its subject is one invariant and the behaviour that follows from it:
|
|
|
|
Undo deletes zero accepted turns.
|
|
|
|
Everything else here is a consequence. Redo exists because the turns are still
|
|
there. Divergence retires a future rather than removing it. Memory and summary
|
|
coverage narrow and widen again as the head moves, without anything being
|
|
re-embedded. Export carries where the reader stopped, because that is now a
|
|
decision rather than a fact about the newest row.
|
|
|
|
The tests are named for the acceptance items they discharge — D01-D10, E01-E04,
|
|
I01-I03, I07, L01-L02 in `planning/V1-ACCEPTANCE-TESTS.md` — so a reader can go
|
|
from a failing test to the requirement it belongs to without a map.
|
|
|
|
The world state is instrumentation here, not the subject. Each scripted reply
|
|
banks ten gold, which makes "the state at this position" a number a test can
|
|
assert instead of a paragraph it has to interpret. M5 replaces that machinery
|
|
with genre-neutral narrative state; these tests then need the *instrumentation*
|
|
moved, not the assertions removed, because what they measure is where the story
|
|
is being read.
|
|
|
|
python -m pytest tests/test_head_cursor.py -v
|
|
"""
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import limits, models
|
|
from app.providers import ProviderError
|
|
from app.context import cursors, lineage
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.main import app
|
|
from app import auth, tree
|
|
from app.routers import adventures
|
|
|
|
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="head@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=GOLD_SCHEMA)
|
|
setup.add(scenario)
|
|
setup.flush()
|
|
adv = models.Adventure(
|
|
user_id=user.id, title="Tavern", scenario_id=scenario.id,
|
|
)
|
|
setup.add(adv)
|
|
setup.flush()
|
|
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter the tavern."))
|
|
setup.commit()
|
|
adv_id, user_id = adv.id, user.id
|
|
setup.close()
|
|
|
|
ScriptedProvider.replies = gold_replies()
|
|
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", type="do", after_id=None):
|
|
payload = {"type": type, "text": text}
|
|
if after_id is not None:
|
|
payload["after_id"] = after_id
|
|
r = client.post(f"/api/adventures/{client.adv_id}/actions", json=payload)
|
|
assert r.status_code == 200, r.text
|
|
return r
|
|
|
|
|
|
def _turns(client, count):
|
|
for n in range(count):
|
|
_play(client, f"turn {n}")
|
|
|
|
|
|
def _undo(client):
|
|
return client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
|
|
|
|
def _redo(client):
|
|
return client.post(f"/api/adventures/{client.adv_id}/redo")
|
|
|
|
|
|
def _adventure(client) -> dict:
|
|
r = client.get(f"/api/adventures/{client.adv_id}")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _texts(client) -> list[str]:
|
|
return [a["text"] for a in _adventure(client)["actions"]]
|
|
|
|
|
|
def _rows(adv_id) -> list[models.Action]:
|
|
"""Every action row, story or not, live or not, head or no head."""
|
|
db = SessionLocal()
|
|
try:
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(models.Action.adventure_id == adv_id)
|
|
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _gold(adv_id) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
adv = db.get(models.Adventure, adv_id)
|
|
return tally_of(adv.narrative_state)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _head(adv_id) -> tuple[int, int]:
|
|
db = SessionLocal()
|
|
try:
|
|
adv = db.get(models.Adventure, adv_id)
|
|
return adv.head_branch_id, adv.head_depth
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------------- D01, D02, D03: undo
|
|
|
|
def test_d01_one_undo_returns_the_transcript_and_the_state(client):
|
|
_turns(client, 2)
|
|
assert _gold(client.adv_id) == 2 * GOLD_PER_TURN
|
|
told = _texts(client)
|
|
|
|
page = _undo(client)
|
|
assert page.status_code == 200, page.text
|
|
|
|
# Both halves of the turn step back together: a player's action and the
|
|
# reply to it are one accepted story step.
|
|
assert [a["text"] for a in page.json()["actions"]] == told[:-2]
|
|
assert _gold(client.adv_id) == 1 * GOLD_PER_TURN
|
|
|
|
|
|
def test_d02_five_consecutive_undos_each_land_where_they_should(client):
|
|
_turns(client, 7)
|
|
assert _gold(client.adv_id) == 7 * GOLD_PER_TURN
|
|
|
|
for step in range(1, 6):
|
|
assert _undo(client).status_code == 200
|
|
assert _gold(client.adv_id) == (7 - step) * GOLD_PER_TURN
|
|
# Two rows per turn, plus the opening.
|
|
assert len(_texts(client)) == 1 + 2 * (7 - step)
|
|
|
|
|
|
def test_d03_undo_walks_back_to_the_campaign_opening_and_stops(client):
|
|
_turns(client, 7)
|
|
|
|
for _ in range(7):
|
|
assert _undo(client).status_code == 200
|
|
|
|
assert _texts(client) == ["You enter the tavern."]
|
|
assert _gold(client.adv_id) == 0
|
|
# The opening is the floor. There is no pre-campaign position to reach.
|
|
refused = _undo(client)
|
|
assert refused.status_code == 400
|
|
assert "Nothing to undo" in refused.json()["detail"]
|
|
assert _adventure(client)["can_undo"] is False
|
|
|
|
|
|
def test_the_m3_invariant_undo_deletes_zero_accepted_turns(client):
|
|
"""The row count is the milestone. Everything else in this file is a
|
|
consequence of it, so it is asserted on its own, over the whole retained
|
|
tree rather than over the story being told."""
|
|
_turns(client, 5)
|
|
before = [a.id for a in _rows(client.adv_id)]
|
|
|
|
for _ in range(5):
|
|
assert _undo(client).status_code == 200
|
|
|
|
after = [a.id for a in _rows(client.adv_id)]
|
|
assert after == before
|
|
assert len(_texts(client)) == 1 # and yet the story is back at its opening
|
|
|
|
|
|
# -------------------------------------------------------------- D04, L02: redo
|
|
|
|
def test_d04_redo_restores_the_continuation_and_its_state(client):
|
|
_turns(client, 4)
|
|
whole = _texts(client)
|
|
|
|
_undo(client)
|
|
_undo(client)
|
|
assert _gold(client.adv_id) == 2 * GOLD_PER_TURN
|
|
|
|
assert _redo(client).status_code == 200
|
|
assert _gold(client.adv_id) == 3 * GOLD_PER_TURN
|
|
assert _redo(client).status_code == 200
|
|
assert _gold(client.adv_id) == 4 * GOLD_PER_TURN
|
|
assert _texts(client) == whole
|
|
# Nowhere further forward to go, and the control says so.
|
|
assert _adventure(client)["can_redo"] is False
|
|
assert _redo(client).status_code == 400
|
|
|
|
|
|
def test_l02_state_matches_the_position_in_both_directions(client):
|
|
"""Each position's state is the snapshot the node left behind, so arriving
|
|
from in front of it and arriving from behind it must agree."""
|
|
_turns(client, 5)
|
|
going_back = []
|
|
for _ in range(5):
|
|
_undo(client)
|
|
going_back.append(_gold(client.adv_id))
|
|
|
|
coming_forward = []
|
|
for _ in range(5):
|
|
_redo(client)
|
|
coming_forward.append(_gold(client.adv_id))
|
|
|
|
assert going_back == [40, 30, 20, 10, 0]
|
|
assert coming_forward == [10, 20, 30, 40, 50]
|
|
|
|
|
|
# ------------------------------------------------- D05, E01, E04: divergence
|
|
|
|
def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client):
|
|
_turns(client, 4)
|
|
abandoned = {a.id for a in _rows(client.adv_id)}
|
|
|
|
_undo(client)
|
|
_undo(client)
|
|
assert _adventure(client)["can_redo"] is True
|
|
|
|
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
|
|
_play(client, "go the other way")
|
|
|
|
# Ordinary Redo cannot walk into the old future any more...
|
|
assert _adventure(client)["can_redo"] is False
|
|
assert _redo(client).status_code == 400
|
|
# ...and not one row of it was deleted to achieve that.
|
|
kept = {a.id for a in _rows(client.adv_id)}
|
|
assert abandoned <= kept
|
|
# The story now tells the new continuation.
|
|
assert _texts(client)[-1].startswith("A different road.")
|
|
|
|
|
|
def test_the_departed_branch_records_where_the_story_left_it(client):
|
|
"""`DATA-MODEL.md` §5 gives a branch a disposition. It is stored as the fact
|
|
that produced it — the depth the story left at — and nothing reads it to
|
|
decide behaviour, so this test is what makes a divergence observable."""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
left_at = _head(client.adv_id)[1]
|
|
|
|
_play(client, "elsewhere")
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
superseded = (
|
|
db.query(models.Branch)
|
|
.filter(
|
|
models.Branch.adventure_id == client.adv_id,
|
|
models.Branch.superseded_at.isnot(None),
|
|
)
|
|
.all()
|
|
)
|
|
assert len(superseded) == 1
|
|
assert superseded[0].superseded_depth == left_at
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
|
|
"""E01 and E04 are the same mechanism measured twice: the state that is
|
|
current is the one belonging to the position the story is read at, so a
|
|
number only the abandoned future ever reached cannot survive a divergence."""
|
|
ScriptedProvider.replies = [
|
|
tally_reply("You find a purse.", 10),
|
|
tally_reply("You find the hoard.", 510),
|
|
]
|
|
_play(client, "search")
|
|
_play(client, "keep searching")
|
|
assert _gold(client.adv_id) == 510
|
|
|
|
_undo(client)
|
|
assert _gold(client.adv_id) == 10
|
|
|
|
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
|
|
_play(client, "go home")
|
|
|
|
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
|
|
|
|
|
|
# ------------------------------------------------------ E02, E03: derived work
|
|
|
|
def _attach_memory(adv_id, text, at_depth):
|
|
"""Writes a memory ending on the live node at `at_depth`, the way the
|
|
summarizer would, and anchors the memory cursor there."""
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adv_id)
|
|
node = (
|
|
db.query(models.Action)
|
|
.filter(
|
|
models.Action.adventure_id == adv_id,
|
|
models.Action.depth == at_depth,
|
|
models.Action.live.is_(True),
|
|
)
|
|
.first()
|
|
)
|
|
memory = models.Memory(
|
|
adventure_id=adv_id, text=text, source_start=0, source_end=node.depth,
|
|
)
|
|
tree.attach_memory(memory, node)
|
|
db.add(memory)
|
|
cursors.MEMORY.anchor_at(adventure, node)
|
|
cursors.SUMMARY.anchor_at(adventure, node)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _retrievable(adv_id) -> set[str]:
|
|
"""The memories the story can reach, through the clause `memorybank`
|
|
retrieves with."""
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adv_id)
|
|
rows = (
|
|
db.query(models.Memory)
|
|
.filter(
|
|
models.Memory.adventure_id == adv_id,
|
|
lineage.path_of(db, adventure).clause(models.Memory),
|
|
)
|
|
.all()
|
|
)
|
|
return {m.text for m in rows}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_e02_an_abandoned_memory_is_unreachable_and_still_on_disk(client):
|
|
_turns(client, 3)
|
|
tip_depth = _head(client.adv_id)[1]
|
|
_attach_memory(client.adv_id, "Mara reveals she is a spy.", tip_depth)
|
|
assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."}
|
|
|
|
_undo(client)
|
|
# Negative control: undone, the revelation is not retrievable...
|
|
assert _retrievable(client.adv_id) == set()
|
|
_play(client, "talk about the weather")
|
|
# ...and after diverging it stays unreachable, on a line the story left.
|
|
assert _retrievable(client.adv_id) == set()
|
|
db = SessionLocal()
|
|
try:
|
|
assert db.query(models.Memory).count() == 1, "unreachable, not deleted"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_e02_positive_control_the_memory_returns_on_the_line_it_belongs_to(client):
|
|
"""The negative control alone would pass if memories were simply broken.
|
|
Redo puts the head back on the lineage the memory was written for, and it
|
|
must be retrievable again — without having been re-embedded."""
|
|
_turns(client, 3)
|
|
_attach_memory(client.adv_id, "Mara reveals she is a spy.", _head(client.adv_id)[1])
|
|
|
|
_undo(client)
|
|
assert _retrievable(client.adv_id) == set()
|
|
_redo(client)
|
|
assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."}
|
|
|
|
|
|
def test_e03_a_summary_anchor_cannot_claim_coverage_past_the_head(client):
|
|
"""E03 without redesigning the summarizer. The anchor says how far the
|
|
derived work has read; resolved against a head-capped path it can never
|
|
report a stretch in the abandoned future as already covered, so that content
|
|
is re-derived for the new line rather than carried into it."""
|
|
_turns(client, 3)
|
|
tip_depth = _head(client.adv_id)[1]
|
|
_attach_memory(client.adv_id, "Everything up to the reveal.", tip_depth)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
assert cursors.SUMMARY.depth(db, adventure) == tip_depth
|
|
finally:
|
|
db.close()
|
|
|
|
_undo(client)
|
|
_play(client, "a different question")
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
covered = cursors.SUMMARY.depth(db, adventure)
|
|
assert covered < tip_depth, "coverage from the abandoned line was claimed"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------------------ D06-D08: retry and takes
|
|
|
|
def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client):
|
|
ScriptedProvider.replies = gold_replies("Take")
|
|
_play(client, "knock")
|
|
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
|
|
|
|
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
|
|
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert r.status_code == 200, r.text
|
|
|
|
ai_rows = [a for a in _rows(client.adv_id) if a.type == "ai"]
|
|
assert len(ai_rows) == 2, "the earlier take is retained"
|
|
assert first.id in {a.id for a in ai_rows}
|
|
# Both takes sit at the same coordinate, which is what makes them takes
|
|
# rather than turns, and the state is one turn's worth either way — the
|
|
# retry replaced the take rather than stacking on top of it.
|
|
assert {a.depth for a in ai_rows} == {first.depth}
|
|
assert _gold(client.adv_id) == GOLD_PER_TURN
|
|
|
|
|
|
def test_d07_a_retry_from_behind_the_tip_branches_instead_of_amending(client):
|
|
"""A turn with an accepted future is not a leaf, whatever the capped read
|
|
says. Retrying it has to leave that future on the line it was written for,
|
|
which is a branch — the same operation `add_take` performs for a turn the
|
|
story has already moved past."""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
before = {a.id for a in _rows(client.adv_id)}
|
|
|
|
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert r.status_code == 200, r.text
|
|
|
|
after = {a.id for a in _rows(client.adv_id)}
|
|
assert before <= after, "the retained future kept every row"
|
|
db = SessionLocal()
|
|
try:
|
|
assert db.query(models.Branch).filter_by(adventure_id=client.adv_id).count() == 2
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_switching_a_take_is_refused_while_a_kept_future_hangs_off_it(client):
|
|
"""The one operation that cannot be made safe by branching, because it
|
|
changes which take is live in place. It reports rather than guesses."""
|
|
ScriptedProvider.replies = gold_replies()
|
|
_play(client, "knock")
|
|
client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
_play(client, "go in")
|
|
_undo(client)
|
|
|
|
newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
|
|
r = client.post(
|
|
f"/api/adventures/{client.adv_id}/actions/{newest.id}/variant", json={"index": 0}
|
|
)
|
|
assert r.status_code == 400
|
|
assert "undone but kept" in r.json()["detail"]
|
|
|
|
|
|
# ---------------------------------------------------------- D09, D10: editing
|
|
|
|
def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
|
|
"""D09 and D10 through the operation the product actually offers. Editing
|
|
an earlier turn and replaying it is one endpoint — `takes` — and it is
|
|
already the "return to the parent state, then continue differently" shape
|
|
the semantics ask for. The plain text edit beside it is a correction to
|
|
prose that creates no continuation, and M3 does not change it.
|
|
"""
|
|
ScriptedProvider.replies = [
|
|
tally_reply("You accuse her.", 10),
|
|
tally_reply("She draws a knife.", 30),
|
|
]
|
|
_play(client, "I accuse Mara of stealing the key.")
|
|
_play(client, "wait")
|
|
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
|
|
old_future = {a.id for a in _rows(client.adv_id)}
|
|
|
|
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
|
|
r = client.post(
|
|
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
|
|
json={"text": "I quietly ask Mara whether she has seen the key."},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# The old future is retained whole...
|
|
assert old_future <= {a.id for a in _rows(client.adv_id)}
|
|
# ...the edited input is what the story now tells...
|
|
told = _texts(client)
|
|
assert any("quietly ask Mara" in t for t in told)
|
|
assert not any("accuse her" in t for t in told)
|
|
# ...and no state from the abandoned line leaked into it.
|
|
assert _gold(client.adv_id) == 1
|
|
|
|
|
|
# ------------------------------------------------------------------ L01
|
|
|
|
def test_l01_a_failed_turn_accepts_no_narration_and_strands_no_state(client):
|
|
"""A provider that fails must not leave the story half-advanced.
|
|
|
|
The head does move, by exactly one node, and that is deliberate rather than
|
|
a gap in the guarantee: A05 keeps the player's submitted text so it can be
|
|
tried again, and the head sits on it. What L01 forbids is an *accepted*
|
|
narration with half-written state behind it, a head that has moved past a
|
|
turn that did not happen, or earlier story becoming unreachable — so those
|
|
are what this asserts.
|
|
"""
|
|
_turns(client, 2)
|
|
branch, depth = _head(client.adv_id)
|
|
told = _texts(client)
|
|
banked = _gold(client.adv_id)
|
|
ai_before = [a.id for a in _rows(client.adv_id) if a.type == "ai"]
|
|
|
|
ScriptedProvider.replies = [ProviderError("the model refused")]
|
|
client.post(f"/api/adventures/{client.adv_id}/actions",
|
|
json={"type": "do", "text": "the turn that fails"})
|
|
|
|
# One step, onto the retained player action, on the same branch.
|
|
assert _head(client.adv_id) == (branch, depth + 1)
|
|
# No narration was accepted, and no state moved with a turn that did not
|
|
# finish.
|
|
assert [a.id for a in _rows(client.adv_id) if a.type == "ai"] == ai_before
|
|
assert _gold(client.adv_id) == banked
|
|
# Every earlier turn is still readable, in order, unchanged.
|
|
assert _texts(client)[:len(told)] == told
|
|
|
|
# And the story still moves: one Undo steps back over the stranded input
|
|
# and the position is a complete turn again.
|
|
ScriptedProvider.replies = gold_replies()
|
|
assert _undo(client).status_code == 200
|
|
assert _texts(client) == told
|
|
assert _gold(client.adv_id) == banked
|
|
|
|
|
|
# ------------------------------------------------- I01, I02, I03, I07: bundles
|
|
|
|
def _export(client) -> dict:
|
|
r = client.get(f"/api/adventures/{client.adv_id}/export")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _import(client, bundle) -> dict:
|
|
"""Imports a bundle and returns the new adventure.
|
|
|
|
"A fresh data directory" is an adventure this file has never touched: the
|
|
import allocates its own branch rows and its own nodes, and resolves the
|
|
file's local branch numbers against them, which is the whole of what the
|
|
round trip has to get right. Sharing a database with the original does not
|
|
weaken that — the two adventures share no row.
|
|
"""
|
|
r = client.post("/api/adventures/import", json=bundle)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def _story_of(adv_id) -> list[str]:
|
|
"""The story an imported adventure tells, read through its own head."""
|
|
db = SessionLocal()
|
|
try:
|
|
adventure = db.get(models.Adventure, adv_id)
|
|
rows = (
|
|
db.query(models.Action)
|
|
.filter(
|
|
models.Action.adventure_id == adv_id,
|
|
lineage.path_of(db, adventure).clause(models.Action),
|
|
)
|
|
.order_by(models.Action.depth, models.Action.id)
|
|
.all()
|
|
)
|
|
return [a.text for a in rows]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_i01_i02_a_campaign_round_trips(client):
|
|
_turns(client, 3)
|
|
told = _texts(client)
|
|
|
|
imported = _import(client, _export(client))
|
|
|
|
assert _story_of(imported["id"]) == told
|
|
assert _gold(imported["id"]) == 3 * GOLD_PER_TURN
|
|
|
|
|
|
def test_i03_the_bundle_carries_the_history_the_story_no_longer_tells(client):
|
|
"""A backup that keeps only the active line is not a backup: the retained
|
|
future is what Redo and every later recovery feature read."""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
_play(client, "another way")
|
|
active = _texts(client)
|
|
|
|
bundle = _export(client)
|
|
assert len(bundle["branches"]) == 2
|
|
imported = _import(client, bundle)
|
|
|
|
assert _story_of(imported["id"]) == active
|
|
# Every row of the abandoned line arrived too, on its own branch.
|
|
db = SessionLocal()
|
|
try:
|
|
assert (
|
|
db.query(models.Action).filter_by(adventure_id=imported["id"]).count()
|
|
== len(_rows(client.adv_id))
|
|
)
|
|
assert db.query(models.Branch).filter_by(adventure_id=imported["id"]).count() == 2
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_i07_an_undone_head_survives_export_and_import(client):
|
|
"""The Phase 0B finding this milestone exists to close. Before M3 the head
|
|
depth was derived on import from the newest retained row, so a campaign
|
|
exported after two Undos came back silently redone to its tip."""
|
|
_turns(client, 5)
|
|
whole = _texts(client)
|
|
_undo(client)
|
|
_undo(client)
|
|
undone = _texts(client)
|
|
assert len(undone) == len(whole) - 4
|
|
|
|
bundle = _export(client)
|
|
assert bundle["headDepth"] == _head(client.adv_id)[1]
|
|
|
|
imported = _import(client, bundle)
|
|
|
|
# It opens exactly where it was left...
|
|
assert _story_of(imported["id"]) == undone
|
|
assert imported["can_redo"] is True
|
|
# ...the later turns arrived as retained history...
|
|
db = SessionLocal()
|
|
try:
|
|
assert (
|
|
db.query(models.Action).filter_by(adventure_id=imported["id"]).count()
|
|
== len(_rows(client.adv_id))
|
|
)
|
|
finally:
|
|
db.close()
|
|
# ...and Redo still walks forward into them, twice, to the same story.
|
|
for _ in range(2):
|
|
r = client.post(f"/api/adventures/{imported['id']}/redo")
|
|
assert r.status_code == 200, r.text
|
|
assert _story_of(imported["id"]) == whole
|
|
|
|
|
|
def test_a_bundle_written_before_m3_opens_at_its_tip(client):
|
|
"""Backward compatibility. A file with no `headDepth` is one written when
|
|
the head could not be anywhere but the tip, so deriving it is not a
|
|
fallback — it is the position that file recorded."""
|
|
_turns(client, 3)
|
|
told = _texts(client)
|
|
bundle = _export(client)
|
|
del bundle["headDepth"]
|
|
|
|
imported = _import(client, bundle)
|
|
|
|
assert _story_of(imported["id"]) == told
|
|
|
|
|
|
def test_a_bundle_that_reads_past_its_own_story_is_refused(client):
|
|
"""Checked in `plan`, before a row is written, for the reason the module
|
|
docstring gives: an import that discovers a bad coordinate afterwards has
|
|
already written half a tree."""
|
|
_turns(client, 2)
|
|
bundle = _export(client)
|
|
bundle["headDepth"] = 999
|
|
|
|
r = client.post("/api/adventures/import", json=bundle)
|
|
assert r.status_code == 400
|
|
assert "ends at" in r.json()["detail"]
|
|
|
|
|
|
def test_the_bundle_carries_which_branches_the_story_left(client):
|
|
"""Every row of an abandoned line arrives on an import either way, so the
|
|
disposition is the only thing that distinguishes it from an active one.
|
|
Losing it on a round trip would leave a restored backup unable to tell them
|
|
apart — which is what the later cleanup and recovery features select on."""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
_play(client, "another way")
|
|
|
|
imported = _import(client, _export(client))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
left = (
|
|
db.query(models.Branch)
|
|
.filter(
|
|
models.Branch.adventure_id == imported["id"],
|
|
models.Branch.superseded_at.isnot(None),
|
|
)
|
|
.all()
|
|
)
|
|
origin = (
|
|
db.query(models.Branch)
|
|
.filter(
|
|
models.Branch.adventure_id == client.adv_id,
|
|
models.Branch.superseded_at.isnot(None),
|
|
)
|
|
.all()
|
|
)
|
|
assert len(left) == len(origin) == 1
|
|
assert left[0].superseded_depth == origin[0].superseded_depth
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_a_bundle_with_half_a_disposition_imports_as_active(client):
|
|
"""Neither column is read to decide anything, so a file carrying one of them
|
|
is taken as carrying neither rather than having its missing half invented."""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
_play(client, "another way")
|
|
bundle = _export(client)
|
|
for branch in bundle["branches"]:
|
|
branch.pop("supersededDepth", None)
|
|
|
|
imported = _import(client, bundle)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
assert (
|
|
db.query(models.Branch)
|
|
.filter(
|
|
models.Branch.adventure_id == imported["id"],
|
|
models.Branch.superseded_at.isnot(None),
|
|
)
|
|
.count()
|
|
== 0
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ------------------------------------- the narrator-edit guard (M3 closeout)
|
|
|
|
def _edit(client, action_id, text):
|
|
return client.patch(
|
|
f"/api/adventures/{client.adv_id}/actions/{action_id}", json={"text": text}
|
|
)
|
|
|
|
|
|
def _live_ai_at(adv_id, depth):
|
|
return [
|
|
a for a in _rows(adv_id)
|
|
if a.type == "ai" and a.live and a.depth == depth
|
|
][0]
|
|
|
|
|
|
def test_editing_a_turn_with_nothing_after_it_is_still_allowed(client):
|
|
"""The guard has to stay out of the way of the operation it protects. A
|
|
correction to the newest turn contradicts nothing, and that is the case the
|
|
edit control exists for."""
|
|
_turns(client, 2)
|
|
newest = [a for a in _rows(client.adv_id) if a.type == "ai"][-1]
|
|
|
|
r = _edit(client, newest.id, "Corrected.")
|
|
|
|
assert r.status_code == 200, r.text
|
|
assert _texts(client)[-1] == "Corrected."
|
|
|
|
|
|
def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
|
|
"""Mid-story is not by itself unsafe: everything descending from the turn is
|
|
on screen, so the reader can see what their correction has to agree with.
|
|
Making that case fork is `STORY-BRANCH-SEMANTICS.md` §14-15 and belongs to
|
|
M5 with the state re-evaluation it needs."""
|
|
_turns(client, 3)
|
|
older = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
|
|
|
|
r = _edit(client, older.id, "Mara wears a green cloak.")
|
|
|
|
assert r.status_code == 200, r.text
|
|
assert "Mara wears a green cloak." in _texts(client)
|
|
|
|
|
|
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
|
|
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
|
|
|
|
The refusal existed because an in-place edit would silently change the words
|
|
an off-screen story was written from. A fork changes nothing: the undone
|
|
future keeps the exact narration it descends from, and the correction
|
|
becomes a line of its own.
|
|
"""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
|
|
target = sorted(at_head, key=lambda a: a.depth)[-2]
|
|
before, target_id = target.text, target.id
|
|
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
|
|
assert undone, "the fixture needs a future to leave behind"
|
|
|
|
r = _edit(client, target_id, "Something else entirely.")
|
|
|
|
assert r.status_code == 200, r.text
|
|
rows = {a.id: a for a in _rows(client.adv_id)}
|
|
# §15.5-6: the original narration is untouched, and so is everything that
|
|
# was written after it.
|
|
assert rows[target_id].text == before
|
|
assert all(old_id in rows for old_id in undone)
|
|
# §15.4: the correction is what the story now tells.
|
|
assert "Something else entirely." in _texts(client)
|
|
assert before not in _texts(client)
|
|
|
|
|
|
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
|
|
"""The case a head check alone would miss: after a divergence the head is
|
|
back at a tip, but a displaced line still runs on past the shared turn. It
|
|
keeps its words too."""
|
|
_turns(client, 3)
|
|
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
|
|
shared_id, before = shared.id, shared.text
|
|
_undo(client)
|
|
_undo(client)
|
|
_play(client, "a different road")
|
|
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
|
|
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
|
|
|
|
r = _edit(client, shared_id, "Rewritten under both lines.")
|
|
|
|
assert r.status_code == 200, r.text
|
|
rows = {a.id: a for a in _rows(client.adv_id)}
|
|
assert rows[shared_id].text == before
|
|
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
|
|
assert "Rewritten under both lines." in _texts(client)
|
|
|
|
|
|
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
|
|
"""The guard must be narrow. A branch that forked *before* a turn does not
|
|
descend from it, so a correction there contradicts nothing on that line."""
|
|
_turns(client, 2)
|
|
_undo(client)
|
|
_play(client, "a different road") # forks below turn 1
|
|
_turns(client, 2) # and continues past the fork
|
|
newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
|
|
target = sorted(newest, key=lambda a: a.depth)[-1]
|
|
|
|
r = _edit(client, target.id, "Corrected on the live line.")
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def test_a_take_that_is_not_live_stays_editable(client):
|
|
"""A take the story is not telling has no continuation of its own — keeping
|
|
one is what forking is for — so correcting its text cannot contradict
|
|
anything."""
|
|
ScriptedProvider.replies = gold_replies()
|
|
_play(client, "knock")
|
|
client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
discarded = [a for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
|
|
|
r = _edit(client, discarded.id, "The take nobody chose, corrected.")
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def test_the_guard_lifts_when_the_story_is_brought_back(client):
|
|
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
|
|
has to make the edit possible again.
|
|
|
|
The guard now covers a player's own input only. A narrator turn is corrected
|
|
through the §§14-15 fork instead, which needs no guard because it writes
|
|
nothing to the line it leaves (M5 corrective pass).
|
|
"""
|
|
_turns(client, 3)
|
|
_undo(client)
|
|
live = sorted(
|
|
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
|
|
key=lambda a: a.depth,
|
|
)
|
|
target = live[-1]
|
|
assert _edit(client, target.id, "x").status_code == 400
|
|
|
|
_redo(client)
|
|
|
|
r = _edit(client, target.id, "Corrected once the story was whole again.")
|
|
assert r.status_code == 200, r.text
|