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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,19 +27,44 @@
|
||||
"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": {
|
||||
"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": "This companion's physical 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 this companion trusts the player; rises with smart, loyal play and falls with recklessness.",
|
||||
"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"]]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"camp_cleared": { "desc": "Clear the bandit camp of enemies" },
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,9 +399,16 @@ 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)
|
||||
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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<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]) => (
|
||||
<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
|
||||
// stats with bands + bars, and a milestones checklist. Renders nothing unless
|
||||
// the adventure's scenario defines a stat_schema.
|
||||
function WorldStateDrawer({ advId, refreshKey, cards }) {
|
||||
function WorldStateDrawer({ advId, refreshKey }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [data, setData] = useState(null) // { state, schema }
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -552,10 +552,8 @@ function WorldStateDrawer({ advId, refreshKey, cards }) {
|
||||
if (!schema) return null // no RPG layer for this adventure
|
||||
|
||||
const state = data?.state || {}
|
||||
const cardName = (id) =>
|
||||
cards?.find((c) => String(c.id) === String(id))?.name || `NPC ${id}`
|
||||
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 flagState = state.flags || {}
|
||||
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="You" defs={schema.player} values={state.player} />
|
||||
{npcIds.map((id) => (
|
||||
<StatGroup key={id} title={cardName(id)} defs={schema.npc} values={npcState[id]} />
|
||||
{npcs.map(([id, def]) => (
|
||||
<StatGroup key={id} title={def.name || id} desc={def.desc}
|
||||
defs={def.stats} values={npcState[id]} />
|
||||
))}
|
||||
{flags.length > 0 && (
|
||||
<div className="ws-group">
|
||||
@@ -973,7 +972,7 @@ export default function Play() {
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<div className="page play-page">
|
||||
<div className="page-header">
|
||||
|
||||
@@ -195,10 +195,11 @@ export default function ScenarioEditor() {
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
|
||||
</div>
|
||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||
Optional. Define stats (with bands and rules) and milestones as a JSON object,
|
||||
and the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
|
||||
Leave blank for a plain narrative scenario. NPC stats apply to story cards of the
|
||||
configured <code>npc_card_types</code>.
|
||||
Optional. Define stats (with bands and rules), NPCs, flags, and milestones as a
|
||||
JSON object, and the AI will track them each turn — HP, mana, an NPC’s trust,
|
||||
quest objectives. Each NPC in <code>npcs</code> has its own <code>name</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>
|
||||
<textarea
|
||||
className="schema-editor"
|
||||
@@ -206,7 +207,7 @@ export default function ScenarioEditor() {
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
rows={12}
|
||||
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>}
|
||||
|
||||
|
||||
@@ -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
|
||||
"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": 0, "max_delta_per_turn": 20,
|
||||
"bands": [[-100,-30,"hostile"],[-30,30,"neutral"],[30,100,"ally"]] }
|
||||
"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.
|
||||
|
||||
Reference in New Issue
Block a user