Let a player's own turn be played again

Retry has always given an AI turn another take. Nothing gave one to the
message the player wrote, so the only way to change something you had typed
was to overwrite it -- destructively, losing whatever the story made of it.
That is the half of the tree the player asked for first, and it never existed:
`POST /actions/{id}/fork` answers 400 unless a second take is already there,
so "branch from here" was not a thing the API could do.

`tree.branch_at` is the missing piece. `fork` moves a node that already exists
onto a line of its own; this is the same branch with nothing in it yet, for a
take that has not been written. The head lands one depth short, so the next
node written is the new take -- same depth, same parent, resolved from the
path by `place_action` without being told. The line being left keeps its node,
keeps it live, and keeps everything played after it. Nothing is copied.

Two things the tests said rather than confirmed. A player turn is never the
tip: the reply to it is, so retaking even the newest thing typed still owes a
branch, and the guard against forking for nothing only fires for a player
action with no reply under it. And player text is stored formatted, so a test
that compares it whole is testing the formatter.

421 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 e126bda387
commit 2726840c60
4 changed files with 232 additions and 0 deletions
+42
View File
@@ -177,6 +177,48 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
return db.get(models.Branch, new_id)
def branch_at(
db: Session, adventure: models.Adventure, fork_depth: int
) -> models.Branch:
"""An empty branch leaving the path being read at `fork_depth`.
`fork` moves a node that already exists onto a line of its own. This is the
same branch with nothing in it yet, for the case where the take that will
live there has not been written: the player asking for another take of a
turn the story has moved past (SP9). The head lands at `fork_depth`, so the
next node written is the new take, at the same depth as the one it is a take
of, with the same parent — `place_action` resolves that from the path, and
the path now ends at exactly the node the original hangs off.
The line being left is not touched at all. It keeps its node at that depth,
it keeps that node live, and it keeps everything played after it.
"""
parent = head_branch(db, adventure)
inherited = [
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
for branch_id, cap in lineage.entries_of(parent)
]
# Core insert with the lineage written second, for the reason `root_branch`
# spells out: this can run inside a flush, and the lineage names its own id.
new_id = db.execute(
insert(models.Branch).values(
adventure_id=adventure.id,
parent_branch_id=parent.id,
fork_depth=fork_depth,
lineage=[],
created_at=models.utcnow(),
)
).inserted_primary_key[0]
db.execute(
update(models.Branch)
.where(models.Branch.id == new_id)
.values(lineage=[[new_id, None]] + inherited)
)
adventure.head_branch_id = new_id
adventure.head_depth = fork_depth
return db.get(models.Branch, new_id)
def place_action(
db: Session,
adventure: models.Adventure,