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:
co-authored by
Claude Opus 4.8
parent
4dac445f90
commit
cf464a48b0
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user