diff --git a/backend/app/context/builder.py b/backend/app/context/builder.py index ee802b1..87eab6b 100644 --- a/backend/app/context/builder.py +++ b/backend/app/context/builder.py @@ -16,7 +16,7 @@ from dataclasses import dataclass import tiktoken -from .. import models +from .. import models, worldstate 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 @@ -56,6 +56,23 @@ def _script_memory(adventure: models.Adventure) -> dict: 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]: """AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word ('boat' triggers on 'boats'). Returns one record per card with the keyword that fired.""" @@ -82,6 +99,20 @@ def build_context( # ----- Always-included components ----- 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(): system_sections.append(Section("script_context", script_mem["context"].strip())) if adventure.ai_instructions.strip(): diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 5cbc461..87f031e 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -79,6 +79,17 @@ MIGRATIONS: list[tuple[int, str]] = [ # action's hooks ran, enabling undo/retry to roll state back. JSON is valid # on both SQLite and Postgres. (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) diff --git a/backend/app/models.py b/backend/app/models.py index ae0fbe1..a047693 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -62,6 +62,9 @@ class Scenario(Base): authors_note: Mapped[str] = mapped_column(Text, default="") ai_instructions: Mapped[str] = mapped_column(Text, 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) 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="") story_summary: Mapped[str] = mapped_column(Text, default="") 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) auto_summarize: 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. # NULL for actions created before this column existed. 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) 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 # max_output_tokens so story output keeps its full budget. 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( Text, default=( diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 3d815bd..a4461fa 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse from sqlalchemy import func 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 ..database import get_db from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError @@ -91,6 +91,8 @@ def create_adventure( memory=fill_placeholders(scenario.memory, 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 "", + # 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.flush() @@ -154,6 +156,21 @@ def get_script_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: """Deep copy of the shared script_state, to staple onto an action so undo/ retry can restore it. Independent of later hook mutations.""" @@ -161,6 +178,13 @@ def snapshot_state(adventure: models.Adventure) -> dict: 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) def update_adventure( adventure_id: int, @@ -269,6 +293,7 @@ async def generate_turn( # 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. state_before = snapshot_state(adventure) + world_state_before = snapshot_world_state(adventure) system_text, story_text, snapshot = build_context(adventure, settings, memories) # onModelContext: scripts see (and may rewrite) the whole assembled context. @@ -331,14 +356,30 @@ async def generate_turn( return 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( adventure_id=adventure.id, - index=next_index(adventure), + index=ai_index, type="ai", text=text, reasoning="".join(reasoning_chunks).strip() or None, context_snapshot=snapshot, state_before=state_before, + world_state_before=world_state_before, ) db.add(ai_action) 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 # undo restores to (the AI action keeps its own post-input snapshot). state_before = snapshot_state(adventure) + world_state_before = snapshot_world_state(adventure) # onInput sees the formatted text (as in AI Dungeon: "> You ..."). formatted = format_player_input(payload.type, payload.text) modified, stop = pipeline.run("input", formatted) @@ -390,6 +432,7 @@ async def run_player_turn( type=payload.type, text=modified, state_before=state_before, + world_state_before=world_state_before, ) db.add(player_action) db.commit() @@ -448,6 +491,8 @@ def retry_action( # top of the discarded attempt. NULL for pre-migration actions. if last_ai.state_before is not None: 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.commit() db.refresh(adventure) @@ -488,6 +533,8 @@ def undo_turn( db.delete(first_removed) if first_removed.state_before is not None: 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.expire(adventure, ["actions"]) memorybank.prune_dangling_memories(adventure, db) @@ -514,6 +561,7 @@ def export_adventure( "aiInstructions": adv.ai_instructions, "storySummary": adv.story_summary, "scriptState": adv.script_state, + "worldState": adv.world_state, "autoSummarize": adv.auto_summarize, "memoryBankEnabled": adv.memory_bank_enabled, "memoryCursor": adv.memory_cursor, @@ -577,6 +625,7 @@ def import_adventure( ai_instructions=str(bundle.get("aiInstructions") or ""), story_summary=str(bundle.get("storySummary") or ""), script_state=bundle.get("scriptState") or {}, + world_state=bundle.get("worldState") or {}, auto_summarize=bool(bundle.get("autoSummarize", False)), memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)), memory_cursor=int(bundle.get("memoryCursor", 0)), diff --git a/backend/app/routers/scenarios.py b/backend/app/routers/scenarios.py index ba11c32..b20283b 100644 --- a/backend/app/routers/scenarios.py +++ b/backend/app/routers/scenarios.py @@ -109,6 +109,7 @@ def export_scenario( "authorsNote": s.authors_note, "aiInstructions": s.ai_instructions, "tags": s.tags, + "statSchema": s.stat_schema, "storyCards": [ {"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes} for c in s.story_cards @@ -137,6 +138,7 @@ _SCENARIO_KEYS = { "instructions": "ai_instructions", } _IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags", + "statSchema", "stat_schema", "createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"} @@ -165,6 +167,10 @@ def import_scenario( elif isinstance(tags, str): 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) if not scenario.title: scenario.title = "Imported Scenario" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 44a8fd7..24444f1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -66,6 +66,9 @@ class ScenarioBase(BaseModel): authors_note: Prose = "" ai_instructions: Prose = "" 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): @@ -80,6 +83,7 @@ class ScenarioUpdate(BaseModel): authors_note: Prose | None = None ai_instructions: Prose | None = None tags: Tags | None = None + stat_schema: dict | None = None script_ids: list[int] | None = None diff --git a/backend/app/seed.py b/backend/app/seed.py index cf35262..6d3b9d1 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -94,6 +94,8 @@ def _matches(scenario: models.Scenario, data: dict) -> bool: the write and avoid churning rows on every boot.""" if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS): 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) want_cards = sorted( _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: for field in _SCALARS: 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: diff --git a/backend/app/seed_data/04-rpg-world-state.json b/backend/app/seed_data/04-rpg-world-state.json new file mode 100644 index 0000000..be496e7 --- /dev/null +++ b/backend/app/seed_data/04-rpg-world-state.json @@ -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": [] +} diff --git a/backend/app/worldstate/__init__.py b/backend/app/worldstate/__init__.py new file mode 100644 index 0000000..2284ad5 --- /dev/null +++ b/backend/app/worldstate/__init__.py @@ -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", +] diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py new file mode 100644 index 0000000..5d1d62c --- /dev/null +++ b/backend/app/worldstate/engine.py @@ -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..trust"; "flags.": true or false to ' + 'toggle an on/off state; and "milestones.": true when an objective is ' + "completed. Send only things that actually changed; never restate unchanged " + "values. If nothing changed, omit the block entirely. Example:\n" + '```state\n{"player.hp": -15, "flags.has_key": true, "milestones.escaped": true}\n```' +) + +# ```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. — 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. — 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. / player. + 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.. + 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) diff --git a/backend/tests/test_worldstate.py b/backend/tests/test_worldstate.py new file mode 100644 index 0000000..e9b029f --- /dev/null +++ b/backend/tests/test_worldstate.py @@ -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 diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py new file mode 100644 index 0000000..dfd8f7f --- /dev/null +++ b/backend/tests/test_worldstate_integration.py @@ -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 diff --git a/frontend/src/api.js b/frontend/src/api.js index 1743ad6..ef43785 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -80,6 +80,7 @@ export const api = { listAdventures: () => request('/adventures'), getAdventure: (id) => request(`/adventures/${id}`), getScriptState: (id) => request(`/adventures/${id}/script-state`), + getWorldState: (id) => request(`/adventures/${id}/world-state`), createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }), updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }), diff --git a/frontend/src/index.css b/frontend/src/index.css index a3f33f4..5c75edc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -576,6 +576,96 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n from { opacity: 0; transform: translateX(16px); } 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 h2 { margin: 0; diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 64c3b0a..2813f4a 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -42,6 +42,9 @@ const SECTION_LABELS = { plot_essentials: 'Plot Essentials', story_summary: 'Story Summary', 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)', history: 'Story history', 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 ( +
+
+ {name} + + {val}{typeof max === 'number' ? `/${max}` : ''} + {label ? · {label} : null} + +
+ {pct != null && ( +
+ )} +
+ ) +} + +function StatGroup({ title, defs, values }) { + const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object') + if (entries.length === 0) return null + return ( +
+ {title &&

{title}

} + {entries.map(([name, def]) => ( + + ))} +
+ ) +} + +// 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 ( +
+ + {open && ( +
+
+

World State

+ +
+ {failed ? ( +
Couldn’t load world state.
+ ) : ( + <> + + + {npcIds.map((id) => ( + + ))} + {flags.length > 0 && ( +
+

Flags

+
    + {flags.map(([fid, def]) => { + const on = flagState[fid] ?? !!def.initial + return ( +
  • + + {fid} + {on ? 'yes' : 'no'} +
  • + ) + })} +
+
+ )} + {milestones.length > 0 && ( +
+

Milestones

+
    + {milestones.map(([mid, def]) => { + const done = reached[mid]?.reached + return ( +
  • + {done ? '☑' : '☐'} + {def.desc || mid} +
  • + ) + })} +
+
+ )} + + )} +
+ )} +
+ ) +} + +// 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 ( +
+
World State
+
No changes this turn.
+
+ ) + } + return ( +
+
World State changes
+
    + {paths.map((p) => ( +
  • + {p} {String(delta[p])} + {clamped.has(p) && clamped} +
  • + ))} + {rejected.map((r, i) => ( +
  • + {r.path} rejected — {r.reason} +
  • + ))} +
+
+ ) +} + function ScriptReport({ script }) { if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) { return null @@ -591,6 +769,7 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
))} + ) } @@ -794,6 +973,7 @@ export default function Play() { return (
+
diff --git a/frontend/src/pages/ScenarioEditor.jsx b/frontend/src/pages/ScenarioEditor.jsx index ee15760..4b23fb5 100644 --- a/frontend/src/pages/ScenarioEditor.jsx +++ b/frontend/src/pages/ScenarioEditor.jsx @@ -9,6 +9,9 @@ export default function ScenarioEditor() { const [scenario, setScenario] = useState(null) const [allScripts, setAllScripts] = 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 // save of whatever was edited previously within the debounce window. const saveTimers = useRef(new Map()) @@ -18,7 +21,10 @@ export default function ScenarioEditor() { } 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(() => {}) }, [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 card = await api.createStoryCard({ scenario_id: Number(id) }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] }) @@ -152,6 +191,25 @@ export default function ScenarioEditor() { onChange={updateCard} onDelete={() => deleteCard(card.id)} /> ))} +
+

World State (RPG)

+
+

+ Optional. Define stats (with bands and rules) and milestones as a JSON object, + and the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives. + Leave blank for a plain narrative scenario. NPC stats apply to story cards of the + configured npc_card_types. +

+