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
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user