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
144 lines
5.7 KiB
Python
144 lines
5.7 KiB
Python
"""Moving around the story tree: what is newest, what comes next, what to remove.
|
|
|
|
These functions answer questions about action nodes without knowing which
|
|
endpoint asked. They do not touch the turn lock and they do not stream, so any
|
|
module in the package can import them.
|
|
"""
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session, undefer
|
|
|
|
from ... import attempts, head, memorybank, models, tree
|
|
from ...context import cursors
|
|
from ...context import lineage
|
|
|
|
|
|
def next_depth(adventure: models.Adventure) -> int:
|
|
"""Returns the depth for the next node played onto this story, one past the tip.
|
|
|
|
A depth is a position along one story, and the branch is what makes it
|
|
unambiguous. Two branches each hold a node at depth 4, and they are
|
|
alternatives rather than duplicates.
|
|
"""
|
|
return adventure.head_depth + 1
|
|
|
|
|
|
def last_action(adventure: models.Adventure, db: Session) -> models.Action | None:
|
|
"""Returns the newest action of any kind on the story being played, or `None`.
|
|
|
|
This runs a query rather than reading `adventure.actions[-1]`, which loads
|
|
the entire story to read one row. That collection also holds every branch's
|
|
actions, so it sometimes returns a row from the wrong branch.
|
|
"""
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(
|
|
models.Action.adventure_id == adventure.id,
|
|
lineage.path_of(db, adventure).clause(models.Action),
|
|
)
|
|
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
|
.first()
|
|
)
|
|
|
|
|
|
def _move_to_after(
|
|
db: Session, adventure: models.Adventure, after_id: int | None
|
|
) -> None:
|
|
"""Moves the story to `after_id` before the turn is played.
|
|
|
|
This is where a branch is created (SP9). Reading an attempt that the story
|
|
moved past changes nothing on the server. Writing below one is the first time
|
|
the player states which line they mean, and that is when the fork happens.
|
|
|
|
An attempt already on the path needs no move, because the story is already
|
|
there.
|
|
"""
|
|
if after_id is None:
|
|
return
|
|
node = db.get(models.Action, after_id)
|
|
if node is None or node.adventure_id != adventure.id:
|
|
raise HTTPException(404, "Action not found")
|
|
if node.live and lineage.path_of(db, adventure).contains(node):
|
|
return
|
|
if not node.live and len(attempts.group(db, node)) < 2:
|
|
# The pager cannot reach this node, so no legitimate action put the
|
|
# player here.
|
|
raise HTTPException(400, "That take is not one of this turn's.")
|
|
stand_on(db, adventure, node)
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
|
|
|
|
def delete_turn(
|
|
db: Session, adventure: models.Adventure, node: models.Action
|
|
) -> None:
|
|
"""Removes a turn, including every attempt at it and not only the one on screen.
|
|
|
|
A discarded attempt is a leaf at the same coordinate, and the only way to
|
|
reach it is through that coordinate. Leaving it behind when the turn is
|
|
deleted orphans a row that no read can reach. Whatever the turn produced is
|
|
withdrawn once, because a memory is attached to the coordinate rather than to
|
|
one attempt.
|
|
"""
|
|
memorybank.forget_node(db, adventure, node)
|
|
# Scoped to this node's branch (SP9). Groups span branches now, and an
|
|
# attempt forked onto its own line belongs to another branch's story. See
|
|
# `attempts.on_branch`.
|
|
for attempt in attempts.on_branch(attempts.group(db, node), node):
|
|
db.delete(attempt)
|
|
|
|
|
|
def db_tip(db: Session, adventure: models.Adventure) -> models.Action | None:
|
|
"""Returns the newest node of the story as it stands, with its outcome loaded."""
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(
|
|
models.Action.adventure_id == adventure.id,
|
|
lineage.path_of(db, adventure).clause(models.Action),
|
|
)
|
|
.options(
|
|
undefer(models.Action.state_after),
|
|
undefer(models.Action.world_state_after),
|
|
)
|
|
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
|
.first()
|
|
)
|
|
|
|
|
|
def stand_on(
|
|
db: Session, adventure: models.Adventure, action: models.Action
|
|
) -> None:
|
|
"""Makes `action` the attempt the story tells, forking only if that is needed.
|
|
|
|
There are two cases, and the caller does not have to know which one applies.
|
|
While the turn is still the tip, its attempts are leaves that nothing was
|
|
built on, so this is a switch and no branch is created. Once the story has
|
|
moved past the turn, the line being left keeps every turn it has, so the
|
|
attempt needs a branch of its own.
|
|
|
|
The fork endpoint calls this function, and so does a turn played below an
|
|
attempt the story moved past. Both are the same operation, once as a request
|
|
and once as a step on the way to writing (SP9).
|
|
"""
|
|
newest = last_action(adventure, db)
|
|
at_the_tip = (
|
|
newest is not None
|
|
and newest.branch_id == action.branch_id
|
|
and newest.depth == action.depth
|
|
# A turn the head rests on is still not a leaf while a retained future
|
|
# descends from it. `last_action` reads the capped path and cannot see
|
|
# that future, so switching in place here would strand it (M3).
|
|
and not head.behind_tip(db, adventure)
|
|
)
|
|
if at_the_tip:
|
|
# The story at this coordinate is about to change, so withdraw whatever
|
|
# was derived from it. A retry does the same thing. A fork needs none of
|
|
# this, because it leaves the coordinate and its memory where they are.
|
|
# See `tree.fork`.
|
|
memorybank.forget_node(db, adventure, action)
|
|
cursors.rewind_all(adventure, action.branch_id, (action.depth or 0) - 1)
|
|
attempts.make_live(db, adventure, action)
|
|
else:
|
|
tree.fork(db, adventure, action)
|
|
attempts.restore_state(adventure, action)
|