diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 1d20e59..941fee3 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -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: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9762b5a..8965bc1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/tree.py b/backend/app/tree.py index 29ba005..bf8353f 100644 --- a/backend/app/tree.py +++ b/backend/app/tree.py @@ -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, diff --git a/backend/tests/test_take_parentage.py b/backend/tests/test_take_parentage.py index 4df8437..9dd9ab4 100644 --- a/backend/tests/test_take_parentage.py +++ b/backend/tests/test_take_parentage.py @@ -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)