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
+19
View File
@@ -98,6 +98,7 @@ def create_adventure(
db.flush()
if scenario:
existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards}
for card in scenario.story_cards:
db.add(
models.StoryCard(
@@ -109,6 +110,24 @@ def create_adventure(
notes=card.notes,
)
)
# Phase 12: each defined NPC gets a story card (for its description as
# lore + in-scene triggering), unless a card with that name already exists.
for npc_key, ndef in (scenario.stat_schema or {}).get("npcs", {}).items():
if not isinstance(ndef, dict):
continue
name = worldstate.npc_name(ndef, npc_key)
if name.strip().lower() in existing_names:
continue
db.add(
models.StoryCard(
adventure_id=adventure.id,
type="character",
name=name,
keys=fill_placeholders(str(ndef.get("keys") or name), values),
entry=fill_placeholders(str(ndef.get("desc") or ""), values),
notes="",
)
)
for position, script in enumerate(scenario.scripts):
db.add(
models.AdventureScript(
+36 -19
View File
@@ -7,7 +7,6 @@
"ai_instructions": "Write in second person, present tense. End each reply where the player can act. Let the world state guide the fiction — if the player is badly hurt, show it.",
"tags": "demo, rpg, world-state, combat, short",
"stat_schema": {
"npc_card_types": ["character"],
"world": {
"day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." }
},
@@ -28,18 +27,43 @@
"alarm_raised": { "desc": "True once the bandits know they're under attack; stealth is blown.", "initial": false },
"player_hidden": { "desc": "True while the player is out of sight in cover.", "initial": true }
},
"npc": {
"health": {
"desc": "This companion's physical health.",
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35,
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"],
[50, 80, "scratched"], [80, 101, "healthy"]]
"npcs": {
"gwen": {
"name": "Gwen",
"keys": "Gwen, ranger, her",
"desc": "A loyal ranger and the player's ally. Quick with a bow, dry-humoured, fiercely protective. Her trust rises when the player fights smart and watches her back, and falls when they are reckless with her life.",
"stats": {
"health": {
"desc": "Gwen's physical health.",
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35,
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"],
[50, 80, "scratched"], [80, 101, "healthy"]]
},
"trust": {
"desc": "How much Gwen trusts the player; rises with smart, loyal play and falls with recklessness.",
"min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
"bands": [[-100, -30, "hostile"], [-30, 30, "wary"], [30, 70, "friendly"],
[70, 101, "devoted"]]
}
}
},
"trust": {
"desc": "How much this companion trusts the player; rises with smart, loyal play and falls with recklessness.",
"min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
"bands": [[-100, -30, "hostile"], [-30, 30, "wary"], [30, 70, "friendly"],
[70, 101, "devoted"]]
"bandit_leader": {
"name": "Bandit Leader",
"keys": "leader, chief, boss, scarred",
"desc": "The scarred leader of the bandit camp, guarding the strongbox. Fights harder the more cornered he becomes.",
"stats": {
"health": {
"desc": "The leader's physical health.",
"min": 0, "max": 120, "initial": 120, "max_delta_per_turn": 40,
"bands": [[0, 1, "dead"], [1, 30, "near death"], [30, 70, "bloodied"],
[70, 121, "unhurt"]]
},
"aggression": {
"desc": "How aggressively the leader fights; climbs as the fight turns against him.",
"min": 0, "max": 100, "initial": 40, "max_delta_per_turn": 25,
"bands": [[0, 30, "cautious"], [30, 70, "fierce"], [70, 101, "berserk"]]
}
}
}
},
"milestones": {
@@ -49,13 +73,6 @@
}
},
"story_cards": [
{
"type": "character",
"name": "Gwen",
"keys": "Gwen, ranger, her",
"entry": "Gwen is a loyal ranger and the player's ally. Quick with a bow, dry-humoured, fiercely protective. Her trust in the player rises when they fight smart and watch her back, and falls when they are reckless with her life.",
"notes": ""
},
{
"type": "location",
"name": "Bandit Camp",
+4 -2
View File
@@ -8,7 +8,8 @@ from .engine import (
extract_delta,
has_schema,
instantiate,
npc_types,
npc_name,
npc_triggers,
render_reference,
render_state_section,
)
@@ -20,7 +21,8 @@ __all__ = [
"extract_delta",
"has_schema",
"instantiate",
"npc_types",
"npc_name",
"npc_triggers",
"render_reference",
"render_state_section",
]
+53 -27
View File
@@ -16,18 +16,18 @@ import re
# stat_schema top-level sections that hold stat definitions.
STAT_SECTIONS = ("world", "player")
DEFAULT_NPC_TYPES = ("character", "npc")
# Appended once to the system prompt so the model knows how to report changes.
EMIT_RULE = (
"After your narration, if and ONLY IF something in the world state changed this "
"turn, append a fenced code block labelled `state` containing a JSON object of "
"the CHANGES ONLY, as deltas (not new totals). Use paths like "
'"player.hp", "world.day", "npc.<id>.trust"; "flags.<name>": true or false to '
'toggle an on/off state; and "milestones.<id>": true when an objective is '
"completed. Send only things that actually changed; never restate unchanged "
"values. If nothing changed, omit the block entirely. Example:\n"
'```state\n{"player.hp": -15, "flags.has_key": true, "milestones.escaped": true}\n```'
'"player.hp", "world.day", "npc.<id>.<stat>" (use the exact npc id shown in the '
'world state, e.g. npc.gwen.trust); "flags.<name>": true or false to toggle an '
'on/off state; and "milestones.<id>": true when an objective is completed. Send '
"only things that actually changed; never restate unchanged values. If nothing "
"changed, omit the block entirely. Example:\n"
'```state\n{"player.hp": -15, "npc.gwen.trust": 5, "milestones.escaped": true}\n```'
)
# ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL.
@@ -42,14 +42,20 @@ def has_schema(stat_schema: dict | None) -> bool:
return False
return any(
isinstance(stat_schema.get(k), dict) and stat_schema[k]
for k in (*STAT_SECTIONS, "npc", "milestones", "flags")
for k in (*STAT_SECTIONS, "npcs", "milestones", "flags")
)
def npc_types(stat_schema: dict) -> set[str]:
raw = stat_schema.get("npc_card_types")
types = raw if isinstance(raw, list) and raw else DEFAULT_NPC_TYPES
return {str(t).lower() for t in types}
def npc_name(ndef: dict, key: str) -> str:
name = ndef.get("name")
return name.strip() if isinstance(name, str) and name.strip() else key
def npc_triggers(ndef: dict, key: str) -> list[str]:
"""Lower-cased trigger words for detecting an NPC in scene: its `keys`
field, falling back to its display name."""
raw = ndef.get("keys") or npc_name(ndef, key)
return [k.strip().lower() for k in str(raw).split(",") if k.strip()]
def _initials(defs: dict) -> dict:
@@ -67,7 +73,12 @@ def instantiate(stat_schema: dict | None) -> dict:
ws: dict = {}
for section in STAT_SECTIONS:
ws[section] = _initials(stat_schema.get(section) or {})
ws["npc"] = {} # per-card, filled lazily on first change
# Each defined NPC gets its own stat block from its own `stats` defs.
ws["npc"] = {
key: _initials(ndef.get("stats") or {})
for key, ndef in (stat_schema.get("npcs") or {}).items()
if isinstance(ndef, dict)
}
ws["milestones"] = {} # only reached ones are stored
ws["flags"] = {
name: bool(d.get("initial", False))
@@ -216,7 +227,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") or {}
npc_defs = stat_schema.get("npc") or {}
npcs = stat_schema.get("npcs") or {}
for raw_path, change in delta.items():
path = str(raw_path)
@@ -265,14 +276,19 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
action_index, meta, report)
continue
# npc.<cardId>.<stat>
# npc.<npcId>.<stat> — each NPC has its own stat defs.
if parts[0] == "npc" and len(parts) == 3:
stat_def = npc_defs.get(parts[2])
ndef = npcs.get(parts[1])
if not isinstance(ndef, dict):
report["rejected"].append({"path": path, "reason": "unknown npc"})
continue
stat_defs = ndef.get("stats") or {}
stat_def = stat_defs.get(parts[2])
if not isinstance(stat_def, dict):
report["rejected"].append({"path": path, "reason": "unknown npc stat"})
continue
npcs = ws.setdefault("npc", {})
container = npcs.setdefault(parts[1], _initials(npc_defs))
npc_state = ws.setdefault("npc", {})
container = npc_state.setdefault(parts[1], _initials(stat_defs))
_apply_stat(container, parts[2], stat_def, change, path,
action_index, meta, report)
continue
@@ -316,13 +332,16 @@ def render_state_section(world_state: dict, stat_schema: dict,
if player_line:
lines.append(f"You: {player_line}.")
npc_defs = stat_schema.get("npc") or {}
npcs = stat_schema.get("npcs") or {}
npc_state = ws.get("npc") or {}
for card_id, name in visible_npcs.items():
values = npc_state.get(card_id) or _initials(npc_defs)
npc_line = _stat_line(npc_defs, values)
for npc_key, name in visible_npcs.items():
ndef = npcs.get(npc_key) or {}
stat_defs = ndef.get("stats") or {}
values = npc_state.get(npc_key) or _initials(stat_defs)
npc_line = _stat_line(stat_defs, values)
if npc_line:
lines.append(f"{name}: {npc_line}.")
# Show the id so the AI can address it as npc.<id>.<stat>.
lines.append(f"{name} (npc.{npc_key}): {npc_line}.")
flag_defs = stat_schema.get("flags") or {}
flag_state = ws.get("flags") or {}
@@ -380,11 +399,18 @@ def render_reference(stat_schema: dict) -> str:
row = _describe_stat(name, d)
if row:
lines.append(row)
for name, d in (stat_schema.get("npc") or {}).items():
if isinstance(d, dict):
row = _describe_stat(f"NPC {name}", d)
if row:
lines.append(row)
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
if not isinstance(ndef, dict):
continue
name = npc_name(ndef, npc_key)
desc = ndef.get("desc")
if isinstance(desc, str) and desc.strip():
lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.")
for sname, sdef in (ndef.get("stats") or {}).items():
if isinstance(sdef, dict):
row = _describe_stat(f"{name} {sname}", sdef)
if row:
lines.append(row)
for name, d in (stat_schema.get("flags") or {}).items():
if isinstance(d, dict):
desc = d.get("desc")
+34 -9
View File
@@ -13,7 +13,18 @@ SCHEMA = {
[40, 60, "minor damage"], [60, 90, "healthy"],
[90, 100, "full health"]]},
},
"npc": {"trust": {"min": -100, "max": 100, "initial": 0, "cooldown": 2}},
"npcs": {
"gwen": {
"name": "Gwen",
"keys": "Gwen, ranger",
"desc": "A loyal ranger",
"stats": {"trust": {"min": -100, "max": 100, "initial": 0, "cooldown": 2}},
},
"drake": {
"name": "The Drake",
"stats": {"ferocity": {"min": 0, "max": 100, "initial": 50}},
},
},
"flags": {
"has_key": {"desc": "Holds the key", "initial": False},
"disguised": {"desc": "In disguise"},
@@ -30,7 +41,18 @@ def test_instantiate_uses_initials():
ws = fresh()
assert ws["world"] == {"day": 1}
assert ws["player"] == {"hp": 100}
assert ws["npc"] == {} and ws["milestones"] == {}
# Each defined NPC is instantiated up front with its own stats.
assert ws["npc"] == {"gwen": {"trust": 0}, "drake": {"ferocity": 50}}
assert ws["milestones"] == {}
def test_per_npc_distinct_stats():
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.drake.ferocity": 20}, 1)
assert ws["npc"]["drake"]["ferocity"] == 70
# gwen has no "ferocity" stat, drake has no "trust" — cross paths are rejected.
ws, report = w.apply_delta(ws, SCHEMA, {"npc.gwen.ferocity": 5, "npc.bogus.trust": 5}, 2)
reasons = {r["reason"] for r in report["rejected"]}
assert reasons == {"unknown npc stat", "unknown npc"}
def test_has_schema():
@@ -63,16 +85,16 @@ def test_counter_rejects_negative():
assert ws["world"]["day"] == 2
def test_npc_lazy_init_and_cooldown():
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.12.trust": 10}, 7)
assert ws["npc"]["12"]["trust"] == 10 # instantiated from template + applied
def test_npc_cooldown():
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.gwen.trust": 10}, 7)
assert ws["npc"]["gwen"]["trust"] == 10
# cooldown 2: another change at index 8 is too soon.
ws, report = w.apply_delta(ws, SCHEMA, {"npc.12.trust": 10}, 8)
assert ws["npc"]["12"]["trust"] == 10
ws, report = w.apply_delta(ws, SCHEMA, {"npc.gwen.trust": 10}, 8)
assert ws["npc"]["gwen"]["trust"] == 10
assert report["rejected"][0]["reason"] == "cooldown"
# far enough later, it applies.
ws, _ = w.apply_delta(ws, SCHEMA, {"npc.12.trust": 10}, 10)
assert ws["npc"]["12"]["trust"] == 20
ws, _ = w.apply_delta(ws, SCHEMA, {"npc.gwen.trust": 10}, 10)
assert ws["npc"]["gwen"]["trust"] == 20
def test_milestone_sticky():
@@ -115,6 +137,9 @@ def test_reference_includes_desc_and_bands_independently():
assert "very weak" in guide and "range 0–100" in guide
# day (a counter here has no desc/bands) contributes nothing; flags show desc.
assert "has_key (flag) — Holds the key." in guide
# NPCs contribute their own description and per-NPC stat lines.
assert "NPC Gwen (gwen) — A loyal ranger." in guide
assert "Gwen trust" in guide and "The Drake ferocity" in guide
def test_unknown_paths_rejected_not_fatal():
+10 -7
View File
@@ -25,17 +25,22 @@ from app.routers import adventures
SCHEMA = {
"player": {"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30}},
"npc": {"trust": {"min": -100, "max": 100, "initial": 0}},
"npcs": {
"gwen": {
"name": "Gwen", "keys": "Gwen",
"desc": "A loyal ranger ally.",
"stats": {"trust": {"min": -100, "max": 100, "initial": 0}},
},
},
"flags": {"alarm": {"desc": "The enemy is alerted", "initial": False}},
"milestones": {"win": {"desc": "Win the fight"}},
"npc_card_types": ["character"],
}
# The faked model narrates and appends a delta that exceeds the per-turn cap
# (so we can see the engine clamp it), flips a flag, and completes a milestone.
AI_REPLY = (
"The goblin's blade bites deep and Gwen nods at your resolve.\n\n"
'```state\n{"player.hp": -80, "npc.9.trust": 15, "flags.alarm": true, "milestones.win": true}\n```'
'```state\n{"player.hp": -80, "npc.gwen.trust": 15, "flags.alarm": true, "milestones.win": true}\n```'
)
@@ -64,11 +69,9 @@ def client(monkeypatch):
)
setup.add(adv)
setup.flush()
# "Gwen" in the story text makes her NPC in-scene (matches the "gwen" npc's keys).
setup.add(models.Action(adventure_id=adv.id, index=0, type="start",
text="You face a goblin. Gwen watches."))
# NPC story card so "Gwen" is in scene (matches npc.9 in the delta).
setup.add(models.StoryCard(adventure_id=adv.id, id=9, type="character",
name="Gwen", keys="Gwen", entry="A loyal ranger."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -121,7 +124,7 @@ def test_turn_applies_clamped_delta_and_strips_block(client):
_play(client)
ws = _world(client.adv_id)
assert ws["player"]["hp"] == 70 # -80 capped to -30
assert ws["npc"]["9"]["trust"] == 15
assert ws["npc"]["gwen"]["trust"] == 15
assert ws["flags"]["alarm"] is True
assert ws["milestones"]["win"]["reached"] is True
# The state block is not shown to the player.