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]: def _visible_npcs(adventure: models.Adventure, stat_schema: dict) -> dict[str, str]:
"""NPC story cards (by schema-configured type) whose keys appear in the """Defined NPCs whose trigger words appear in the recent story — the ones
recent story — the ones "in scene", so only their stats get injected.""" "in scene", so only their stats get injected. Maps npc id -> display name."""
actions = [a for a in adventure.actions if a.text.strip()] actions = [a for a in adventure.actions if a.text.strip()]
recent = SEPARATOR.join(a.text for a in actions[-6:]).lower() recent = SEPARATOR.join(a.text for a in actions[-6:]).lower()
types = worldstate.npc_types(stat_schema)
visible: dict[str, str] = {} visible: dict[str, str] = {}
for card in adventure.story_cards: for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
if (card.type or "").lower() not in types: if not isinstance(ndef, dict):
continue continue
for key in (k.strip().lower() for k in card.keys.split(",")): if any(trigger in recent for trigger in worldstate.npc_triggers(ndef, npc_key)):
if key and key in recent: visible[npc_key] = worldstate.npc_name(ndef, npc_key)
visible[str(card.id)] = card.name or f"NPC {card.id}"
break
return visible return visible
+19
View File
@@ -98,6 +98,7 @@ def create_adventure(
db.flush() db.flush()
if scenario: if scenario:
existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards}
for card in scenario.story_cards: for card in scenario.story_cards:
db.add( db.add(
models.StoryCard( models.StoryCard(
@@ -109,6 +110,24 @@ def create_adventure(
notes=card.notes, 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): for position, script in enumerate(scenario.scripts):
db.add( db.add(
models.AdventureScript( 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.", "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", "tags": "demo, rpg, world-state, combat, short",
"stat_schema": { "stat_schema": {
"npc_card_types": ["character"],
"world": { "world": {
"day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." } "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 }, "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 } "player_hidden": { "desc": "True while the player is out of sight in cover.", "initial": true }
}, },
"npc": { "npcs": {
"health": { "gwen": {
"desc": "This companion's physical health.", "name": "Gwen",
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35, "keys": "Gwen, ranger, her",
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"], "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.",
[50, 80, "scratched"], [80, 101, "healthy"]] "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": { "bandit_leader": {
"desc": "How much this companion trusts the player; rises with smart, loyal play and falls with recklessness.", "name": "Bandit Leader",
"min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20, "keys": "leader, chief, boss, scarred",
"bands": [[-100, -30, "hostile"], [-30, 30, "wary"], [30, 70, "friendly"], "desc": "The scarred leader of the bandit camp, guarding the strongbox. Fights harder the more cornered he becomes.",
[70, 101, "devoted"]] "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": { "milestones": {
@@ -49,13 +73,6 @@
} }
}, },
"story_cards": [ "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", "type": "location",
"name": "Bandit Camp", "name": "Bandit Camp",
+4 -2
View File
@@ -8,7 +8,8 @@ from .engine import (
extract_delta, extract_delta,
has_schema, has_schema,
instantiate, instantiate,
npc_types, npc_name,
npc_triggers,
render_reference, render_reference,
render_state_section, render_state_section,
) )
@@ -20,7 +21,8 @@ __all__ = [
"extract_delta", "extract_delta",
"has_schema", "has_schema",
"instantiate", "instantiate",
"npc_types", "npc_name",
"npc_triggers",
"render_reference", "render_reference",
"render_state_section", "render_state_section",
] ]
+53 -27
View File
@@ -16,18 +16,18 @@ import re
# stat_schema top-level sections that hold stat definitions. # stat_schema top-level sections that hold stat definitions.
STAT_SECTIONS = ("world", "player") STAT_SECTIONS = ("world", "player")
DEFAULT_NPC_TYPES = ("character", "npc")
# Appended once to the system prompt so the model knows how to report changes. # Appended once to the system prompt so the model knows how to report changes.
EMIT_RULE = ( EMIT_RULE = (
"After your narration, if and ONLY IF something in the world state changed this " "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 " "turn, append a fenced code block labelled `state` containing a JSON object of "
"the CHANGES ONLY, as deltas (not new totals). Use paths like " "the CHANGES ONLY, as deltas (not new totals). Use paths like "
'"player.hp", "world.day", "npc.<id>.trust"; "flags.<name>": true or false to ' '"player.hp", "world.day", "npc.<id>.<stat>" (use the exact npc id shown in the '
'toggle an on/off state; and "milestones.<id>": true when an objective is ' 'world state, e.g. npc.gwen.trust); "flags.<name>": true or false to toggle an '
"completed. Send only things that actually changed; never restate unchanged " 'on/off state; and "milestones.<id>": true when an objective is completed. Send '
"values. If nothing changed, omit the block entirely. Example:\n" "only things that actually changed; never restate unchanged values. If nothing "
'```state\n{"player.hp": -15, "flags.has_key": true, "milestones.escaped": true}\n```' "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. # ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL.
@@ -42,14 +42,20 @@ def has_schema(stat_schema: dict | None) -> bool:
return False return False
return any( return any(
isinstance(stat_schema.get(k), dict) and stat_schema[k] 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]: def npc_name(ndef: dict, key: str) -> str:
raw = stat_schema.get("npc_card_types") name = ndef.get("name")
types = raw if isinstance(raw, list) and raw else DEFAULT_NPC_TYPES return name.strip() if isinstance(name, str) and name.strip() else key
return {str(t).lower() for t in types}
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: def _initials(defs: dict) -> dict:
@@ -67,7 +73,12 @@ def instantiate(stat_schema: dict | None) -> dict:
ws: dict = {} ws: dict = {}
for section in STAT_SECTIONS: for section in STAT_SECTIONS:
ws[section] = _initials(stat_schema.get(section) or {}) 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["milestones"] = {} # only reached ones are stored
ws["flags"] = { ws["flags"] = {
name: bool(d.get("initial", False)) 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 {} milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") 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(): for raw_path, change in delta.items():
path = str(raw_path) path = str(raw_path)
@@ -265,14 +276,19 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
action_index, meta, report) action_index, meta, report)
continue continue
# npc.<cardId>.<stat> # npc.<npcId>.<stat> — each NPC has its own stat defs.
if parts[0] == "npc" and len(parts) == 3: 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): if not isinstance(stat_def, dict):
report["rejected"].append({"path": path, "reason": "unknown npc stat"}) report["rejected"].append({"path": path, "reason": "unknown npc stat"})
continue continue
npcs = ws.setdefault("npc", {}) npc_state = ws.setdefault("npc", {})
container = npcs.setdefault(parts[1], _initials(npc_defs)) container = npc_state.setdefault(parts[1], _initials(stat_defs))
_apply_stat(container, parts[2], stat_def, change, path, _apply_stat(container, parts[2], stat_def, change, path,
action_index, meta, report) action_index, meta, report)
continue continue
@@ -316,13 +332,16 @@ def render_state_section(world_state: dict, stat_schema: dict,
if player_line: if player_line:
lines.append(f"You: {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 {} npc_state = ws.get("npc") or {}
for card_id, name in visible_npcs.items(): for npc_key, name in visible_npcs.items():
values = npc_state.get(card_id) or _initials(npc_defs) ndef = npcs.get(npc_key) or {}
npc_line = _stat_line(npc_defs, values) 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: 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_defs = stat_schema.get("flags") or {}
flag_state = ws.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) row = _describe_stat(name, d)
if row: if row:
lines.append(row) lines.append(row)
for name, d in (stat_schema.get("npc") or {}).items(): for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
if isinstance(d, dict): if not isinstance(ndef, dict):
row = _describe_stat(f"NPC {name}", d) continue
if row: name = npc_name(ndef, npc_key)
lines.append(row) 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(): for name, d in (stat_schema.get("flags") or {}).items():
if isinstance(d, dict): if isinstance(d, dict):
desc = d.get("desc") desc = d.get("desc")
+34 -9
View File
@@ -13,7 +13,18 @@ SCHEMA = {
[40, 60, "minor damage"], [60, 90, "healthy"], [40, 60, "minor damage"], [60, 90, "healthy"],
[90, 100, "full health"]]}, [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": { "flags": {
"has_key": {"desc": "Holds the key", "initial": False}, "has_key": {"desc": "Holds the key", "initial": False},
"disguised": {"desc": "In disguise"}, "disguised": {"desc": "In disguise"},
@@ -30,7 +41,18 @@ def test_instantiate_uses_initials():
ws = fresh() ws = fresh()
assert ws["world"] == {"day": 1} assert ws["world"] == {"day": 1}
assert ws["player"] == {"hp": 100} 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(): def test_has_schema():
@@ -63,16 +85,16 @@ def test_counter_rejects_negative():
assert ws["world"]["day"] == 2 assert ws["world"]["day"] == 2
def test_npc_lazy_init_and_cooldown(): def test_npc_cooldown():
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.12.trust": 10}, 7) ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.gwen.trust": 10}, 7)
assert ws["npc"]["12"]["trust"] == 10 # instantiated from template + applied assert ws["npc"]["gwen"]["trust"] == 10
# cooldown 2: another change at index 8 is too soon. # cooldown 2: another change at index 8 is too soon.
ws, report = w.apply_delta(ws, SCHEMA, {"npc.12.trust": 10}, 8) ws, report = w.apply_delta(ws, SCHEMA, {"npc.gwen.trust": 10}, 8)
assert ws["npc"]["12"]["trust"] == 10 assert ws["npc"]["gwen"]["trust"] == 10
assert report["rejected"][0]["reason"] == "cooldown" assert report["rejected"][0]["reason"] == "cooldown"
# far enough later, it applies. # far enough later, it applies.
ws, _ = w.apply_delta(ws, SCHEMA, {"npc.12.trust": 10}, 10) ws, _ = w.apply_delta(ws, SCHEMA, {"npc.gwen.trust": 10}, 10)
assert ws["npc"]["12"]["trust"] == 20 assert ws["npc"]["gwen"]["trust"] == 20
def test_milestone_sticky(): 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 assert "very weak" in guide and "range 0–100" in guide
# day (a counter here has no desc/bands) contributes nothing; flags show desc. # day (a counter here has no desc/bands) contributes nothing; flags show desc.
assert "has_key (flag) — Holds the key." in guide 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(): def test_unknown_paths_rejected_not_fatal():
+10 -7
View File
@@ -25,17 +25,22 @@ from app.routers import adventures
SCHEMA = { SCHEMA = {
"player": {"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30}}, "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}}, "flags": {"alarm": {"desc": "The enemy is alerted", "initial": False}},
"milestones": {"win": {"desc": "Win the fight"}}, "milestones": {"win": {"desc": "Win the fight"}},
"npc_card_types": ["character"],
} }
# The faked model narrates and appends a delta that exceeds the per-turn cap # 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. # (so we can see the engine clamp it), flips a flag, and completes a milestone.
AI_REPLY = ( AI_REPLY = (
"The goblin's blade bites deep and Gwen nods at your resolve.\n\n" "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.add(adv)
setup.flush() 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", setup.add(models.Action(adventure_id=adv.id, index=0, type="start",
text="You face a goblin. Gwen watches.")) 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() setup.commit()
adv_id, user_id = adv.id, user.id adv_id, user_id = adv.id, user.id
setup.close() setup.close()
@@ -121,7 +124,7 @@ def test_turn_applies_clamped_delta_and_strips_block(client):
_play(client) _play(client)
ws = _world(client.adv_id) ws = _world(client.adv_id)
assert ws["player"]["hp"] == 70 # -80 capped to -30 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["flags"]["alarm"] is True
assert ws["milestones"]["win"]["reached"] is True assert ws["milestones"]["win"]["reached"] is True
# The state block is not shown to the player. # The state block is not shown to the player.
+8 -9
View File
@@ -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') const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
if (entries.length === 0) return null if (entries.length === 0) return null
return ( return (
<div className="ws-group"> <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]) => ( {entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def} value={values?.[name]} /> <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 // Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC
// stats with bands + bars, and a milestones checklist. Renders nothing unless // stats with bands + bars, and a milestones checklist. Renders nothing unless
// the adventure's scenario defines a stat_schema. // the adventure's scenario defines a stat_schema.
function WorldStateDrawer({ advId, refreshKey, cards }) { function WorldStateDrawer({ advId, refreshKey }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [data, setData] = useState(null) // { state, schema } const [data, setData] = useState(null) // { state, schema }
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -552,10 +552,8 @@ function WorldStateDrawer({ advId, refreshKey, cards }) {
if (!schema) return null // no RPG layer for this adventure if (!schema) return null // no RPG layer for this adventure
const state = data?.state || {} const state = data?.state || {}
const cardName = (id) =>
cards?.find((c) => String(c.id) === String(id))?.name || `NPC ${id}`
const npcState = state.npc || {} 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 flags = Object.entries(schema.flags || {})
const flagState = state.flags || {} const flagState = state.flags || {}
const milestones = Object.entries(schema.milestones || {}) 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={null} defs={schema.world} values={state.world} />
<StatGroup title="You" defs={schema.player} values={state.player} /> <StatGroup title="You" defs={schema.player} values={state.player} />
{npcIds.map((id) => ( {npcs.map(([id, def]) => (
<StatGroup key={id} title={cardName(id)} defs={schema.npc} values={npcState[id]} /> <StatGroup key={id} title={def.name || id} desc={def.desc}
defs={def.stats} values={npcState[id]} />
))} ))}
{flags.length > 0 && ( {flags.length > 0 && (
<div className="ws-group"> <div className="ws-group">
@@ -973,7 +972,7 @@ export default function Play() {
return ( return (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}> <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} /> <StatusDrawer advId={id} refreshKey={actions.length} />
<div className="page play-page"> <div className="page play-page">
<div className="page-header"> <div className="page-header">
+6 -5
View File
@@ -195,10 +195,11 @@ export default function ScenarioEditor() {
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2> <h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
</div> </div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}> <p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Optional. Define stats (with bands and rules) and milestones as a JSON object, Optional. Define stats (with bands and rules), NPCs, flags, and milestones as a
and the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives. JSON object, and the AI will track them each turn — HP, mana, an NPC’s trust,
Leave blank for a plain narrative scenario. NPC stats apply to story cards of the quest objectives. Each NPC in <code>npcs</code> has its own <code>name</code>,
configured <code>npc_card_types</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> </p>
<textarea <textarea
className="schema-editor" className="schema-editor"
@@ -206,7 +207,7 @@ export default function ScenarioEditor() {
onChange={(e) => setSchema(e.target.value)} onChange={(e) => setSchema(e.target.value)}
rows={12} rows={12}
spellCheck={false} 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>} {schemaError && <div className="schema-error">⚠ {schemaError}</div>}
+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 - **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 (`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. down, instead of doing math it's bad at.
- **NPCs = story cards.** No new NPC table. NPC stats live in the adventure's world - **Dedicated NPCs, each with its own stats.** NPCs are defined in a `npcs` section of
state keyed by story-card id; a card is treated as an NPC when its `type` is the schema, keyed by a stable id (`gwen`). Each has a `name`, `desc`, trigger `keys`,
character-ish (config below). Only NPCs **triggered this turn** get their stats and its **own** `stats` block (a dragon can have `ferocity`, a merchant `prices`) — no
injected — reuses the existing card-trigger logic in `build_context`. 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 - **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. template (what stats exist, their bands + rules); the adventure holds current values.
- **Milestones are sticky story flags.** Predefined objectives the AI marks reached via - **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"]] }, [40,60,"minor damage"],[60,90,"healthy"],[90,100,"full health"]] },
"mana": { "min": 0, "max": 50, "initial": 20, "max_delta_per_turn": 15 } "mana": { "min": 0, "max": 50, "initial": 20, "max_delta_per_turn": 15 }
}, },
"npc": { // template applied to each NPC card "npcs": { // each NPC has its OWN stats
"health": { "min": 0, "max": 100, "initial": 100, "bands": [...] }, "gwen": {
"trust": { "min": -100, "max": 100, "initial": 0, "max_delta_per_turn": 20, "name": "Gwen", "keys": "Gwen, ranger, her",
"bands": [[-100,-30,"hostile"],[-30,30,"neutral"],[30,100,"ally"]] } "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 "flags": { // two-way on/off booleans
"has_key": { "desc": "Player holds the dungeon key", "initial": false }, "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 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). attempts to re-set or un-set (undo is the only way back).
`npc_card_types` (scenario-level, defaults `["character","npc"]`): which story-card Each NPC in `npcs` carries `name`, `desc`, trigger `keys`, and its own `stats` block
types get the NPC stat template. (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 `{}`) ### Live values — `Adventure.world_state` (JSON, default `{}`)
@@ -105,14 +116,14 @@ Migration `(27, "ALTER TABLE adventures ADD COLUMN world_state JSON")`.
{ {
"world": { "day": 3 }, "world": { "day": 3 },
"player": { "hp": 55, "mana": 10 }, "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 } }, "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 `_meta.last_changed` backs the `cooldown` rule. NPC blocks are instantiated up front from
the `npc` template the first time that card is triggered. each NPC's own `stats`.
### Undo/retry snapshot — `Action.world_state_before` (JSON, nullable) ### 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**; - end its reply with a fenced `state` block **only when something actually changed**;
- **omit the block entirely** when nothing changed this turn (no empty `{}`); - **omit the block entirely** when nothing changed this turn (no empty `{}`);
- include **only the stats that changed** as deltas — never restate unchanged stats, - 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 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. 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 block, tolerate missing/extra fences, trailing commas, `+N` numbers; return `{}` on
parse failure (never break the turn — same philosophy as a broken script). parse failure (never break the turn — same philosophy as a broken script).
- `apply_delta(adventure, delta, action_index) -> report` — for each `path: change`: - `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). `milestones.<id>`) against the schema; unknown paths ignored (logged).
2. **milestone path** → accept only `true`, set `{reached: true, at: action_index}`, 2. **milestone path** → accept only `true`, set `{reached: true, at: action_index}`,
ignore if already reached; skip the numeric steps below. 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. 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 - No dice / skill checks / combat resolution — this phase is stat tracking only. A
deterministic resolver is a possible Phase 13. deterministic resolver is a possible Phase 13.
- NPC stats are keyed by story-card id; deleting a card orphans its `_meta`/`npc` entry - NPC stats are keyed by the schema NPC id; editing a scenario's `npcs` between play
(harmless, ignored on read). 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. - Cooldown/`max_delta_per_turn` are per-turn heuristics, not a full rules engine.
## Test checklist ## 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. - `cooldown: 2` on a stat: two consecutive changes → second is rejected until 2 actions pass.
- Counter (`day`): a negative delta is rejected; `+1` advances. - 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. - 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. - 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). - 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. - Undo after a stat change restores the prior value; retry doesn't double-apply.