Files
interactive-story/backend/app/routers/adventures/crud.py
T
JesseMarkowitzandClaude Opus 5 44edece67e M9: a campaign you can actually get back
A campaign could already be exported and imported. What could not survive the
trip was everything that explains it: the state events behind the authoritative
document, the prompt each turn was actually given, the passages it was shown,
the summaries that carry long-story continuity, and which take belonged to which
turn. An imported campaign could be read and could no longer say why it was what
it was — and a manual correction, the one state change no narration explains,
was indistinguishable from something the story had established.

The bundle is now `ai-dnd-adventure-v3`, and the version is the design rather
than a side effect. Everything added here could have been another optional key,
the way persona, Save Points, narrative state and imported knowledge each were.
That mechanism stops working at exactly this addition: a v2 file with no prompt
provenance is ambiguous between "written before M9" and "written by M9 from a
campaign that has none", and those are different facts about a campaign. A
version number is how a recovery file states what it was capable of recording.
v1 and v2 still import, and every seam from pre-active-head onward is tested for
the rule that an older file is never reinterpreted under a newer assumption.

Two categories became three. "Chosen travels, derived is recomputed" was enough
until stored prompts had to be decided: they are derived, and they must travel
anyway. The test that separates evidence from cache is not "could this be
recomputed" but "would a recomputation answer the same question" — a rebuilt
search index answers the same question, a rebuilt prompt says what the turn
would be told *now*, which is the opposite of what the inspector is for.

Also here: a real SQLite backup, through the online backup API rather than a
file copy, taken while the application is running and verified before it is
kept; story cards settled as compatibility-only legacy data and taken out of the
narrator's prompt, because they were the untracked path around knowledge
authority that IMPORTED-KNOWLEDGE-DESIGN §73 already forbade; and no schema
change at all, proved against a database M8's own code wrote.

Three defects, found by running the milestone's own tests rather than by reading
them. Deleting a campaign leaked its FTS index rows, and SQLite then handed the
freed ids to the next source imported into any campaign, which failed with an
integrity error that Reindex could not repair — both ends are closed, and a
database already carrying the damage now repairs itself. An imported node with
no state snapshot was being stamped with the campaign's head state, so an Undo
to turn 2 showed what the story knew at turn 20. And the snapshot relink did not
persist at all, because it mutated a dict in place on a column SQLAlchemy tracks
by assignment: it looked correct in memory and wrote the wrong ids to disk.

Carrying per-turn prompts looked like it would halve the length of campaign that
can be restored. Measured — and after compressing them inside the file —
everything M9 added costs 12% of it: the import ceiling moves from about 318
turns to about 279, against a 100-turn certification target. The dominant cost
is not M9's at all. The per-position narrative state document is 74% of a
bundle, and v2 already carried it.

Backend 1,102 passed / 14 skipped / 0 failed. Frontend 145 passed. Lint,
production build and Docker build clean. Verified across two server processes
with two data directories, and in a real browser against a real narrator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
2026-09-07 01:55:45 -04:00

364 lines
16 KiB
Python

"""Listing, creating, reading, renaming, and deleting adventures.
The world-state and script-state readers are here too, because they report an
adventure's stored state rather than play a turn.
"""
from fastapi import Body, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import set_committed_value
from ... import (
attempts, head, images, limits, memorybank, models, schemas, summaries, tree,
worldstate,
)
from ...database import get_db
from ...knowledge import embeddings as knowledge_embeddings
from ...knowledge import importer as knowledge_importer
from .deps import CurrentUser, current_adventure, router
from .paging import action_window, annotate_takes
from .scenario_text import fill_placeholders, scenario_card_specs
# How many characters of the last narration a Continue card shows. The limit is
# long enough to re-establish the scene and short enough to keep the card
# small.
SNIPPET_MAX = 220
def _snippet(text: str) -> str:
"""Condenses stored action text into a single line for a card."""
# `turns._generate_turn` strips the world-state block before storing AI text,
# so this function only has to normalize whitespace.
collapsed = " ".join((text or "").split())
if len(collapsed) <= SNIPPET_MAX:
return collapsed
# Cut at a word boundary rather than mid-word. CSS adds 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 newly created adventure has. Without `start`, a new
# story's card would show no text at all. `do` and `say` are excluded because
# the card quotes the story's voice rather than the player's.
NARRATION_TYPES = ("ai", "story", "start")
def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]:
"""Maps each adventure id to the text of its most recent narrated action.
This runs one window-function query rather than one lookup per adventure, so
the list endpoint makes a fixed number of round trips.
The query is scoped by head branch rather than by the full lineage, and this
is the only place in the codebase that does so. A lineage clause per
adventure would add a hundred OR terms to the index screen's query to select
one row each. The two scopes differ only for a branch with no nodes of its
own, and playing a turn onto a branch is what creates it, so that state does
not occur. An adventure with no branch 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),
# Sibling attempts share a depth, and the newest has the highest
# id. Without this filter the snippet quotes the attempt written
# last rather than the one the story tells. After you switch back to
# an earlier attempt, the index screen would quote the discarded one
# and disagree with the story on screen.
models.Action.live.is_(True),
)
.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):
# Select named columns rather than the whole Adventure entity. The entity is
# sixteen columns wide and includes `script_state`, `world_state`,
# `placeholders`, `story_summary`, `memory`, `authors_note`, and
# `ai_instructions`. That is about 15 kB per row in production, fetched once
# per adventure on every index load, and this screen uses none of it. Naming
# the columns also means a wide column added to Adventure later 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 primary keys. Postgres requires every selected column
# to be grouped or aggregated. The Adventure columns are covered by its
# own grouped primary key, but the Scenario columns come from a joined
# table and have to be listed as well. SQLite accepts the shorter form,
# and 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 uses
# the scenario's `updated_at`, not the adventure's.
image_url=images.public_url(scenario_id, image or "", scenario_updated),
icon=icon or "",
)
# `count` counts every action in the adventure, not only the ones on
# the path. With one branch the two numbers are equal. After forking
# ships, the index screen overstates a story that has sibling branches.
# The fix belongs to SP5, which is 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
]
@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)
# A scenario is playable if the user owns it or if it is public.
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 {},
# Stored so that a later "Update from scenario" fills the copied text
# with the same answers instead of inserting literal `${...}` tokens.
placeholders=dict(values),
# Phase 18: the persona the player named before starting. It belongs to
# the adventure rather than the scenario, so nothing is copied here and
# "Update from scenario" never touches it.
persona_name=payload.persona_name.strip(),
persona_pronouns=payload.persona_pronouns.strip(),
persona_desc=payload.persona_desc.strip(),
)
db.add(adventure)
db.flush()
# Give every adventure a story tree as soon as it exists, before anything
# is played onto it. Otherwise the tree code has to tolerate a NULL head
# everywhere, which buys nothing.
tree.head_branch(db, adventure)
# M8: canon written at setup. Stored in the same document the prompt and the
# validator already read, so nothing downstream learns a second shape.
rules = [r.strip() for r in payload.canon_rules if r.strip()]
if rules:
adventure.campaign_canon = {"rules": rules}
if scenario:
for ref, spec in scenario_card_specs(scenario, values).items():
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
# The opening scene. A scenario's prompt and M8's `opening` field are the
# same thing arriving by different routes, so they build the same node —
# the scenario wins when both are present, because it is the more specific
# request. Everything downstream (Undo to the opening, retrying the first
# turn, the drop cap) keys on the `start` type and is unchanged.
opening_text = (
fill_placeholders(scenario.prompt, values)
if scenario and scenario.prompt.strip()
else payload.opening.strip()
)
if opening_text:
opening = models.Action(
adventure_id=adventure.id,
type="start",
text=opening_text,
)
# Record the starting state on the opening node, so undoing or
# retrying the first turn has a state 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(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the adventure and the newest window of its story.
`actions` holds the last `ACTION_PAGE` actions, not all of them.
`action_count` reports the real total, so the reader knows that more actions
exist above. `GET /{id}/actions` serves the older pages as the reader
scrolls up.
"""
actions, total, _ = action_window(db, adventure)
# Annotate before handing over the window. This path serializes through the
# relationship rather than building `ActionOut` itself, so the pager numbers
# have to be on the rows before Pydantic reads them.
annotate_takes(db, adventure.id, actions)
# Attach the window as if the relationship had loaded it.
# `set_committed_value` is the only safe way to do this. Assigning
# `adventure.actions = [...]` marks the collection dirty, and the
# relationship cascades delete-orphan, so the next flush deletes every
# action outside the window. `set_committed_value` records the rows as the
# already-loaded, unmodified value, so serialization triggers no lazy load
# and leaves nothing pending.
set_committed_value(adventure, "actions", actions)
out = schemas.AdventureOut.model_validate(adventure)
out.action_count = total
# M3. Opening a story has to render its history controls correctly, and a
# campaign whose head sits behind the retained tip — undone and then closed —
# must come back with Redo available.
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
return out
@router.get("/{adventure_id}/world-state")
def get_world_state(
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the live RPG world state and the scenario's `stat_schema`.
The play view uses both to render the character sheet and the milestones.
`schema` is null when the adventure has no RPG layer.
"""
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(
overrides: dict = Body(...),
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Edits the live RPG values directly, as a manual correction rather than a turn.
`overrides` maps paths such as `player.hp`, `npc.gwen.trust`, `flags.x`, and
`milestones.y` to their new absolute values. The endpoint rejects unknown
paths and wrong types one at a time, and applies the rest.
"""
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}
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure(
payload: schemas.AdventureUpdate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
fields = payload.model_dump(exclude_unset=True)
# M8. `canon_rules` is a read-only view onto the stored `campaign_canon`
# document, so it is written by hand rather than by the setattr loop — and
# only the `rules` key is replaced. Whatever else the document holds
# (`forbidden_status_changes`, which has no browser editor) is left exactly
# as it was, so editing canon through the browser cannot silently discard
# the structured half a fixture or an import wrote.
if "canon_rules" in fields:
rules = [r.strip() for r in (fields.pop("canon_rules") or []) if r.strip()]
canon = dict(adventure.campaign_canon or {})
if rules:
canon["rules"] = rules
else:
canon.pop("rules", None)
adventure.campaign_canon = canon or None
for field, value in fields.items():
setattr(adventure, field, value)
# M6: a summary the reader typed is still a summary, so it is anchored to
# the position they typed it at rather than left in a column with no
# lineage. Otherwise a hand-written summary would survive an Undo and a
# divergence that its generated equivalent correctly does not (E03).
if "story_summary" in fields:
typed = (fields["story_summary"] or "").strip()
held = summaries.current(db, adventure)
if typed and (held is None or held.text.strip() != typed):
summaries.record(db, adventure, typed, trigger="manual")
db.commit()
return adventure
@router.delete("/{adventure_id}", status_code=204)
def delete_adventure(
adventure_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
# M9. The lexical index first, while the chunks that locate it still exist.
# It is a virtual table, so nothing cascades into it, and an orphaned index
# row makes the *next* import into *any* campaign fail — see
# `knowledge.importer.clear_campaign_index`.
knowledge_importer.clear_campaign_index(db, adventure)
db.delete(adventure)
db.commit()
# No later request reads this adventure's vectors, so drop them now. The
# cache would otherwise hold them until the process restarted.
memorybank.forget_cached_vectors(adventure_id)
knowledge_embeddings.forget_cached(adventure_id)