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
87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
"""Reading, editing, and deleting individual actions.
|
|
|
|
`list_actions` pages through the current branch. The edit and delete endpoints
|
|
change one node, and deleting one removes the whole attempt group at its
|
|
coordinate through `nodes.delete_turn`.
|
|
"""
|
|
|
|
from fastapi import Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import models, schemas, tree
|
|
from ...database import get_db
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
from .nodes import delete_turn
|
|
from .paging import ACTION_PAGE, action_window, annotate_takes
|
|
|
|
|
|
@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage)
|
|
def list_actions(
|
|
before_id: int | None = None,
|
|
limit: int = ACTION_PAGE,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns a page of the story, working backwards from the newest action.
|
|
|
|
`before_id` is the oldest action the caller already holds, so scrolling up
|
|
asks for what comes before it. Omit `before_id` for the newest window. See
|
|
`action_window` for why this anchors on a row rather than an offset.
|
|
"""
|
|
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 annotate_takes(db, adventure.id, 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),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
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 holds one text. Nothing mirrors it now, so nothing else has to be
|
|
# updated. The edit used to have to be written into the live variant entry
|
|
# as well, or paging away and back 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),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
action = db.get(models.Action, action_id)
|
|
if action is None or action.adventure_id != adventure_id:
|
|
raise HTTPException(404, "Action not found")
|
|
# This works like undo. The turn is deleted with all of its attempts, and
|
|
# whatever it produced is withdrawn. Nothing else is needed, because the
|
|
# marks are depths, and a depth does not move when an action before it is
|
|
# deleted.
|
|
delete_turn(db, adventure, action)
|
|
db.flush()
|
|
db.expire(adventure, ["actions"])
|
|
# Deleting the newest action moves the tip. Deleting an action in the
|
|
# middle leaves a gap in the depths, which is intended. See
|
|
# `_backfill_tree`.
|
|
tree.refresh_head(db, adventure)
|
|
db.commit()
|