diff --git a/backend/app/context/lineage.py b/backend/app/context/lineage.py index 4f994d2..5eb6c16 100644 --- a/backend/app/context/lineage.py +++ b/backend/app/context/lineage.py @@ -78,7 +78,16 @@ class Path: """One story, expressed as a SQL clause and as a Python predicate. The object holds the lineage entries newest first, plus the depth of the - tip. The tip is used only to estimate how much story each entry covers. + head. Every entry is read as capped at the head, which is what makes the + active head a position the whole application honours (M3). + + Before M3 the head was always the deepest node, so the cap never bit and the + tip was used only to estimate how much story each entry covers. Undo now + moves the head backward without deleting anything, so a path can have live + nodes past its head, and those nodes are not part of the story being told. + Capping here is what hides them, and it hides them from every read at once: + the transcript, the context builder, `attempts.preceding`, and memory + retrieval all funnel through `path_of`. """ def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None): @@ -91,6 +100,40 @@ class Path: def __len__(self) -> int: return len(self.entries) + # ------------------------------------------------------------- the head + + def _cap(self, max_depth: int | None) -> int | None: + """Returns `max_depth` limited by the head, which no read may pass. + + Three cases, and the third is the one M3 added: + + * No head recorded (`tip is None`). The caller asked for the lineage + without a position, so the entry's own cap stands. `tree` builds such + a path when it resolves the node in front of a depth. + * An uncapped entry, which means "this branch through to its tip". The + head is the cap. + * A capped entry, which is an ancestor capped at the fork depth. The + head still wins when it sits behind that fork, because undoing below + a fork point is undoing into the shared prefix. Taking the smaller of + the two is what lets Undo walk back past a fork instead of stopping + there — safe now that it deletes nothing. + """ + if self.tip is None: + return max_depth + if max_depth is None: + return self.tip + return min(max_depth, self.tip) + + def uncapped(self) -> "Path": + """Returns the same lineage read through to its retained tip. + + This is the retained history, head or no head: what Redo can still walk + forward into, and what a write below the head has to fork away from. + Only those two callers should use it. Every read of *the story* wants + the capped path. + """ + return Path(self.entries, None) + # ---------------------------------------------------------------- SQL def clause( @@ -125,7 +168,9 @@ class Path: entries = self.entries if count is None else self.entries[:count] if not entries: return false() - on_path = or_(*[self._entry_clause(model, b, d) for b, d in entries]) + on_path = or_( + *[self._entry_clause(model, b, self._cap(d)) for b, d in entries] + ) if model is models.Action: return and_(on_path, models.Action.live.is_(True)) return on_path @@ -155,9 +200,10 @@ class Path: for branch_id, max_depth in self.entries: if node.branch_id != branch_id: continue - if max_depth is None: + cap = self._cap(max_depth) + if cap is None: return True - if node.depth is not None and node.depth <= max_depth: + if node.depth is not None and node.depth <= cap: return True return False @@ -187,7 +233,7 @@ class Path: return total covered = 0 for i, (_, max_depth) in enumerate(self.entries): - top = self.tip if max_depth is None else max_depth + top = self._cap(max_depth) below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH if top is None or below is None: # Either no tip was recorded, or a hand-written row is missing a @@ -211,7 +257,8 @@ class Path: story has forked. """ for i, (_, max_depth) in enumerate(self.entries): - if max_depth is not None and max_depth <= depth: + cap = self._cap(max_depth) + if cap is not None and cap <= depth: return i return len(self.entries) @@ -241,7 +288,8 @@ class Path: return depth for entry_branch, max_depth in self.entries: if entry_branch == branch_id: - return depth if max_depth is None else min(depth, max_depth) + cap = self._cap(max_depth) + return depth if cap is None else min(depth, cap) return NO_DEPTH diff --git a/backend/app/head.py b/backend/app/head.py new file mode 100644 index 0000000..49d33b6 --- /dev/null +++ b/backend/app/head.py @@ -0,0 +1,304 @@ +"""M3: where the story is being read, and what moving that point costs. + +Undo used to delete. It removed the trailing nodes, let `tree.refresh_head` +recompute the tip from what survived, and the story was wherever the rows ended. +That made the head a derived value and made Redo impossible, because the turns it +would have moved forward into were gone. + +The head is now a stored position that can sit behind the retained tip. Nothing +is deleted, so three things that used to be the same question are now three +different ones: + +* **the active head** — `adventure.head_branch_id` and `adventure.head_depth`, + the end of the story being told. Every read of the story stops here, because + `lineage.Path` caps every entry at it. +* **the retained tip** — the deepest live node still on the lineage. Redo walks + toward it. It is read through `Path.uncapped()`, and only this module and the + divergence check may look at it. +* **the opening** — the shallowest node on the story, which is the floor Undo + may not pass. + +Everything that moves the head or asks a question about it lives here, so the +turn engine, Retry, Add-take, Undo, Redo and Edit share one set of rules rather +than four similar ones. The Phase 0B spike put the fork check in the write path +and left Retry and Add-take on the old one, which is exactly the divergence this +module exists to prevent. + +The state that belongs to a position is not recomputed. Every node carries the +world state it left behind (`attempts.snapshot_outcome`), so moving the head is a +row lookup plus `attempts.restore_state`, at any distance, in either direction. +""" + +from sqlalchemy.orm import Session, undefer + +from . import attempts, models, tree +from .context import lineage + +# The kinds of node a player writes. An undo or a redo steps over a whole turn, +# which is one of these followed by the reply to it, so both ends need to agree +# on what "a player's half of a turn" is. +PLAYER_TYPES = ("do", "say", "story", "continue") + + +# ------------------------------------------------------------------ reading + +def opening_depth(db: Session, adventure: models.Adventure) -> int | None: + """Returns the depth of the first node of the story, or None if there is none. + + This is Undo's floor. The Phase 0B spike moved the head to -1 and rendered an + empty transcript, because its guard tested for a node of type `start` and an + adventure opened with a player-written `story` action has none. Asking the + path for its shallowest node needs no such special case: whatever the opening + is called, it is the node with the smallest depth, and the story keeps it. + + The read is uncapped. The opening does not move when the head does, and + capping would make the floor depend on where the head already is. + """ + return ( + db.query(models.Action.depth) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).uncapped().clause(models.Action), + ) + .order_by(models.Action.depth.asc(), models.Action.id.asc()) + .limit(1) + .scalar() + ) + + +def retained_tip(db: Session, adventure: models.Adventure) -> int | None: + """Returns the depth of the deepest live node still retained on this lineage. + + This is what the head would be if the story had never been undone, and it is + what Redo can reach. It is not the head, and no read of the story may use it. + """ + return ( + db.query(models.Action.depth) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).uncapped().clause(models.Action), + ) + .order_by(models.Action.depth.desc(), models.Action.id.desc()) + .limit(1) + .scalar() + ) + + +def behind_tip(db: Session, adventure: models.Adventure) -> bool: + """Returns whether retained story sits past the head. + + This one predicate answers every "does this write need to fork?" question in + the application. It is true exactly when the user has undone and not redone, + which is the only situation in which writing can displace an accepted future. + + It is also what makes "is this turn the tip?" answerable again. Retry, + Add-take and `stand_on` each decide between amending a turn in place and + giving it a branch, and each used to ask `last_action`, which reads the + *capped* path and therefore reports the node at the head as the newest one. + Under a moved-back head that answer is wrong in the dangerous direction: it + says a turn with an accepted future is a leaf, and amending it in place would + leave that future descending from a take that is no longer live. + """ + tip = retained_tip(db, adventure) + return tip is not None and tip > adventure.head_depth + + +def node_at( + db: Session, adventure: models.Adventure, depth: int +) -> models.Action | None: + """Returns the live node at `depth` on the retained lineage, outcome loaded. + + The read is uncapped on purpose: Redo asks for a node it is about to move the + head onto, which is by definition past the head at the time of asking. The + outcome columns are undeferred because the only reason to fetch this row is + to restore the state it left behind. + """ + return ( + db.query(models.Action) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).uncapped().clause(models.Action), + models.Action.depth == depth, + ) + .options( + undefer(models.Action.state_after), + undefer(models.Action.world_state_after), + ) + .order_by(models.Action.id) + .first() + ) + + +def redo_target(db: Session, adventure: models.Adventure) -> int | None: + """Returns the depth the head moves to on Redo, or None if there is nowhere. + + Redo steps over a whole turn, the same unit Undo steps back over, so a + player's action and the reply to it move together. Landing between them would + show the story an input with no answer and would leave the next Undo undoing + half a turn. + + The walk is along the retained lineage, which is what makes Redo follow the + continuation that was active rather than choosing among branches. After a + divergence the new branch *is* the lineage, and the displaced future is no + longer on it, so this returns None without having to know that a divergence + happened. That is `STORY-BRANCH-SEMANTICS.md` §8 falling out of the lineage + rather than being enforced by a flag. + """ + ahead = ( + db.query(models.Action) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).uncapped().clause(models.Action), + models.Action.depth > adventure.head_depth, + ) + .order_by(models.Action.depth.asc(), models.Action.id.asc()) + .limit(2) + .all() + ) + if not ahead: + return None + first = ahead[0] + if ( + first.type in PLAYER_TYPES + and len(ahead) > 1 + and ahead[1].type == "ai" + and ahead[1].depth == (first.depth or 0) + 1 + ): + return ahead[1].depth + return first.depth + + +def can_redo(db: Session, adventure: models.Adventure) -> bool: + """Returns whether an ordinary Redo is available from where the head is.""" + return redo_target(db, adventure) is not None + + +def undo_target( + db: Session, adventure: models.Adventure +) -> tuple[int, models.Action] | None: + """Returns where Undo moves the head, and the first node it steps back over. + + None means there is nothing to undo, which is either an empty story or a head + already resting on the opening. The caller turns that into a 400; this + function does not raise, so that the same question can be asked without + committing to undoing. + + A turn is the player's node plus the reply to it, and both move together for + the reason given in `redo_target`. The player half is only claimed when it is + directly in front of the reply, so a bare `continue`, which writes no player + node, steps back over the reply alone. + """ + newest = ( + db.query(models.Action) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).clause(models.Action), + ) + .order_by(models.Action.depth.desc(), models.Action.id.desc()) + .limit(2) + .all() + ) + if not newest: + return None + last = newest[0] + first_stepped = last + before = newest[1] if len(newest) > 1 else None + if ( + last.type == "ai" + and before is not None + and before.type in PLAYER_TYPES + and before.depth == (last.depth or 0) - 1 + ): + first_stepped = before + floor = opening_depth(db, adventure) + if first_stepped.depth is None or floor is None: + return None + if first_stepped.depth <= floor: + # Stepping back over this turn would hide the opening of the campaign, + # which is the pre-campaign state `STORY-BRANCH-SEMANTICS.md` §4 stops + # at. The floor is the opening node itself rather than depth -1, so an + # adventure that opens on a player-written `story` action stops in the + # same place as one that opens on a `start` node. + return None + return first_stepped.depth - 1, first_stepped + + +def can_undo(db: Session, adventure: models.Adventure) -> bool: + """Returns whether an ordinary Undo is available from where the head is.""" + return undo_target(db, adventure) is not None + + +# ------------------------------------------------------------------ writing + +def move_to(db: Session, adventure: models.Adventure, depth: int) -> None: + """Moves the active head to `depth` and restores the state recorded there. + + This is the whole of Undo and Redo. Nothing is deleted, nothing is + recomputed, and the direction of travel does not matter: the node at the + destination carries the world state it left behind, so arriving from in front + of it and arriving from behind it restore the same value. + + A destination with no node — the head resting one step in front of the + opening — leaves the live state alone, which is `attempts.restore_state`'s + rule for a missing snapshot and the reason it is not this function's job to + invent an empty one. + """ + adventure.head_depth = depth + attempts.restore_state(adventure, node_at(db, adventure, depth)) + + +def fork_if_behind_head(db: Session, adventure: models.Adventure) -> bool: + """Gives the story a new branch when a write would displace a retained future. + + Returns whether a branch was created, which is what a caller reports as a + divergence. + + Called before every write that continues the story, and it does nothing on + the ordinary path where the head is already at the tip. That is the property + worth keeping: a story that is never undone forks exactly as often as it did + before M3, so the branch table does not fill up with one branch per turn. + + Undo alone must not fork. Moving the head is not a decision to abandon + anything — the user may be reading, or about to Redo. Only the first write + below the head states which continuation they mean, which is + `STORY-BRANCH-SEMANTICS.md` §8 and §20 and what makes Redo survive an Undo. + + `tree.branch_at` leaves the departed branch exactly as it is: its nodes stay + live, at their depths, on their branch. The new branch inherits the story up + to the head and owns everything written from here, so the displaced future + remains reachable through the branch it was written on. + """ + if not behind_tip(db, adventure): + return False + departed = lineage.branch_of(db, adventure) + at_depth = adventure.head_depth + tree.branch_at(db, adventure, at_depth) + if departed is not None: + mark_superseded(departed, at_depth) + return True + + +def mark_superseded(branch: models.Branch, depth: int) -> None: + """Records that this branch's story past `depth` was displaced. + + `DATA-MODEL.md` §5 gives a branch a disposition of active, retained or + disposable. This is that disposition, stored as the fact that produced it + rather than as a word: the depth the story left at, and when. A branch with + no `superseded_at` is active; one with a value has retained history past that + depth which no active head is reading. + + Nothing in the application reads these columns to make a decision, and that + is deliberate. Redo is decided by the lineage, not by a flag, so a stale or + hand-edited value here cannot make the story wrong. They exist so that the + cleanup and discarded-history features `STORY-BRANCH-SEMANTICS.md` §28 and + §29 leave to a later version have something to select on, and so that a + divergence is observable in a test. + + The shallowest departure wins. A branch left at depth 9 and later left again + at depth 4 has retained history from 4 onward, and recording the later, deeper + value would understate what was displaced. + """ + if branch.superseded_depth is None or depth < branch.superseded_depth: + branch.superseded_depth = depth + if branch.superseded_at is None: + branch.superseded_at = models.utcnow() diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 435011f..90aa1dd 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -342,6 +342,13 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [ # client, which a cold model load on a CPU-only machine can exceed. The # default matches `providers.openai_compatible.DEFAULT_READ_TIMEOUT`. (77, "ALTER TABLE settings ADD COLUMN model_timeout_seconds INTEGER NOT NULL DEFAULT 300"), + + # M3. A branch left behind by a divergent write records where the story left + # it. NULL means active, which is what every existing branch is: before M3 + # the head could not sit behind the tip, so no branch had been superseded. + # No backfill. + (78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"), + (79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"), ] LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) diff --git a/backend/app/models.py b/backend/app/models.py index fca4e65..e9a7978 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -228,6 +228,17 @@ class Branch(Base): # branch by its fork depth, which deleting a branch does not change. name: Mapped[str | None] = mapped_column(String(80), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + # M3: the disposition `DATA-MODEL.md` §5 gives a branch, stored as the fact + # that produced it. NULL means active. A value means a divergent write left + # this branch at `superseded_depth`, so its nodes past that depth are + # retained history that no active head is reading. + # + # Nothing reads these to decide behaviour. Redo follows the lineage, so a + # wrong value here cannot make the story wrong; they exist for the cleanup + # and discarded-history features that `STORY-BRANCH-SEMANTICS.md` §28-29 + # leave to a later version. See `head.mark_superseded`. + superseded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True) class Memory(Base): diff --git a/backend/app/routers/adventures/actions.py b/backend/app/routers/adventures/actions.py index 7c46ce1..cd1cfee 100644 --- a/backend/app/routers/adventures/actions.py +++ b/backend/app/routers/adventures/actions.py @@ -81,6 +81,7 @@ def delete_action( # This works like undo. The turn is deleted with all of its attempts, # and whatever it produced is withdrawn. The marks are depths, and a # depth does not move when an action before it is deleted. + was_at = adventure.head_depth delete_turn(db, adventure, action) db.flush() db.expire(adventure, ["actions"]) @@ -88,6 +89,13 @@ def delete_action( # middle leaves a gap in the depths, which is intended. See # `_backfill_tree`. tree.refresh_head(db, adventure) + # `refresh_head` recomputes the tip, which since M3 is not the head. A + # story sitting behind its retained tip must not be dragged forward to + # the tip by an unrelated delete — that would silently Redo it. Keep the + # head where the reader left it, unless the delete took the ground out + # from under it, in which case the new tip is as far as it can stay. + if was_at < adventure.head_depth: + adventure.head_depth = was_at # The script state and the world state belong to the adventure, not to # the node, so deleting the node does not take back what it did to # them. Put them back to what the story now ends with, which is the diff --git a/backend/app/routers/adventures/crud.py b/backend/app/routers/adventures/crud.py index b90ac11..1e06ff2 100644 --- a/backend/app/routers/adventures/crud.py +++ b/backend/app/routers/adventures/crud.py @@ -9,7 +9,9 @@ from sqlalchemy import func from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import set_committed_value -from ... import attempts, images, limits, memorybank, models, schemas, tree, worldstate +from ... import ( + attempts, head, images, limits, memorybank, models, schemas, tree, worldstate, +) from ...database import get_db from .deps import CurrentUser, current_adventure, router @@ -239,6 +241,11 @@ def get_adventure( set_committed_value(adventure, "actions", actions) out = schemas.AdventureOut.model_validate(adventure) out.action_count = total + # M3. Opening a story has to render its history controls correctly, and a + # campaign whose head sits behind the retained tip — undone and then closed — + # must come back with Redo available. + out.can_undo = head.can_undo(db, adventure) + out.can_redo = head.can_redo(db, adventure) return out diff --git a/backend/app/routers/adventures/nodes.py b/backend/app/routers/adventures/nodes.py index c55b279..73f986e 100644 --- a/backend/app/routers/adventures/nodes.py +++ b/backend/app/routers/adventures/nodes.py @@ -8,7 +8,7 @@ module in the package can import them. from fastapi import HTTPException from sqlalchemy.orm import Session, undefer -from ... import attempts, memorybank, models, tree +from ... import attempts, head, memorybank, models, tree from ...context import cursors from ...context import lineage @@ -125,6 +125,10 @@ def stand_on( newest is not None and newest.branch_id == action.branch_id and newest.depth == action.depth + # A turn the head rests on is still not a leaf while a retained future + # descends from it. `last_action` reads the capped path and cannot see + # that future, so switching in place here would strand it (M3). + and not head.behind_tip(db, adventure) ) if at_the_tip: # The story at this coordinate is about to change, so withdraw whatever diff --git a/backend/app/routers/adventures/paging.py b/backend/app/routers/adventures/paging.py index 92c0c23..c7b75cb 100644 --- a/backend/app/routers/adventures/paging.py +++ b/backend/app/routers/adventures/paging.py @@ -8,7 +8,7 @@ columns and apply the same numbering, so both live here. from sqlalchemy import func from sqlalchemy.orm import Session, load_only -from ... import models, schemas +from ... import head, models, schemas from ...context import lineage @@ -165,4 +165,6 @@ def current_window(db: Session, adventure: models.Adventure) -> schemas.ActionPa ], total=total, has_more=has_more, + can_undo=head.can_undo(db, adventure), + can_redo=head.can_redo(db, adventure), ) diff --git a/backend/app/routers/adventures/takes.py b/backend/app/routers/adventures/takes.py index b292151..06a3317 100644 --- a/backend/app/routers/adventures/takes.py +++ b/backend/app/routers/adventures/takes.py @@ -11,7 +11,7 @@ from fastapi import Depends, HTTPException, Request from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session -from ... import attempts, limits, memorybank, models, schemas, tree +from ... import attempts, head, limits, memorybank, models, schemas, tree from ...context import cursors from ...context import lineage from ...database import get_db @@ -19,8 +19,8 @@ from ...sse import SSE_HEADERS from . import turns from .deps import CurrentUser, current_adventure, router -from .nodes import delete_turn, last_action, stand_on -from .paging import action_window, annotate_takes, current_window +from .nodes import last_action, stand_on +from .paging import current_window @router.post("/{adventure_id}/retry") @@ -33,24 +33,44 @@ def retry_action( ): """Regenerates the last AI action and keeps the discarded attempt. - The attempt on screen stays as it was written. The shared script state and - world state roll back to what the node before it left behind, and the new - attempt is stored as a sibling at the same coordinate. No text the AI wrote - is rewritten or deleted. + The attempt on screen stays as it was written. The world state rolls back to + what the node before it left behind, and the new attempt is stored as a + sibling at the same coordinate. No text the AI wrote is rewritten or deleted. + + M3 added the one case that cannot be a sibling. Retrying the turn the head + rests on while a retained future still descends from it would leave that + future hanging off a take that is no longer live — the story after it was + written to continue the old text. So a retry from behind the tip takes a + branch instead, exactly as `add_take` does for a turn the story has moved + past. It is the same operation reached from a different button. """ turns.acquire_turn_lock(adventure_id) last_ai = None try: newest = last_action(adventure, db) if newest is not None and newest.type == "ai": - last_ai = newest + # Read this before anything moves, and note it is *not* + # `fork_if_behind_head`: this fork leaves the path just in front of + # the turn being retried rather than at the head, so the new take + # lands at the same depth under the same parent. + diverging = head.behind_tip(db, adventure) + if diverging: + departed = lineage.branch_of(db, adventure) + tree.branch_at(db, adventure, (newest.depth or 0) - 1) + if departed is not None: + head.mark_superseded(departed, (newest.depth or 0) - 1) + else: + # Only a sibling attempt names the node it replaces. A branched + # take is a fresh node at the same coordinate, so `generate_turn` + # places it through the tree rather than through `add_attempt`. + last_ai = newest # Roll the state back to before this AI turn's hooks ran, so that # regenerating starts from a clean state rather than applying output # mutations on top of the attempt being replaced. If the preceding # node has no snapshot, which happens for a pre-SP4 row that the # migration could not derive one for, this call does nothing and # leaves the state as it is. - attempts.roll_back_before(db, adventure, last_ai) + attempts.roll_back_before(db, adventure, newest) db.commit() db.refresh(adventure) except BaseException: @@ -137,6 +157,17 @@ def select_variant( "Only the latest message can be switched — the story has already " "continued from this one.", ) + if head.behind_tip(db, adventure): + # The head is behind the retained tip, so this turn reads as the newest + # one but still has an accepted future descending from it. Switching the + # live take in place would leave that future continuing text the story + # no longer tells. Forking is the operation that does this safely, and + # `/fork` is where it lives. + raise HTTPException( + 400, + "This turn has a later story that was undone but kept. Redo first, " + "or use another take to start a new line from here.", + ) turns.acquire_turn_lock(adventure_id) try: chosen = rows[payload.index] @@ -263,7 +294,15 @@ def add_take( retry_of = None try: newest = last_action(adventure, db) - at_the_tip = newest is not None and newest.id == action.id + # `last_action` reads the capped path, so under a moved-back head it + # reports the node at the head as the newest one. A turn with a retained + # future is not a leaf, whatever the capped read says, so ask the head + # module rather than trusting the depth comparison alone (M3). + at_the_tip = ( + newest is not None + and newest.id == action.id + and not head.behind_tip(db, adventure) + ) if at_the_tip and action.type == "ai": # Nothing was played after it, so its attempts are still leaves and # a branch would serve no purpose. This is the `retry` path. @@ -274,7 +313,10 @@ def add_take( # text that is there now. The new attempt leaves the path just # before the turn, so that story keeps the attempt it was written # for. + departed = lineage.branch_of(db, adventure) tree.branch_at(db, adventure, action.depth - 1) + if departed is not None: + head.mark_superseded(departed, action.depth - 1) attempts.roll_back_before(db, adventure, action) adventure.updated_at = models.utcnow() db.commit() @@ -309,71 +351,78 @@ def undo_turn( db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): - """Deletes the last turn: the trailing AI action and its player action, if any. + """Moves the story back one turn. Deletes nothing (M3). - The endpoint also rolls the shared `script_state` back to before that turn - ran, and it prunes any memory that summarized the removed actions. The turn - lock prevents an undo while a turn is still generating. + This endpoint used to remove the trailing AI action and the player action in + front of it, prune the memories that covered them, and let the tip fall back + to whatever survived. Undoing was therefore the one operation in the + application that destroyed accepted story, and it was why there was no Redo: + the turns to move forward into no longer existed. + + Now it moves `adventure.head_depth`. The rows stay exactly where they are, + still live, still on their branch, and `lineage.Path` stops every read at the + head instead. The transcript, the assembled context, `attempts.preceding` and + memory retrieval all narrow together, because all four already funnelled + through the same path object. + + The memory bank needs no pruning for the same reason. A memory carries the + coordinate of the node its block ends on, so a memory derived from a turn + that is now past the head falls outside the capped clause and stops being + retrievable — and becomes eligible again on Redo, without having been deleted + and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33 for free. + + The state comes back from the node the story now ends on, which recorded what + it left behind when it played. See `head.move_to`. """ turns.acquire_turn_lock(adventure_id) try: - # Only the last turn is removed, so fetch the two actions it can - # consist of rather than the whole story. - newest = ( - db.query(models.Action) - .filter( - models.Action.adventure_id == adventure.id, - lineage.path_of(db, adventure).clause(models.Action), - ) - .order_by(models.Action.depth.desc(), models.Action.id.desc()) - .limit(2) - .all() - ) - if not newest or newest[0].type == "start": + target = head.undo_target(db, adventure) + if target is None: raise HTTPException(400, "Nothing to undo") - last = newest[0] - before_that = newest[1] if len(newest) > 1 else None - # Undo only what this branch owns. Everything before the fork is - # borrowed from an ancestor and is part of that ancestor's story too, so - # an undo here must never delete a turn out of another branch. The test - # reads the row's own branch rather than the fork depth, because the - # branch is what decides the case. - if last.branch_id != adventure.head_branch_id: - raise HTTPException( - 400, "Nothing to undo on this branch — the turns before it " - "belong to the branch it was forked from.", - ) - first_removed = last - if (last.type == "ai" and before_that is not None - and before_that.type in ("do", "say", "story") - and before_that.branch_id == adventure.head_branch_id): - first_removed = before_that - # The state the story returns to once the turn is gone, which is what - # the node before the earliest removed one left behind. Read it before - # the deletes, while those rows are still in the story. - restore_to = attempts.preceding(db, adventure, first_removed) - delete_turn(db, adventure, last) - if first_removed is not last: - delete_turn(db, adventure, first_removed) - attempts.restore_state(adventure, restore_to) - db.flush() # Apply the deletes before anything reads the story back. - db.expire(adventure, ["actions"]) - # The tip moves back with the deleted rows. - tree.refresh_head(db, adventure) + depth, _first_stepped = target + head.move_to(db, adventure, depth) + adventure.updated_at = models.utcnow() db.commit() db.refresh(adventure) # Return the newest window rather than the whole story. The client # replaces its transcript with this response, and the transcript is a # window. Returning everything would defeat the paging on the action a # player is most likely to repeat several times in a row. - actions, total, has_more = action_window(db, adventure) - return schemas.ActionPage( - actions=[ - schemas.ActionOut.model_validate(a) - for a in annotate_takes(db, adventure.id, actions) - ], - total=total, - has_more=has_more, - ) + return current_window(db, adventure) + finally: + turns._active_turns.discard(adventure_id) + + +@router.post("/{adventure_id}/redo", response_model=schemas.ActionPage) +def redo_turn( + adventure_id: int, + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + """Moves the story forward again into the continuation Undo stepped out of. + + Redo exists because Undo stopped deleting. It walks the head forward over one + whole turn along the retained lineage, and restores the state that turn left + behind. + + It follows the lineage rather than choosing among branches, which is what + makes it invalidate itself correctly. Writing below a moved-back head forks, + and from the new branch the displaced future is no longer on the lineage at + all — so there is nothing ahead to walk into and this returns 400 without any + flag having to be set or cleared. `STORY-BRANCH-SEMANTICS.md` §8. + + 400 is also what a head already at the tip gets, which is the ordinary case + for a story that has never been undone. + """ + turns.acquire_turn_lock(adventure_id) + try: + depth = head.redo_target(db, adventure) + if depth is None: + raise HTTPException(400, "Nothing to redo") + head.move_to(db, adventure, depth) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(adventure) + return current_window(db, adventure) finally: turns._active_turns.discard(adventure_id) diff --git a/backend/app/routers/adventures/turns.py b/backend/app/routers/adventures/turns.py index 8bf973b..28e26a7 100644 --- a/backend/app/routers/adventures/turns.py +++ b/backend/app/routers/adventures/turns.py @@ -13,7 +13,7 @@ from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from ... import ( - attempts, limits, memorybank, models, schemas, tree, worldstate, + attempts, head, limits, memorybank, models, schemas, tree, worldstate, ) from ...context import build_context, cursors from ...database import get_db @@ -333,6 +333,14 @@ def create_action( acquire_turn_lock(adventure_id) try: _move_to_after(db, adventure, payload.after_id) + # The first write below a moved-back head is where a divergence happens + # (M3). Undo alone does not fork — the user may be reading, or about to + # Redo — so this is the moment the story states which continuation it + # means. The displaced future keeps its rows on the branch being left. + # A head already at the tip, which is every ordinary turn, forks nothing. + if head.fork_if_behind_head(db, adventure): + db.commit() + db.refresh(adventure) except BaseException: _active_turns.discard(adventure_id) raise diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 02c81ee..fa07084 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -317,6 +317,12 @@ class AdventureOut(ORMModel): # story's length, which is how the client knows more actions exist above. actions: list[ActionOut] = [] action_count: int = 0 + # M3. Whether the history controls have anywhere to go from where the story + # is. The client cannot work either out for itself: `can_undo` needs the + # campaign opening, which may be off the top of the loaded window, and + # `can_redo` needs the retained future, which the client is never sent. + can_undo: bool = False + can_redo: bool = False class ActionPage(BaseModel): @@ -327,6 +333,10 @@ class ActionPage(BaseModel): # Whether anything older than this slice exists. The server computes it, so # the client never has to do arithmetic on positions to find the end. has_more: bool = False + # The same two flags `AdventureOut` carries, so that the response to Undo, + # Redo or a turn updates the controls without a second request. + can_undo: bool = False + can_redo: bool = False # ---------- Memory bank (Phase 6) ----------