Files
interactive-story/backend/tests/test_persona.py
T
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

368 lines
14 KiB
Python

"""Phase 18: the adventure's persona — who the player is playing as.
Before this existed the protagonist had no name anywhere in the prompt. The
player's stat block rendered as `You: hp 100/100` and the summarizer was handed
second-person prose with nothing to say who "you" was.
The rules this file holds in place:
* An empty `persona_name` behaves exactly as the app did before personas
existed, so an adventure that predates the migration is not a special case.
* The persona is in the **system** block. It is user-only, so it never changes
during a story, and putting it in the cached prefix is what makes it free.
* A persona works without an RPG layer. That is the case it was added for.
* Naming the block does not rename the path. The model still writes
`player.hp`, and a delta aimed at `persona.*` is refused.
python -m pytest tests/test_persona.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models, worldstate
from app.context import builder
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
SCHEMA = {
"player": {"hp": {"min": 0, "max": 100, "initial": 100, "desc": "Health"}},
"world": {"day": {"min": 1, "initial": 1, "desc": "Day"}},
}
def _persona(**kwargs) -> models.Adventure:
"""An unsaved adventure carrying only the fields `render_persona` reads."""
return models.Adventure(
persona_name=kwargs.get("name", ""),
persona_pronouns=kwargs.get("pronouns", ""),
persona_desc=kwargs.get("desc", ""),
)
# ------------------------------------------------------------- the rendering
def test_no_persona_renders_nothing():
assert builder.render_persona(_persona()) == ""
def test_whitespace_only_is_the_same_as_empty():
"""The columns default to '', but a player can clear a field to spaces."""
assert builder.render_persona(_persona(name=" ", pronouns=" ", desc="\n")) == ""
def test_name_and_pronouns_and_description():
text = builder.render_persona(
_persona(name="Kaelen", pronouns="he/him", desc="A half-elf ranger.")
)
assert text.startswith("Player character:\n")
assert "You are Kaelen (he/him)." in text
assert "A half-elf ranger." in text
def test_name_alone_is_enough():
text = builder.render_persona(_persona(name="Kaelen"))
assert "You are Kaelen." in text
assert "(" not in text, "no empty pronoun bracket"
def test_description_alone_is_enough():
"""A player who names no character still gets their description through."""
text = builder.render_persona(_persona(desc="A nameless wanderer."))
assert "A nameless wanderer." in text
assert "You are" not in text
# ---------------------------------------------------- the world-state labels
def test_state_section_labels_the_block_with_the_name():
state = worldstate.instantiate(SCHEMA)
block = worldstate.render_state_section(state, SCHEMA, {}, "Kaelen")
# The path travels with the name. Without it the model reads "Kaelen" in
# the prose and writes `kaelen.hp`, which `apply_delta` then refuses.
assert "Kaelen (player): hp 100/100" in block
def test_state_section_falls_back_to_you():
state = worldstate.instantiate(SCHEMA)
block = worldstate.render_state_section(state, SCHEMA, {}, "")
assert "You: hp 100/100" in block
assert "(player)" not in block
def test_reference_ties_the_name_to_the_path():
guide = worldstate.render_reference(SCHEMA, "Kaelen")
assert "Protagonist Kaelen" in guide
assert "player.<stat>" in guide
def test_reference_without_a_persona_is_unchanged():
assert "Protagonist" not in worldstate.render_reference(SCHEMA, "")
def test_reference_does_not_repeat_the_description():
"""The description has its own section. The guide is about paths."""
guide = worldstate.render_reference(SCHEMA, "Kaelen")
assert "half-elf" not in guide
# ------------------------------------------------------ the assembled prompt
@pytest.fixture()
def story():
"""An adventure with an RPG layer, a persona, and a little history."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="persona@example.com")
db.add(user)
db.flush()
settings = models.Settings(user_id=user.id, api_key="enc:dummy", model="m")
db.add(settings)
scenario = models.Scenario(
user_id=user.id, title="S", prompt="A road.", stat_schema=SCHEMA
)
db.add(scenario)
db.flush()
adventure = models.Adventure(
user_id=user.id, title="A", scenario_id=scenario.id, script_state={},
memory="The hero hunts bandits.",
ai_instructions="Write in second person.",
story_summary="The hero left the village.",
world_state=worldstate.instantiate(SCHEMA),
persona_name="Kaelen",
persona_pronouns="he/him",
persona_desc="A half-elf ranger, exiled from the northern holds.",
)
db.add(adventure)
db.flush()
for i in range(6):
db.add(models.Action(adventure_id=adventure.id,
type="ai" if i % 2 else "do",
text=f"[{i}] The road bends onward past the treeline."))
db.commit()
db.expire_all()
adventure = db.get(models.Adventure, adventure.id)
settings = db.get(models.Settings, settings.id)
try:
yield db, adventure, settings
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def test_persona_is_in_the_system_block(story):
"""It is user-only, so it belongs in the cached prefix rather than below
the history with the values that move."""
db, adventure, settings = story
system_text, story_text, _ = builder.build_context(adventure, settings)
assert "You are Kaelen (he/him)." in system_text
assert "A half-elf ranger" in system_text
assert "Kaelen (he/him)" not in story_text
def test_persona_does_not_move_between_turns(story):
"""The static block has to be byte-identical across turns. A persona that
changed with the world state would re-price the whole history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
# Move the live state. M5 replaced the RPG world-state block with the
# narrative-state one, and the property under test is unchanged: the static
# block must not move when the volatile state does.
adventure.narrative_state = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after, "the static block moved when the state did"
assert "the lantern is lit" in story_text, "the new state did not reach the model"
def test_persona_sits_above_the_plot_essentials(story):
db, adventure, settings = story
_, _, report = builder.build_context(adventure, settings)
labels = [s["label"] for s in report["sections"]]
assert labels.index("persona") < labels.index("plot_essentials")
def test_no_persona_section_when_the_fields_are_blank(story):
db, adventure, settings = story
adventure.persona_name = ""
adventure.persona_pronouns = ""
adventure.persona_desc = ""
db.commit()
system_text, story_text, report = builder.build_context(adventure, settings)
assert "persona" not in [s["label"] for s in report["sections"]]
assert "Player character" not in system_text
# The story text still assembles; what it no longer carries is the RPG
# stat line the persona used to be rendered into (M5).
assert story_text
def test_persona_is_charged_to_the_token_budget(story):
"""It moved into `system_sections`, and everything there is counted in
`reserved`. If it were not, the history would overrun the budget."""
db, adventure, settings = story
_, _, report = builder.build_context(adventure, settings)
persona = next(s for s in report["sections"] if s["label"] == "persona")
assert persona["tokens"] > 0
assert report["tokens"]["total"] >= persona["tokens"]
# --------------------------------------------- a persona with no RPG layer
@pytest.fixture()
def plain():
"""A blank adventure: no scenario, so no `stat_schema` at all."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="plain@example.com")
db.add(user)
db.flush()
settings = models.Settings(user_id=user.id, api_key="enc:dummy", model="m")
db.add(settings)
adventure = models.Adventure(
user_id=user.id, title="A", script_state={}, world_state={},
persona_name="Wren", persona_pronouns="they/them",
persona_desc="A courier who reads other people's letters.",
)
db.add(adventure)
db.flush()
db.add(models.Action(adventure_id=adventure.id, type="do", text="Walk east."))
db.commit()
db.expire_all()
adventure = db.get(models.Adventure, adventure.id)
settings = db.get(models.Settings, settings.id)
try:
yield db, adventure, settings
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def test_persona_works_without_an_rpg_layer(plain):
"""The case the persona exists for. It must not be gated behind `has_ws`."""
db, adventure, settings = plain
system_text, _, _ = builder.build_context(adventure, settings)
assert "You are Wren (they/them)." in system_text
assert "A courier who reads other people's letters." in system_text
# ------------------------------------------------- the AI cannot rewrite it
@pytest.mark.parametrize("path", ["persona.name", "persona.desc", "persona"])
def test_a_delta_aimed_at_the_persona_is_refused(path):
"""Nothing was built for this: `_resolve` rejects unknown paths already.
The test holds the behavior in place, because the persona sitting in the
cached prefix depends on the model being unable to move it."""
state = worldstate.instantiate(SCHEMA)
_, report = worldstate.apply_delta(state, SCHEMA, {path: "Bob"}, 1)
assert [r["path"] for r in report["rejected"]] == [path]
assert not report["applied"]
def test_the_player_path_still_works_with_a_persona_set():
"""Naming the block does not rename the path."""
state = worldstate.instantiate(SCHEMA)
new_state, report = worldstate.apply_delta(state, SCHEMA, {"player.hp": -15}, 1)
assert new_state["player"]["hp"] == 85
assert [r["path"] for r in report["applied"]] == ["player.hp"]
# ------------------------------------- setting it at the start, editing it later
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="persona-api@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
setup.commit()
user_id = user.id
setup.close()
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)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_a_persona_can_be_named_when_the_adventure_is_created(client):
"""The modal that used to appear only for `${Placeholder}` scenarios now
always opens, and this is what it posts."""
r = client.post("/api/adventures", json={
"title": "A", "persona_name": "Kaelen", "persona_pronouns": "he/him",
"persona_desc": "A half-elf ranger.",
})
assert r.status_code == 201, r.text
body = r.json()
assert body["persona_name"] == "Kaelen"
assert body["persona_pronouns"] == "he/him"
assert body["persona_desc"] == "A half-elf ranger."
def test_creating_without_a_persona_still_works(client):
"""Every field is optional. A blank adventure is the old behavior."""
r = client.post("/api/adventures", json={"title": "A"})
assert r.status_code == 201, r.text
assert r.json()["persona_name"] == ""
def test_the_persona_is_stripped_on_the_way_in(client):
r = client.post("/api/adventures", json={
"title": "A", "persona_name": " Kaelen ", "persona_desc": " ranger \n",
})
assert r.json()["persona_name"] == "Kaelen"
assert r.json()["persona_desc"] == "ranger"
def test_the_persona_can_be_edited_later(client):
"""A player renames their character mid-story; nothing else moves."""
adv_id = client.post("/api/adventures", json={
"title": "A", "persona_name": "Kaelen",
}).json()["id"]
r = client.patch(f"/api/adventures/{adv_id}", json={"persona_name": "Aria"})
assert r.status_code == 200, r.text
assert r.json()["persona_name"] == "Aria"
assert client.get(f"/api/adventures/{adv_id}").json()["persona_name"] == "Aria"
def test_an_over_long_name_is_refused_rather_than_truncated(client):
"""The column is VARCHAR(80). A 422 here is a 500 at INSERT otherwise."""
r = client.post("/api/adventures", json={"title": "A", "persona_name": "K" * 81})
assert r.status_code == 422
def test_placeholders_and_the_persona_are_independent(client):
"""A scenario asking for `${Name}` is asking its own question. Nothing
fills it in from the persona, and nothing fills the persona from it."""
db = SessionLocal()
scenario = models.Scenario(
user_id=db.query(models.User).first().id,
title="S", prompt="A guard sneers at ${Name}.",
)
db.add(scenario)
db.commit()
scenario_id = scenario.id
db.close()
adv = client.post("/api/adventures", json={
"scenario_id": scenario_id,
"persona_name": "Kaelen",
"placeholders": {"Name": "Wren"},
}).json()
assert adv["persona_name"] == "Kaelen"
opening = next(a for a in adv["actions"] if a["type"] == "start")
assert "A guard sneers at Wren." == opening["text"]