Files
interactive-story/backend/app/routers/adventures/crud.py
T
JesseMarkowitzandClaude Opus 5 903fa7a74f M3: move the story's head instead of deleting its turns
Undo deleted. It removed the trailing AI action and the player action in
front of it, pruned the memories covering them, and let the tip fall back
to whatever survived. That made it the one operation in the application
that destroyed accepted story, and it was why there was no Redo: the
turns to move forward into no longer existed. Phase 0B demonstrated the
head-cursor alternative in a disposable spike; this is that concept as
production code.

backend/app/head.py is the whole of it. Three questions that used to be
one — where the story is being read, how far it is retained, and where it
opens — are now three functions, and every caller that moves the head or
asks about it goes through this module. The spike put the fork check in
the write path and left Retry and Add-take on the old one; sharing the
rules is what stops that divergence coming back.

lineage.Path now caps every entry at the head, so hiding the retained
future costs nothing at the call sites: the transcript, the context
builder, attempts.preceding and memory retrieval already funnelled
through path_of and narrow together. Path.uncapped() is the deliberate
exception, and only Redo and the fork check may use it. The memory bank
needs no pruning for the same reason — a memory carries the coordinate of
the node its block ends on, so one derived past the head falls outside
the capped clause and becomes retrievable again on Redo without having
been deleted and re-embedded.

Undo alone does not fork. Moving the head is not a decision to abandon
anything, since the user may be reading or about to Redo; the first write
below the head is where the story states which continuation it means. A
head already at the tip forks nothing, so a story that is never undone
forks exactly as often as it did before and the branch table does not
fill up with one branch per turn. Redo follows the lineage rather than
choosing among branches, which is what invalidates it after a divergence
with no flag to set or clear.

Migrations 78 and 79 give a branch superseded_at and superseded_depth.
Nothing reads them to decide behaviour — Redo is decided by the lineage,
so a stale or hand-edited value here cannot make the story wrong. They
exist so the cleanup and discarded-history features left to a later
version have something to select on, and so a divergence is observable in
a test.

Deleting an action no longer drags a moved-back head forward to the
recomputed tip, which would have silently redone the story. can_undo and
can_redo ride on AdventureOut and ActionPage because the client can work
out neither for itself: the campaign opening may be off the top of the
loaded window, and the retained future is never sent to it.

This is a checkpoint, not the finished milestone. 601 backend tests pass.
Five still assert the destructive contract — they count rows after an
undo and expect the story to be shorter — and need rewriting against the
new one; the world-state assertions inside them already pass. Export and
import do not yet carry the head coordinate, so a bundle still reopens at
the deepest node and can silently redo an undone story, which is the
Phase 0B finding this milestone exists to close. The browser has no Redo
control yet. None of the M3 acceptance coverage (D01-D10, E01-E04,
I01-I03, I07, L01-L02) is written.

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

314 lines
14 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, tree, worldstate,
)
from ...database import get_db
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)
if scenario:
for ref, spec in scenario_card_specs(scenario, values).items():
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
# 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),
):
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),
adventure: models.Adventure = Depends(current_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)