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
364 lines
16 KiB
Python
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)
|