Give the adventure a persona, so the protagonist has a name

The player had stats but no identity. `stat_schema.player` carried hp and
mana beside `npc.gwen.trust`, but where an NPC has a name and a
description the player had neither, so the block rendered as
`You: hp 100/100` and nothing in the prompt said who "you" was.

Three columns on `adventures`: name, pronouns, description. All
user-only, all optional, and an empty name means the app behaves exactly
as it did before — no backfill, no special case for an adventure that
predates the migration.

They are adventure columns rather than part of `stat_schema` for two
reasons. An adventure with no RPG layer still has a protagonist, and
that is the case this was added for. And `worldstate.schema._initials`
treats every dict inside a stat section as a stat definition, so a
persona placed there would be instantiated, rendered in the guide, and
handed an `initial` value as though it were one.

The paths do not change. `player.hp` stays `player.hp`; only the label
moves, to `Kaelen (player): hp 100/100`, the same way NPC lines already
print a display name beside the id. A path carrying the persona's name
would break the moment a player renamed their character, because
`_history_text` replays every past turn's stored delta into the prompt
and those blobs hold literal `player.hp` strings.

The section sits in the system block. Only the user can edit it, so it
never changes mid-story and stays inside the cached prefix. That is what
makes it free, and it is why the AI must not be able to move it — a
delta aimed at `persona.*` is already refused by `_resolve`, and there
is now a test holding that in place.

The modal that used to appear only for scenarios with `${Placeholder}`
tokens now always opens, and is where the character is named. Persona
and placeholders stay independent: a scenario asking for `${Name}` is
asking its own question. No scenario in the repo uses placeholders at
all, so the overlap is hypothetical.

Phase 2, which feeds the persona and the cast to the summarizer, is
written up in plan/18 and not started. That is where the memory-quality
problem actually gets fixed; this change is what gives it a name to use.

Not yet driven in a browser — plan/18 lists what to check by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPyQN926gkZTAYfgugcaok
This commit is contained in:
Claude
2026-08-31 15:03:20 +05:30
committed by Parth
parent 9faf788bd5
commit 9ee052c51e
21 changed files with 1076 additions and 39 deletions
+47
View File
@@ -586,3 +586,50 @@ def test_an_unknown_format_is_refused(client):
r = _import(client, {"format": "ai-dnd-adventure-v3", "title": "From the future"})
assert r.status_code == 400, r.text
assert bundle.FORMAT in r.json()["detail"]
# ------------------------------------------------------- the persona (Phase 18)
def test_the_persona_survives_the_round_trip(client):
adv_id = client.adv_id
assert client.patch(f"/api/adventures/{adv_id}", json={
"persona_name": "Kaelen",
"persona_pronouns": "he/him",
"persona_desc": "A half-elf ranger, exiled from the northern holds.",
}).status_code == 200
payload = _export(client, adv_id)
assert payload["persona"] == {
"name": "Kaelen",
"pronouns": "he/him",
"desc": "A half-elf ranger, exiled from the northern holds.",
}
copy_id = _imported(client, payload)
r = client.get(f"/api/adventures/{copy_id}")
assert r.status_code == 200, r.text
copy = r.json()
assert copy["persona_name"] == "Kaelen"
assert copy["persona_pronouns"] == "he/him"
assert copy["persona_desc"].startswith("A half-elf ranger")
def test_a_bundle_written_before_personas_still_imports(client):
"""The key is read with `.get`, so a v2 file from before Phase 18 lands
with an empty persona rather than failing. No FORMAT bump was needed."""
payload = _export(client, client.adv_id)
del payload["persona"]
copy = client.get(f"/api/adventures/{_imported(client, payload)}").json()
assert copy["persona_name"] == ""
assert copy["persona_pronouns"] == ""
assert copy["persona_desc"] == ""
def test_an_over_long_persona_in_a_raw_bundle_is_truncated(client):
"""A raw-dict import bypasses the schemas, so the widths the columns
declare are enforced here instead. Postgres would reject the INSERT."""
payload = _export(client, client.adv_id)
payload["persona"] = {"name": "K" * 500, "pronouns": "p" * 500, "desc": "d"}
copy = client.get(f"/api/adventures/{_imported(client, payload)}").json()
assert len(copy["persona_name"]) == 80
assert len(copy["persona_pronouns"]) == 40