Add Phase 12: native RPG world state

Structured world/player/NPC stats, two-way flags, and sticky milestones
per scenario (stat_schema). The AI proposes a per-turn delta; a Python
engine referees it (clamp to min/max, per-turn cap, cooldown, counters).
Band word-labels plus a fixed stat guide (descriptions + full ranges)
keep the model grounded. World State drawer + Insights delta report;
undo/retry roll it back via the Phase 11 snapshot pattern.

- migrations 26-28 (scenarios.stat_schema, adventures.world_state,
  actions.world_state_before); all nullable, additive, safe on existing rows
- migration 29 raises the default context budget 4096 -> 16384
  (custom values preserved)
- seeded demo scenario 04-rpg-world-state.json (Bandit Camp)
- 19 new tests (33 total pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parththakkar106
2026-07-21 14:47:29 +05:30
co-authored by Claude Opus 4.8
parent 64356414b1
commit 4dac445f90
18 changed files with 1509 additions and 5 deletions
+32 -1
View File
@@ -16,7 +16,7 @@ from dataclasses import dataclass
import tiktoken import tiktoken
from .. import models from .. import models, worldstate
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
@@ -56,6 +56,23 @@ def _script_memory(adventure: models.Adventure) -> dict:
return memory if isinstance(memory, dict) else {} return memory if isinstance(memory, dict) else {}
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."""
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:
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
return visible
def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]: def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
"""AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word """AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word
('boat' triggers on 'boats'). Returns one record per card with the keyword that fired.""" ('boat' triggers on 'boats'). Returns one record per card with the keyword that fired."""
@@ -82,6 +99,20 @@ def build_context(
# ----- Always-included components ----- # ----- Always-included components -----
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())] system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
# RPG world state (Phase 12): current stats/milestones + how to report changes.
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
guide = worldstate.render_reference(stat_schema)
if guide:
system_sections.append(Section("world_state_guide", guide))
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(adventure, stat_schema)
)
if block:
system_sections.append(Section("world_state", block))
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip(): if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip())) system_sections.append(Section("script_context", script_mem["context"].strip()))
if adventure.ai_instructions.strip(): if adventure.ai_instructions.strip():
+11
View File
@@ -79,6 +79,17 @@ MIGRATIONS: list[tuple[int, str]] = [
# action's hooks ran, enabling undo/retry to roll state back. JSON is valid # action's hooks ran, enabling undo/retry to roll state back. JSON is valid
# on both SQLite and Postgres. # on both SQLite and Postgres.
(25, "ALTER TABLE actions ADD COLUMN state_before JSON"), (25, "ALTER TABLE actions ADD COLUMN state_before JSON"),
# Phase 12: RPG world state. `stat_schema` defines the stats/bands/rules and
# milestones for a scenario; `world_state` holds an adventure's live values;
# `world_state_before` snapshots it per action for undo/retry (mirrors
# state_before). JSON is valid on both SQLite and Postgres.
(26, "ALTER TABLE scenarios ADD COLUMN stat_schema JSON"),
(27, "ALTER TABLE adventures ADD COLUMN world_state JSON"),
(28, "ALTER TABLE actions ADD COLUMN world_state_before JSON"),
# Raise the default context budget 4096 -> 16384 (Phase 12 injects a stat
# guide + world state each turn). Only bumps rows still on the old default,
# so anyone who picked a custom value keeps it.
(29, "UPDATE settings SET context_token_budget = 16384 WHERE context_token_budget = 4096"),
] ]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+9 -1
View File
@@ -62,6 +62,9 @@ class Scenario(Base):
authors_note: Mapped[str] = mapped_column(Text, default="") authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="") ai_instructions: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[str] = mapped_column(String(500), default="") tags: Mapped[str] = mapped_column(String(500), default="")
# Phase 12: RPG world-state template — stat definitions (bands, rules) and
# milestones. NULL/empty means this scenario has no RPG layer.
stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
@@ -88,6 +91,9 @@ class Adventure(Base):
ai_instructions: Mapped[str] = mapped_column(Text, default="") ai_instructions: Mapped[str] = mapped_column(Text, default="")
story_summary: Mapped[str] = mapped_column(Text, default="") story_summary: Mapped[str] = mapped_column(Text, default="")
script_state: Mapped[dict] = mapped_column(JSON, default=dict) script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 6: opt-in per adventure (extra AI calls) # Phase 6: opt-in per adventure (extra AI calls)
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False) auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False) memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -185,6 +191,8 @@ class Action(Base):
# script hooks ran, so undo/retry can roll the shared scoreboard back. # script hooks ran, so undo/retry can roll the shared scoreboard back.
# NULL for actions created before this column existed. # NULL for actions created before this column existed.
state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True) state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Phase 12: same idea for the RPG world_state, so undo/retry rolls it back too.
world_state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions") adventure: Mapped[Adventure] = relationship(back_populates="actions")
@@ -258,7 +266,7 @@ class Settings(Base):
# `reasoning: {max_tokens}`); 0 = param not sent. Added on top of # `reasoning: {max_tokens}`); 0 = param not sent. Added on top of
# max_output_tokens so story output keeps its full budget. # max_output_tokens so story output keeps its full budget.
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0) reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
context_token_budget: Mapped[int] = mapped_column(Integer, default=4096) context_token_budget: Mapped[int] = mapped_column(Integer, default=16384)
narrator_prompt: Mapped[str] = mapped_column( narrator_prompt: Mapped[str] = mapped_column(
Text, Text,
default=( default=(
+51 -2
View File
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import auth, limits, memorybank, models, schemas from .. import auth, limits, memorybank, models, schemas, worldstate
from ..context import build_context from ..context import build_context
from ..database import get_db from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
@@ -91,6 +91,8 @@ def create_adventure(
memory=fill_placeholders(scenario.memory, values) if scenario else "", memory=fill_placeholders(scenario.memory, values) if scenario else "",
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "", authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "", ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
# Phase 12: seed the live RPG state from the scenario's template.
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
) )
db.add(adventure) db.add(adventure)
db.flush() db.flush()
@@ -154,6 +156,21 @@ def get_script_state(
return {"state": state} return {"state": state}
@router.get("/{adventure_id}/world-state")
def get_world_state(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""The RPG world state (live values) plus the scenario's stat_schema, so the
play view can render the sheet + milestones. `schema` is null with no RPG layer."""
adventure = get_adventure_or_404(adventure_id, db, user)
schema = adventure.scenario.stat_schema if adventure.scenario else None
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
return {
"state": state,
"schema": schema if worldstate.has_schema(schema) else None,
}
def snapshot_state(adventure: models.Adventure) -> dict: def snapshot_state(adventure: models.Adventure) -> dict:
"""Deep copy of the shared script_state, to staple onto an action so undo/ """Deep copy of the shared script_state, to staple onto an action so undo/
retry can restore it. Independent of later hook mutations.""" retry can restore it. Independent of later hook mutations."""
@@ -161,6 +178,13 @@ def snapshot_state(adventure: models.Adventure) -> dict:
return copy.deepcopy(state) return copy.deepcopy(state)
def snapshot_world_state(adventure: models.Adventure) -> dict:
"""Deep copy of the RPG world_state, for the same undo/retry rollback as
snapshot_state (Phase 12)."""
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
return copy.deepcopy(state)
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut) @router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure( def update_adventure(
adventure_id: int, adventure_id: int,
@@ -269,6 +293,7 @@ async def generate_turn(
# Scoreboard as it stands before this AI turn's context/output hooks mutate # Scoreboard as it stands before this AI turn's context/output hooks mutate
# it — stapled onto the AI action so retry can start over from here. # it — stapled onto the AI action so retry can start over from here.
state_before = snapshot_state(adventure) state_before = snapshot_state(adventure)
world_state_before = snapshot_world_state(adventure)
system_text, story_text, snapshot = build_context(adventure, settings, memories) system_text, story_text, snapshot = build_context(adventure, settings, memories)
# onModelContext: scripts see (and may rewrite) the whole assembled context. # onModelContext: scripts see (and may rewrite) the whole assembled context.
@@ -331,14 +356,30 @@ async def generate_turn(
return return
snapshot["script"] = snapshot["script"] | pipeline.report() snapshot["script"] = snapshot["script"] | pipeline.report()
# RPG world state (Phase 12): pull the AI's state delta out of the reply,
# let the engine referee it, and strip the block from the shown text.
ai_index = next_index(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield sse({"type": "error", "detail": "The AI returned only a state update and no story text."})
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_index
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
ai_action = models.Action( ai_action = models.Action(
adventure_id=adventure.id, adventure_id=adventure.id,
index=next_index(adventure), index=ai_index,
type="ai", type="ai",
text=text, text=text,
reasoning="".join(reasoning_chunks).strip() or None, reasoning="".join(reasoning_chunks).strip() or None,
context_snapshot=snapshot, context_snapshot=snapshot,
state_before=state_before, state_before=state_before,
world_state_before=world_state_before,
) )
db.add(ai_action) db.add(ai_action)
adventure.updated_at = models.utcnow() adventure.updated_at = models.utcnow()
@@ -377,6 +418,7 @@ async def run_player_turn(
# Scoreboard before the input hook mutates it — the pre-turn state that # Scoreboard before the input hook mutates it — the pre-turn state that
# undo restores to (the AI action keeps its own post-input snapshot). # undo restores to (the AI action keeps its own post-input snapshot).
state_before = snapshot_state(adventure) state_before = snapshot_state(adventure)
world_state_before = snapshot_world_state(adventure)
# onInput sees the formatted text (as in AI Dungeon: "> You ..."). # onInput sees the formatted text (as in AI Dungeon: "> You ...").
formatted = format_player_input(payload.type, payload.text) formatted = format_player_input(payload.type, payload.text)
modified, stop = pipeline.run("input", formatted) modified, stop = pipeline.run("input", formatted)
@@ -390,6 +432,7 @@ async def run_player_turn(
type=payload.type, type=payload.type,
text=modified, text=modified,
state_before=state_before, state_before=state_before,
world_state_before=world_state_before,
) )
db.add(player_action) db.add(player_action)
db.commit() db.commit()
@@ -448,6 +491,8 @@ def retry_action(
# top of the discarded attempt. NULL for pre-migration actions. # top of the discarded attempt. NULL for pre-migration actions.
if last_ai.state_before is not None: if last_ai.state_before is not None:
adventure.script_state = copy.deepcopy(last_ai.state_before) adventure.script_state = copy.deepcopy(last_ai.state_before)
if last_ai.world_state_before is not None:
adventure.world_state = copy.deepcopy(last_ai.world_state_before)
db.delete(last_ai) db.delete(last_ai)
db.commit() db.commit()
db.refresh(adventure) db.refresh(adventure)
@@ -488,6 +533,8 @@ def undo_turn(
db.delete(first_removed) db.delete(first_removed)
if first_removed.state_before is not None: if first_removed.state_before is not None:
adventure.script_state = copy.deepcopy(first_removed.state_before) adventure.script_state = copy.deepcopy(first_removed.state_before)
if first_removed.world_state_before is not None:
adventure.world_state = copy.deepcopy(first_removed.world_state_before)
db.flush() # apply deletes so pruning sees the shrunken action list db.flush() # apply deletes so pruning sees the shrunken action list
db.expire(adventure, ["actions"]) db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db) memorybank.prune_dangling_memories(adventure, db)
@@ -514,6 +561,7 @@ def export_adventure(
"aiInstructions": adv.ai_instructions, "aiInstructions": adv.ai_instructions,
"storySummary": adv.story_summary, "storySummary": adv.story_summary,
"scriptState": adv.script_state, "scriptState": adv.script_state,
"worldState": adv.world_state,
"autoSummarize": adv.auto_summarize, "autoSummarize": adv.auto_summarize,
"memoryBankEnabled": adv.memory_bank_enabled, "memoryBankEnabled": adv.memory_bank_enabled,
"memoryCursor": adv.memory_cursor, "memoryCursor": adv.memory_cursor,
@@ -577,6 +625,7 @@ def import_adventure(
ai_instructions=str(bundle.get("aiInstructions") or ""), ai_instructions=str(bundle.get("aiInstructions") or ""),
story_summary=str(bundle.get("storySummary") or ""), story_summary=str(bundle.get("storySummary") or ""),
script_state=bundle.get("scriptState") or {}, script_state=bundle.get("scriptState") or {},
world_state=bundle.get("worldState") or {},
auto_summarize=bool(bundle.get("autoSummarize", False)), auto_summarize=bool(bundle.get("autoSummarize", False)),
memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)), memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)),
memory_cursor=int(bundle.get("memoryCursor", 0)), memory_cursor=int(bundle.get("memoryCursor", 0)),
+6
View File
@@ -109,6 +109,7 @@ def export_scenario(
"authorsNote": s.authors_note, "authorsNote": s.authors_note,
"aiInstructions": s.ai_instructions, "aiInstructions": s.ai_instructions,
"tags": s.tags, "tags": s.tags,
"statSchema": s.stat_schema,
"storyCards": [ "storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes} {"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in s.story_cards for c in s.story_cards
@@ -137,6 +138,7 @@ _SCENARIO_KEYS = {
"instructions": "ai_instructions", "instructions": "ai_instructions",
} }
_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags", _IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags",
"statSchema", "stat_schema",
"createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"} "createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"}
@@ -165,6 +167,10 @@ def import_scenario(
elif isinstance(tags, str): elif isinstance(tags, str):
fields["tags"] = tags fields["tags"] = tags
schema = bundle.get("statSchema") or bundle.get("stat_schema")
if isinstance(schema, dict):
fields["stat_schema"] = schema
scenario = models.Scenario(**fields, user_id=user.id) scenario = models.Scenario(**fields, user_id=user.id)
if not scenario.title: if not scenario.title:
scenario.title = "Imported Scenario" scenario.title = "Imported Scenario"
+4
View File
@@ -66,6 +66,9 @@ class ScenarioBase(BaseModel):
authors_note: Prose = "" authors_note: Prose = ""
ai_instructions: Prose = "" ai_instructions: Prose = ""
tags: Tags = "" tags: Tags = ""
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
# None means no RPG layer.
stat_schema: dict | None = None
class ScenarioCreate(ScenarioBase): class ScenarioCreate(ScenarioBase):
@@ -80,6 +83,7 @@ class ScenarioUpdate(BaseModel):
authors_note: Prose | None = None authors_note: Prose | None = None
ai_instructions: Prose | None = None ai_instructions: Prose | None = None
tags: Tags | None = None tags: Tags | None = None
stat_schema: dict | None = None
script_ids: list[int] | None = None script_ids: list[int] | None = None
+4
View File
@@ -94,6 +94,8 @@ def _matches(scenario: models.Scenario, data: dict) -> bool:
the write and avoid churning rows on every boot.""" the write and avoid churning rows on every boot."""
if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS): if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS):
return False return False
if (scenario.stat_schema or None) != (data.get("stat_schema") or None):
return False
have_cards = sorted(_card_tuple(c, lambda o, f: getattr(o, f)) for c in scenario.story_cards) have_cards = sorted(_card_tuple(c, lambda o, f: getattr(o, f)) for c in scenario.story_cards)
want_cards = sorted( want_cards = sorted(
_card_tuple(c, lambda o, f: o.get(f, "")) _card_tuple(c, lambda o, f: o.get(f, ""))
@@ -133,6 +135,8 @@ def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
def _apply_scalars(scenario: models.Scenario, data: dict) -> None: def _apply_scalars(scenario: models.Scenario, data: dict) -> None:
for field in _SCALARS: for field in _SCALARS:
setattr(scenario, field, data.get(field, "")) setattr(scenario, field, data.get(field, ""))
# Phase 12: RPG world-state template (a JSON dict, not a scalar string).
scenario.stat_schema = data.get("stat_schema") or None
def _populate_children(db, scenario: models.Scenario, data: dict) -> None: def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
@@ -0,0 +1,68 @@
{
"title": "[Demo] The Bandit Camp (RPG world state)",
"description": "A short RPG scene showing the built-in world-state system: the AI tracks your HP and mana, an in-game day counter, an NPC ally's health and trust, and story milestones. No scripting — the engine keeps the numbers honest. Open the World State panel on the left to watch it change.",
"prompt": "Dawn breaks grey over the treeline as you and Gwen crouch at the edge of the bandit camp. Smoke curls from a dying fire; three bedrolls lie empty. Somewhere ahead, the stolen caravan strongbox waits.\n\nGwen checks her bowstring and looks to you. \"Quiet, or loud?\"",
"memory": "The player and Gwen, a loyal ranger ally, are raiding a bandit camp to recover a stolen strongbox. The player is a capable adventurer. This is a dangerous but winnable encounter.",
"authors_note": "Keep it tense and consequential. Reckless moves should cost health; clever ones should pay off. Gwen reacts to how the player treats her.",
"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." }
},
"player": {
"hp": {
"desc": "The player's physical health. At 0 they fall.",
"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, 100, "healthy"], [100, 101, "full health"]]
},
"mana": {
"desc": "Magical energy for spells; spent casting, restored by resting.",
"min": 0, "max": 50, "initial": 30, "max_delta_per_turn": 25,
"bands": [[0, 10, "drained"], [10, 30, "steady"], [30, 51, "brimming"]]
}
},
"flags": {
"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"]]
},
"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"]]
}
},
"milestones": {
"camp_cleared": { "desc": "Clear the bandit camp of enemies" },
"strongbox_found": { "desc": "Recover the stolen strongbox" },
"gwen_survives": { "desc": "Escape with Gwen still alive" }
}
},
"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",
"keys": "camp, bandits, strongbox",
"entry": "A rough camp of bandits in a forest clearing, holding a stolen caravan strongbox.",
"notes": ""
}
],
"scripts": []
}
+26
View File
@@ -0,0 +1,26 @@
"""Phase 12 — RPG world state: the AI proposes stat/milestone deltas, this
module validates and clamps them against a scenario's stat_schema."""
from .engine import (
EMIT_RULE,
apply_delta,
band_label,
extract_delta,
has_schema,
instantiate,
npc_types,
render_reference,
render_state_section,
)
__all__ = [
"EMIT_RULE",
"apply_delta",
"band_label",
"extract_delta",
"has_schema",
"instantiate",
"npc_types",
"render_reference",
"render_state_section",
]
+395
View File
@@ -0,0 +1,395 @@
"""RPG world-state engine.
The scenario carries a `stat_schema` (the template: which stats exist, their
bands and rules, and the milestones). An adventure carries a live `world_state`
instantiated from it. Each turn the AI proposes a *delta* (only what changed);
`apply_delta` is the referee — it clamps to min/max, caps per-turn change,
enforces cooldowns, and marks milestones sticky.
Nothing here ever raises on bad AI output: a malformed delta yields `{}` and the
turn continues, exactly like a broken script never breaks a turn.
"""
import copy
import json
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```'
)
# ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL.
_FENCE_RE = re.compile(r"```(?:state|json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE)
# Fallback: a bare JSON object hugging the end of the text.
_TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL)
def has_schema(stat_schema: dict | None) -> bool:
"""True when a scenario actually defines an RPG layer."""
if not isinstance(stat_schema, dict):
return False
return any(
isinstance(stat_schema.get(k), dict) and stat_schema[k]
for k in (*STAT_SECTIONS, "npc", "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 _initials(defs: dict) -> dict:
return {
name: d.get("initial", 0)
for name, d in defs.items()
if isinstance(d, dict)
}
def instantiate(stat_schema: dict | None) -> dict:
"""Build a fresh live world_state from a schema (initial values only)."""
if not has_schema(stat_schema):
return {}
ws: dict = {}
for section in STAT_SECTIONS:
ws[section] = _initials(stat_schema.get(section) or {})
ws["npc"] = {} # per-card, filled lazily on first change
ws["milestones"] = {} # only reached ones are stored
ws["flags"] = {
name: bool(d.get("initial", False))
for name, d in (stat_schema.get("flags") or {}).items()
if isinstance(d, dict)
}
ws["_meta"] = {"last_changed": {}}
return ws
def band_label(stat_def: dict, value) -> str | None:
"""The word label for `value` from a stat def's bands, if any.
Bands are [lo, hi, label]; matched as lo <= value < hi, with the top band
inclusive of its upper bound so a maxed stat still gets a label.
"""
bands = stat_def.get("bands")
if not isinstance(bands, list) or not isinstance(value, (int, float)):
return None
last_hi = None
for band in bands:
if not (isinstance(band, list) and len(band) == 3):
continue
lo, hi, label = band
last_hi = hi
if lo <= value < hi:
return str(label)
# Inclusive top edge.
if bands and value == last_hi:
return str(bands[-1][2])
return None
# --------------------------------------------------------------------------- #
# Delta extraction
# --------------------------------------------------------------------------- #
def _tolerant_load(blob: str) -> dict:
# Strip trailing commas and leading + on numbers, both of which weaker
# free models emit and strict JSON rejects.
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def extract_delta(text: str) -> tuple[str, dict]:
"""Pull the trailing state block out of an AI response.
Returns (clean_text, delta). `delta` is `{}` when there is no block or it
can't be parsed; `clean_text` has the block removed. Only strips a bare
trailing object when it actually parses to a delta, so ordinary prose
ending in `}` is never eaten.
"""
matches = list(_FENCE_RE.finditer(text))
if matches:
m = matches[-1]
delta = _tolerant_load(m.group(1))
clean = (text[: m.start()] + text[m.end():]).strip()
return clean, delta
m = _TRAILING_RE.search(text)
if m:
delta = _tolerant_load(m.group(1))
if delta and all("." in str(k) for k in delta):
clean = text[: m.start()].strip()
return clean, delta
return text.strip(), {}
# --------------------------------------------------------------------------- #
# Delta application (the referee)
# --------------------------------------------------------------------------- #
def _coerce_number(value):
if isinstance(value, bool): # bool is an int subclass — reject here
return None
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
def _apply_stat(container: dict, key: str, stat_def: dict, change,
path: str, action_index: int, meta: dict, report: dict) -> None:
delta = _coerce_number(change)
if delta is None:
report["rejected"].append({"path": path, "reason": "not a number"})
return
cooldown = stat_def.get("cooldown") or 0
last = meta["last_changed"].get(path)
if cooldown and last is not None and action_index - last < cooldown:
report["rejected"].append({"path": path, "reason": "cooldown"})
return
if stat_def.get("type") == "counter" and delta < 0:
report["rejected"].append({"path": path, "reason": "counter can't decrease"})
return
clamped = False
cap = stat_def.get("max_delta_per_turn")
if cap is not None and abs(delta) > cap:
delta = cap if delta > 0 else -cap
clamped = True
old = container.get(key, stat_def.get("initial", 0))
new = old + delta
lo, hi = stat_def.get("min"), stat_def.get("max")
if lo is not None and new < lo:
new, clamped = lo, True
if hi is not None and new > hi:
new, clamped = hi, True
# Keep ints integral for display.
if isinstance(old, int) and float(new).is_integer():
new = int(new)
container[key] = new
meta["last_changed"][path] = action_index
entry = {"path": path, "old": old, "new": new}
report["applied"].append(entry)
if clamped:
report["clamped"].append(entry)
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
action_index: int) -> tuple[dict, dict]:
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
`world_state`. Returns (new_world_state, report)."""
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
if not ws:
ws = instantiate(stat_schema)
ws.setdefault("_meta", {}).setdefault("last_changed", {})
meta = ws["_meta"]
report: dict = {"applied": [], "clamped": [], "rejected": []}
if not isinstance(delta, dict):
return ws, report
milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") or {}
npc_defs = stat_schema.get("npc") or {}
for raw_path, change in delta.items():
path = str(raw_path)
parts = path.split(".")
# flags.<name> — free two-way boolean, either value accepted.
if parts[0] == "flags" and len(parts) == 2:
fid = parts[1]
if fid not in flag_defs:
report["rejected"].append({"path": path, "reason": "unknown flag"})
continue
if not isinstance(change, bool):
report["rejected"].append({"path": path, "reason": "not a boolean"})
continue
flags = ws.setdefault("flags", {})
old = bool(flags.get(fid, False))
if change != old:
flags[fid] = change
report["applied"].append({"path": path, "old": old, "new": change})
continue
# milestones.<id> — sticky boolean, only `true` accepted.
if parts[0] == "milestones" and len(parts) == 2:
mid = parts[1]
if mid not in milestones:
report["rejected"].append({"path": path, "reason": "unknown milestone"})
continue
if change is not True:
report["rejected"].append({"path": path, "reason": "not true"})
continue
reached = ws.setdefault("milestones", {})
if reached.get(mid, {}).get("reached"):
continue # already done — silent no-op
reached[mid] = {"reached": True, "at": action_index}
report["applied"].append({"path": path, "old": False, "new": True})
continue
# world.<stat> / player.<stat>
if parts[0] in STAT_SECTIONS and len(parts) == 2:
stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1])
if not isinstance(stat_def, dict):
report["rejected"].append({"path": path, "reason": "unknown stat"})
continue
container = ws.setdefault(parts[0], {})
_apply_stat(container, parts[1], stat_def, change, path,
action_index, meta, report)
continue
# npc.<cardId>.<stat>
if parts[0] == "npc" and len(parts) == 3:
stat_def = npc_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))
_apply_stat(container, parts[2], stat_def, change, path,
action_index, meta, report)
continue
report["rejected"].append({"path": path, "reason": "unknown path"})
return ws, report
# --------------------------------------------------------------------------- #
# Context rendering
# --------------------------------------------------------------------------- #
def _stat_line(defs: dict, values: dict) -> str:
parts = []
for name, d in defs.items():
if not isinstance(d, dict):
continue
val = values.get(name, d.get("initial", 0))
hi = d.get("max")
shown = f"{val}/{hi}" if hi is not None else f"{val}"
label = band_label(d, val)
parts.append(f"{name} {shown}" + (f" ({label})" if label else ""))
return ", ".join(parts)
def render_state_section(world_state: dict, stat_schema: dict,
visible_npcs: dict[str, str]) -> str:
"""Compact, always-included context block. `visible_npcs` maps card-id ->
display name for NPCs currently in scene."""
ws = world_state if isinstance(world_state, dict) else {}
lines: list[str] = []
world_defs = stat_schema.get("world") or {}
world_line = _stat_line(world_defs, ws.get("world") or {})
header = "World state" + (f" — {world_line}." if world_line else ".")
lines.append(header)
player_defs = stat_schema.get("player") or {}
player_line = _stat_line(player_defs, ws.get("player") or {})
if player_line:
lines.append(f"You: {player_line}.")
npc_defs = stat_schema.get("npc") 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)
if npc_line:
lines.append(f"{name}: {npc_line}.")
flag_defs = stat_schema.get("flags") or {}
flag_state = ws.get("flags") or {}
flag_parts = [
f"{name} {'yes' if flag_state.get(name, bool(d.get('initial', False))) else 'no'}"
for name, d in flag_defs.items() if isinstance(d, dict)
]
if flag_parts:
lines.append("Flags: " + ", ".join(flag_parts) + ".")
milestones = stat_schema.get("milestones") or {}
reached = ws.get("milestones") or {}
goals = [d.get("desc", mid) for mid, d in milestones.items()
if not reached.get(mid, {}).get("reached")]
done = [d.get("desc", mid) for mid, d in milestones.items()
if reached.get(mid, {}).get("reached")]
if goals:
lines.append("Goals: " + "; ".join(goals) + ".")
if done:
lines.append("Achieved: " + "; ".join(done) + ".")
return "\n".join(lines)
def _describe_stat(name: str, d: dict) -> str | None:
"""One reference line for a stat. Description and band-ladder are independent —
each is included only when present, so a stat may have either, both, or neither."""
bits: list[str] = []
desc = d.get("desc")
if isinstance(desc, str) and desc.strip():
# Fragments are joined with "; " and end with a single ".", so drop any
# trailing period the author already put on the description.
bits.append(desc.strip().rstrip("."))
lo, hi = d.get("min"), d.get("max")
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
bits.append(f"range {lo}–{hi}")
bands = d.get("bands")
if isinstance(bands, list) and bands:
ladder = ", ".join(
f"{b[0]}–{b[1]} {b[2]}"
for b in bands if isinstance(b, list) and len(b) == 3
)
if ladder:
bits.append(f"bands: {ladder}")
return f"{name} — {'; '.join(bits)}." if bits else None
def render_reference(stat_schema: dict) -> str:
"""A fixed, per-scenario legend describing what each stat means (its `desc`)
and its band ladder. Static across turns — separate from the live values."""
lines: list[str] = []
for section in STAT_SECTIONS:
for name, d in (stat_schema.get(section) or {}).items():
if isinstance(d, dict):
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 name, d in (stat_schema.get("flags") or {}).items():
if isinstance(d, dict):
desc = d.get("desc")
if isinstance(desc, str) and desc.strip():
lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.")
if not lines:
return ""
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
+151
View File
@@ -0,0 +1,151 @@
"""Unit tests for the RPG world-state engine (Phase 12): delta extraction and
the clamp/cooldown/milestone referee.
python -m pytest tests/test_worldstate.py -v
"""
from app import worldstate as w
SCHEMA = {
"world": {"day": {"type": "counter", "min": 1, "initial": 1}},
"player": {
"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30,
"bands": [[0, 20, "very weak"], [20, 40, "hurt"],
[40, 60, "minor damage"], [60, 90, "healthy"],
[90, 100, "full health"]]},
},
"npc": {"trust": {"min": -100, "max": 100, "initial": 0, "cooldown": 2}},
"flags": {
"has_key": {"desc": "Holds the key", "initial": False},
"disguised": {"desc": "In disguise"},
},
"milestones": {"rescue_gwen": {"desc": "Rescue Gwen"}},
}
def fresh():
return w.instantiate(SCHEMA)
def test_instantiate_uses_initials():
ws = fresh()
assert ws["world"] == {"day": 1}
assert ws["player"] == {"hp": 100}
assert ws["npc"] == {} and ws["milestones"] == {}
def test_has_schema():
assert w.has_schema(SCHEMA)
assert not w.has_schema(None)
assert not w.has_schema({})
assert not w.has_schema({"npc_card_types": ["npc"]}) # config only, no stats
def test_max_delta_per_turn_clamps():
ws, report = w.apply_delta(fresh(), SCHEMA, {"player.hp": -50}, 5)
assert ws["player"]["hp"] == 70 # -50 capped to -30
assert report["clamped"]
def test_clamp_to_min():
ws, _ = w.apply_delta(fresh(), SCHEMA, {"player.hp": -30}, 1)
ws, _ = w.apply_delta(ws, SCHEMA, {"player.hp": -30}, 3)
ws, _ = w.apply_delta(ws, SCHEMA, {"player.hp": -30}, 5)
ws, report = w.apply_delta(ws, SCHEMA, {"player.hp": -30}, 7)
assert ws["player"]["hp"] == 0 # 100-30-30-30-30 clamps at min 0
assert report["clamped"]
def test_counter_rejects_negative():
ws, report = w.apply_delta(fresh(), SCHEMA, {"world.day": -1}, 3)
assert ws["world"]["day"] == 1
assert report["rejected"][0]["reason"] == "counter can't decrease"
ws, _ = w.apply_delta(ws, SCHEMA, {"world.day": 1}, 4)
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
# 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
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
def test_milestone_sticky():
ws, report = w.apply_delta(fresh(), SCHEMA, {"milestones.rescue_gwen": True}, 9)
assert ws["milestones"]["rescue_gwen"] == {"reached": True, "at": 9}
assert report["applied"]
# second set is a silent no-op.
ws, report = w.apply_delta(ws, SCHEMA, {"milestones.rescue_gwen": True}, 11)
assert ws["milestones"]["rescue_gwen"]["at"] == 9
assert not report["applied"]
# false is ignored.
ws, report = w.apply_delta(ws, SCHEMA, {"milestones.rescue_gwen": False}, 13)
assert ws["milestones"]["rescue_gwen"]["reached"] is True
def test_flags_toggle_both_ways():
ws = fresh()
assert ws["flags"] == {"has_key": False, "disguised": False} # initials
ws, report = w.apply_delta(ws, SCHEMA, {"flags.has_key": True}, 1)
assert ws["flags"]["has_key"] is True
assert report["applied"]
# flip back off — flags are two-way (unlike sticky milestones).
ws, _ = w.apply_delta(ws, SCHEMA, {"flags.has_key": False}, 2)
assert ws["flags"]["has_key"] is False
# setting to the same value is a no-op.
ws, report = w.apply_delta(ws, SCHEMA, {"flags.has_key": False}, 3)
assert not report["applied"]
def test_flag_rejects_non_bool_and_unknown():
ws, report = w.apply_delta(fresh(), SCHEMA, {"flags.has_key": 1, "flags.nope": True}, 1)
reasons = {r["reason"] for r in report["rejected"]}
assert reasons == {"not a boolean", "unknown flag"}
assert ws["flags"]["has_key"] is False
def test_reference_includes_desc_and_bands_independently():
guide = w.render_reference(SCHEMA)
# hp has both a description and a band ladder.
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
def test_unknown_paths_rejected_not_fatal():
ws, report = w.apply_delta(fresh(), SCHEMA, {"player.stamina": -5, "bogus": 1}, 2)
reasons = {r["reason"] for r in report["rejected"]}
assert reasons == {"unknown stat", "unknown path"}
assert ws["player"]["hp"] == 100 # untouched
def test_extract_fenced_delta_tolerates_mess():
text = 'You strike.\n\n```state\n{"player.hp": -15, "npc.12.trust": +5,}\n```'
clean, delta = w.extract_delta(text)
assert clean == "You strike."
assert delta == {"player.hp": -15, "npc.12.trust": 5}
def test_extract_no_block():
clean, delta = w.extract_delta("Just prose that ends normally.")
assert delta == {}
assert clean == "Just prose that ends normally."
def test_extract_prose_ending_in_brace_not_eaten():
# A bare object with no dotted keys is not a delta — leave the text alone.
clean, delta = w.extract_delta('He said {this}')
assert delta == {}
assert clean == "He said {this}"
def test_band_label():
d = SCHEMA["player"]["hp"]
assert w.band_label(d, 10) == "very weak"
assert w.band_label(d, 55) == "minor damage"
assert w.band_label(d, 100) == "full health" # inclusive top edge
@@ -0,0 +1,155 @@
"""End-to-end HTTP test for RPG world state (Phase 12): a scenario with a
stat_schema, a turn whose (faked) AI reply carries a state delta block, and
undo rolling the world state back.
python -m pytest tests/test_worldstate_integration.py -v
"""
import os
import tempfile
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
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}},
"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```'
)
class FakeProvider:
def __init__(self, *a, **k):
pass
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
yield ("text", AI_REPLY)
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="rpg@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = models.Scenario(user_id=user.id, title="Dungeon", stat_schema=SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, scenario_id=scenario.id, title="Run",
world_state=adventures.worldstate.instantiate(SCHEMA),
)
setup.add(adv)
setup.flush()
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()
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", FakeProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
adventures._active_turns.clear()
Base.metadata.drop_all(bind=engine)
def _world(adv_id):
db = SessionLocal()
try:
return db.get(models.Adventure, adv_id).world_state
finally:
db.close()
def _last_ai_text(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.actions[-1].text
finally:
db.close()
def _play(client, text="attack the goblin"):
r = client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": "do", "text": text})
assert r.status_code == 200, r.text
return r
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["flags"]["alarm"] is True
assert ws["milestones"]["win"]["reached"] is True
# The state block is not shown to the player.
assert "```state" not in _last_ai_text(client.adv_id)
assert "goblin's blade" in _last_ai_text(client.adv_id)
def test_world_state_endpoint(client):
_play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
assert r.status_code == 200, r.text
body = r.json()
assert body["schema"]["player"]["hp"]["max"] == 100
assert body["state"]["player"]["hp"] == 70
def test_undo_reverts_world_state(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 100 # back to initial
assert _world(client.adv_id)["milestones"] == {}
def test_retry_does_not_double_apply(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 70 # not 40
+1
View File
@@ -80,6 +80,7 @@ export const api = {
listAdventures: () => request('/adventures'), listAdventures: () => request('/adventures'),
getAdventure: (id) => request(`/adventures/${id}`), getAdventure: (id) => request(`/adventures/${id}`),
getScriptState: (id) => request(`/adventures/${id}/script-state`), getScriptState: (id) => request(`/adventures/${id}/script-state`),
getWorldState: (id) => request(`/adventures/${id}/world-state`),
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }), createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }), deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
+90
View File
@@ -576,6 +576,96 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
from { opacity: 0; transform: translateX(16px); } from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
/* ---------- World State drawer (Phase 12 RPG) ---------- */
.ws-group { margin-bottom: 18px; }
.ws-group-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin: 0 0 8px;
}
.ws-stat { padding: 5px 0; }
.ws-stat-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 0.85rem;
}
.ws-stat-name { color: var(--text); text-transform: capitalize; }
.ws-stat-val {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
text-align: right;
}
.ws-band { color: var(--accent-bright); }
.ws-bar {
margin-top: 4px;
height: 5px;
border-radius: 3px;
background: var(--bg-input);
overflow: hidden;
}
.ws-bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.35s ease;
}
.ws-milestones { list-style: none; margin: 0; padding: 0; }
.ws-milestones li {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 4px 0;
font-size: 0.85rem;
color: var(--text);
}
.ws-milestones li.done { color: var(--text-dim); }
.ws-check { flex-shrink: 0; color: var(--accent-bright); }
.ws-milestones li.done .ws-check { color: var(--player); }
.ws-flags { list-style: none; margin: 0; padding: 0; }
.ws-flags li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.85rem;
color: var(--text);
}
.ws-flag-dot {
width: 9px; height: 9px; border-radius: 50%;
flex-shrink: 0;
background: var(--bg-input);
border: 1px solid var(--border);
}
.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); }
.ws-flag-name { flex: 1; }
.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
.schema-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
line-height: 1.5;
tab-size: 2;
white-space: pre;
overflow-x: auto;
}
.schema-error {
margin-top: 6px;
color: var(--danger, #e5484d);
font-size: 0.82rem;
}
.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
.ws-report li { padding: 2px 0; color: var(--text); }
.ws-report code { color: var(--accent-bright); }
.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
.ws-flag { color: var(--text-dim); font-style: italic; }
.ws-rejected { color: var(--text-dim); }
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.side-panel-header h2 { .side-panel-header h2 {
margin: 0; margin: 0;
+180
View File
@@ -42,6 +42,9 @@ const SECTION_LABELS = {
plot_essentials: 'Plot Essentials', plot_essentials: 'Plot Essentials',
story_summary: 'Story Summary', story_summary: 'Story Summary',
used_memories: 'Used Memories (memory bank)', used_memories: 'Used Memories (memory bank)',
world_state_guide: 'World State (stat guide)',
world_state: 'World State (RPG)',
world_state_rule: 'World State (reporting rule)',
world_lore: 'World Lore (story cards)', world_lore: 'World Lore (story cards)',
history: 'Story history', history: 'Story history',
authors_note: "Author's Note", authors_note: "Author's Note",
@@ -481,6 +484,181 @@ function StatusDrawer({ advId, refreshKey }) {
) )
} }
// Word label for a value from a stat def's bands (mirrors worldstate.band_label).
function bandLabel(def, value) {
const bands = def?.bands
if (!Array.isArray(bands) || typeof value !== 'number') return null
for (const b of bands) {
if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2]
}
const last = bands[bands.length - 1]
if (last && value === last[1]) return last[2]
return null
}
function StatRow({ name, def, value }) {
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
const { min, max } = def || {}
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min
const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null
const label = bandLabel(def, val)
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
<span className="ws-stat-val">
{val}{typeof max === 'number' ? `/${max}` : ''}
{label ? <span className="ws-band"> · {label}</span> : null}
</span>
</div>
{pct != null && (
<div className="ws-bar"><div className="ws-bar-fill" style={{ width: `${pct}%` }} /></div>
)}
</div>
)
}
function StatGroup({ title, defs, values }) {
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>}
{entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def} value={values?.[name]} />
))}
</div>
)
}
// 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 }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null) // { state, schema }
const [failed, setFailed] = useState(false)
const load = useCallback(() => {
api.getWorldState(advId)
.then((r) => { setData(r); setFailed(false) })
.catch(() => setFailed(true))
}, [advId])
// Load once to learn whether there's an RPG layer, then refresh after turns.
useEffect(() => { load() }, [load, refreshKey])
const schema = data?.schema
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 flags = Object.entries(schema.flags || {})
const flagState = state.flags || {}
const milestones = Object.entries(schema.milestones || {})
const reached = state.milestones || {}
return (
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
title="RPG world state">
{open ? '‹' : '›'}<span className="status-toggle-label">World</span>
</button>
{open && (
<div className="status-body">
<div className="side-panel-header">
<h2>World State</h2>
<button onClick={load} title="Refresh">↻</button>
</div>
{failed ? (
<div className="empty">Couldn’t load world state.</div>
) : (
<>
<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]} />
))}
{flags.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Flags</h3>
<ul className="ws-flags">
{flags.map(([fid, def]) => {
const on = flagState[fid] ?? !!def.initial
return (
<li key={fid} title={def.desc || undefined}>
<span className={`ws-flag-dot ${on ? 'on' : ''}`} />
<span className="ws-flag-name">{fid}</span>
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
</li>
)
})}
</ul>
</div>
)}
{milestones.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Milestones</h3>
<ul className="ws-milestones">
{milestones.map(([mid, def]) => {
const done = reached[mid]?.reached
return (
<li key={mid} className={done ? 'done' : ''}>
<span className="ws-check">{done ? '☑' : '☐'}</span>
{def.desc || mid}
</li>
)
})}
</ul>
</div>
)}
</>
)}
</div>
)}
</div>
)
}
// Per-turn RPG state change, shown when inspecting a past turn's snapshot.
function WorldStateReport({ worldState }) {
if (!worldState) return null
const delta = worldState.delta || {}
const report = worldState.report || {}
const paths = Object.keys(delta)
const rejected = report.rejected || []
const clamped = new Set((report.clamped || []).map((c) => c.path))
if (paths.length === 0 && rejected.length === 0) {
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>World State</span></div>
<div className="dim" style={{ fontSize: '0.82rem' }}>No changes this turn.</div>
</div>
)
}
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>World State changes</span></div>
<ul className="ws-report">
{paths.map((p) => (
<li key={p}>
<code>{p}</code> <span className="ws-delta">{String(delta[p])}</span>
{clamped.has(p) && <span className="ws-flag"> clamped</span>}
</li>
))}
{rejected.map((r, i) => (
<li key={`r${i}`} className="ws-rejected">
<code>{r.path}</code> rejected — {r.reason}
</li>
))}
</ul>
</div>
)
}
function ScriptReport({ script }) { function ScriptReport({ script }) {
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) { if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
return null return null
@@ -591,6 +769,7 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
</div> </div>
))} ))}
<ScriptReport script={report.script} /> <ScriptReport script={report.script} />
<WorldStateReport worldState={report.world_state} />
</div> </div>
) )
} }
@@ -794,6 +973,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} />
<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">
+59 -1
View File
@@ -9,6 +9,9 @@ export default function ScenarioEditor() {
const [scenario, setScenario] = useState(null) const [scenario, setScenario] = useState(null)
const [allScripts, setAllScripts] = useState([]) const [allScripts, setAllScripts] = useState([])
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
// Raw text buffer for the stat_schema JSON editor + a live parse error.
const [schemaText, setSchemaText] = useState('')
const [schemaError, setSchemaError] = useState('')
// One timer per field/card: a single shared timer would cancel the pending // One timer per field/card: a single shared timer would cancel the pending
// save of whatever was edited previously within the debounce window. // save of whatever was edited previously within the debounce window.
const saveTimers = useRef(new Map()) const saveTimers = useRef(new Map())
@@ -18,7 +21,10 @@ export default function ScenarioEditor() {
} }
useEffect(() => { useEffect(() => {
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios')) api.getScenario(id).then((s) => {
setScenario(s)
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
}).catch(() => navigate('/scenarios'))
api.listScripts().then(setAllScripts).catch(() => {}) api.listScripts().then(setAllScripts).catch(() => {})
}, [id, navigate]) }, [id, navigate])
@@ -32,6 +38,39 @@ export default function ScenarioEditor() {
}) })
} }
// RPG world-state schema: edited as raw JSON, only saved when it parses to an
// object (empty text clears the RPG layer). Invalid JSON shows an inline error
// and holds off saving.
const setSchema = (text) => {
setSchemaText(text)
const trimmed = text.trim()
if (!trimmed) {
setSchemaError('')
debounceSave('stat_schema', () => saveSchema(null))
return
}
let parsed
try {
parsed = JSON.parse(trimmed)
} catch (err) {
setSchemaError(`Invalid JSON: ${err.message}`)
return
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
setSchemaError('The schema must be a JSON object.')
return
}
setSchemaError('')
debounceSave('stat_schema', () => saveSchema(parsed))
}
const saveSchema = async (parsed) => {
await api.updateScenario(id, { stat_schema: parsed })
setScenario((s) => ({ ...s, stat_schema: parsed }))
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}
const addCard = async () => { const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) }) const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
@@ -152,6 +191,25 @@ export default function ScenarioEditor() {
onChange={updateCard} onDelete={() => deleteCard(card.id)} /> onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))} ))}
<div className="page-header" style={{ marginTop: 28 }}>
<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>.
</p>
<textarea
className="schema-editor"
value={schemaText}
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}'}
/>
{schemaError && <div className="schema-error">⚠ {schemaError}</div>}
<div className="page-header" style={{ marginTop: 28 }}> <div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2> <h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
</div> </div>
+9
View File
@@ -100,3 +100,12 @@ Open questions are recorded at the top of each phase file under "Ask before impl
serving, database decision. serving, database decision.
10. **[Phase 10 — Deploy & publish](10-phase-deploy.md)**: Render blueprint + deploy, seeded 10. **[Phase 10 — Deploy & publish](10-phase-deploy.md)**: Render blueprint + deploy, seeded
demo scenarios, live smoke test, resume/website links and blurb. demo scenarios, live smoke test, resume/website links and blurb.
## Post-launch
- **[State revert + retry fix](11-state-revert-and-retry-fix.md)**: undo/retry roll the shared
`script_state` back; per-action `state_before` snapshots; undo concurrency lock.
- **[Phase 12 — RPG world state](12-phase-rpg-world-state.md)**: structured world/player/NPC stats
+ milestones per scenario (`stat_schema`); the AI proposes deltas, a Python engine clamps them
(min/max, per-turn cap, cooldown, sticky milestones); band descriptions keep the model honest;
World State drawer + Insights delta report; reuses the Phase 11 undo/retry snapshot.
+258
View File
@@ -0,0 +1,258 @@
# 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.
- **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`.
- **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 }
},
"npc": { // template applied to each NPC card
"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"]] }
},
"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).
`npc_card_types` (scenario-level, defaults `["character","npc"]`): which story-card
types get the NPC stat template.
### 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": { "12": { "health": 80, "trust": 20 } }, // keyed by story-card id
"milestones": { "rescue_gwen": { "reached": true, "at": 7 } },
"_meta": { "last_changed": { "player.hp": 7, "npc.12.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.
### 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.12.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.<cardId>.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 story-card id; deleting a card orphans its `_meta`/`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.
- NPC stat auto-instantiates from template on first trigger at `initial`.
- 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.