Stage 2, items 1, 4, and 5 of `plan/17-refactor.md`. **One path resolver in `worldstate`.** `apply_delta` and `apply_override` routed `flags.<name>`, `milestones.<id>`, `world.<stat>`, `player.<stat>`, and `npc.<id>.<stat>` with parallel code, about 100 lines each. `_resolve` now says what a path points at and returns either a target or the rejection to report. Each function keeps its own write rule, because the rules genuinely differ: an override sets a number rather than adding to it, ignores `cooldown`, `max_delta_per_turn`, and the rule that a counter only counts up, and can un-set a milestone. A differential check ran both implementations over 3960 payloads: twenty paths, fourteen values, three starting states, plus every three-path combination. The results are identical except that 674 rejections from `apply_override` now carry a `fix` string. `apply_delta` already worded those, and the world-state editor renders them, so an override that names an unknown flag now explains itself the way a delta does. **`sse`, `SSE_HEADERS`, and `turn_error` move to `app/sse.py`.** Two routers stream, and `chat.py` had to import from `routers.adventures` to reach them. **`get_adventure_or_404` becomes the `current_adventure` dependency.** All 32 handlers repeated the call as their first statement. The ownership check now reads in the signature and runs before the body. FastAPI caches a dependency for one request, so the handler's `db` is the session the adventure came from. The generated OpenAPI document is byte-identical except on `rename_branch`, where `branch_id` is now listed before `adventure_id`, because that handler no longer names `adventure_id` itself. Parameter order in the document is cosmetic. Six tests in `test_state_revert.py` call `undo_turn` and `retry_action` directly rather than over HTTP. They pass the adventure they already hold instead of an id. 549 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""The branch endpoints: list, rename, delete, and switch.
|
|
|
|
Attempts accumulate at the tip as siblings, which costs nothing. An attempt
|
|
becomes a branch only when the player continues the story from it and leaves the
|
|
line that moved past it. That is the same event as playing a turn past the
|
|
attempt. Creating the branch then rather than on the next turn means a branch
|
|
exists only for a divergence someone built on, and the line being left is not
|
|
modified.
|
|
"""
|
|
|
|
from fastapi import Depends, HTTPException
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import attempts, models, schemas, tree
|
|
from ...context import cursors
|
|
from ...context import lineage
|
|
from ...database import get_db
|
|
|
|
from . import turns
|
|
from .deps import CurrentUser, current_adventure, router
|
|
from .nodes import db_tip
|
|
from .paging import current_window
|
|
|
|
|
|
@router.get("/{adventure_id}/branches", response_model=list[schemas.BranchOut])
|
|
def list_branches(
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns every branch of the adventure and where each one leaves its parent.
|
|
|
|
A tree view is drawn from this shape. `fork_depth` gives the depth where the
|
|
line splits off, and `depth` gives the depth where it currently ends. The
|
|
whole picture costs one query over `branches` plus one grouped query over
|
|
`actions`, never one query per branch, so a view of a hundred forks does not
|
|
cost a hundred round trips.
|
|
"""
|
|
branches = (
|
|
db.query(models.Branch)
|
|
.filter(models.Branch.adventure_id == adventure.id)
|
|
.order_by(models.Branch.id)
|
|
.all()
|
|
)
|
|
owned = {
|
|
branch_id: (count, tip)
|
|
for branch_id, count, tip in db.query(
|
|
models.Action.branch_id,
|
|
func.count(models.Action.id),
|
|
func.max(models.Action.depth),
|
|
)
|
|
.filter(
|
|
models.Action.adventure_id == adventure.id,
|
|
models.Action.live.is_(True),
|
|
)
|
|
.group_by(models.Action.branch_id)
|
|
.all()
|
|
}
|
|
out = []
|
|
for branch in branches:
|
|
count, tip = owned.get(branch.id, (0, None))
|
|
out.append(schemas.BranchOut(
|
|
id=branch.id,
|
|
parent_branch_id=branch.parent_branch_id,
|
|
fork_depth=branch.fork_depth,
|
|
# A branch with no nodes of its own sits at its fork point. That
|
|
# node is the last one its story contains. The node is borrowed, but
|
|
# it is still the tip. This matches `tree.refresh_head`.
|
|
depth=tip if tip is not None else (
|
|
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
|
),
|
|
own_actions=count,
|
|
is_head=(branch.id == adventure.head_branch_id),
|
|
name=branch.name,
|
|
created_at=branch.created_at,
|
|
))
|
|
return out
|
|
|
|
|
|
def get_branch_or_404(
|
|
adventure: models.Adventure, branch_id: int, db: Session
|
|
) -> models.Branch:
|
|
"""Returns one branch of this adventure.
|
|
|
|
If the branch belongs to another adventure, the 404 does not confirm that the
|
|
branch exists.
|
|
"""
|
|
branch = db.get(models.Branch, branch_id)
|
|
if branch is None or branch.adventure_id != adventure.id:
|
|
raise HTTPException(404, "Branch not found")
|
|
return branch
|
|
|
|
|
|
@router.patch(
|
|
"/{adventure_id}/branches/{branch_id}", response_model=schemas.BranchOut
|
|
)
|
|
def rename_branch(
|
|
branch_id: int,
|
|
payload: schemas.BranchRename,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Names a branch, or clears the name to leave it unnamed.
|
|
|
|
A blank string means the same thing as `null`. A name of only spaces is not a
|
|
name anyone chose, and storing one gives the client an empty label to draw
|
|
instead of the fork depth.
|
|
"""
|
|
branch = get_branch_or_404(adventure, branch_id, db)
|
|
name = (payload.name or "").strip()
|
|
branch.name = name or None
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
db.refresh(branch)
|
|
# Read both numbers in one pass, and count them the way `list_branches`
|
|
# counts them, as live rows on this branch. A renamed branch is the same
|
|
# branch, so this response has to match the row the panel would fetch.
|
|
tip, own = (
|
|
db.query(func.max(models.Action.depth), func.count(models.Action.id))
|
|
.filter(
|
|
models.Action.adventure_id == adventure.id,
|
|
models.Action.branch_id == branch.id,
|
|
models.Action.live.is_(True),
|
|
)
|
|
.one()
|
|
)
|
|
return schemas.BranchOut(
|
|
id=branch.id,
|
|
parent_branch_id=branch.parent_branch_id,
|
|
fork_depth=branch.fork_depth,
|
|
depth=tip if tip is not None else (
|
|
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
|
),
|
|
own_actions=own,
|
|
is_head=(branch.id == adventure.head_branch_id),
|
|
name=branch.name,
|
|
created_at=branch.created_at,
|
|
)
|
|
|
|
|
|
@router.delete("/{adventure_id}/branches/{branch_id}", status_code=204)
|
|
def delete_branch(
|
|
adventure_id: int,
|
|
branch_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Deletes a branch and everything forked from it.
|
|
|
|
Nothing prunes the tree automatically, so this endpoint is what keeps a
|
|
heavily retried adventure from growing without bound. That is why it ships
|
|
with the view that first lets anyone create a fork rather than after it.
|
|
|
|
Two kinds of branch cannot be deleted. The root cannot, because it holds the
|
|
turns every other branch borrows, so deleting it deletes the whole story. The
|
|
branch currently being read cannot, and neither can any branch it was forked
|
|
from, because the cascade would remove the head under the player and leave
|
|
`head_branch_id` dangling. Switch branches first.
|
|
|
|
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
|
|
cascade on `branches.parent_branch_id`, so the delete is a single statement
|
|
however deep the subtree is.
|
|
"""
|
|
branch = get_branch_or_404(adventure, branch_id, db)
|
|
if branch.parent_branch_id is None:
|
|
raise HTTPException(
|
|
400, "This is the story's first branch — deleting it would delete "
|
|
"the adventure. Delete the adventure itself instead.",
|
|
)
|
|
head = db.get(models.Branch, adventure.head_branch_id)
|
|
# The head's lineage lists itself and every branch it borrows from, so one
|
|
# membership test covers both the branch being read and any branch forked
|
|
# from it.
|
|
if head is not None and branch.id in {
|
|
entry_id for entry_id, _ in lineage.entries_of(head)
|
|
}:
|
|
raise HTTPException(
|
|
400, "You are reading this branch, or one forked from it. Switch to "
|
|
"another branch first.",
|
|
)
|
|
turns.acquire_turn_lock(adventure_id)
|
|
try:
|
|
# Collect the subtree before the delete, because afterwards there is no
|
|
# way to ask which branches were removed. A cursor left pointing at a
|
|
# deleted branch is harmless on Postgres, which never reuses ids, but it
|
|
# is a bug on SQLite, where the next fork can receive the id that was
|
|
# just freed. A stale anchor then resolves onto a branch it never saw.
|
|
doomed = _branch_subtree(db, adventure, branch)
|
|
for cursor in cursors.ALL:
|
|
stored_branch, _ = cursor.stored(adventure)
|
|
if stored_branch in doomed:
|
|
cursor.clear(adventure)
|
|
db.delete(branch)
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
finally:
|
|
turns._active_turns.discard(adventure_id)
|
|
|
|
|
|
# The deleted branch's memories are deleted with it, and their cached
|
|
# vectors drop out of the catalogue on the next read, so no invalidation
|
|
# call is needed. See the note on the `memorybank` cache.
|
|
|
|
|
|
def _branch_subtree(
|
|
db: Session, adventure: models.Adventure, root: models.Branch
|
|
) -> set[int]:
|
|
"""Returns `root` and every branch descended from it, following parent pointers.
|
|
|
|
The walk runs over the adventure's own branch rows rather than one query per
|
|
level. An adventure has few branches, so the walk costs one round trip, and a
|
|
recursive CTE would have to be written twice for the two dialects this
|
|
codebase supports.
|
|
"""
|
|
children: dict[int | None, list[int]] = {}
|
|
for bid, parent in db.query(models.Branch.id, models.Branch.parent_branch_id).filter(
|
|
models.Branch.adventure_id == adventure.id
|
|
):
|
|
children.setdefault(parent, []).append(bid)
|
|
found: set[int] = set()
|
|
stack = [root.id]
|
|
while stack:
|
|
current = stack.pop()
|
|
if current in found:
|
|
continue
|
|
found.add(current)
|
|
stack.extend(children.get(current, ()))
|
|
return found
|
|
|
|
|
|
@router.post(
|
|
"/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage
|
|
)
|
|
def switch_branch(
|
|
adventure_id: int,
|
|
branch_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Reads and plays a different branch of the story.
|
|
|
|
No row is copied and no row is rewritten. The head pointer moves, and the
|
|
shared script state and world state are restored to what that branch's tip
|
|
left behind. The restore is what makes a switch safe. Both states are stored
|
|
per adventure, so a branch that did not restore them would be played with
|
|
another branch's numbers, including the world-state cooldown clock inside the
|
|
snapshot.
|
|
"""
|
|
branch = db.get(models.Branch, branch_id)
|
|
if branch is None or branch.adventure_id != adventure.id:
|
|
raise HTTPException(404, "Branch not found")
|
|
turns.acquire_turn_lock(adventure_id)
|
|
try:
|
|
adventure.head_branch_id = branch.id
|
|
tree.refresh_head(db, adventure)
|
|
attempts.restore_state(adventure, db_tip(db, adventure))
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
return current_window(db, adventure)
|
|
finally:
|
|
turns._active_turns.discard(adventure_id)
|