"""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"