diff --git a/backend/app/context/builder.py b/backend/app/context/builder.py index 87eab6b..8fa98b4 100644 --- a/backend/app/context/builder.py +++ b/backend/app/context/builder.py @@ -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 diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index a4461fa..e79816a 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -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( diff --git a/backend/app/seed_data/04-rpg-world-state.json b/backend/app/seed_data/04-rpg-world-state.json index be496e7..6c882a4 100644 --- a/backend/app/seed_data/04-rpg-world-state.json +++ b/backend/app/seed_data/04-rpg-world-state.json @@ -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", diff --git a/backend/app/worldstate/__init__.py b/backend/app/worldstate/__init__.py index 2284ad5..958188f 100644 --- a/backend/app/worldstate/__init__.py +++ b/backend/app/worldstate/__init__.py @@ -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", ] diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py index 5d1d62c..c18bbf0 100644 --- a/backend/app/worldstate/engine.py +++ b/backend/app/worldstate/engine.py @@ -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..trust"; "flags.": true or false to ' - 'toggle an on/off state; and "milestones.": 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.." (use the exact npc id shown in the ' + 'world state, e.g. npc.gwen.trust); "flags.": true or false to toggle an ' + 'on/off state; and "milestones.": 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.. + # npc.. — 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... + 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") diff --git a/backend/tests/test_worldstate.py b/backend/tests/test_worldstate.py index e9b029f..70ff9f3 100644 --- a/backend/tests/test_worldstate.py +++ b/backend/tests/test_worldstate.py @@ -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(): diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py index dfd8f7f..82fb4bb 100644 --- a/backend/tests/test_worldstate_integration.py +++ b/backend/tests/test_worldstate_integration.py @@ -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. diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 2813f4a..3dfc424 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -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 (
- {title &&

{title}

} + {title &&

{title}

} {entries.map(([name, def]) => ( ))} @@ -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 }) { <> - {npcIds.map((id) => ( - + {npcs.map(([id, def]) => ( + ))} {flags.length > 0 && (
@@ -973,7 +972,7 @@ export default function Play() { return (
- +
diff --git a/frontend/src/pages/ScenarioEditor.jsx b/frontend/src/pages/ScenarioEditor.jsx index 4b23fb5..bce9c0f 100644 --- a/frontend/src/pages/ScenarioEditor.jsx +++ b/frontend/src/pages/ScenarioEditor.jsx @@ -195,10 +195,11 @@ export default function ScenarioEditor() {

World State (RPG)

- 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 npc_card_types. + 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 npcs has its own name, + desc, trigger keys, and stats; a story card + is created for it automatically. Leave blank for a plain narrative scenario.