Files
interactive-story/backend/app/routers/adventures.py
T
parththakkar106andClaude Opus 5 0a12d9cd47 Make a retry a node, not a rewrite
Every attempt at a turn is now its own row at the same (branch, depth),
with `live` naming the one the story tells. The JSON repeating group on
`actions.variants` is read one last time, by a migration that writes it
out as the sibling rows it always described, and then goes unread.

The snapshots turn around with it: an action carries the state it left
behind rather than the state it started from, because attempts at one
turn share a starting position and differ exactly in their outcome.
Rolling back is "what the node in front left behind", one lookup on the
path, and it is what undo and retry now both read.

And the memory holdback goes. It existed because retry rewrote a row
under a mark that had already moved past it; a retry writes a sibling
now, and replacing what a coordinate says withdraws what was derived
from it — the same repair undo and delete already made.

The assembled prompt is still stored once per turn: it moves with the
live flag, so a superseded attempt keeps only the few hundred bytes that
were its own. Measured on the 600-action fixture: 700 rows for the same
600-turn story, prompt archive byte-identical at 0.50 MB, index 1.8 kB
and page load 62.7 kB unmoved.

347 tests green. `tests/test_story_tree_baseline.py` and
`tests/test_retry_variants.py` pass unmodified — SP4 was allowed to move
the baseline for the variant-count semantics and did not need to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30

1798 lines
73 KiB
Python

import json
import re
import threading
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only
from sqlalchemy.orm.attributes import set_committed_value
from .. import (
attempts, auth, images, limits, memorybank, models, schemas, tree, worldstate,
)
from ..context import build_context, cursors
from ..context import history as context_history
from ..context import lineage
from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
from ..scripting import ScriptPipeline
from .settings import get_settings
router = APIRouter(prefix="/api/adventures", tags=["adventures"])
CurrentUser = Depends(auth.get_current_user)
# Exactly what schemas.ActionOut renders, named rather than implied.
#
# `deferred=True` in models.py already keeps the four heavy columns out of a
# bulk read, but it makes narrowness the default that a *future* column has to
# remember to ask for — and both egress blowouts this project has had were a
# column nobody remembered. Listing what a list response carries inverts that:
# a new column costs nothing here until someone adds it to this tuple.
#
# `world_delta` is on the list because ActionOut.world_changes is computed from
# it. Leaving it off would not save the bytes, it would spend them one row at a
# time as a lazy load, which is worse.
ACTION_LIST_COLUMNS = (
models.Action.adventure_id,
models.Action.index,
models.Action.type,
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
models.Action.variant_count,
models.Action.variant_index,
models.Action.created_at,
)
# How many actions an adventure opens with, and how many arrive per scroll.
#
# Opening a finished adventure used to fetch the whole story in one response —
# 589.5 kB on production's longest, and growing, because a story only ever gets
# longer. 60 is a few screens of reading: enough that the common case (open,
# read the end, take a turn) never pages at all, small enough that the worst
# case is bounded by the window rather than by the story.
ACTION_PAGE = 60
def action_window(
db: Session,
adventure: models.Adventure,
before_id: int | None = None,
limit: int = ACTION_PAGE,
) -> tuple[list[models.Action], int, bool]:
"""The `limit` actions immediately older than `before_id`, oldest first.
Returns (actions, total, has_more). `before_id=None` is the newest window.
Scoped to the story being played — the head branch's lineage — rather than
to the adventure, so a sibling branch's turns can never appear in the
transcript. `total` counts the same path, because it is what tells the
reader there is more above.
Anchored on an action, not on a count, and never on arithmetic over depth.
Two separate reasons, and both bite:
* **Appends.** Counting back from the newest means every older position
shifts when a turn lands. A reader who scrolls up while a turn is
generating would be handed a window one row out — re-sending one action
and silently skipping another. An anchor is fixed: "older than this one"
means the same thing before and after the story grows.
* **The story tree.** Depth is dense today and branching ends that.
Comparing depths to order a path survives; treating them as positions
does not.
`has_more` comes from asking for one row past the window rather than from
counting, so it costs a row and not a scan.
"""
path = lineage.path_of(db, adventure)
on_path = (
models.Action.adventure_id == adventure.id,
path.clause(models.Action),
)
total = db.query(func.count(models.Action.id)).filter(*on_path).scalar()
if limit <= 0:
return [], total, total > 0
query = db.query(models.Action).options(load_only(*ACTION_LIST_COLUMNS)).filter(*on_path)
if before_id is not None:
anchor = (
db.query(models.Action.depth)
.filter(models.Action.id == before_id, *on_path)
.scalar()
)
if anchor is None:
# The anchor was deleted (undo, or a turn edited away) while the
# reader was scrolling, or it belongs to a story this branch is not
# on. Nothing older can be identified relative to a row that is not
# here, so report the end rather than guessing and handing back a
# duplicate page.
return [], total, False
query = query.filter(models.Action.depth < anchor)
rows = (
query.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
rows = rows[:limit]
rows.reverse()
return rows, total, has_more
# Exactly what schemas.MemoryOut renders. `embedded` is a real column and is on
# the list; the vector it describes is not, and must never be.
MEMORY_LIST_COLUMNS = (
models.Memory.adventure_id,
models.Memory.text,
models.Memory.pinned,
models.Memory.forgotten,
models.Memory.embedded,
models.Memory.use_count,
models.Memory.last_used_at,
models.Memory.source_start,
models.Memory.source_end,
models.Memory.created_at,
)
def get_adventure_or_404(
adventure_id: int, db: Session, user: models.User
) -> models.Adventure:
adventure = db.get(models.Adventure, adventure_id)
if adventure is None or adventure.user_id != user.id:
raise HTTPException(404, "Adventure not found")
return adventure
# How much of the last narrative beat a Continue card shows. Long enough to
# re-establish the scene, short enough that the card stays a card.
SNIPPET_MAX = 220
def _snippet(text: str) -> str:
"""Condense stored action text into one flowing line for a card."""
# Stored AI text already has any world-state block stripped (see the
# streaming handler below), so this only has to tidy whitespace.
collapsed = " ".join((text or "").split())
if len(collapsed) <= SNIPPET_MAX:
return collapsed
# Cut on a word boundary rather than mid-word, then let CSS add the ellipsis.
cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0]
return f"{cut}…"
# Action types that read as narration. `start` is the scenario's opening prompt,
# which is the only text a freshly-created adventure has — without it a brand-new
# story's card would claim nothing had been written yet. `do`/`say` are excluded:
# "where you left off" should be the story's voice, not the player's.
NARRATION_TYPES = ("ai", "story", "start")
def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]:
"""Map adventure id -> text of its most recent narrated action.
One window-function query rather than a per-adventure lookup, so the list
endpoint stays at a fixed number of round trips.
Scoped by head *branch* rather than by the full lineage, which is the one
place in the codebase that is allowed to be: a lineage clause per adventure
would put a hundred OR-terms on the index screen's query to pick one row
each. The two answers differ only for a branch with no nodes of its own,
and a branch is created by playing a turn onto it, so that state does not
exist. An adventure with no branch at all has no story to quote.
"""
branch_ids = [b for b in head_branches.values() if b is not None]
if not branch_ids:
return {}
ranked = (
db.query(
models.Action.adventure_id.label("adventure_id"),
models.Action.text.label("text"),
func.row_number()
.over(
partition_by=models.Action.adventure_id,
order_by=(models.Action.depth.desc(), models.Action.id.desc()),
)
.label("rank"),
)
.filter(
models.Action.adventure_id.in_(list(head_branches)),
models.Action.branch_id.in_(branch_ids),
models.Action.type.in_(NARRATION_TYPES),
)
.subquery()
)
rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all()
return {adventure_id: text for adventure_id, text in rows}
@router.get("", response_model=list[schemas.AdventureListItem])
def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
# Four columns of Adventure, named, rather than the entity. The entity is
# sixteen columns wide and carries script_state, world_state, placeholders,
# story_summary, memory, authors_note and ai_instructions — ~15 kB a row in
# production, none of it on this screen, all of it fetched once per
# adventure every time the index loads. Naming the columns also means the
# next wide column added to Adventure has to opt *in* to being listed here.
rows = (
db.query(
models.Adventure.id,
models.Adventure.scenario_id,
models.Adventure.title,
models.Adventure.updated_at,
models.Adventure.head_branch_id,
func.count(models.Action.id),
models.Scenario.title,
models.Scenario.image,
models.Scenario.icon,
models.Scenario.updated_at,
)
.outerjoin(models.Action)
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
.filter(models.Adventure.user_id == user.id)
# Group by both PKs: Postgres requires every selected column to be
# grouped or aggregated. Adventure.* rides on its own grouped PK, but
# the Scenario columns come from a joined table and must be listed too
# (SQLite is lax here; Postgres rejects it).
.group_by(
models.Adventure.id,
models.Scenario.id,
models.Scenario.title,
models.Scenario.image,
models.Scenario.icon,
models.Scenario.updated_at,
)
.order_by(models.Adventure.updated_at.desc())
.all()
)
narration = _latest_narration(db, {row[0]: row[4] for row in rows})
return [
schemas.AdventureListItem(
id=adv_id,
scenario_id=scenario_id,
scenario_title=scenario_title,
title=title,
updated_at=updated_at,
action_count=count,
snippet=_snippet(narration.get(adv_id, "")),
# The art belongs to the scenario, so the cache-busting stamp is the
# scenario's updated_at, not the adventure's.
image_url=images.public_url(scenario_id, image or "", scenario_updated),
icon=icon or "",
)
# `count` is every action of the adventure, not of the path. Under one
# branch they are the same number; once forking ships the index screen
# will overstate a story that has siblings hanging off it, and the fix
# belongs with SP5, where a fork can first exist.
for (adv_id, scenario_id, title, updated_at, _head_branch_id, count,
scenario_title, image, icon, scenario_updated) in rows
]
PLACEHOLDER_RE = re.compile(r"\$\{([^}]+)\}")
def fill_placeholders(text: str, values: dict[str, str]) -> str:
"""Replace ${Name} with the player-provided value; unknown names are left as-is."""
if not text or not values:
return text
return PLACEHOLDER_RE.sub(
lambda m: values.get(m.group(1).strip(), m.group(0)), text
)
# 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)
def create_adventure(
payload: schemas.AdventureCreate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
limits.check_row_cap("adventures", db, user)
scenario = None
if payload.scenario_id is not None:
scenario = db.get(models.Scenario, payload.scenario_id)
# Playable = your own scenario or a shared demo one.
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
raise HTTPException(404, "Scenario not found")
values = payload.placeholders
adventure = models.Adventure(
user_id=user.id,
scenario_id=scenario.id if scenario else None,
title=payload.title or (scenario.title if scenario else "Untitled Adventure"),
memory=fill_placeholders(scenario.memory, values) if scenario else "",
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
# Phase 12: seed the live RPG state from the scenario's template.
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
# 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()
# Every adventure has a story tree from the moment it exists, even before
# anything is played onto it — an adventure with a NULL head is a state the
# tree would otherwise have to tolerate everywhere for no gain.
tree.head_branch(db, adventure)
if scenario:
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(
adventure_id=adventure.id,
source_script_id=script.id,
position=position,
name=script.name,
description=script.description,
library_js=script.library_js,
input_js=script.input_js,
context_js=script.context_js,
output_js=script.output_js,
)
)
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
index=0,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
# The opening node leaves behind the state the adventure starts
# with, so undoing or retrying the first turn has somewhere to
# roll back to.
attempts.snapshot_outcome(adventure, opening)
tree.place_action(db, adventure, opening)
db.add(opening)
db.commit()
db.refresh(adventure)
return adventure
@router.get("/{adventure_id}", response_model=schemas.AdventureOut)
def get_adventure(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""The adventure, and the newest window of its story.
`actions` is the last ACTION_PAGE, not all of them; `action_count` says how
many there are so the reader knows there is more above. Older pages come
from GET /{id}/actions as they scroll up.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
actions, total, _ = action_window(db, adventure)
# Hand the response the window as if the relationship had loaded it.
# `set_committed_value` is the only way to do this safely: assigning
# `adventure.actions = [...]` marks the collection dirty, and the
# relationship cascades delete-orphan, so the actions left out of the
# window would be deleted on the next flush. This records them as the
# loaded, unmodified value instead, so serialising touches no lazy load
# and nothing is pending.
set_committed_value(adventure, "actions", actions)
out = schemas.AdventureOut.model_validate(adventure)
out.action_count = total
return out
@router.get("/{adventure_id}/script-state")
def get_script_state(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""The scripting `state` object — every variable scripts read/write via
`state.x`, persisted after each hook. Empty {} until a script sets one."""
adventure = get_adventure_or_404(adventure_id, db, user)
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
return {"state": state}
@router.get("/{adventure_id}/world-state")
def get_world_state(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""The RPG world state (live values) plus the scenario's stat_schema, so the
play view can render the sheet + milestones. `schema` is null with no RPG layer."""
adventure = get_adventure_or_404(adventure_id, db, user)
schema = adventure.scenario.stat_schema if adventure.scenario else None
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
return {
"state": state,
"schema": schema if worldstate.has_schema(schema) else None,
}
@router.put("/{adventure_id}/world-state")
def override_world_state(
adventure_id: int,
overrides: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Directly edit the live RPG values (a manual correction, not a turn).
`overrides` maps paths (e.g. "player.hp", "npc.gwen.trust", "flags.x",
"milestones.y") to their new absolute value. Unknown paths/wrong types are
rejected individually; the rest still apply."""
adventure = get_adventure_or_404(adventure_id, db, user)
schema = adventure.scenario.stat_schema if adventure.scenario else None
if not worldstate.has_schema(schema):
raise HTTPException(400, "This adventure has no RPG world-state layer")
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
new_state, report = worldstate.apply_override(state, schema, overrides)
adventure.world_state = new_state
db.commit()
return {"state": new_state, "report": report}
# ---------- Retry history (sibling attempts) ----------
#
# Retry used to delete the AI action and generate a replacement, then kept the
# row and pushed each attempt into a JSON list on it. Now every attempt is its
# own node: same branch, same depth, one of them `live`. `app/attempts.py` owns
# the group and both of its invariants; the endpoints below only ask it things.
def world_delta_of(snapshot: dict | None) -> dict | None:
"""The bulk-read slice of a context snapshot, for Action.world_delta.
context_snapshot is deferred (it holds the whole assembled prompt), so the
two things that ARE needed for every action — the world-change chips and
the emit block replayed into history — get their own small column. Keep
this in step with the snapshot wherever one is written."""
ws = (snapshot or {}).get("world_state")
if not isinstance(ws, dict):
return None
return {
"delta": ws.get("delta") or {},
"applied": (ws.get("report") or {}).get("applied") or [],
}
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure(
adventure_id: int,
payload: schemas.AdventureUpdate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
adventure = get_adventure_or_404(adventure_id, db, user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(adventure, field, value)
db.commit()
return adventure
@router.delete("/{adventure_id}", status_code=204)
def delete_adventure(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
adventure = get_adventure_or_404(adventure_id, db, user)
db.delete(adventure)
db.commit()
# Nothing else would ever ask for this adventure's vectors again, so the
# cache would hold them until the process restarted.
memorybank.forget_cached_vectors(adventure_id)
# ---------- Turn engine ----------
# One turn at a time per adventure (in-memory; fine for a single-process local app).
# Sync endpoints run in a threadpool, so the check-and-add must be guarded — and
# it must happen in the request phase, not when the SSE generator first runs,
# or two rapid requests both pass the check and generate concurrently.
_active_turns: set[int] = set()
_active_turns_guard = threading.Lock()
def acquire_turn_lock(adventure_id: int):
"""Atomically claim the adventure's turn slot; with_turn_lock releases it."""
with _active_turns_guard:
if adventure_id in _active_turns:
raise HTTPException(409, "A turn is already generating for this adventure.")
_active_turns.add(adventure_id)
async def with_turn_lock(adventure_id: int, gen):
"""Wrap an SSE generator so the lock (from acquire_turn_lock) is released."""
try:
async for event in gen:
yield event
finally:
_active_turns.discard(adventure_id)
def format_player_input(action_type: str, text: str) -> str:
"""AI Dungeon input conventions."""
text = text.strip()
if action_type == "say":
text = text.strip('"')
if text and text[-1] not in ".!?…":
text += "."
return f'> You say "{text}"'
if action_type == "do":
if text.lower().startswith("you "):
text = text[4:]
if text and text[-1] not in ".!?…":
text += "."
return f"> You {text}"
return text # story: raw text appended
def sse(obj: dict) -> str:
return f"data: {json.dumps(obj)}\n\n"
# no-cache defeats any intermediary caching; X-Accel-Buffering makes
# nginx-style reverse proxies (hosted deploys) flush each event immediately
# instead of buffering the stream.
SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
def action_json(action: models.Action) -> dict:
return schemas.ActionOut.model_validate(action).model_dump(mode="json")
def next_index(adventure: models.Adventure) -> int:
return context_history.max_action_index(adventure) + 1
def last_action(adventure: models.Adventure, db: Session) -> models.Action | None:
"""The newest action of any kind on the story being played, or None.
A query rather than `adventure.actions[-1]`, which would load the entire
story to look at one row — and, since that collection is every branch's
actions, would sometimes look at the wrong one.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.first()
)
async def generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
"""SSE generator: streams the AI continuation through the context/output
script hooks, then stores the result.
With `retry_of`, the result is stored as a *sibling* of that AI action —
same turn, same coordinate, another take — and the discarded attempt stays
exactly where it was written. The caller must have rolled the adventure
back to before the turn first (see `retry_action`); if this generator ends
without saving, that rollback is undone so state can't drift from the text
still on screen."""
saved = False
try:
async for event in _generate_turn(adventure, db, pipeline, user, retry_of):
if event is _SAVED:
saved = True
continue
yield event
finally:
if retry_of is not None and not saved:
# Provider error, empty reply, a script stop, or the client hanging
# up: no sibling was written, so the attempt on screen is still the
# live one — put back the state it produced.
attempts.restore_state(adventure, retry_of)
db.commit()
# Sentinel yielded by _generate_turn once the action is committed, so the
# wrapper above knows the rollback must stand rather than be reversed.
_SAVED = object()
async def _generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
# On a retry the attempt being replaced is still the live node of its turn
# — it stays live until a replacement exists to take over — so it has to be
# filtered out of the context, or the model is shown the attempt it is
# supposed to be replacing as established story and writes a sequel to it.
replacing_id = retry_of.id if retry_of is not None else None
if cfg.using_demo:
# No embedding/summarization calls on the server-funded key: memory
# retrieval is skipped (with a visible note when the bank is on).
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id
)
system_text, story_text, snapshot = build_context(
adventure, settings, memories, exclude_action_id=replacing_id
)
# onModelContext: scripts see (and may rewrite) the whole assembled context.
combined = f"{system_text}\n\n{story_text}" if system_text else story_text
modified, stop = pipeline.run("context", combined)
if stop:
yield sse({"type": "stopped", "script": pipeline.report()})
return
context_changed = modified != combined
parts = (
PromptParts(system="", story=modified)
if context_changed
else PromptParts(system=system_text, story=story_text)
)
snapshot["script"] = pipeline.report() | {
"context_changed": context_changed,
"context_before": combined if context_changed else None,
"context_after": modified if context_changed else None,
}
provider = OpenAICompatibleProvider(
cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
settings.reasoning_max_tokens,
)
chunks: list[str] = []
reasoning_chunks: list[str] = []
try:
async for kind, chunk in provider.generate(
parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens
):
if kind == "reasoning":
reasoning_chunks.append(chunk)
yield sse({"type": "reasoning", "text": chunk})
else:
chunks.append(chunk)
yield sse({"type": "chunk", "text": chunk})
except ProviderError as exc:
yield sse({"type": "error", "detail": str(exc)})
return
text = "".join(chunks).strip()
# The model's literal reply, kept for the Insights "Raw AI output" view —
# this still contains any world-state block before it gets stripped below.
raw_output = text
if not text:
# If the model streamed reasoning but no story text, it spent its whole
# budget thinking — say so instead of a mysterious "empty response".
if reasoning_chunks:
detail = (
"The model used its entire token budget on reasoning and returned no "
'story text. Raise "Max output tokens" in Settings, set a "Reasoning '
'max tokens" cap, or switch to a non-reasoning model.'
)
else:
detail = "The AI returned an empty response."
yield sse({"type": "error", "detail": detail})
return
# onOutput
text, _ = pipeline.run("output", text)
if not text.strip():
yield sse({"type": "error", "detail": "A script's output modifier returned empty text."})
return
snapshot["script"] = snapshot["script"] | pipeline.report()
# RPG world state (Phase 12): pull the AI's state delta out of the reply,
# let the engine referee it, and strip the block from the shown text.
# A retry re-runs the *same* turn, so it is played at that turn's depth —
# the clock the cooldown rules run on is a position in the story, and a
# second take on turn 12 is still turn 12. (It was `retry_of.index` until
# SP4, which held the same number; depth is the one that stays true once a
# branch has its own numbering.)
# `next_index` because `tree.place_action` still derives a new node's depth
# from its legacy index while the two columns coexist; they hold the same
# number, and SP8 removes the question.
ai_depth = retry_of.depth if retry_of is not None else next_index(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield sse({"type": "error", "detail": "The AI returned only a state update and no story text."})
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_depth
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
snapshot["raw_output"] = raw_output
reasoning = "".join(reasoning_chunks).strip() or None
ai_action = models.Action(
adventure_id=adventure.id,
# A sibling shares the turn's legacy index for the same reason it
# shares its depth: it is the same turn. Two rows then hold one index,
# which `max_action_index` (a maximum, not a count) survives, and
# nothing else still reads the column.
index=retry_of.index if retry_of is not None else ai_depth,
type="ai",
text=text,
reasoning=reasoning,
context_snapshot=snapshot,
world_delta=world_delta_of(snapshot),
)
attempts.snapshot_outcome(adventure, ai_action)
if retry_of is not None:
attempts.add_attempt(db, adventure, retry_of, ai_action)
db.add(ai_action)
# The text at this coordinate has just changed, so whatever was derived
# from it is no longer about the story: withdraw the memory hanging off
# the node and hand the ground back to both passes. Before SP4 this was
# unreachable, because the summarizer held the newest action back until
# a turn had landed on top of it — see `memorybank`.
memorybank.forget_node(db, adventure, retry_of)
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
db.flush()
attempts.renumber(attempts.group(db, ai_action))
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
adventure.updated_at = models.utcnow()
if cfg.using_demo:
# Successful demo turns count against the daily cap (checked up front
# in the endpoint); failed provider calls above don't reach here.
auth.count_demo_turn(user)
db.commit()
db.refresh(ai_action)
yield _SAVED
yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()})
# Phase 6: fire-and-forget summarization/embedding (opens its own DB
# session). Skipped on the demo key — background AI calls would be
# unmetered spend on the server-funded key.
if not cfg.using_demo:
memorybank.schedule_post_turn(adventure)
def check_demo_cap(db: Session, user: models.User) -> None:
"""409/429-style guard before a turn starts, so a capped player's input
isn't stored and then left without a reply."""
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0:
raise HTTPException(429, auth.DEMO_CAP_MESSAGE)
async def run_player_turn(
adventure: models.Adventure,
db: Session,
payload: schemas.ActionCreate,
user: models.User,
):
pipeline = ScriptPipeline(adventure, db)
# An empty do/say/story is just a continue.
if payload.type != "continue" and payload.text.strip():
# onInput sees the formatted text (as in AI Dungeon: "> You ...").
formatted = format_player_input(payload.type, payload.text)
modified, stop = pipeline.run("input", formatted)
if not modified.strip():
yield sse({"type": "error", "detail": "A script's input modifier returned empty text.",
"script": pipeline.report()})
return
player_action = models.Action(
adventure_id=adventure.id,
index=next_index(adventure),
type=payload.type,
text=modified,
)
# The scoreboard once the input hook has run — what this node left
# behind, which is where the AI turn after it starts and where a retry
# of that turn rolls back to.
attempts.snapshot_outcome(adventure, player_action)
tree.place_action(db, adventure, player_action)
db.add(player_action)
db.commit()
db.refresh(player_action)
# The new action was added via its FK, so the loaded adventure.actions
# collection is stale — without this, build_context and next_index for
# the AI action would not see the player action just saved.
db.expire(adventure, ["actions"])
yield sse({"type": "player", "action": action_json(player_action)})
if stop:
# onInput { stop: true } prevents the AI call.
yield sse({"type": "stopped", "script": pipeline.report()})
return
async for event in generate_turn(adventure, db, pipeline, user):
yield event
@router.post("/{adventure_id}/actions")
def create_action(
adventure_id: int,
payload: schemas.ActionCreate,
request: Request,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
adventure = get_adventure_or_404(adventure_id, db, user)
limits.rate_limit("turn", request, user)
limits.check_row_cap("actions", db, user, adventure=adventure)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
return StreamingResponse(
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@router.post("/{adventure_id}/retry")
def retry_action(
adventure_id: int,
request: Request,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Regenerate the last AI action, keeping the discarded attempt.
The attempt on screen is left exactly as it was written; the shared
script/world state rolls back to what the node in front of it left behind,
and the new take is stored as a sibling at the same coordinate. Nothing the
AI wrote is ever rewritten, let alone thrown away.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
limits.rate_limit("turn", request, user)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
last_ai = None
try:
newest = last_action(adventure, db)
if newest is not None and newest.type == "ai":
last_ai = newest
# Roll the scoreboard back to before this AI turn's hooks ran, so
# regenerating starts fresh instead of stacking output mutations on
# top of the attempt being replaced. A no-op where the preceding
# node has no snapshot (a row written before SP4 the migration
# could not derive one for), which leaves the state alone rather
# than resetting it.
attempts.roll_back_before(db, adventure, last_ai)
db.commit()
db.refresh(adventure)
except BaseException:
_active_turns.discard(adventure_id)
raise
return StreamingResponse(
with_turn_lock(
adventure_id,
generate_turn(
adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai
),
),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@router.get(
"/{adventure_id}/actions/{action_id}/variants",
response_model=list[schemas.VariantOut],
)
def list_variants(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Every attempt made for one AI turn. Fetched on demand — the adventure
payload carries only the counts, so old narration doesn't ride along on
every page load.
Addressed by *any* attempt at the turn, not only the live one: switching
changes which row the story tells, and a client holding the id it was given
a moment ago must still be able to ask about the same turn.
"""
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
rows = attempts.group(db, action)
if len(rows) < 2:
return [] # never retried: the turn is its own only take
return [
schemas.VariantOut(
index=i,
text=row.text,
reasoning=row.reasoning,
created_at=row.created_at.isoformat() if row.created_at else None,
active=row.live,
)
for i, row in enumerate(rows)
]
@router.post(
"/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut
)
def select_variant(
adventure_id: int,
action_id: int,
payload: schemas.VariantSelect,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Make an earlier attempt the live one again, restoring the script/world
state it produced.
Only the last action can be switched: the turns after an older one were
written as a continuation of the text that's currently active, so swapping
it out from underneath them would leave the story contradicting itself.
Earlier turns' attempts stay readable through `list_variants`.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
rows = attempts.group(db, action)
if not 0 <= payload.index < len(rows) or len(rows) < 2:
raise HTTPException(400, "No such attempt for this action")
newest = last_action(adventure, db)
if newest is None or newest.depth != action.depth or newest.branch_id != action.branch_id:
raise HTTPException(
400,
"Only the latest message can be switched — the story has already "
"continued from this one.",
)
acquire_turn_lock(adventure_id)
try:
chosen = rows[payload.index]
if not chosen.live:
# The story at this coordinate is about to say something else, so
# anything derived from what it used to say is withdrawn — the same
# move a retry makes, for the same reason.
memorybank.forget_node(db, adventure, chosen)
cursors.rewind_all(adventure, chosen.branch_id, (chosen.depth or 0) - 1)
attempts.make_live(db, adventure, chosen)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(chosen)
# The row that answers is the one now in the story, which is a
# *different row* from the one addressed — that is the whole change: an
# attempt is a node, so choosing one moves the story onto it rather
# than rewriting anything.
return chosen
finally:
_active_turns.discard(adventure_id)
def delete_turn(
db: Session, adventure: models.Adventure, node: models.Action
) -> None:
"""Remove a turn: every attempt at it, not only the one on screen.
A discarded attempt is a leaf hanging off the same coordinate, and it is
only reachable *through* that coordinate — leaving it behind when the turn
goes would leave a row nothing can name and no read can see. Whatever the
turn produced is withdrawn once, because a memory hangs off the coordinate
rather than off one of its attempts.
"""
memorybank.forget_node(db, adventure, node)
for attempt in attempts.group(db, node):
db.delete(attempt)
@router.post("/{adventure_id}/undo", response_model=schemas.ActionPage)
def undo_turn(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""Delete the last turn: the trailing AI action plus its player action, if any.
Also rolls the shared script_state back to before that turn ran and prunes
any memory that summarized the removed actions. The turn lock guards against
undoing while a turn is still generating."""
adventure = get_adventure_or_404(adventure_id, db, user)
acquire_turn_lock(adventure_id)
try:
# Only the last turn is ever removed, so fetch the two actions it can
# consist of rather than the whole story.
newest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(2)
.all()
)
if not newest or newest[0].type == "start":
raise HTTPException(400, "Nothing to undo")
last = newest[0]
before_that = newest[1] if len(newest) > 1 else None
first_removed = last
if last.type == "ai" and before_that is not None and before_that.type in ("do", "say", "story"):
first_removed = before_that
# Where the story stands once the turn is gone: what the node in front
# of the earliest removed one left behind. Read before the deletes, so
# the question is asked of a story that still has them in it.
restore_to = attempts.preceding(db, adventure, first_removed)
delete_turn(db, adventure, last)
if first_removed is not last:
delete_turn(db, adventure, first_removed)
attempts.restore_state(adventure, restore_to)
db.flush() # apply the deletes before anything reads the story back
db.expire(adventure, ["actions"])
# The tip moved back with them.
tree.refresh_head(db, adventure)
db.commit()
db.refresh(adventure)
# The newest window, not the whole story: the client replaces its
# transcript with this, and the transcript is a window now. Returning
# everything here would undo the paging on the one action most likely
# to be repeated several times in a row.
actions, total, has_more = action_window(db, adventure)
return schemas.ActionPage(
actions=[schemas.ActionOut.model_validate(a) for a in actions],
total=total,
has_more=has_more,
)
finally:
_active_turns.discard(adventure_id)
# ---------- Import / Export ----------
@router.get("/{adventure_id}/export")
def export_adventure(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""Full backup: plot components, story cards, scripts (+state), every action."""
adv = get_adventure_or_404(adventure_id, db, user)
# The one read deliberately left un-pathed: a backup wants the whole
# adventure, not the branch its owner happens to be standing on. `index`
# orders it because the v1 bundle is a flat list keyed on index and its
# reader has no idea branches exist — which is exactly why SP6 replaces the
# format rather than quietly widening this query.
#
# Attempts at one turn share that index (SP4), so the flat list is built by
# folding each group back into the `variants` array the format expects.
# That array is the *only* remaining producer of the v1 shape: nothing in
# the database holds one any more.
exported_actions = (
db.query(models.Action)
.filter(models.Action.adventure_id == adv.id)
.order_by(models.Action.index, models.Action.variant_index, models.Action.id)
.all()
)
turns: list[list[models.Action]] = []
for action in exported_actions:
if turns and turns[-1][0].index == action.index:
turns[-1].append(action)
else:
turns.append([action])
return {
"format": "ai-dnd-adventure-v1",
"title": adv.title,
"memory": adv.memory,
"authorsNote": adv.authors_note,
"aiInstructions": adv.ai_instructions,
"storySummary": adv.story_summary,
"scriptState": adv.script_state,
"worldState": adv.world_state,
"autoSummarize": adv.auto_summarize,
"memoryBankEnabled": adv.memory_bank_enabled,
# The bundle's coordinate system is a position in the story, and the
# cursors are nodes now, so they are counted back into one. A v1 bundle
# has to stay readable by builds that never heard of a depth — SP6's v2
# format carries the anchors themselves.
"memoryCursor": cursors.position_of(adv, cursors.MEMORY.depth(db, adv)),
"summaryCursor": cursors.position_of(adv, cursors.SUMMARY.depth(db, adv)),
"memories": [
{
"text": m.text, "pinned": m.pinned, "forgotten": m.forgotten,
"sourceStart": m.source_start, "sourceEnd": m.source_end,
"useCount": m.use_count,
}
for m in adv.memories
],
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in adv.story_cards
],
"scripts": [
{
"position": s.position, "enabled": s.enabled,
"name": s.name, "description": s.description,
"library": s.library_js, "input": s.input_js,
"context": s.context_js, "output": s.output_js,
}
for s in adv.scripts
],
"actions": [_exported_turn(group) for group in turns],
}
def _exported_turn(group: list[models.Action]) -> dict:
"""One turn as a v1 bundle entry: the attempt in the story, plus the rest.
Narration only — a bundle carries no context snapshots, so the per-attempt
script/world state a switch would restore isn't there to export either.
"""
live = next((a for a in group if a.live), group[0])
return {
"index": live.index, "type": live.type, "text": live.text,
"reasoning": live.reasoning,
"variants": [
{"text": a.text, "reasoning": a.reasoning,
"createdAt": a.created_at.isoformat() if a.created_at else None}
for a in group
] if len(group) > 1 else None,
"variantIndex": group.index(live),
"createdAt": live.created_at.isoformat(),
}
def _imported_turn(
adventure: models.Adventure, entry: dict, index: int
) -> list[models.Action]:
"""A v1 bundle entry as the nodes it describes: one per attempt.
A bundle's `variants` array is the repeating group SP4 unpacked, so
importing one is the same split the migration does — every attempt gets a
row at the turn's coordinate, and `variantIndex` picks which is live.
Clamped, because a hand-edited bundle can name an attempt its own list
doesn't have, and a turn with no live node is a turn no read can see.
"""
kind = str(entry.get("type") or "story")[:20] # VARCHAR(20)
variants = [v for v in (entry.get("variants") or []) if isinstance(v, dict)]
if not variants:
variants = [{"text": entry["text"], "reasoning": entry.get("reasoning")}]
live = min(max(int(entry.get("variantIndex", 0)), 0), len(variants) - 1)
rows = []
for i, variant in enumerate(variants):
text = str(variant.get("text") or "")
reasoning = variant.get("reasoning")
rows.append(models.Action(
adventure_id=adventure.id,
index=index,
type=kind,
text=text,
reasoning=str(reasoning) if reasoning else None,
live=(i == live),
variant_index=i,
variant_count=len(variants) if len(variants) > 1 else 0,
))
return rows
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
def import_adventure(
request: Request,
bundle: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
if bundle.get("format") != "ai-dnd-adventure-v1":
raise HTTPException(400, "Not an adventure export file (expected format ai-dnd-adventure-v1).")
limits.rate_limit("import", request, user)
limits.check_row_cap("adventures", db, user)
limits.check_bundle_lists(
story_cards=bundle.get("storyCards"),
memories=bundle.get("memories"),
actions=bundle.get("actions"),
)
# Raw-dict import bypasses the schemas — clamp strings headed for VARCHAR
# columns (Postgres enforces the widths; see schemas.py).
adventure = models.Adventure(
user_id=user.id,
title=str(bundle.get("title") or "Imported Adventure")[:schemas.NAME_MAX],
memory=str(bundle.get("memory") or ""),
authors_note=str(bundle.get("authorsNote") or ""),
ai_instructions=str(bundle.get("aiInstructions") or ""),
story_summary=str(bundle.get("storySummary") or ""),
script_state=bundle.get("scriptState") or {},
world_state=bundle.get("worldState") or {},
auto_summarize=bool(bundle.get("autoSummarize", False)),
memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)),
memory_cursor=int(bundle.get("memoryCursor", 0)),
summary_cursor=int(bundle.get("summaryCursor", 0)),
)
db.add(adventure)
db.flush()
# A v1 bundle is a linear story, which is a tree with one branch. SP6's v2
# format carries the branches themselves.
tree.head_branch(db, adventure)
for m in bundle.get("memories") or []:
if isinstance(m, dict) and str(m.get("text") or "").strip():
memory = models.Memory(
adventure_id=adventure.id,
text=str(m["text"]),
pinned=bool(m.get("pinned", False)),
forgotten=bool(m.get("forgotten", False)),
source_start=m.get("sourceStart"),
source_end=m.get("sourceEnd"),
use_count=int(m.get("useCount", 0)),
)
tree.place_memory(db, adventure, memory)
db.add(memory)
for card in bundle.get("storyCards") or []:
if isinstance(card, dict):
db.add(models.StoryCard(
adventure_id=adventure.id,
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
name=str(card.get("name") or "")[:schemas.NAME_MAX],
keys=str(card.get("keys") or ""),
entry=str(card.get("entry") or ""),
notes=str(card.get("notes") or ""),
))
for i, s in enumerate(bundle.get("scripts") or []):
if isinstance(s, dict):
db.add(models.AdventureScript(
adventure_id=adventure.id,
position=int(s.get("position", i)),
enabled=bool(s.get("enabled", True)),
name=str(s.get("name") or "Imported Script")[:schemas.NAME_MAX],
description=str(s.get("description") or ""),
library_js=str(s.get("library") or ""),
input_js=str(s.get("input") or ""),
context_js=str(s.get("context") or ""),
output_js=str(s.get("output") or ""),
))
for i, a in enumerate(bundle.get("actions") or []):
if isinstance(a, dict) and str(a.get("text") or ""):
for action in _imported_turn(adventure, a, int(a.get("index", i))):
tree.place_action(db, adventure, action)
db.add(action)
# The bundle's cursors are positions in a flat story and the marks are
# nodes, so the translation waits until the actions exist — this is the
# only moment the two coordinate systems can be lined up against each
# other. The legacy columns keep the numbers the bundle gave: they are what
# a rolled-back build would read.
db.flush()
db.expire(adventure, ["actions"])
cursors.anchor_at_position(adventure, cursors.MEMORY, adventure.memory_cursor)
cursors.anchor_at_position(adventure, cursors.SUMMARY, adventure.summary_cursor)
db.commit()
db.refresh(adventure)
return adventure
# ---------- Adventure scripts ----------
# Fields copied from a library Script into its adventure-script snapshot, and
# compared to decide whether a copy is out of date.
SYNC_FIELDS = ("name", "description", "library_js", "input_js", "context_js", "output_js")
def resolve_library_script(
adv_script: models.AdventureScript, db: Session, user: models.User
) -> models.Script | None:
"""The player-owned library Script an adventure-script can re-sync from:
the one it was copied from, or — for legacy copies with no link — one of
the player's own scripts sharing its name. Only the player's own scripts
are ever considered, so a demo-derived copy has nothing to sync to."""
if adv_script.source_script_id is not None:
script = db.get(models.Script, adv_script.source_script_id)
if script is not None and script.user_id == user.id:
return script
return (
db.query(models.Script)
.filter(models.Script.user_id == user.id, models.Script.name == adv_script.name)
.order_by(models.Script.updated_at.desc())
.first()
)
def _mark_out_of_date(
adv_script: models.AdventureScript, db: Session, user: models.User
) -> models.AdventureScript:
"""Attach a transient `out_of_date` flag (read by AdventureScriptOut):
True/False when a syncable library version exists, None when it doesn't."""
library = resolve_library_script(adv_script, db, user)
adv_script.out_of_date = (
None if library is None
else any(getattr(adv_script, f) != getattr(library, f) for f in SYNC_FIELDS)
)
return adv_script
@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut])
def list_adventure_scripts(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
adventure = get_adventure_or_404(adventure_id, db, user)
return [_mark_out_of_date(s, db, user) for s in adventure.scripts]
@router.post(
"/{adventure_id}/scripts/{adv_script_id}/sync",
response_model=schemas.AdventureScriptOut,
)
def sync_adventure_script(
adventure_id: int,
adv_script_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Overwrite this copy's code with the latest from its library script,
keeping `enabled`, `position`, and the adventure's shared script_state."""
get_adventure_or_404(adventure_id, db, user)
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
library = resolve_library_script(script, db, user)
if library is None:
raise HTTPException(404, "No library script to sync from")
for field in SYNC_FIELDS:
setattr(script, field, getattr(library, field))
# Adopt the link so a name-matched legacy copy syncs by id next time.
script.source_script_id = library.id
db.commit()
db.refresh(script)
return _mark_out_of_date(script, db, user)
@router.patch(
"/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut
)
def update_adventure_script(
adventure_id: int,
adv_script_id: int,
payload: schemas.AdventureScriptUpdate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
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")
async def dry_run_context(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""What would be sent to the AI if the player continued right now."""
adventure = get_adventure_or_404(adventure_id, db, user)
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo:
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
_, _, report = build_context(adventure, settings, memories)
return report
@router.get("/{adventure_id}/actions/{action_id}/context")
def action_context(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
if action.context_snapshot is None:
raise HTTPException(404, "No context snapshot for this action")
return action.context_snapshot
# ---------- Memory bank (Phase 6) ----------
@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut])
def list_memories(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
get_adventure_or_404(adventure_id, db, user)
# A query naming its columns, not a walk of `adventure.memories`. The walk
# is what retrieval used to do, and it is the reason a turn cost megabytes:
# a relationship load takes whole entities, so it picks up whatever the
# model happens to carry. `embedding_blob` is deferred and so would stay
# out today — this is about the next wide column, not that one.
#
# Adventure-wide, not path-scoped, and that is the split: retrieval reads
# the story being played, the drawer manages the bank. Hiding a branch's
# memories from the drawer would mean memories nobody can find to delete,
# in a phase whose rule is that nothing is ever removed automatically.
return (
db.query(models.Memory)
.options(load_only(*MEMORY_LIST_COLUMNS))
.filter(models.Memory.adventure_id == adventure_id)
.order_by(models.Memory.id)
.all()
)
@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201)
def create_memory(
adventure_id: int,
payload: schemas.MemoryCreate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Manually add a memory; it gets embedded by the next post-turn pass."""
adventure = get_adventure_or_404(adventure_id, db, user)
limits.check_row_cap("memories", db, user, adventure=adventure)
if not payload.text.strip():
raise HTTPException(400, "Memory text cannot be empty")
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
# No node produced this one, so it gets a branch but no depth.
tree.place_memory(db, adventure, memory)
db.add(memory)
db.commit()
db.refresh(memory)
return memory
@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut)
def update_memory(
adventure_id: int,
memory_id: int,
payload: schemas.MemoryUpdate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
memory = db.get(models.Memory, memory_id)
if memory is None or memory.adventure_id != adventure_id:
raise HTTPException(404, "Memory not found")
fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None}
if "text" in fields and fields["text"].strip() != memory.text:
memorybank.set_vector(memory, None) # re-embed on the next post-turn pass
for field, value in fields.items():
setattr(memory, field, value)
db.commit()
return memory
@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204)
def delete_memory(
adventure_id: int,
memory_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
memory = db.get(models.Memory, memory_id)
if memory is None or memory.adventure_id != adventure_id:
raise HTTPException(404, "Memory not found")
db.delete(memory)
db.commit()
# ---------- Actions (CRUD) ----------
@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage)
def list_actions(
adventure_id: int,
before_id: int | None = None,
limit: int = ACTION_PAGE,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""A page of the story, walking backwards from the newest action.
`before_id` is the oldest action the caller already holds, so scrolling up
is "give me what comes before this". Omit it for the newest window. See
action_window for why this anchors on a row rather than an offset.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
limit = max(1, min(limit, ACTION_PAGE * 4))
actions, total, has_more = action_window(
db, adventure, before_id=before_id, limit=limit
)
return schemas.ActionPage(
actions=[schemas.ActionOut.model_validate(a) for a in actions],
total=total,
has_more=has_more,
)
@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut)
def update_action(
adventure_id: int,
action_id: int,
payload: schemas.ActionUpdate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# One row, one text. Nothing mirrors it any more, so nothing has to be kept
# in step — the edit used to have to be written into the live variant entry
# as well, or paging away and back silently reverted it.
action.text = payload.text
db.commit()
return action
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
adventure = get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# Same as undo: the turn goes, attempts and all, and whatever it produced
# is withdrawn. Nothing else needs doing — the marks are depths, and a
# depth does not move because an action in front of it went away.
delete_turn(db, adventure, action)
db.flush()
db.expire(adventure, ["actions"])
# Deleting the newest action moves the tip; deleting a middle one leaves a
# gap in the depths, deliberately — see _backfill_tree.
tree.refresh_head(db, adventure)
db.commit()