Make the branch happen when you write, not when you look

Stepping between takes used to be two controls and two meanings. At the tip a
chip switched; above the tip it only *previewed*, and taking that line needed a
second button next to it. Which one you got depended on where you were standing,
which is the thing that made the tree unusable when it was driven by hand.

So the server stops caring that anyone is looking. Reading a take the story
moved past changes nothing and creates nothing. `ActionCreate.after_id` names
the node a turn is played after, and naming a take the story left is the first
moment the player has said which line they mean -- so that is where the fork
happens, and only there.

`stand_on` is the old fork endpoint's body, lifted out whole. It already knew
the two cases and got them right: at the tip the takes are leaves nobody built
on, so it is a switch and no branch is made; past the tip the line being left
keeps every turn it has, so the take needs a branch. Both callers now share it,
which is the point -- a fork asked for and a fork arrived at are the same move.

Six tests. Five fail with the grouping reverted to the coordinate, and the one
that does not is deliberate: naming the tip in `after_id` must stay an ordinary
turn that forks nothing, which guards against over-correcting rather than
against the original bug. The nesting test passes both ways too and is kept for
what it says, not for what it catches -- a coordinate separates C1's takes from
C2's by accident, because the fork has already put them on different branches.

415 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-18 21:34:12 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 6fa213f6db
commit e126bda387
3 changed files with 331 additions and 17 deletions
+66 -17
View File
@@ -889,6 +889,11 @@ def create_action(
limits.check_row_cap("actions", db, user, adventure=adventure)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
try:
_move_to_after(db, adventure, payload.after_id)
except BaseException:
_active_turns.discard(adventure_id)
raise
return StreamingResponse(
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)),
media_type="text/event-stream",
@@ -896,6 +901,33 @@ def create_action(
)
def _move_to_after(
db: Session, adventure: models.Adventure, after_id: int | None
) -> None:
"""Put the story where `after_id` says before the turn is played.
This is the moment a branch is born (SP9). Reading a take the story moved
past changes nothing on the server — the player is looking, and looking is
free. Writing below one is the first time they have said which line they
mean, and that is when the fork happens.
A take already on the path needs nothing: it is where the story is.
"""
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:
# Not reachable through the pager, so nothing 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)
@router.post("/{adventure_id}/retry")
def retry_action(
adventure_id: int,
@@ -1381,23 +1413,7 @@ def fork_from_attempt(
)
acquire_turn_lock(adventure_id)
try:
newest = last_action(adventure, db)
at_the_tip = (
newest is not None
and newest.branch_id == action.branch_id
and newest.depth == action.depth
)
if at_the_tip:
# The story at this coordinate is about to say something else, so
# what was derived from it is withdrawn — the same move retry
# makes. A fork needs none of that: it leaves the coordinate, and
# its memory, exactly 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)
stand_on(db, adventure, action)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
@@ -1406,6 +1422,39 @@ def fork_from_attempt(
_active_turns.discard(adventure_id)
def stand_on(
db: Session, adventure: models.Adventure, action: models.Action
) -> None:
"""Make `action` the take the story tells, forking only if it has to.
Two cases, and the caller does not have to know which. While the turn is
still the tip its takes are leaves nobody has built on, so this is a switch
and no branch is created. Once the story has moved past, the line being left
keeps every turn it has, so the take needs a branch of its own.
Called from the fork endpoint and from a turn played below a take the story
moved past — the same move, once as a request and once as the thing that
happens 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
)
if at_the_tip:
# The story at this coordinate is about to say something else, so what
# was derived from it is withdrawn — the same move retry makes. A fork
# needs none of that: it leaves the coordinate, and its memory, exactly
# 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)
@router.post("/{adventure_id}/undo", response_model=schemas.ActionPage)
def undo_turn(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser