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
+71 -13
View File
@@ -199,30 +199,83 @@ export function extractPlaceholders(...texts) {
return names
}
export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
// The modal shown before an adventure begins. It always asks who the player is
// playing as, and it also collects any `${Placeholder}` answers the scenario's
// text asks for.
//
// The two sets of fields are independent on purpose. A scenario that writes
// `${Name}` is asking its own question, and the persona does not answer it. No
// scenario in the repo uses placeholders at all, so the overlap is hypothetical;
// pre-filling one from the other is a small change here if it ever bites.
//
// `onSubmit` receives `{ persona, placeholders }`. Every persona field is
// optional — submitting them all blank gives an adventure with no persona,
// which behaves exactly as adventures did before personas existed.
export function BeginAdventureModal({ title, names = [], onSubmit, onCancel }) {
const [persona, setPersona] = useState({ name: '', pronouns: '', desc: '' })
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
const setField = (field, value) => setPersona((p) => ({ ...p, [field]: value }))
const submit = (e) => {
e.preventDefault()
onSubmit(values)
onSubmit({ persona, placeholders: values })
}
return (
<div className="modal-overlay" onClick={onCancel}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{title}</h2>
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
{names.map((name, i) => (
<label key={name} className="field">
<span className="label">{name}</span>
<p className="modal-hint">
Who are you playing as? Leave these blank to play as an unnamed character.
</p>
<div className="modal-row">
<label className="field">
<span className="label">Name</span>
<input
type="text"
autoFocus={i === 0}
value={values[name]}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
autoFocus
maxLength={80}
placeholder="Kaelen"
value={persona.name}
onChange={(e) => setField('name', e.target.value)}
/>
</label>
))}
<label className="field field-narrow">
<span className="label">Pronouns</span>
<input
type="text"
maxLength={40}
placeholder="they/them"
value={persona.pronouns}
onChange={(e) => setField('pronouns', e.target.value)}
/>
</label>
</div>
<label className="field">
<span className="label">Description</span>
<textarea
rows={3}
placeholder="A half-elf ranger, exiled from the northern holds. Wary of nobles, soft on strays."
value={persona.desc}
onChange={(e) => setField('desc', e.target.value)}
/>
</label>
{names.length > 0 && (
<>
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
{names.map((name) => (
<label key={name} className="field">
<span className="label">{name}</span>
<input
type="text"
value={values[name]}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
/>
</label>
))}
</>
)}
<div className="modal-buttons">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="submit" className="primary">Begin Adventure</button>
@@ -354,14 +407,19 @@ export function AutoTextarea({ value, ...props }) {
return <textarea ref={ref} value={value} {...props} />
}
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
// `maxLength` mirrors the column width the server enforces. Without it an
// over-long value is only rejected at save time, as a 422 the player sees as a
// toast after the text is already typed.
export function Field({ label, value, onChange, textarea, rows, placeholder, maxLength }) {
return (
<label className="field">
<span className="label">{label}</span>
{textarea ? (
<textarea rows={rows || 3} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
<textarea rows={rows || 3} value={value} placeholder={placeholder} maxLength={maxLength}
onChange={(e) => onChange(e.target.value)} />
) : (
<input type="text" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
<input type="text" value={value} placeholder={placeholder} maxLength={maxLength}
onChange={(e) => onChange(e.target.value)} />
)}
</label>
)
+13 -6
View File
@@ -4,7 +4,7 @@ import { api } from '../api'
import {
CardSkeleton,
extractPlaceholders,
PlaceholderModal,
BeginAdventureModal,
ScenarioArt,
useToast,
} from '../components'
@@ -61,9 +61,15 @@ export default function Home() {
const ongoing = useMemo(() => (adventures || []).slice(0, CONTINUE_LIMIT), [adventures])
const featured = useMemo(() => (scenarios || []).slice(0, SCENARIO_LIMIT), [scenarios])
const begin = async (scenarioId, placeholders = {}) => {
const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => {
try {
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
const adv = await api.createAdventure({
scenario_id: scenarioId,
placeholders,
persona_name: persona.name || '',
persona_pronouns: persona.pronouns || '',
persona_desc: persona.desc || '',
})
navigate(`/play/${adv.id}`)
} catch (err) {
toast(err.message, 'error')
@@ -78,7 +84,8 @@ export default function Home() {
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
)
if (names.length === 0) return begin(scenarioId)
// Always open the modal, even with no placeholders: it is where the
// player names their character.
setPending({ scenario, names })
} catch (err) {
toast(err.message, 'error')
@@ -218,11 +225,11 @@ export default function Home() {
</section>
{pending && (
<PlaceholderModal
<BeginAdventureModal
title={pending.scenario.title}
names={pending.names}
onCancel={() => setPending(null)}
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
onSubmit={(answers) => { setPending(null); begin(pending.scenario.id, answers) }}
/>
)}
</div>
@@ -139,7 +139,7 @@ function sliceDraft(draft, prefix) {
// the adventure's scenario defines a stat_schema. An edit mode lets the
// player/author directly override the live values (a manual correction, not
// a turn) — it never adds new stats, only edits ones the schema already defines.
function WorldStateDrawer({ advId, refreshKey }) {
function WorldStateDrawer({ advId, refreshKey, personaName }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null) // { state, schema }
const [failed, setFailed] = useState(false)
@@ -209,7 +209,10 @@ function WorldStateDrawer({ advId, refreshKey }) {
<StatGroup title={null} defs={schema.world} values={state.world}
editing={editing} draft={sliceDraft(draft, 'world')}
onEdit={(name, v) => setPath(`world.${name}`, v)} />
<StatGroup title="You" defs={schema.player} values={state.player}
{/* Named after the persona when there is one, matching the label
the prompt uses. Read-only here — the name is edited in the
Plot panel, so there is one place to change it. */}
<StatGroup title={personaName || 'You'} defs={schema.player} values={state.player}
editing={editing} draft={sliceDraft(draft, 'player')}
onEdit={(name, v) => setPath(`player.${name}`, v)} />
{npcs.length > 0 && (
+2 -1
View File
@@ -514,7 +514,8 @@ export default function Play() {
so neither can key on the story's length alone: switching between two
branches whose windows are both full changes every number in here
without changing `actions.length` by one. */}
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`}
personaName={adventure?.persona_name} />
<StatusDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
<div className="page play-page">
<div className="page-header">
@@ -102,6 +102,25 @@ function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) {
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
)}
{/* Phase 18. The persona sits above the plot text because it is the one
section that describes the player rather than the world, and because
it is the first thing a reader looks for when they want to rename
their character. It is edited only here: the world-state drawer shows
the name as a heading but does not offer a second way to change it. */}
<div className="persona-block">
<div className="persona-row">
<Field label="Character Name" value={adventure.persona_name}
onChange={(v) => setField('persona_name', v)}
maxLength={80} placeholder="Kaelen" />
<Field label="Pronouns" value={adventure.persona_pronouns}
onChange={(v) => setField('persona_pronouns', v)}
maxLength={40} placeholder="they/them" />
</div>
<Field label="Character Description" value={adventure.persona_desc}
onChange={(v) => setField('persona_desc', v)} textarea rows={3}
placeholder="Who you are playing as. The AI never rewrites this — only you can." />
</div>
<Field label="Plot Essentials (Memory)" value={adventure.memory}
onChange={(v) => setField('memory', v)} textarea
placeholder="Key facts the AI should always remember." />
+24 -11
View File
@@ -5,7 +5,7 @@ import {
CardSkeleton,
extractPlaceholders,
pickJSONFile,
PlaceholderModal,
BeginAdventureModal,
ScenarioArt,
useToast,
} from '../components'
@@ -49,8 +49,17 @@ export default function Scenarios() {
navigate(`/scenarios/${scenario.id}`)
}
const begin = async (scenarioId, placeholders = {}) => {
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => {
const adv = await api.createAdventure({
scenario_id: scenarioId,
// A blank adventure has no scenario to take a title from, so name it here
// exactly as the button that starts it did before the modal existed.
title: scenarioId ? null : 'Blank Adventure',
placeholders,
persona_name: persona.name || '',
persona_pronouns: persona.pronouns || '',
persona_desc: persona.desc || '',
})
navigate(`/play/${adv.id}`)
}
@@ -62,14 +71,15 @@ export default function Scenarios() {
// Cards can carry ${placeholders} in trigger keys too, not just entries.
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
)
if (names.length === 0) return begin(scenarioId)
// Always open the modal, even with no placeholders: it is where the
// player names their character.
setPending({ scenario, names })
}
const startBlank = async () => {
const adv = await api.createAdventure({ title: 'Blank Adventure' })
navigate(`/play/${adv.id}`)
}
// A blank adventure has no scenario, so there are no placeholders to collect,
// but the player still names their character. `pending.scenario` is null for
// this path, and `begin` is called with no scenario id.
const startBlank = () => setPending({ scenario: null, names: [] })
return (
<div className="page">
@@ -157,11 +167,14 @@ export default function Scenarios() {
)}
{pending && (
<PlaceholderModal
title={pending.scenario.title}
<BeginAdventureModal
title={pending.scenario ? pending.scenario.title : 'Blank Adventure'}
names={pending.names}
onCancel={() => setPending(null)}
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
onSubmit={(answers) => {
setPending(null)
begin(pending.scenario ? pending.scenario.id : null, answers)
}}
/>
)}
</div>
+12
View File
@@ -54,4 +54,16 @@
}
.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
/* The begin-adventure modal puts name and pronouns on one line. Pronouns need
only a few characters, so the name takes the slack. Both stack on a narrow
screen rather than shrinking the name field to nothing. */
.modal .modal-row { display: flex; gap: 10px; }
.modal .modal-row > .field { flex: 1 1 auto; min-width: 0; }
.modal .modal-row > .field-narrow { flex: 0 1 9rem; }
@media (max-width: 480px) {
.modal .modal-row { display: block; }
.modal .modal-row > .field-narrow { flex: none; }
}
/* A second hint, introducing the scenario's own questions below the persona. */
.modal .modal-hint + .field, .modal .field + .modal-hint { margin-top: 4px; }
+12
View File
@@ -63,3 +63,15 @@
white-space: pre;
}
/* Phase 18: the persona block at the top of the Plot panel. The rule under it
separates who the player is from what the world is, which are the two halves
of that panel. */
.persona-block { margin-bottom: 18px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
.persona-block .persona-row { display: flex; gap: 10px; }
.persona-block .persona-row > .field { flex: 1 1 auto; min-width: 0; }
.persona-block .persona-row > .field:last-child { flex: 0 1 9rem; }
@media (max-width: 480px) {
.persona-block .persona-row { display: block; }
.persona-block .persona-row > .field:last-child { flex: none; }
}