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
+69
View File
@@ -1422,6 +1422,75 @@ def fork_from_attempt(
_active_turns.discard(adventure_id)
@router.post("/{adventure_id}/actions/{action_id}/takes")
def add_take(
adventure_id: int,
action_id: int,
payload: schemas.TakeCreate,
request: Request,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Play one of the player's own turns again, differently.
The gap SP7 left: a retry gives an AI turn another take, and nothing gave
one to the player's own message. So the only way to change something you
had written was to overwrite it and lose the story it led to.
This is the same move a retry makes, on the other kind of node. The turn is
played again with new text; whatever the story did with the old one stays
exactly where it is, on the line it was written on.
A branch is needed here for the same reason `fork` needs one — the turn
being retaken already has a story after it, and that story was written as a
continuation of the old text. `branch_at` leaves the path just before this
turn, so the new take is written at the same depth, under the same parent,
with the line it is leaving untouched. Nothing below is copied.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
limits.rate_limit("turn", request, user)
limits.check_row_cap("actions", db, user, adventure=adventure)
check_demo_cap(db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
if action.type == "ai":
raise HTTPException(
400, "Retry gives an AI turn another take; this is for your own."
)
if action.type not in ("do", "say", "story", "continue"):
# The opening is not a turn anybody played, so there is no second way
# to have played it. Editing the scenario is what changes it.
raise HTTPException(400, "The opening of a story has no other take.")
if action.depth is None or not lineage.path_of(db, adventure).contains(action):
raise HTTPException(400, "That turn is not on the story you are reading.")
acquire_turn_lock(adventure_id)
try:
# Only when something was played after it. At the tip there is no story
# to protect and no branch is owed — the same rule `stand_on` follows.
if adventure.head_depth > action.depth:
tree.branch_at(db, adventure, action.depth - 1)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
except BaseException:
_active_turns.discard(adventure_id)
raise
return StreamingResponse(
with_turn_lock(
adventure_id,
run_player_turn(
adventure,
db,
schemas.ActionCreate(type=action.type, text=payload.text),
user,
),
),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
def stand_on(
db: Session, adventure: models.Adventure, action: models.Action
) -> None:
+6
View File
@@ -254,6 +254,12 @@ class ActionCreate(BaseModel):
after_id: int | None = None
class TakeCreate(BaseModel):
"""Another take of a turn the player wrote themselves (SP9)."""
text: ActionText
class AdventureOut(ORMModel):
id: int
scenario_id: int | None
+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,
+115
View File
@@ -246,6 +246,121 @@ def test_takes_under_one_parent_do_not_count_takes_under_its_sibling(client):
assert _group_size(under_c1[0].id) == 2, "C1's line counts only its own two"
def _take(client, action_id, text):
return client.post(
f"/api/adventures/{client.adv_id}/actions/{action_id}/takes",
json={"text": text},
)
def _path_texts(client) -> list[str]:
return [
a["text"]
for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
]
def _user_rows(adv_id) -> list[models.Action]:
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adv_id,
models.Action.type == "do",
)
.order_by(models.Action.id)
.all()
)
finally:
db.close()
def test_a_players_own_turn_can_be_played_again(client):
"""The gap SP7 left: nothing could give a player's own message another take."""
_play(client, "open the door")
_play(client, "press on")
before = _branch_count(client.adv_id)
first = _user_rows(client.adv_id)[0]
r = _take(client, first.id, "smash the door instead")
assert r.status_code == 200, r.text
assert _branch_count(client.adv_id) == before + 1
# Stored text carries the player-action formatting ("> You ..."), so these
# are substring checks rather than equality.
blob = "\n".join(_path_texts(client))
assert "smash the door instead" in blob
assert "open the door" not in blob, "the new take replaces it on this line"
assert "press on" not in blob, "what followed the old text stays behind"
def test_the_line_left_behind_keeps_its_whole_story(client):
_play(client, "open the door")
_play(client, "press on")
first = _user_rows(client.adv_id)[0]
_take(client, first.id, "smash the door instead")
# Everything written on the original line is still there, untouched.
kept = "\n".join(a.text for a in _user_rows(client.adv_id))
for written in ("open the door", "press on", "smash the door instead"):
assert written in kept
def test_both_takes_of_a_players_turn_are_one_group(client):
_play(client, "open the door")
_play(client, "press on")
first = _user_rows(client.adv_id)[0]
_take(client, first.id, "smash the door instead")
new_take = [a for a in _user_rows(client.adv_id)
if "smash the door instead" in a.text][0]
assert _group_size(new_take.id) == 2, "a pager here reads 2/2"
assert _group_size(first.id) == 2, "and reads the same from the other take"
def test_an_ai_turn_is_refused_by_the_take_endpoint(client):
_play(client)
ai = _ai_rows(client.adv_id)[0]
r = _take(client, ai.id, "nope")
assert r.status_code == 400
assert "Retry" in r.json()["detail"]
def test_the_opening_has_no_other_take(client):
db = SessionLocal()
try:
start = (
db.query(models.Action)
.filter(models.Action.adventure_id == client.adv_id,
models.Action.type == "start")
.one()
)
start_id = start.id
finally:
db.close()
r = _take(client, start_id, "a different beginning")
assert r.status_code == 400
def test_retaking_even_the_newest_player_turn_forks(client):
"""A player turn is never the tip: the reply to it is.
Retaking the *last* thing the player typed still has a story to protect —
the AI answered it, and that answer was written for the old text. So the
branch is owed here too, and the guard against forking for nothing only
ever fires for a player action with no reply under it.
"""
_play(client, "open the door")
before = _branch_count(client.adv_id)
first = _user_rows(client.adv_id)[0]
r = _take(client, first.id, "knock politely")
assert r.status_code == 200, r.text
assert _branch_count(client.adv_id) == before + 1
def test_naming_a_take_that_is_already_the_story_just_plays_on(client):
"""`after_id` pointing at the tip is an ordinary turn, and forks nothing."""
_play(client)