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
368 lines
14 KiB
Python
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"]
|