"""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 attempts, head, memorybank, models, narrative, schemas, tree from ...context import cursors, lineage from ...database import get_db from . import turns from .deps import CurrentUser, current_adventure, router from .nodes import db_tip, 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, # Every page carries them, not just the newest window: the client reads # the flags off whichever page arrived last, and scrolling up must not # be able to grey out a Redo that is still available (M3). can_undo=head.can_undo(db, adventure), can_redo=head.can_redo(db, adventure), ) @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") # A narrator turn the story is currently telling is corrected through the # §§14-15 path, which forks. A take the story is *not* telling is a # different thing: it has no continuation of its own — keeping one is what # forking is for — so correcting its words cannot contradict anything, and # it stays the plain in-place edit it has always been. if action.type == "ai" and lineage.path_of(db, adventure).contains(action): return _edit_narration(db, adventure, action, payload.text) # A player's own words. Editing one rewrites this row and re-evaluates # nothing after it, which is what makes it a correction rather than a new # continuation. That is safe while everything descending from the row is on # screen, and unsafe the moment something descends from it that is not — an # undone future, or a line a divergence left behind. The reader cannot see # that story, so they cannot see what their correction has just contradicted # (M3, `STORY-BRANCH-SEMANTICS.md` §13). if head.displaced_history_under(db, adventure, action): raise HTTPException( 400, "This turn has a later story that is not on screen — undone, or " "left behind by a new continuation. Editing it here would change " "the words that story was written from. Redo to bring it back " "first, or play the turn again to start a new line from here.", ) turns.acquire_turn_lock(adventure_id) try: action.text = payload.text db.commit() finally: turns._active_turns.discard(adventure_id) db.refresh(action) return action def _edit_narration( db: Session, adventure: models.Adventure, action: models.Action, text: str ) -> models.Action: """Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15. A narrator edit is not a rewrite of a row. It is a continuation written from the same place the original was written from, using the reader's words instead of the model's. §15 lists what that has to mean, and each clause maps to a step below: 1. return to the state immediately before the edited narration — the preceding node's snapshot, one row read; 2. treat the edited text as the accepted narrator output — it is stored verbatim, with only the protocol block stripped, and no model is called; 3. re-evaluate the state that output implies — the normal M5 extraction and validation path, run against that starting state; 4. create a new active continuation — a new node, and the head on it; 5. retain the original narration and its future as disposable history — nothing on the old line is written to at all. The M5 review found the previous implementation failing 3-5 together: it edited the row in place and rewound the campaign's live state to that position while the head stayed at the tip, so the reader saw a full transcript over a state document describing an earlier moment, and the snapshots below the edit still described prose that no longer existed (Finding 1). Forking is what fixes it, and no new machinery is needed to fork — this function is the ⑂ path from `takes.py` with the reader's text in place of a generated one. Two shapes, chosen by whether anything was written after the turn: at the tip the attempts of the turn are still leaves, so the correction joins them as a sibling take and the original is retained beside it in the pager; anything below the story after the turn was written as a continuation of the words that are there now, so it keeps them: the correction leaves the path just before the turn and the old line keeps its node, its future, and its live flag. The §14A refusal is gone from this path, and this is what replaces it. It refused an in-place edit under an off-screen future because the edit would silently change the words that story was written from. Nothing is changed now — the off-screen future keeps the exact narration it descends from — so the case that had to be refused is simply handled. """ if action.depth is None: raise HTTPException(400, "That turn is not on the story you are reading.") turns.acquire_turn_lock(adventure.id) try: # §15.2. The reader's words are the narration; a block they pasted in is # protocol and is stripped before storage, exactly as a model's is. prose, parsed, raw_block = narrative.extract.split(text) # §15.1. Not the campaign's current state — the state this turn was # played from. One row read, not a replay (ADR 012). before = attempts.preceding(db, adventure, action) starting_state = ( narrative.model.normalize(before.narrative_state_after) if before is not None and isinstance(before.narrative_state_after, dict) else narrative.model.empty() ) corrected = models.Action( adventure_id=adventure.id, type="ai", text=prose, # No model was called, so there is no prompt to show for this node. # In the sibling case the turn's assembled prompt moves to whichever # attempt is live, which is what the Insights viewer reads; in the # forked case the original keeps it, because the original is still # the live node of its own line. context_snapshot=None, ) tip = db_tip(db, adventure) # A turn the head rests on is not a leaf while a retained future # descends from it, and `db_tip` reads the capped path and cannot see # that future. Ask the head module as well (M3). at_the_tip = ( tip is not None and tip.id == action.id and not head.behind_tip(db, adventure) ) if at_the_tip: # §15.4-5 as a take. The original stays at this coordinate as a # prior attempt, reachable through the pager, and the correction # becomes the one the story tells. attempts.hand_over_the_prompt(action, corrected) attempts.add_attempt(db, adventure, action, corrected) db.add(corrected) # The words at this coordinate changed, so anything derived from # them no longer describes the story. memorybank.forget_node(db, adventure, action) cursors.rewind_all(adventure, action.branch_id, action.depth - 1) db.flush() else: # §15.4-5 as a branch. Nothing on the departed line is written to: # the original node keeps its text, its live flag and every turn # that was played after it. departed = lineage.branch_of(db, adventure) tree.branch_at(db, adventure, action.depth - 1) if departed is not None: head.mark_superseded(departed, action.depth - 1) tree.place_action(db, adventure, corrected) db.add(corrected) db.flush() # §15.3. The same validation path a generated turn takes, so a hand # -typed event is no more trusted than a model's: the allowlist, the # schema, the references and the canon all still apply. review = narrative.validate.review( parsed if parsed is not None else {"events": []}, starting_state, narrative.store.canon_of(adventure), ) # `record` writes the events and the provenance. Its returned document # applies them to the campaign's *current* state, which is not what an # edit derives from, so the document this node leaves behind is computed # from the turn's own starting point below. narrative.store.record( db, adventure, review=review, raw_block=raw_block, parsed=parsed, action=corrected, branch_id=corrected.branch_id, depth=corrected.depth, source="narrator_edit", ) new_state = narrative.apply.apply_events( starting_state, review.accepted, branch_id=corrected.branch_id, depth=corrected.depth, source="narrator_edit", ) corrected.state_changes = { "accepted": review.accepted, "rejected": [r.as_dict() for r in review.rejected], "summary": narrative.apply.diff(starting_state, new_state), } # The head is on the corrected node, so the campaign's live state is # what that node leaves behind, and the node's own snapshot is the same # document. That equality is the invariant the review found broken: # visible position == head == authoritative state. narrative.store.set_current(adventure, new_state) attempts.snapshot_outcome(adventure, corrected) adventure.updated_at = models.utcnow() db.commit() finally: turns._active_turns.discard(adventure.id) db.refresh(corrected) return corrected @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") # The lock is held for the same reason undo holds it: this endpoint puts # the shared state back, and a turn that is still generating is about to # write it. turns.acquire_turn_lock(adventure_id) try: # This works like undo. The turn is deleted with all of its attempts, # and whatever it produced is withdrawn. The marks are depths, and a # depth does not move when an action before it is deleted. was_at = adventure.head_depth 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) # `refresh_head` recomputes the tip, which since M3 is not the head. A # story sitting behind its retained tip must not be dragged forward to # the tip by an unrelated delete — that would silently Redo it. Keep the # head where the reader left it, unless the delete took the ground out # from under it, in which case the new tip is as far as it can stay. if was_at < adventure.head_depth: adventure.head_depth = was_at # The script state and the world state belong to the adventure, not to # the node, so deleting the node does not take back what it did to # them. Put them back to what the story now ends with, which is the # same restore a branch switch does. # # The world state carries the cooldown clock in `_meta.last_changed`, # and that clock is a depth. Leaving it set marked the deleted turn's # changes as having happened at the depth the next turn is played at, # so the referee refused them as changed too recently — on a turn the # story no longer contains. Deleting a middle action restores the tip's # own outcome, which is the state the adventure is already in. attempts.restore_state(adventure, db_tip(db, adventure)) db.commit() finally: turns._active_turns.discard(adventure_id)