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:
committed by
Parth
co-authored by
Claude Opus 5
parent
6fa213f6db
commit
e126bda387
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user