Files
interactive-story/backend/tests/test_worldstate_integration.py
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
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
2026-09-05 07:01:50 -04:00

216 lines
8.2 KiB
Python

"""M5: the RPG world state is demoted, and this file is what holds it there.
This was the end-to-end test for Phase 12's RPG world state: a scenario with a
`stat_schema`, a turn whose reply carried a relative-delta block, the referee
applying it, and undo rolling it back.
**M5 removed that pipeline from the turn path** (ADR 010). Relative deltas were
ambiguous by construction — a number in a delta field is syntactically legal
whether the model meant "add 50" or "set to 50" — and the genre-neutral typed
events in `app/narrative/` replaced them. The engine in `app/worldstate/` is
retained so a pre-M5 database opens unchanged and its numbers stay readable, and
its own logic is still covered by `test_worldstate.py`; what no longer exists is
its authority over new turns.
So this file now tests the demotion, which is worth a test precisely because it
is an absence: nothing else would notice if the delta pipeline were quietly
rewired, and a second authoritative state engine is the specific failure the M5
brief names.
What was deleted, and why, rather than being re-instrumented:
* `test_turn_applies_clamped_delta_and_strips_block`, `test_action_world_changes_summary`,
`test_world_state_endpoint`, `test_undo_reverts_world_state`,
`test_retry_does_not_double_apply` — each asserted that a turn applies a
delta through the referee. That is the removed capability itself, not
instrumentation for something else. The invariants they *also* touched —
undo restoring state, a retry not double-applying — are alive and better
covered in `test_head_cursor.py`, `test_take_state.py` and
`test_narrative_state.py`, now measured through the production state path.
python -m pytest tests/test_worldstate_integration.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models, worldstate
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
SCHEMA = {
"player": {"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30}},
"npcs": {
"gwen": {
"name": "Gwen", "keys": "Gwen",
"desc": "A loyal ranger ally.",
"stats": {"trust": {"min": -100, "max": 100, "initial": 0}},
},
},
"flags": {"alarm": {"desc": "The enemy is alerted", "initial": False}},
"milestones": {"win": {"desc": "Win the fight"}},
}
# The faked model narrates and appends a delta that exceeds the per-turn
# cap, so the test can confirm the engine clamps it. It also flips a flag
# and completes a milestone.
AI_REPLY = (
"The goblin's blade bites deep and Gwen nods at your resolve.\n\n"
'```state\n{"player.hp": -80, "npc.gwen.trust": 15, "flags.alarm": true, "milestones.win": true}\n```'
)
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="rpg@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="Dungeon", stat_schema=SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, scenario_id=scenario.id, title="Run",
world_state=worldstate.instantiate(SCHEMA),
)
setup.add(adv)
setup.flush()
# "Gwen" in the story text makes her NPC in-scene (matches the "gwen" npc's keys).
setup.add(models.Action(adventure_id=adv.id, type="start",
text="You face a goblin. Gwen watches."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = [AI_REPLY]
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 _world(adv_id):
db = SessionLocal()
try:
return db.get(models.Adventure, adv_id).world_state
finally:
db.close()
def _last_ai_text(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.actions[-1].text
finally:
db.close()
def _play(client, text="attack the goblin"):
r = client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": "do", "text": text})
assert r.status_code == 200, r.text
return r
def test_a_turn_no_longer_applies_a_relative_delta_block(client):
"""The demotion, asserted.
The fixture's reply carries the old protocol. Under Phase 12 this moved hp
from 100 to 70. Under M5 the referee is not in the turn path at all, so the
numbers stay where they were — and the block still leaves the prose, because
a reader must never see the protocol whichever protocol it is.
"""
_play(client)
world = _world(client.adv_id)
assert world["player"]["hp"] == 100, "the delta pipeline is still wired into turns"
assert world.get("milestones", {}) == {}
assert world.get("flags", {}) in ({}, {"alarm": False})
text = _last_ai_text(client.adv_id)
assert "```state" not in text, "the protocol reached the reader"
assert "goblin's blade" in text
# The raw reply is still kept, so the Insights view and the audit can show
# what the model actually sent.
db = SessionLocal()
try:
snapshot = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
finally:
db.close()
assert '"player.hp": -80' in snapshot["raw_output"]
def test_an_old_style_block_proposes_no_narrative_events(client):
"""A delta block is not a typed proposal, and must not be read as one.
`{"player.hp": -80}` is a JSON object with no `events` list. It parses, so
it is not malformed; it simply proposes nothing. What matters is that no
part of it is coerced into an event — a state engine that guessed here would
reintroduce exactly the ambiguity ADR 010 removed.
"""
_play(client)
db = SessionLocal()
try:
proposal = (
db.query(models.StateProposal)
.filter_by(adventure_id=client.adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
assert proposal is not None, "no proposal was recorded for the turn"
assert db.query(models.StateEvent).filter_by(
adventure_id=client.adv_id).count() == 0
finally:
db.close()
def test_the_narrative_state_is_what_the_turn_now_establishes(client):
"""And the replacement works on the same campaign, in the same turn shape."""
ScriptedProvider.replies = [
'The blade turns aside.\n```state\n{"events": ['
'{"type": "create_entity", "entity": "gwen", "entity_type": "character",'
' "name": "Gwen"},'
'{"type": "set_entity_status", "entity": "gwen", "status": "wary"}'
']}\n```'
]
_play(client, "parry")
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
document = r.json()["document"]
assert document["entities"]["gwen"]["status"] == "wary"
def test_override_world_state_endpoint(client):
r = client.put(f"/api/adventures/{client.adv_id}/world-state",
json={"player.hp": 5, "flags.alarm": True, "npc.bogus.trust": 1})
assert r.status_code == 200, r.text
body = r.json()
assert body["state"]["player"]["hp"] == 5
assert body["state"]["flags"]["alarm"] is True
assert body["report"]["rejected"][0]["reason"] == "unknown npc"
# persisted to the DB, not just the response.
assert _world(client.adv_id)["player"]["hp"] == 5
# This bypasses max_delta_per_turn (30) because it is a direct correction, not a turn.
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
assert r.json()["state"]["player"]["hp"] == 100