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>
270 lines
14 KiB
Markdown
270 lines
14 KiB
Markdown
# 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")`.
|
||
|
||
```jsonc
|
||
{
|
||
"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")`.
|
||
|
||
```jsonc
|
||
{
|
||
"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.
|