diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 1fe5fa2..1c54e8a 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -95,6 +95,15 @@ MIGRATIONS: list[tuple[int, str]] = [ (30, "ALTER TABLE scenarios ADD COLUMN image TEXT NOT NULL DEFAULT ''"), # Emoji/glyph fallback used when `image` is empty. (31, "ALTER TABLE scenarios ADD COLUMN icon VARCHAR(16) NOT NULL DEFAULT ''"), + # The ${Placeholder} answers given when the adventure was started. Kept so + # "Update from scenario" can re-fill re-copied text; NULL for adventures + # created before this column, which re-prompt for them on first refresh. + (32, "ALTER TABLE adventures ADD COLUMN placeholders JSON"), + # Which piece of the scenario a copied story card came from ("card:" or + # "npc:"), so a refresh can update/remove exactly the scenario-derived + # cards and leave player-authored ones alone. NULL = player-authored, or a + # copy predating this column (matched by name once, then adopted). + (33, "ALTER TABLE story_cards ADD COLUMN source_ref VARCHAR(64)"), ] LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) diff --git a/backend/app/models.py b/backend/app/models.py index eac6251..f27dfec 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -104,6 +104,10 @@ class Adventure(Base): # 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) + # The ${Placeholder} answers collected when this adventure was started, kept + # so "Update from scenario" can re-fill freshly copied scenario text with the + # same values. NULL for adventures created before this column existed. + placeholders: Mapped[dict | None] = mapped_column(JSON, nullable=True) # 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) @@ -181,6 +185,11 @@ class StoryCard(Base): keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers entry: Mapped[str] = mapped_column(Text, default="") notes: Mapped[str] = mapped_column(Text, default="") + # Adventure copies only: which piece of the scenario this card came from — + # "card:" or "npc:". "Update from scenario" + # refreshes/removes exactly these; NULL means player-authored (left alone), + # or a copy predating the column (matched by name, then adopted). + source_ref: Mapped[str | None] = mapped_column(String(64), nullable=True) scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards") adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards") diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 1927411..18e97a6 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -142,6 +142,51 @@ def fill_placeholders(text: str, values: dict[str, str]) -> str: ) +# Adventure fields that start as a copy of the scenario's text and so can be +# re-copied by "Update from scenario". `title` is excluded on purpose: it is the +# adventure's own name, which players rename, and `story_summary` is play output, +# not scenario content. +SCENARIO_TEXT_FIELDS = ("memory", "authors_note", "ai_instructions") + +# Story-card fields copied from the scenario, and compared to detect drift. +CARD_FIELDS = ("type", "name", "keys", "entry", "notes") + + +def scenario_card_specs(scenario: models.Scenario, values: dict[str, str]) -> dict[str, dict]: + """Every story card a scenario implies, keyed by a stable `source_ref`: + its own cards ("card:") plus one per NPC defined in its stat_schema + ("npc:"), with placeholders already filled in. + + Shared by adventure creation and refresh so the two can't drift. + """ + specs: dict[str, dict] = {} + existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards} + for card in scenario.story_cards: + specs[f"card:{card.id}"] = { + "type": card.type, + "name": card.name, + "keys": fill_placeholders(card.keys, values), + "entry": fill_placeholders(card.entry, values), + "notes": card.notes, + } + # Phase 12: each defined NPC gets a story card (for its description as lore + + # in-scene triggering), unless a card with that name already exists. + for npc_key, ndef in (scenario.stat_schema or {}).get("npcs", {}).items(): + if not isinstance(ndef, dict): + continue + name = worldstate.npc_name(ndef, npc_key) + if name.strip().lower() in existing_names: + continue + specs[f"npc:{npc_key}"] = { + "type": "character", + "name": name, + "keys": fill_placeholders(str(ndef.get("keys") or name), values), + "entry": fill_placeholders(str(ndef.get("desc") or ""), values), + "notes": "", + } + return specs + + @router.post("", response_model=schemas.AdventureOut, status_code=201) def create_adventure( payload: schemas.AdventureCreate, @@ -166,41 +211,16 @@ def create_adventure( 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 {}, + # Kept so a later "Update from scenario" can re-fill re-copied text with + # the same answers instead of re-injecting literal ${...} tokens. + placeholders=dict(values), ) db.add(adventure) db.flush() if scenario: - existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards} - for card in scenario.story_cards: - db.add( - models.StoryCard( - adventure_id=adventure.id, - type=card.type, - name=card.name, - keys=fill_placeholders(card.keys, values), - entry=fill_placeholders(card.entry, values), - notes=card.notes, - ) - ) - # Phase 12: each defined NPC gets a story card (for its description as - # lore + in-scene triggering), unless a card with that name already exists. - for npc_key, ndef in (scenario.stat_schema or {}).get("npcs", {}).items(): - if not isinstance(ndef, dict): - continue - name = worldstate.npc_name(ndef, npc_key) - if name.strip().lower() in existing_names: - continue - db.add( - models.StoryCard( - adventure_id=adventure.id, - type="character", - name=name, - keys=fill_placeholders(str(ndef.get("keys") or name), values), - entry=fill_placeholders(str(ndef.get("desc") or ""), values), - notes="", - ) - ) + for ref, spec in scenario_card_specs(scenario, values).items(): + db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) for position, script in enumerate(scenario.scripts): db.add( models.AdventureScript( @@ -900,6 +920,203 @@ def update_adventure_script( return script +# ---------- Refresh from scenario ---------- +# +# An adventure copies the scenario's plot text and story cards at creation so +# later authoring never disturbs a story in progress (same reasoning as the +# per-script "Sync from library" above). This is the explicit opt-out: pull the +# scenario's current content back down over the copy. + + +def resolve_source_scenario( + adventure: models.Adventure, db: Session, user: models.User +) -> models.Scenario | None: + """The scenario an adventure can refresh from — the one it was started from, + if it still exists and is still readable (own, or a shared demo one). None + once the scenario is deleted (scenario_id goes NULL) or was unshared.""" + if adventure.scenario_id is None: + return None + scenario = db.get(models.Scenario, adventure.scenario_id) + if scenario is None or (scenario.user_id != user.id and not scenario.is_public): + return None + return scenario + + +def _placeholder_names(*texts: str) -> list[str]: + """Unique ${Placeholder} names across the given texts, first appearance first + (mirrors the frontend's extractPlaceholders).""" + names: list[str] = [] + for text in texts: + for match in PLACEHOLDER_RE.finditer(text or ""): + name = match.group(1).strip() + if name and name not in names: + names.append(name) + return names + + +def scenario_placeholder_names(scenario: models.Scenario) -> list[str]: + """Every placeholder the scenario's *refreshable* content asks for. The + opening prompt is excluded — a refresh never rewrites it.""" + texts = [scenario.memory, scenario.authors_note, scenario.ai_instructions] + for card in scenario.story_cards: + texts += [card.keys, card.entry] + for ndef in (scenario.stat_schema or {}).get("npcs", {}).values(): + if isinstance(ndef, dict): + texts += [str(ndef.get("keys") or ""), str(ndef.get("desc") or "")] + return _placeholder_names(*texts) + + +def _scenario_cards(adventure: models.Adventure) -> dict[str, models.StoryCard]: + """The adventure's scenario-derived cards, keyed by source_ref. + + Adventures created before `source_ref` existed have none, so those fall back + to matching the scenario's cards by name — but only when the adventure has no + tagged cards at all, otherwise a player-authored card that happens to share a + scenario card's name would be adopted and overwritten. + """ + return {c.source_ref: c for c in adventure.story_cards if c.source_ref} + + +def _match_legacy( + adventure: models.Adventure, specs: dict[str, dict] +) -> dict[str, models.StoryCard]: + by_name: dict[str, models.StoryCard] = {} + for card in adventure.story_cards: + by_name.setdefault((card.name or "").strip().lower(), card) + matched: dict[str, models.StoryCard] = {} + for ref, spec in specs.items(): + card = by_name.get((spec["name"] or "").strip().lower()) + if card is not None: + matched[ref] = card + return matched + + +def plan_refresh( + adventure: models.Adventure, scenario: models.Scenario, values: dict[str, str] +) -> tuple[dict, dict, dict]: + """Work out what a refresh would change, without touching anything. + + Returns (plan, specs, matched) — `plan` is the UI-facing summary, `specs` the + scenario's card specs by ref, `matched` the existing adventure card for each + ref that already has one. + """ + fields = { + field: {"old": getattr(adventure, field), "new": fill_placeholders( + getattr(scenario, field), values)} + for field in SCENARIO_TEXT_FIELDS + } + changed_fields = {f: v for f, v in fields.items() if v["old"] != v["new"]} + + specs = scenario_card_specs(scenario, values) + tagged = _scenario_cards(adventure) + matched = tagged or _match_legacy(adventure, specs) + + added, updated = [], [] + for ref, spec in specs.items(): + card = matched.get(ref) + if card is None: + added.append(spec["name"]) + elif any(getattr(card, f) != spec[f] for f in CARD_FIELDS): + updated.append(card.name or spec["name"]) + # Only cards the scenario is known to have produced are removable; a + # player-authored card (no source_ref) is never touched. + removed = [c.name for ref, c in tagged.items() if ref not in specs] + + _, world = worldstate.reconcile(adventure.world_state, scenario.stat_schema) + + plan = { + "scenario_id": scenario.id, + "scenario_title": scenario.title, + "fields": changed_fields, + "cards": {"added": added, "updated": updated, "removed": removed}, + "world_state": world, + } + plan["has_changes"] = bool( + changed_fields or added or updated or removed + or world["added"] or world["removed"] + ) + return plan, specs, matched + + +@router.get("/{adventure_id}/refresh", response_model=schemas.RefreshPlan) +def preview_refresh( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """What "Update from scenario" would change, for the confirm dialog.""" + adventure = get_adventure_or_404(adventure_id, db, user) + scenario = resolve_source_scenario(adventure, db, user) + if scenario is None: + raise HTTPException(404, "No scenario to update from") + stored = adventure.placeholders if isinstance(adventure.placeholders, dict) else {} + plan, _, _ = plan_refresh(adventure, scenario, stored) + # Adventures started before placeholder answers were stored have none, and an + # author can add a new ${...} after the fact — either way the player is asked + # for the missing ones, and the answers are saved for next time. + plan["placeholders_needed"] = [ + n for n in scenario_placeholder_names(scenario) if n not in stored + ] + return plan + + +@router.post("/{adventure_id}/refresh", response_model=schemas.AdventureOut) +def refresh_from_scenario( + adventure_id: int, + payload: schemas.AdventureRefresh = Body(default=schemas.AdventureRefresh()), + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Pull the scenario's current plot text, story cards and stat schema down + over this adventure's copy. + + Overwrites the plot fields and every scenario-derived card, adds what the + scenario gained and removes what it dropped. Deliberately left alone: the + opening `start` action (the story is built on it, and it is baked into + memories and the summary), the adventure's own title, its story summary, its + player-authored story cards, and — via `worldstate.reconcile` — the live + value of every stat the schema still defines. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + scenario = resolve_source_scenario(adventure, db, user) + if scenario is None: + raise HTTPException(404, "No scenario to update from") + + values = {**(adventure.placeholders if isinstance(adventure.placeholders, dict) else {}), + **payload.placeholders} + + # A refresh rewrites the same state a turn is mid-way through mutating, so it + # takes the turn slot rather than racing the generator. + acquire_turn_lock(adventure_id) + try: + _, specs, matched = plan_refresh(adventure, scenario, values) + + for field in SCENARIO_TEXT_FIELDS: + setattr(adventure, field, fill_placeholders(getattr(scenario, field), values)) + + for ref, spec in specs.items(): + card = matched.get(ref) + if card is None: + db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) + continue + for field in CARD_FIELDS: + setattr(card, field, spec[field]) + # Adopt the ref so a name-matched legacy card syncs by id next time. + card.source_ref = ref + for ref, card in _scenario_cards(adventure).items(): + if ref not in specs: + db.delete(card) + + adventure.world_state, _ = worldstate.reconcile( + adventure.world_state, scenario.stat_schema + ) + adventure.placeholders = values + db.commit() + finally: + _active_turns.discard(adventure_id) + + db.refresh(adventure) + return adventure + + # ---------- Insights ---------- @router.get("/{adventure_id}/context") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e3281f8..186d1e0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -149,6 +149,31 @@ class AdventureUpdate(BaseModel): memory_bank_enabled: bool | None = None +class AdventureRefresh(BaseModel): + """Body for "Update from scenario". `placeholders` supplies answers the + adventure has no stored value for (see AdventureCreate.placeholders); they + are merged over the stored ones and saved.""" + + placeholders: dict[str, str] = {} + + +class RefreshPlan(BaseModel): + """What a refresh would change — drives the confirm dialog.""" + + scenario_id: int + scenario_title: str + has_changes: bool + # field name -> {"old": ..., "new": ...}, only for fields that differ. + fields: dict[str, dict] = {} + # {"added"|"updated"|"removed": [card name, ...]} + cards: dict[str, list[str]] = {} + # {"added"|"removed": [stat path, ...]} — live values are otherwise kept. + world_state: dict[str, list[str]] = {} + # ${Placeholder} names the scenario asks for that the adventure has no + # stored answer to; the client must collect these and send them back. + placeholders_needed: list[str] = [] + + class ActionOut(ORMModel): id: int adventure_id: int diff --git a/backend/app/worldstate/__init__.py b/backend/app/worldstate/__init__.py index 54b83fd..6398e15 100644 --- a/backend/app/worldstate/__init__.py +++ b/backend/app/worldstate/__init__.py @@ -12,6 +12,7 @@ from .engine import ( instantiate, npc_name, npc_triggers, + reconcile, render_delta_block, render_reference, render_state_section, @@ -28,6 +29,7 @@ __all__ = [ "instantiate", "npc_name", "npc_triggers", + "reconcile", "render_delta_block", "render_reference", "render_state_section", diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py index ed2faf3..ac4db33 100644 --- a/backend/app/worldstate/engine.py +++ b/backend/app/worldstate/engine.py @@ -120,6 +120,111 @@ def instantiate(stat_schema: dict | None) -> dict: return ws +def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict, dict]: + """Bring a live world_state back in line with an edited schema. + + Deliberately NOT `instantiate`: a value the schema still defines keeps + whatever it has reached in play (re-instantiating would heal the player to + full and wipe their milestones). Only the difference is applied — stats, + NPCs, flags and milestones the schema gained appear at their initial value, + and ones it no longer defines are dropped, along with their `last_changed` + bookkeeping. Returns (new_state, report) where the report lists paths under + `added` / `removed` so the UI can show what a refresh would do. + + Note the additions are mostly cosmetic: rendering and delta-application both + fall back to a stat def's `initial` when the live state has no value for it, + so a newly added stat already behaves correctly. This materialises it (and, + unlike those read-through paths, actually cleans up removals). + """ + report: dict = {"added": [], "removed": []} + if not has_schema(stat_schema): + # The scenario dropped its RPG layer entirely — so does the adventure. + stale = bool(world_state) + if stale: + report["removed"].append("(all world state)") + return {}, report + + ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} + if not ws: + return instantiate(stat_schema), report + + def sync_section(container: dict, defs: dict, prefix: str) -> dict: + out = {} + for name, d in defs.items(): + if not isinstance(d, dict): + continue + if name in container: + out[name] = container[name] + else: + out[name] = d.get("initial", "" if d.get("type") == "text" else 0) + report["added"].append(f"{prefix}.{name}") + for name in container: + if name not in out: + report["removed"].append(f"{prefix}.{name}") + return out + + for section in STAT_SECTIONS: + ws[section] = sync_section( + ws.get(section) if isinstance(ws.get(section), dict) else {}, + stat_schema.get(section) or {}, + section, + ) + + npc_defs = stat_schema.get("npcs") or {} + old_npcs = ws.get("npc") if isinstance(ws.get("npc"), dict) else {} + new_npcs = {} + for key, ndef in npc_defs.items(): + if not isinstance(ndef, dict): + continue + old = old_npcs.get(key) if isinstance(old_npcs.get(key), dict) else {} + new_npcs[key] = sync_section(old, ndef.get("stats") or {}, f"npc.{key}") + for key in old_npcs: + if key not in new_npcs: + report["removed"].append(f"npc.{key}") + ws["npc"] = new_npcs + + flag_defs = stat_schema.get("flags") or {} + old_flags = ws.get("flags") if isinstance(ws.get("flags"), dict) else {} + new_flags = {} + for name, d in flag_defs.items(): + if not isinstance(d, dict): + continue + if name in old_flags: + new_flags[name] = bool(old_flags[name]) + else: + new_flags[name] = bool(d.get("initial", False)) + report["added"].append(f"flags.{name}") + for name in old_flags: + if name not in new_flags: + report["removed"].append(f"flags.{name}") + ws["flags"] = new_flags + + # Milestones store only the ones reached, so there is nothing to add here — + # an unreached milestone is simply absent. Drop reached ones the scenario + # no longer defines, or they'd sit in "Achieved" forever with no label. + milestone_defs = stat_schema.get("milestones") or {} + reached = ws.get("milestones") if isinstance(ws.get("milestones"), dict) else {} + ws["milestones"] = {k: v for k, v in reached.items() if k in milestone_defs} + for k in reached: + if k not in milestone_defs: + report["removed"].append(f"milestones.{k}") + + # Cooldown bookkeeping for paths that no longer exist would never be read, + # but it accumulates in every stored snapshot — prune it with the rest. + meta = ws.setdefault("_meta", {}) + last_changed = meta.get("last_changed") + if isinstance(last_changed, dict): + removed = set(report["removed"]) + meta["last_changed"] = { + path: at for path, at in last_changed.items() + if not any(path == r or path.startswith(f"{r}.") for r in removed) + } + else: + meta["last_changed"] = {} + + return ws, report + + def band_label(stat_def: dict, value) -> str | None: """The word label for `value` from a stat def's bands, if any. diff --git a/backend/tests/test_scenario_refresh.py b/backend/tests/test_scenario_refresh.py new file mode 100644 index 0000000..d7a89db --- /dev/null +++ b/backend/tests/test_scenario_refresh.py @@ -0,0 +1,382 @@ +"""Tests for "Update from scenario" — pulling an edited scenario's plot text, +story cards and stat schema back down over a running adventure's copy. + + python -m pytest tests/test_scenario_refresh.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, worldstate +from app.database import Base, SessionLocal, engine, get_db +from app.main import app + +SCHEMA = { + "player": {"hp": {"min": 0, "max": 100, "initial": 100}}, + "npcs": { + "gwen": {"name": "Gwen", "keys": "Gwen, ranger", "desc": "A loyal ranger.", + "stats": {"trust": {"min": -100, "max": 100, "initial": 0}}}, + }, + "flags": {"alarm": {"desc": "Alerted", "initial": False}}, + "milestones": {"win": {"desc": "Win the fight"}}, +} + + +@pytest.fixture() +def client(monkeypatch): + Base.metadata.create_all(bind=engine) + setup = SessionLocal() + user = models.User(is_guest=False, email="refresh@example.com") + setup.add(user) + setup.flush() + setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")) + setup.commit() + user_id = user.id + setup.close() + + 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.user_id = user_id + try: + yield c + finally: + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=engine) + + +def make_scenario(client, **kwargs): + db = SessionLocal() + try: + cards = kwargs.pop("cards", []) + scenario = models.Scenario(user_id=client.user_id, **kwargs) + db.add(scenario) + db.flush() + for card in cards: + db.add(models.StoryCard(scenario_id=scenario.id, **card)) + db.commit() + return scenario.id + finally: + db.close() + + +def edit_scenario(scenario_id, cards=None, **fields): + """Author-side edit, straight to the DB (the scenario API is tested elsewhere). + + `cards` is the scenario's full card list afterwards. Cards are matched to + existing rows by name and edited in place, exactly as ScenarioEditor does + (PATCH /story-cards/{id}) — card ids are stable across authoring, which is + what source_ref tracking relies on. + """ + db = SessionLocal() + try: + scenario = db.get(models.Scenario, scenario_id) + for field, value in fields.items(): + setattr(scenario, field, value) + if cards is not None: + existing = {c.name: c for c in scenario.story_cards} + for spec in cards: + card = existing.pop(spec["name"], None) + if card is None: + db.add(models.StoryCard(scenario_id=scenario_id, **spec)) + else: + for field, value in spec.items(): + setattr(card, field, value) + for card in existing.values(): + db.delete(card) + db.commit() + finally: + db.close() + + +def start(client, scenario_id, placeholders=None): + r = client.post("/api/adventures", json={ + "scenario_id": scenario_id, "placeholders": placeholders or {}}) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def get_adventure(client, adv_id): + r = client.get(f"/api/adventures/{adv_id}") + assert r.status_code == 200, r.text + return r.json() + + +def preview(client, adv_id): + r = client.get(f"/api/adventures/{adv_id}/refresh") + assert r.status_code == 200, r.text + return r.json() + + +def refresh(client, adv_id, placeholders=None): + r = client.post(f"/api/adventures/{adv_id}/refresh", + json={"placeholders": placeholders or {}}) + assert r.status_code == 200, r.text + return r.json() + + +def card_named(adventure, name): + return next((c for c in adventure["story_cards"] if c["name"] == name), None) + + +# --------------------------------------------------------------------------- # +# Plot text +# --------------------------------------------------------------------------- # + +def test_refresh_overwrites_plot_text_including_player_edits(client): + sid = make_scenario(client, title="Keep", memory="Old memory", + authors_note="Old note", ai_instructions="Old rules") + adv_id = start(client, sid) + + # The player tunes the adventure's copy, then the author edits the scenario. + client.patch(f"/api/adventures/{adv_id}", json={"memory": "Player's own memory"}) + edit_scenario(sid, memory="New memory", authors_note="New note") + + plan = preview(client, adv_id) + assert plan["has_changes"] is True + assert plan["fields"]["memory"] == {"old": "Player's own memory", "new": "New memory"} + assert "ai_instructions" not in plan["fields"] # unchanged fields aren't listed + + adventure = refresh(client, adv_id) + assert adventure["memory"] == "New memory" + assert adventure["authors_note"] == "New note" + assert adventure["ai_instructions"] == "Old rules" + + +def test_refresh_leaves_the_opening_action_and_title_alone(client): + sid = make_scenario(client, title="Keep", prompt="You stand at the gate.") + adv_id = start(client, sid) + client.patch(f"/api/adventures/{adv_id}", json={"title": "My run"}) + edit_scenario(sid, title="Fortress", prompt="You stand in the throne room.") + + adventure = refresh(client, adv_id) + # The story is built on the opening beat, and it is baked into memories and + # the summary, so a refresh must never rewrite it. + assert adventure["actions"][0]["text"] == "You stand at the gate." + assert adventure["title"] == "My run" + + +def test_no_changes_reported_when_nothing_was_edited(client): + sid = make_scenario(client, title="Keep", memory="Same", stat_schema=SCHEMA, + cards=[{"name": "Gate", "keys": "gate", "entry": "Iron."}]) + adv_id = start(client, sid) + assert preview(client, adv_id)["has_changes"] is False + + +# --------------------------------------------------------------------------- # +# Story cards +# --------------------------------------------------------------------------- # + +def test_cards_added_updated_and_removed_but_player_cards_survive(client): + sid = make_scenario(client, title="Keep", cards=[ + {"name": "Gate", "keys": "gate", "entry": "An iron gate."}, + {"name": "Well", "keys": "well", "entry": "A dry well."}, + ]) + adv_id = start(client, sid) + + # The player writes their own card mid-play. + client.post("/api/story-cards", json={ + "adventure_id": adv_id, "name": "My horse", "keys": "horse", "entry": "Bess."}) + + # The author rewrites the Gate, drops the Well, adds a Tower. + edit_scenario(sid, cards=[ + {"name": "Gate", "keys": "gate, portcullis", "entry": "A rusted iron gate."}, + {"name": "Tower", "keys": "tower", "entry": "It leans."}, + ]) + + plan = preview(client, adv_id) + assert plan["cards"] == {"added": ["Tower"], "updated": ["Gate"], "removed": ["Well"]} + + adventure = refresh(client, adv_id) + names = sorted(c["name"] for c in adventure["story_cards"]) + assert names == ["Gate", "My horse", "Tower"] + assert card_named(adventure, "Gate")["entry"] == "A rusted iron gate." + # A player-authored card is never touched, even though the scenario has no + # matching card — only scenario-derived copies are managed. + assert card_named(adventure, "My horse")["entry"] == "Bess." + + +def test_a_renamed_scenario_card_is_updated_not_duplicated(client): + sid = make_scenario(client, title="Keep", + cards=[{"name": "Gate", "keys": "gate", "entry": "Iron."}]) + adv_id = start(client, sid) + db = SessionLocal() + try: + card = db.query(models.StoryCard).filter_by(scenario_id=sid).one() + card.name = "Portcullis" + db.commit() + finally: + db.close() + + adventure = refresh(client, adv_id) + # Tracked by source_ref, so a rename is a rename — not a delete plus an add. + assert [c["name"] for c in adventure["story_cards"]] == ["Portcullis"] + + +def test_legacy_cards_with_no_source_ref_are_matched_by_name_and_adopted(client): + sid = make_scenario(client, title="Keep", + cards=[{"name": "Gate", "keys": "gate", "entry": "Iron."}]) + adv_id = start(client, sid) + # Simulate an adventure created before source_ref existed. + db = SessionLocal() + try: + for card in db.query(models.StoryCard).filter_by(adventure_id=adv_id): + card.source_ref = None + db.commit() + finally: + db.close() + + edit_scenario(sid, cards=[{"name": "Gate", "keys": "gate", "entry": "Rusted iron."}]) + adventure = refresh(client, adv_id) + assert len(adventure["story_cards"]) == 1 + assert card_named(adventure, "Gate")["entry"] == "Rusted iron." + + db = SessionLocal() + try: + adopted = db.query(models.StoryCard).filter_by(adventure_id=adv_id).one() + assert adopted.source_ref is not None # syncs by id from here on + finally: + db.close() + + +def test_npc_cards_are_created_for_npcs_added_after_the_adventure_started(client): + sid = make_scenario(client, title="Keep", stat_schema={"player": SCHEMA["player"]}) + adv_id = start(client, sid) + assert get_adventure(client, adv_id)["story_cards"] == [] + + edit_scenario(sid, stat_schema=SCHEMA) + adventure = refresh(client, adv_id) + gwen = card_named(adventure, "Gwen") + assert gwen is not None and gwen["keys"] == "Gwen, ranger" + + +# --------------------------------------------------------------------------- # +# World state +# --------------------------------------------------------------------------- # + +def test_refresh_keeps_live_values_but_syncs_the_shape(client): + sid = make_scenario(client, title="Keep", stat_schema=SCHEMA) + adv_id = start(client, sid) + # Play happens: damage taken, a milestone reached. + client.put(f"/api/adventures/{adv_id}/world-state", + json={"player.hp": 40, "npc.gwen.trust": 25, "milestones.win": True}) + + edited = { + "player": {"hp": SCHEMA["player"]["hp"], "gold": {"min": 0, "initial": 10}}, + "npcs": {"gwen": {"name": "Gwen", "keys": "Gwen, ranger", "desc": "A loyal ranger.", + "stats": {"trust": {"min": -100, "max": 100, "initial": 0}}}}, + "flags": {}, # the alarm flag is gone + "milestones": SCHEMA["milestones"], + } + edit_scenario(sid, stat_schema=edited) + + plan = preview(client, adv_id) + assert plan["world_state"] == {"added": ["player.gold"], "removed": ["flags.alarm"]} + + refresh(client, adv_id) + r = client.get(f"/api/adventures/{adv_id}/world-state") + state = r.json()["state"] + # Values the schema still defines survive — a refresh is not a reset. + assert state["player"]["hp"] == 40 + assert state["npc"]["gwen"]["trust"] == 25 + assert state["milestones"]["win"]["reached"] is True + assert state["player"]["gold"] == 10 # new stat, at its initial + assert state["flags"] == {} # dropped stat, cleaned up + + +def test_reconcile_drops_a_removed_stats_cooldown_bookkeeping(): + state = worldstate.instantiate(SCHEMA) + state["_meta"]["last_changed"] = {"player.hp": 3, "npc.gwen.trust": 4} + reduced = {"player": SCHEMA["player"], "milestones": SCHEMA["milestones"]} + + new_state, report = worldstate.reconcile(state, reduced) + assert "npc.gwen" in report["removed"] + assert new_state["_meta"]["last_changed"] == {"player.hp": 3} + + +def test_reconcile_clears_everything_when_the_rpg_layer_is_removed(): + new_state, report = worldstate.reconcile(worldstate.instantiate(SCHEMA), None) + assert new_state == {} + assert report["removed"] == ["(all world state)"] + + +# --------------------------------------------------------------------------- # +# Placeholders +# --------------------------------------------------------------------------- # + +def test_stored_placeholder_answers_are_reused_on_refresh(client): + sid = make_scenario(client, title="Keep", memory="Your name is ${Hero}.", + cards=[{"name": "Sword", "keys": "sword", + "entry": "${Hero}'s blade."}]) + adv_id = start(client, sid, {"Hero": "Wren"}) + assert get_adventure(client, adv_id)["memory"] == "Your name is Wren." + + assert preview(client, adv_id)["placeholders_needed"] == [] + edit_scenario(sid, memory="${Hero}, you are late.") + adventure = refresh(client, adv_id) + # The answer is reused rather than re-injecting a literal ${Hero}. + assert adventure["memory"] == "Wren, you are late." + assert card_named(adventure, "Sword")["entry"] == "Wren's blade." + + +def test_missing_placeholder_answers_are_requested_then_remembered(client): + sid = make_scenario(client, title="Keep", memory="Your name is ${Hero}.") + adv_id = start(client, sid, {"Hero": "Wren"}) + # An adventure predating the stored-answers column. + db = SessionLocal() + try: + db.get(models.Adventure, adv_id).placeholders = None + db.commit() + finally: + db.close() + + assert preview(client, adv_id)["placeholders_needed"] == ["Hero"] + adventure = refresh(client, adv_id, {"Hero": "Wren"}) + assert adventure["memory"] == "Your name is Wren." + # Saved, so the player is only asked once. + assert preview(client, adv_id)["placeholders_needed"] == [] + + +# --------------------------------------------------------------------------- # +# Availability +# --------------------------------------------------------------------------- # + +def test_refresh_is_unavailable_once_the_scenario_is_gone(client): + sid = make_scenario(client, title="Keep", memory="Old") + adv_id = start(client, sid) + db = SessionLocal() + try: + db.delete(db.get(models.Scenario, sid)) + db.commit() + finally: + db.close() + + assert client.get(f"/api/adventures/{adv_id}/refresh").status_code == 404 + assert client.post(f"/api/adventures/{adv_id}/refresh", json={}).status_code == 404 + + +def test_refresh_is_rejected_while_a_turn_is_generating(client): + from app.routers import adventures + + sid = make_scenario(client, title="Keep", memory="Old") + adv_id = start(client, sid) + adventures._active_turns.add(adv_id) + try: + r = client.post(f"/api/adventures/{adv_id}/refresh", json={}) + assert r.status_code == 409 + finally: + adventures._active_turns.discard(adv_id) diff --git a/frontend/src/api.js b/frontend/src/api.js index 047cab5..9e1a966 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -98,6 +98,12 @@ export const api = { importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }), undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }), getAdventureContext: (advId) => request(`/adventures/${advId}/context`), + // "Update from scenario": GET describes what would change, POST applies it. + previewRefresh: (advId) => request(`/adventures/${advId}/refresh`), + refreshFromScenario: (advId, placeholders = {}) => + request(`/adventures/${advId}/refresh`, { + method: 'POST', body: JSON.stringify({ placeholders }), + }), getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`), // Memory bank diff --git a/frontend/src/index.css b/frontend/src/index.css index c17b036..63bb0cd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1090,6 +1090,36 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n .modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; } .modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } +/* "Update from scenario" — a wider modal, since it lists a diff. */ +.refresh-modal { width: min(620px, calc(100vw - 40px)); } +.refresh-changes { + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + font-size: 0.86rem; +} +.refresh-change + .refresh-change { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; } +.refresh-old { text-decoration: line-through; } +.refresh-new { color: var(--accent-bright); } +/* The button sits above the plot fields it would overwrite. */ +.plot-source { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + font-size: 0.82rem; +} +.plot-source .linklike { flex-shrink: 0; } + /* ---------- Debug log (Settings) ---------- */ .debug-entry { diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 04c4b0a..9d7d2ae 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -1,4 +1,5 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components' @@ -52,8 +53,118 @@ const SECTION_LABELS = { front_memory: 'Front memory', } -function PlotPanel({ adventure, setAdventure }) { +const FIELD_LABELS = { + memory: 'Plot Essentials (Memory)', + authors_note: "Author's Note", + ai_instructions: 'AI Instructions', +} + +function clip(text, n = 90) { + const one = (text || '').replace(/\s+/g, ' ').trim() + return one.length > n ? `${one.slice(0, n)}…` : (one || '(empty)') +} + +// Confirms "Update from scenario" by showing exactly what it would change, and +// collects any ${Placeholder} answers the adventure has no stored value for +// (adventures started before those were saved, or a placeholder the author +// added since). Destructive by design — it overwrites plot text and +// scenario-derived cards — so nothing happens until Update is pressed. +function RefreshModal({ plan, onConfirm, onCancel }) { + const [values, setValues] = useState( + Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])), + ) + const [busy, setBusy] = useState(false) + const { added = [], updated = [], removed = [] } = plan.cards || {} + const world = plan.world_state || {} + const fields = Object.entries(plan.fields || {}) + + const submit = (e) => { + e.preventDefault() + setBusy(true) + onConfirm(values).finally(() => setBusy(false)) + } + + // Portalled to : this modal is opened from inside .side-panel, whose + // panel-in animation (fill mode `both`) makes it the containing block for + // position:fixed children — an overlay rendered in place would be trapped in + // the 420px panel and clipped by its overflow. Same trap for any future modal + // opened from a drawer or panel. + return createPortal( +
+
e.stopPropagation()} onSubmit={submit}> +

Update from scenario

+ {!plan.has_changes ? ( +

+ This adventure already matches “{plan.scenario_title}”. Nothing to update. +

+ ) : ( + <> +

+ Pull the current content of “{plan.scenario_title}” down over this adventure. + Your story, its title, summary and your own story cards are untouched — but + the plot text below is overwritten, including any edits you made here. +

+
+ {fields.map(([field, diff]) => ( +
+ {FIELD_LABELS[field] || field} +
− {clip(diff.old)}
+
+ {clip(diff.new)}
+
+ ))} + {added.length > 0 && ( +
Story cards added: {added.join(', ')}
+ )} + {updated.length > 0 && ( +
Story cards overwritten: {updated.join(', ')}
+ )} + {removed.length > 0 && ( +
Story cards removed: {removed.join(', ')}
+ )} + {world.added?.length > 0 && ( +
+ New stats (at their starting value): {world.added.join(', ')} +
+ )} + {world.removed?.length > 0 && ( +
Stats removed: {world.removed.join(', ')}
+ )} +
+ {(plan.placeholders_needed || []).length > 0 && ( + <> +

+ This scenario asks a few questions, and this adventure has no saved + answers for them. They'll be remembered from now on. +

+ {plan.placeholders_needed.map((name) => ( + + ))} + + )} + + )} +
+ + {plan.has_changes && ( + + )} +
+
+
, + document.body, + ) +} + +function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) { const toast = useToast() + const [plan, setPlan] = useState(null) // non-null while the modal is open + const [planning, setPlanning] = useState(false) // 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()) @@ -109,8 +220,49 @@ function PlotPanel({ adventure, setAdventure }) { } } + // Ask the server what a refresh would do, then let the player confirm it. + const openRefresh = async () => { + setPlanning(true) + try { + setPlan(await api.previewRefresh(adventure.id)) + } catch (err) { + // 404 = the scenario was deleted or unshared; there's nothing to sync to. + toast(err.message, 'error') + } finally { + setPlanning(false) + } + } + + const applyRefresh = async (placeholders) => { + try { + const updated = await api.refreshFromScenario(adventure.id, placeholders) + setAdventure(updated) + setPlan(null) + onWorldStateChanged?.() + toast('Updated from scenario.') + } catch (err) { + toast(err.message, 'error') + } + } + return (
+ {adventure.scenario_id != null && ( +
+ + Copied from its scenario when the adventure began; later scenario edits don't + reach it on their own. + + +
+ )} + {plan && ( + setPlan(null)} /> + )} + setField('memory', v)} textarea placeholder="Key facts the AI should always remember." /> @@ -944,6 +1096,9 @@ export default function Play() { const [toast, setToast] = useState(null) const [editing, setEditing] = useState(null) const [panel, setPanel] = useState(null) // null | 'plot' | 'insights' + // Bumped when something outside the turn loop changes the drawers' state + // (currently "Update from scenario"), which no action count would reflect. + const [stateKey, setStateKey] = useState(0) const [inspectActionId, setInspectActionId] = useState(null) const storyEndRef = useRef(null) const abortRef = useRef(null) @@ -1144,7 +1299,7 @@ export default function Play() { return (
- +
@@ -1273,7 +1428,8 @@ export default function Play() {
{panel === 'plot' ? ( - + setStateKey((k) => k + 1)} /> ) : panel === 'memory' ? (