Files
interactive-story/plan/12-phase-rpg-world-state.md
T
parththakkar106andClaude Opus 4.8 cf464a48b0 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>
2026-07-21 15:17:47 +05:30

14 KiB
Raw Blame History

Phase 12 — RPG world state (AI-authored, engine-clamped)

Goal: each turn carries a structured world state — world stats (e.g. day), player stats (hp, mana), per-NPC stats (health, trust, …) for the NPCs currently in scene, and milestones (story-progress flags / quest objectives). After the player acts, the AI reads the current state, narrates, and proposes state changes; the engine validates and clamps them against a schema before storing. Stat meanings are described in words (bands) so the model reasons semantically, not arithmetically.

This is deliberately the "AI owns mechanics, engine enforces limits" design — NOT a deterministic dice engine. The AI proposes; Python is the referee.

Design decisions (settled)

  • AI proposes, engine clamps. The model never owns the numbers directly. It emits a delta; the engine applies min/max, per-turn caps, and cooldowns server-side. The AI cannot be trusted to obey its own frequency rules — the engine must.
  • 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.
  • 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 the same delta channel. Once reached they stay reached (revert only through the undo snapshot). Injected as "Goals" (pending) so the AI drives toward them and "Achieved" so it doesn't re-do them. Emergent/AI-invented milestones are out of scope for v1.
  • Flags are two-way booleans. Separate from milestones: named on/off world/character state (has_key, disguised, alarm_raised) the AI can flip either direction via "flags.<name>": true|false. Have an initial value and a desc; no clamp/cooldown.
  • Stat guide (descriptions + band ranges). A fixed, per-scenario legend injected each turn, describing each stat's desc and its full band ladder (0–20 very weak, …) — handled independently, so a stat may have a description, a range, both, or neither. This is separate from the live values block (which still shows only the current band label), giving the model the whole scale to reason across without bloating the per-turn line.
  • One-call turn. The AI narrates AND appends a fenced state-delta block; the engine parses it and strips it from the visible text. No second LLM call — matters on the rate-limited free-tier demo (20 req/min). Parser is forgiving of messy JSON from weaker free models.
  • Memory bank / multi-memory is untouched. (Confirmed.)
  • Separate from script_state. World state gets its own column so it never collides with the scripting scoreboard, and reuses the same undo/retry snapshot pattern (Action.state_before, see plan/11).

Data model

Schema definition — Scenario.stat_schema (JSON, nullable)

Migration (26, "ALTER TABLE scenarios ADD COLUMN stat_schema JSON").

{
  "world": {
    "day": { "type": "counter", "min": 1, "initial": 1, "desc": "In-game day",
             "max_delta_per_turn": 1, "cooldown": 0 }
  },
  "player": {
    "hp":   { "min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30,
              "cooldown": 0, "bands": [[0,20,"very weak"],[20,40,"hurt"],
              [40,60,"minor damage"],[60,90,"healthy"],[90,100,"full health"]] },
    "mana": { "min": 0, "max": 50, "initial": 20, "max_delta_per_turn": 15 }
  },
  "npcs": {                                  // each NPC has its OWN stats
    "gwen": {
      "name": "Gwen", "keys": "Gwen, ranger, her",
      "desc": "A loyal ranger and the player's ally.",
      "stats": {
        "health": { "min": 0, "max": 100, "initial": 100, "bands": [...] },
        "trust":  { "min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
                    "bands": [[-100,-30,"hostile"],[-30,30,"wary"],[30,100,"ally"]] }
      }
    }
  },
  "flags": {                                 // two-way on/off booleans
    "has_key": { "desc": "Player holds the dungeon key", "initial": false },
    "alarm_raised": { "desc": "The enemy is alerted", "initial": false }
  },
  "milestones": {                            // sticky story-progress flags
    "rescue_gwen": { "desc": "Rescue Gwen from the bandits" },
    "reach_capital": { "desc": "Arrive at the capital city" }
  }
}

Per-stat rule fields (all optional, engine enforces):

  • min / max — hard clamp.
  • initial — value when first instantiated.
  • max_delta_per_turn — largest absolute change allowed in one turn (extra is clamped).
  • cooldown — minimum player actions between changes to this stat (0 = every turn).
  • bands — [lo, hi, label] triples, used only to describe the value to the model.
  • type — "counter" (monotonic, e.g. day) vs default numeric; counters reject negative deltas.

Milestones carry only desc (the objective text). They are boolean and sticky — the 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).

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 {})

Migration (27, "ALTER TABLE adventures ADD COLUMN world_state JSON").

{
  "world":  { "day": 3 },
  "player": { "hp": 55, "mana": 10 },
  "npc":    { "gwen": { "health": 80, "trust": 20 } },   // keyed by NPC id
  "milestones": { "rescue_gwen": { "reached": true, "at": 7 } },
  "_meta":  { "last_changed": { "player.hp": 7, "npc.gwen.trust": 6 } }  // action index
}

_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)

Migration (28, "ALTER TABLE actions ADD COLUMN world_state_before JSON"). Snapshotted and reverted exactly like state_before (Phase 11) — same call sites.


Turn flow

player input → INPUT hook (existing)
context build → inject [World State] section (current values + band scale + emit-rule)
AI response  → narration + trailing ```state { ...delta... } ``` block
             → parse delta → validate/clamp against schema → apply → strip block
             → snapshot world_state onto the action (undo)

1. Context injection (context/builder.py)

New always-on section world_state, placed with the system sections. Keep it terse (competes with story history under the default context budget, raised to 16384 in Phase 12):

World state — day 3.
You: HP 55/100 (minor damage), Mana 10/50.
Gwen: health 80 (healthy), trust 20 (neutral).
Goals: Arrive at the capital city.
Achieved: Rescued Gwen from the bandits.
  • Only inject NPC lines for cards triggered this turn — reuse triggered / card_records already computed in build_context (builder.py:120). No extra work.
  • Milestones: list unreached ones under Goals and reached ones under Achieved (omit either line when empty). These are cheap and always included.
  • Append the value's band label in parentheses so the model reads meaning, not just a number.
  • Append a compact emit rule (once, in the narrator/system text). Instruct the model explicitly to:
    • 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.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.

2. Delta parse + validate (new worldstate/ module)

New module backend/app/worldstate/engine.py (mirrors scripting/ layout):

  • extract_delta(text) -> (clean_text, delta_dict) — pull the trailing ```state 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.<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.
    3. lazily instantiate NPC stat block from template if missing.
    4. reject if cooldown not elapsed (action_index - _meta.last_changed[path] < cooldown).
    5. clamp change to max_delta_per_turn; counters reject negative.
    6. apply, then clamp result to [min, max].
    7. record _meta.last_changed[path] = action_index.
  • Returns a report (applied / clamped / rejected) for the Insights panel, like the script report.

3. Wire into generate_turn (routers/adventures.py:249)

  • Snapshot: world_state_before = snapshot_world_state(adventure) alongside the existing state_before (:271).
  • After the output hook and empty-text check (:340): clean, delta = extract_delta(text), report = apply_delta(...), store text = clean, stash the report into snapshot["world_state"].
  • Persist world_state_before onto the AI Action (:341 block) and commit adventure.world_state.
  • Do this only when scenario.stat_schema is non-empty — zero overhead for plain narrative adventures.

4. Undo / retry (routers/adventures.py)

Reuse the Phase 11 wiring verbatim, in parallel:

  • retry (:449): also restore adventure.world_state from the deleted AI action's world_state_before.
  • undo (:489): also restore from the first removed action's world_state_before (fall back to {}).

UI

  • World State panel (play view): render current world_state as a readable sheet — world / player / per-NPC, with band label and a bar for min..max stats, plus a milestones checklist (reached vs pending). Reuse the collapsible-tree styling from the existing Play State drawer.
  • Insights: show the parsed delta + apply/clamp/reject report per turn (next to the script report already there).
  • Scenario editor: a stat_schema editor. v1 is a raw JSON editor (CodeMirror, reusing the script-slot editor setup) — settled, no form builder for now. A form-based stat builder is a possible later nicety.
  • Graceful when stat_schema is empty: panel + editor hidden, app behaves exactly as today.

Seed / demo

  • New seeded demo scenario seed_data/*.json with a small stat_schema (player hp/mana, day, one or two NPC story cards with health/trust) so the feature is visible on the live demo without the player configuring anything. Keep it :free-model friendly.
  • Extend seed.py idempotently (matches existing seeder contract).

Exit criteria

Play the demo RPG scenario: the World State panel shows hp/mana/day, an NPC's trust, and a milestones checklist; taking a fight action drops hp and the narration matches the new band; a friendly action raises an NPC's trust; completing an objective marks its milestone reached (and it stays reached); a change larger than max_delta_per_turn is clamped; undo rolls every stat and milestone back to the prior turn; a plain (no-schema) adventure is completely unaffected.

Known limits (document, don't fix in v1)

  • The AI can still narrate against the numbers occasionally; injected state + firm emit 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 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

  • Schema with max_delta_per_turn: 30: a delta of -50 applies as -30, clamps at min.
  • 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.
  • 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.
  • Empty stat_schema: no World State section injected, no world_state writes.