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
+6 -9
View File
@@ -57,19 +57,16 @@ def _script_memory(adventure: models.Adventure) -> dict:
def _visible_npcs(adventure: models.Adventure, stat_schema: dict) -> dict[str, str]:
"""NPC story cards (by schema-configured type) whose keys appear in the
recent story — the ones "in scene", so only their stats get injected."""
"""Defined NPCs whose trigger words appear in the recent story — the ones
"in scene", so only their stats get injected. Maps npc id -> display name."""
actions = [a for a in adventure.actions if a.text.strip()]
recent = SEPARATOR.join(a.text for a in actions[-6:]).lower()
types = worldstate.npc_types(stat_schema)
visible: dict[str, str] = {}
for card in adventure.story_cards:
if (card.type or "").lower() not in types:
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
if not isinstance(ndef, dict):
continue
for key in (k.strip().lower() for k in card.keys.split(",")):
if key and key in recent:
visible[str(card.id)] = card.name or f"NPC {card.id}"
break
if any(trigger in recent for trigger in worldstate.npc_triggers(ndef, npc_key)):
visible[npc_key] = worldstate.npc_name(ndef, npc_key)
return visible