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:
co-authored by
Claude Opus 4.8
parent
64356414b1
commit
4dac445f90
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user