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
+30 -19
View File
@@ -18,10 +18,13 @@ deterministic dice engine. The AI proposes; Python is the referee.
- **Band descriptions are the reliability trick.** Stats carry word ranges
(`0–20: very weak`, `20–40: hurt`, …). The model reads "he's badly hurt" and adjusts
down, instead of doing math it's bad at.
- **NPCs = story cards.** No new NPC table. NPC stats live in the adventure's world
state keyed by story-card id; a card is treated as an NPC when its `type` is
character-ish (config below). Only NPCs **triggered this turn** get their stats
injected — reuses the existing card-trigger logic in `build_context`.
- **Dedicated NPCs, each with its own stats.** NPCs are defined in a `npcs` section of
the schema, keyed by a stable id (`gwen`). Each has a `name`, `desc`, trigger `keys`,
and its **own** `stats` block (a dragon can have `ferocity`, a merchant `prices`) — no
forced shared template. On adventure creation each NPC auto-creates a story card (from
its name/keys/desc) so lore injection + in-scene detection keep working. Live values
live in `world_state["npc"]` keyed by the NPC id; only NPCs **in scene this turn** get
their stats injected into context. The AI addresses them as `npc.<id>.<stat>`.
- **Schema on the scenario, live values on the adventure.** The scenario is the
template (what stats exist, their bands + rules); the adventure holds current values.
- **Milestones are sticky story flags.** Predefined objectives the AI marks reached via
@@ -65,10 +68,16 @@ Migration `(26, "ALTER TABLE scenarios ADD COLUMN stat_schema JSON")`.
[40,60,"minor damage"],[60,90,"healthy"],[90,100,"full health"]] },
"mana": { "min": 0, "max": 50, "initial": 20, "max_delta_per_turn": 15 }
},
"npc": { // template applied to each NPC card
"health": { "min": 0, "max": 100, "initial": 100, "bands": [...] },
"trust": { "min": -100, "max": 100, "initial": 0, "max_delta_per_turn": 20,
"bands": [[-100,-30,"hostile"],[-30,30,"neutral"],[30,100,"ally"]] }
"npcs": { // each NPC has its OWN stats
"gwen": {
"name": "Gwen", "keys": "Gwen, ranger, her",
"desc": "A loyal ranger and the player's ally.",
"stats": {
"health": { "min": 0, "max": 100, "initial": 100, "bands": [...] },
"trust": { "min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
"bands": [[-100,-30,"hostile"],[-30,30,"wary"],[30,100,"ally"]] }
}
}
},
"flags": { // two-way on/off booleans
"has_key": { "desc": "Player holds the dungeon key", "initial": false },
@@ -94,8 +103,10 @@ Milestones carry only `desc` (the objective text). They are boolean and sticky
engine accepts a delta of `true` only, records the action index reached, and ignores
attempts to re-set or un-set (undo is the only way back).
`npc_card_types` (scenario-level, defaults `["character","npc"]`): which story-card
types get the NPC stat template.
Each NPC in `npcs` carries `name`, `desc`, trigger `keys`, and its own `stats` block
(same per-stat fields as above). All defined NPCs are instantiated up front; a story card
is auto-created per NPC on adventure creation (skipped if a card with that name already
exists) so descriptions inject as lore and in-scene detection works.
### Live values — `Adventure.world_state` (JSON, default `{}`)
@@ -105,14 +116,14 @@ Migration `(27, "ALTER TABLE adventures ADD COLUMN world_state JSON")`.
{
"world": { "day": 3 },
"player": { "hp": 55, "mana": 10 },
"npc": { "12": { "health": 80, "trust": 20 } }, // keyed by story-card id
"npc": { "gwen": { "health": 80, "trust": 20 } }, // keyed by NPC id
"milestones": { "rescue_gwen": { "reached": true, "at": 7 } },
"_meta": { "last_changed": { "player.hp": 7, "npc.12.trust": 6 } } // action index
"_meta": { "last_changed": { "player.hp": 7, "npc.gwen.trust": 6 } } // action index
}
```
`_meta.last_changed` backs the `cooldown` rule. NPC entries are lazily created from
the `npc` template the first time that card is triggered.
`_meta.last_changed` backs the `cooldown` rule. NPC blocks are instantiated up front from
each NPC's own `stats`.
### Undo/retry snapshot — `Action.world_state_before` (JSON, nullable)
@@ -155,7 +166,7 @@ Achieved: Rescued Gwen from the bandits.
- end its reply with a fenced `state` block **only when something actually changed**;
- **omit the block entirely** when nothing changed this turn (no empty `{}`);
- include **only the stats that changed** as deltas — never restate unchanged stats,
never send full/absolute values, e.g. `{"player.hp": -15, "npc.12.trust": +5}`.
never send full/absolute values, e.g. `{"player.hp": -15, "npc.gwen.trust": +5}`.
This keeps the emitted block tiny (saves output tokens on the free tier) and means the
engine's clamp/cooldown logic only ever sees real changes.
@@ -167,7 +178,7 @@ New module `backend/app/worldstate/engine.py` (mirrors `scripting/` layout):
block, tolerate missing/extra fences, trailing commas, `+N` numbers; return `{}` on
parse failure (never break the turn — same philosophy as a broken script).
- `apply_delta(adventure, delta, action_index) -> report` — for each `path: change`:
1. resolve `path` (`player.hp`, `world.day`, `npc.<cardId>.trust`,
1. resolve `path` (`player.hp`, `world.day`, `npc.<npcId>.trust`,
`milestones.<id>`) against the schema; unknown paths ignored (logged).
2. **milestone path** → accept only `true`, set `{reached: true, at: action_index}`,
ignore if already reached; skip the numeric steps below.
@@ -241,8 +252,8 @@ adventure is completely unaffected.
rule reduces but won't eliminate it. No post-narration consistency check in v1.
- No dice / skill checks / combat resolution — this phase is stat tracking only. A
deterministic resolver is a possible Phase 13.
- NPC stats are keyed by story-card id; deleting a card orphans its `_meta`/`npc` entry
(harmless, ignored on read).
- NPC stats are keyed by the schema NPC id; editing a scenario's `npcs` between play
sessions can orphan a live `npc` entry (harmless, ignored on read).
- Cooldown/`max_delta_per_turn` are per-turn heuristics, not a full rules engine.
## Test checklist
@@ -251,7 +262,7 @@ adventure is completely unaffected.
- `cooldown: 2` on a stat: two consecutive changes → second is rejected until 2 actions pass.
- Counter (`day`): a negative delta is rejected; `+1` advances.
- Milestone: `true` marks it reached with `at`; a second set is a no-op; `false` ignored.
- NPC stat auto-instantiates from template on first trigger at `initial`.
- Each NPC instantiates its own stats at `initial`; `npc.<id>.<stat>` resolves per-NPC.
- Malformed / missing `state` block → turn still completes, delta `{}`, no crash.
- A turn where nothing changes emits no `state` block (and an empty `{}` is a no-op).
- Undo after a stat change restores the prior value; retry doesn't double-apply.