Make NPCs a dedicated section with per-NPC stats

Replace the single shared `npc` stat template (+ npc_card_types) with an
`npcs` section: each NPC keyed by a stable id, carrying its own name,
description, trigger keys, and its OWN stats block. The AI addresses NPCs
as npc.<id>.<stat> (id shown in context), which also fixes the old
card-id-guessing problem. On adventure creation each NPC auto-creates a
story card (name/keys/desc) for lore + in-scene detection, unless a
same-name card already exists. All NPCs instantiate up front.

- engine: npcs instantiate/apply/render/reference, npc_name/npc_triggers
- builder: _visible_npcs matches each NPC's own keys
- create_adventure: auto-create story cards from npcs
- WorldStateDrawer: render defined NPCs with their own stats + desc tooltip
- demo seed: Gwen (health/trust) + Bandit Leader (health/aggression)
- tests updated (34 pass); no new migration (npcs lives in stat_schema JSON)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parththakkar106
2026-07-21 15:17:47 +05:30
co-authored by Claude Opus 4.8
parent 4dac445f90
commit cf464a48b0
10 changed files with 206 additions and 106 deletions
+8 -9
View File
@@ -518,12 +518,12 @@ function StatRow({ name, def, value }) {
)
}
function StatGroup({ title, defs, values }) {
function StatGroup({ title, defs, values, desc }) {
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
if (entries.length === 0) return null
return (
<div className="ws-group">
{title && <h3 className="ws-group-title">{title}</h3>}
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
{entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def} value={values?.[name]} />
))}
@@ -534,7 +534,7 @@ function StatGroup({ title, defs, values }) {
// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC
// stats with bands + bars, and a milestones checklist. Renders nothing unless
// the adventure's scenario defines a stat_schema.
function WorldStateDrawer({ advId, refreshKey, cards }) {
function WorldStateDrawer({ advId, refreshKey }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null) // { state, schema }
const [failed, setFailed] = useState(false)
@@ -552,10 +552,8 @@ function WorldStateDrawer({ advId, refreshKey, cards }) {
if (!schema) return null // no RPG layer for this adventure
const state = data?.state || {}
const cardName = (id) =>
cards?.find((c) => String(c.id) === String(id))?.name || `NPC ${id}`
const npcState = state.npc || {}
const npcIds = Object.keys(npcState)
const npcs = Object.entries(schema.npcs || {}) // [id, def] — each with its own stats
const flags = Object.entries(schema.flags || {})
const flagState = state.flags || {}
const milestones = Object.entries(schema.milestones || {})
@@ -579,8 +577,9 @@ function WorldStateDrawer({ advId, refreshKey, cards }) {
<>
<StatGroup title={null} defs={schema.world} values={state.world} />
<StatGroup title="You" defs={schema.player} values={state.player} />
{npcIds.map((id) => (
<StatGroup key={id} title={cardName(id)} defs={schema.npc} values={npcState[id]} />
{npcs.map(([id, def]) => (
<StatGroup key={id} title={def.name || id} desc={def.desc}
defs={def.stats} values={npcState[id]} />
))}
{flags.length > 0 && (
<div className="ws-group">
@@ -973,7 +972,7 @@ export default function Play() {
return (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
<WorldStateDrawer advId={id} refreshKey={actions.length} cards={adventure.story_cards} />
<WorldStateDrawer advId={id} refreshKey={actions.length} />
<StatusDrawer advId={id} refreshKey={actions.length} />
<div className="page play-page">
<div className="page-header">
+6 -5
View File
@@ -195,10 +195,11 @@ export default function ScenarioEditor() {
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Optional. Define stats (with bands and rules) and milestones as a JSON object,
and the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
Leave blank for a plain narrative scenario. NPC stats apply to story cards of the
configured <code>npc_card_types</code>.
Optional. Define stats (with bands and rules), NPCs, flags, and milestones as a
JSON object, and the AI will track them each turn — HP, mana, an NPC’s trust,
quest objectives. Each NPC in <code>npcs</code> has its own <code>name</code>,
<code>desc</code>, trigger <code>keys</code>, and <code>stats</code>; a story card
is created for it automatically. Leave blank for a plain narrative scenario.
</p>
<textarea
className="schema-editor"
@@ -206,7 +207,7 @@ export default function ScenarioEditor() {
onChange={(e) => setSchema(e.target.value)}
rows={12}
spellCheck={false}
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "npcs": {\n "gwen": { "name": "Gwen", "keys": "Gwen, ranger", "desc": "...",\n "stats": { "trust": { "min": -100, "max": 100, "initial": 20 } } }\n },\n "flags": { "has_key": { "desc": "...", "initial": false } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
/>
{schemaError && <div className="schema-error">⚠ {schemaError}</div>}