Add "Update from scenario" to pull scenario edits into a running adventure
An adventure copies its scenario's plot text and story cards at creation so
later authoring never disturbs a story in progress. This is the explicit
opt-out, alongside the existing per-script "Sync from library".
GET /adventures/{id}/refresh returns a plan (per-field old/new diff, card
add/update/remove, world-state added/removed paths, and any ${...} answers
still needed); POST applies it under the turn lock so it can't race a
generating turn. The Plot panel shows the plan in a confirm modal first.
Overwrites the plot fields and scenario-derived cards. 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 and summary,
player-authored story cards, and the live value of every stat the schema
still defines.
Two enablers were needed:
- adventures.placeholders (migration 32). ${...} answers were used once at
creation and discarded, so re-copying scenario text would have re-injected
a literal ${Hero}. Adventures predating the column re-prompt once via the
existing modal, then the answers are saved.
- story_cards.source_ref (migration 33), "card:<id>" / "npc:<key>", NULL for
player-authored. Adventure cards had no link back to their source, so a
rename read as delete-plus-add and player cards would have been clobbered.
Legacy cards name-match once, then adopt the ref.
World state goes through a new worldstate.reconcile(): keep values the schema
still defines, add missing ones at their initial, drop removed ones and their
cooldown bookkeeping. Not instantiate(), which would heal the player to full
and wipe their milestones.
The confirm modal is portalled to <body>: .side-panel's panel-in animation
has fill mode `both`, which makes it the containing block for position:fixed
descendants, so an overlay rendered in place was trapped in the 420px panel
and clipped by its overflow.
14 new tests in backend/tests/test_scenario_refresh.py; 85 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzBCyXH4hBEVHqEaertcq4
This commit is contained in:
co-authored by
Claude Opus 5
parent
a170f5aed5
commit
aed28d9295
@@ -95,6 +95,15 @@ MIGRATIONS: list[tuple[int, str]] = [
|
|||||||
(30, "ALTER TABLE scenarios ADD COLUMN image TEXT NOT NULL DEFAULT ''"),
|
(30, "ALTER TABLE scenarios ADD COLUMN image TEXT NOT NULL DEFAULT ''"),
|
||||||
# Emoji/glyph fallback used when `image` is empty.
|
# Emoji/glyph fallback used when `image` is empty.
|
||||||
(31, "ALTER TABLE scenarios ADD COLUMN icon VARCHAR(16) NOT NULL DEFAULT ''"),
|
(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:<id>" or
|
||||||
|
# "npc:<key>"), 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)
|
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ class Adventure(Base):
|
|||||||
# Phase 12: live RPG world state (world/player/npc stats + milestones),
|
# 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.
|
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
|
||||||
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
|
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)
|
# Phase 6: opt-in per adventure (extra AI calls)
|
||||||
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
|
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -181,6 +185,11 @@ class StoryCard(Base):
|
|||||||
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
|
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
|
||||||
entry: Mapped[str] = mapped_column(Text, default="")
|
entry: Mapped[str] = mapped_column(Text, default="")
|
||||||
notes: 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:<scenario_card_id>" or "npc:<npc_key>". "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")
|
scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards")
|
||||||
adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards")
|
adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards")
|
||||||
|
|||||||
@@ -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:<id>") plus one per NPC defined in its stat_schema
|
||||||
|
("npc:<key>"), 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)
|
@router.post("", response_model=schemas.AdventureOut, status_code=201)
|
||||||
def create_adventure(
|
def create_adventure(
|
||||||
payload: schemas.AdventureCreate,
|
payload: schemas.AdventureCreate,
|
||||||
@@ -166,41 +211,16 @@ def create_adventure(
|
|||||||
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
|
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
|
||||||
# Phase 12: seed the live RPG state from the scenario's template.
|
# Phase 12: seed the live RPG state from the scenario's template.
|
||||||
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
|
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.add(adventure)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
if scenario:
|
if scenario:
|
||||||
existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards}
|
for ref, spec in scenario_card_specs(scenario, values).items():
|
||||||
for card in scenario.story_cards:
|
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
|
||||||
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 position, script in enumerate(scenario.scripts):
|
for position, script in enumerate(scenario.scripts):
|
||||||
db.add(
|
db.add(
|
||||||
models.AdventureScript(
|
models.AdventureScript(
|
||||||
@@ -900,6 +920,203 @@ def update_adventure_script(
|
|||||||
return 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 ----------
|
# ---------- Insights ----------
|
||||||
|
|
||||||
@router.get("/{adventure_id}/context")
|
@router.get("/{adventure_id}/context")
|
||||||
|
|||||||
@@ -149,6 +149,31 @@ class AdventureUpdate(BaseModel):
|
|||||||
memory_bank_enabled: bool | None = None
|
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):
|
class ActionOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
adventure_id: int
|
adventure_id: int
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .engine import (
|
|||||||
instantiate,
|
instantiate,
|
||||||
npc_name,
|
npc_name,
|
||||||
npc_triggers,
|
npc_triggers,
|
||||||
|
reconcile,
|
||||||
render_delta_block,
|
render_delta_block,
|
||||||
render_reference,
|
render_reference,
|
||||||
render_state_section,
|
render_state_section,
|
||||||
@@ -28,6 +29,7 @@ __all__ = [
|
|||||||
"instantiate",
|
"instantiate",
|
||||||
"npc_name",
|
"npc_name",
|
||||||
"npc_triggers",
|
"npc_triggers",
|
||||||
|
"reconcile",
|
||||||
"render_delta_block",
|
"render_delta_block",
|
||||||
"render_reference",
|
"render_reference",
|
||||||
"render_state_section",
|
"render_state_section",
|
||||||
|
|||||||
@@ -120,6 +120,111 @@ def instantiate(stat_schema: dict | None) -> dict:
|
|||||||
return ws
|
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:
|
def band_label(stat_def: dict, value) -> str | None:
|
||||||
"""The word label for `value` from a stat def's bands, if any.
|
"""The word label for `value` from a stat def's bands, if any.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -98,6 +98,12 @@ export const api = {
|
|||||||
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||||
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
||||||
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
|
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`),
|
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
|
||||||
|
|
||||||
// Memory bank
|
// Memory bank
|
||||||
|
|||||||
@@ -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-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; }
|
.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 log (Settings) ---------- */
|
||||||
|
|
||||||
.debug-entry {
|
.debug-entry {
|
||||||
|
|||||||
+159
-3
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||||
@@ -52,8 +53,118 @@ const SECTION_LABELS = {
|
|||||||
front_memory: 'Front memory',
|
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 <body>: 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(
|
||||||
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
|
<form className="modal refresh-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||||
|
<h2>Update from scenario</h2>
|
||||||
|
{!plan.has_changes ? (
|
||||||
|
<p className="modal-hint">
|
||||||
|
This adventure already matches “{plan.scenario_title}”. Nothing to update.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="modal-hint">
|
||||||
|
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 <b>overwritten</b>, including any edits you made here.
|
||||||
|
</p>
|
||||||
|
<div className="refresh-changes">
|
||||||
|
{fields.map(([field, diff]) => (
|
||||||
|
<div key={field} className="refresh-change">
|
||||||
|
<b>{FIELD_LABELS[field] || field}</b>
|
||||||
|
<div className="dim refresh-old">− {clip(diff.old)}</div>
|
||||||
|
<div className="refresh-new">+ {clip(diff.new)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{added.length > 0 && (
|
||||||
|
<div className="refresh-change">Story cards added: <b>{added.join(', ')}</b></div>
|
||||||
|
)}
|
||||||
|
{updated.length > 0 && (
|
||||||
|
<div className="refresh-change">Story cards overwritten: <b>{updated.join(', ')}</b></div>
|
||||||
|
)}
|
||||||
|
{removed.length > 0 && (
|
||||||
|
<div className="refresh-change">Story cards removed: <b>{removed.join(', ')}</b></div>
|
||||||
|
)}
|
||||||
|
{world.added?.length > 0 && (
|
||||||
|
<div className="refresh-change">
|
||||||
|
New stats (at their starting value): <b>{world.added.join(', ')}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{world.removed?.length > 0 && (
|
||||||
|
<div className="refresh-change">Stats removed: <b>{world.removed.join(', ')}</b></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(plan.placeholders_needed || []).length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="modal-hint">
|
||||||
|
This scenario asks a few questions, and this adventure has no saved
|
||||||
|
answers for them. They'll be remembered from now on.
|
||||||
|
</p>
|
||||||
|
{plan.placeholders_needed.map((name) => (
|
||||||
|
<label key={name} className="field">
|
||||||
|
<span className="label">{name}</span>
|
||||||
|
<input type="text" value={values[name]}
|
||||||
|
onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="modal-buttons">
|
||||||
|
<button type="button" onClick={onCancel}>{plan.has_changes ? 'Cancel' : 'Close'}</button>
|
||||||
|
{plan.has_changes && (
|
||||||
|
<button type="submit" className="primary" disabled={busy}>
|
||||||
|
{busy ? 'Updating…' : 'Update'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) {
|
||||||
const toast = useToast()
|
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
|
// One timer per field/card: a single shared timer would cancel the pending
|
||||||
// save of whatever was edited previously within the debounce window.
|
// save of whatever was edited previously within the debounce window.
|
||||||
const saveTimers = useRef(new Map())
|
const saveTimers = useRef(new Map())
|
||||||
@@ -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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{adventure.scenario_id != null && (
|
||||||
|
<div className="plot-source">
|
||||||
|
<span className="dim">
|
||||||
|
Copied from its scenario when the adventure began; later scenario edits don't
|
||||||
|
reach it on their own.
|
||||||
|
</span>
|
||||||
|
<button className="linklike" onClick={openRefresh} disabled={planning}
|
||||||
|
title="Replace this adventure's plot text and scenario story cards with the scenario's current content">
|
||||||
|
{planning ? 'Checking…' : '⟳ Update from scenario'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plan && (
|
||||||
|
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label="Plot Essentials (Memory)" value={adventure.memory}
|
<Field label="Plot Essentials (Memory)" value={adventure.memory}
|
||||||
onChange={(v) => setField('memory', v)} textarea
|
onChange={(v) => setField('memory', v)} textarea
|
||||||
placeholder="Key facts the AI should always remember." />
|
placeholder="Key facts the AI should always remember." />
|
||||||
@@ -944,6 +1096,9 @@ export default function Play() {
|
|||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
|
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 [inspectActionId, setInspectActionId] = useState(null)
|
||||||
const storyEndRef = useRef(null)
|
const storyEndRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
@@ -1144,7 +1299,7 @@ export default function Play() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
|
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
|
||||||
<WorldStateDrawer advId={id} refreshKey={actions.length} />
|
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
|
||||||
<StatusDrawer advId={id} refreshKey={actions.length} />
|
<StatusDrawer advId={id} refreshKey={actions.length} />
|
||||||
<div className="page play-page">
|
<div className="page play-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -1273,7 +1428,8 @@ export default function Play() {
|
|||||||
<button onClick={() => setPanel(null)}>✕</button>
|
<button onClick={() => setPanel(null)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{panel === 'plot' ? (
|
{panel === 'plot' ? (
|
||||||
<PlotPanel adventure={adventure} setAdventure={setAdventure} />
|
<PlotPanel adventure={adventure} setAdventure={setAdventure}
|
||||||
|
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
|
||||||
) : panel === 'memory' ? (
|
) : panel === 'memory' ? (
|
||||||
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
|
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
|
||||||
refreshKey={actions.length} />
|
refreshKey={actions.length} />
|
||||||
|
|||||||
Reference in New Issue
Block a user