A Save Point is a name for a story position, and restoring one is head movement. That is the whole architecture, and it is what ADR 012 and BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored (branch, depth) and made arriving at one a row lookup plus a state restore, so a Save Point needs no restore machinery of its own. What the user gets: - Name the moment they are reading, keep playing, restart the app, and come back to it. Restoring moves the story back and deletes nothing: the later turns stay, Redo still walks forward into them, and writing something different is what starts a new line while the old one is kept. - Rename, delete, and a list, in a Save Points panel beside the branch panel, with a Save Point button next to Undo and Redo. Both confirmations say what is *not* destroyed, because that is the part the screen cannot show. - Save Points survive export and import. What was deliberately not built: - No second restore path. `head.move_to_node` is the only new movement: its depth half is M3's `head.move_to` unchanged, and its branch half is the single assignment `switch_branch` already makes. No head field is written in the checkpoint router, nothing reconstructs state, nothing prunes a memory, nothing copies or deletes a turn, and restore never forks — the first write below the restored head does, through `fork_if_behind_head`. - No automatic cleanup. A Save Point behind the head, or naming a line the story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal is a cascade: deleting a branch takes its Save Points, as it takes its memories, because the story they named went with it. - No new ADR. ADR 012 already decides the architecture, and a table is not a decision. The one call the planning package did not already make: restore moves the branch half of the head only when the coordinate is off the path being read. Doing it unconditionally would quietly hand back an abandoned continuation whenever a Save Point in a shared prefix was restored; never doing it would make a Save Point on a departed line unrestorable, which contradicts §19. TECHNICAL-DESIGN §8.8 records it. Schema: a `checkpoints` table holding a name, an optional note and a (branch, depth) coordinate — no copy of any story. `create_all` builds it as it did `memories` and `branches`; migration 80 adds the index. No backfill, because nobody had named a position before M4. The coordinate is deliberately not an action id: one coordinate holds every attempt at a turn and exactly one is live, so a coordinate follows a retry where a row id would pin a take the story no longer tells. Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering D11-D14, I04, L03, E-series lineage and memory isolation after restore and divergence, the edge cases, and an M3-database migration. One pre-existing fixture in test_tree_migration.py needed `checkpoints` added to its drop list — SQLite refuses to drop a table another table references. Not verified: the browser. No session has had a usable one, so the Save Point panel's DOM behaviour is unobserved — as M3's Redo control still is. The twenty-step sequence was driven over HTTP against a live server with a real process restart instead, and all seventeen checks pass. M4 is implemented, not accepted: no review has been written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""M4: Save Points — create, list, rename, delete, and restore.
|
|
|
|
A Save Point is a durable named pointer to a story position and nothing else.
|
|
It stores a coordinate, never a copy of any story, and restoring one moves the
|
|
active head to that coordinate. That is the whole design, and it is what
|
|
`BUILD-MILESTONES.md`'s note on M4 and ADR 012 ask for: M3 made the head a
|
|
stored `(branch, depth)` and made arriving at one a row lookup plus a state
|
|
restore, so a Save Point needs no restore machinery of its own.
|
|
|
|
What is deliberately absent from this module, because a second copy of any of it
|
|
would be the failure M4 is warned about:
|
|
|
|
* no head fields are assigned here — `head.move_to_node` moves the head, and
|
|
`head.move_to` under it restores the state, exactly as Undo and Redo do;
|
|
* nothing reconstructs state, prunes a memory, copies a turn, or deletes one;
|
|
* nothing forks. Restore is not a decision to abandon anything, so it creates no
|
|
branch. The first write below the restored head forks, through the same
|
|
`fork_if_behind_head` every other write goes through, and the displaced future
|
|
stays retained (`STORY-BRANCH-SEMANTICS.md` §20).
|
|
|
|
The user-facing word is "Save Point" and the internal one is `checkpoint`
|
|
(`BROWSER-UX-SPEC.md` §23). Error strings here are read by a player, so they say
|
|
Save Point.
|
|
"""
|
|
|
|
from fastapi import Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import head, models, schemas
|
|
from ...context import lineage
|
|
from ...database import get_db
|
|
|
|
from . import turns
|
|
from .deps import current_adventure, router
|
|
from .paging import current_window
|
|
|
|
|
|
def _node_at(
|
|
db: Session, adventure: models.Adventure, branch_id: int, depth: int
|
|
) -> models.Action | None:
|
|
"""Returns the live turn a Save Point's coordinate names, or None.
|
|
|
|
The lookup is by coordinate and is not scoped to any path. That is the
|
|
point of it: a Save Point outlives the reader moving away, so the question
|
|
it has to answer is "is this position still in this campaign's retained
|
|
history", not "is it on the story being read now". Whether it is on the
|
|
current path is a separate question, and `head.move_to_node` is what acts on
|
|
the answer.
|
|
|
|
`live` is what makes the coordinate follow a retry. One coordinate can hold
|
|
several attempts at a turn, and a Save Point names the turn rather than the
|
|
attempt, so it lands on whichever take the story currently tells.
|
|
"""
|
|
return (
|
|
db.query(models.Action)
|
|
.filter(
|
|
models.Action.adventure_id == adventure.id,
|
|
models.Action.branch_id == branch_id,
|
|
models.Action.depth == depth,
|
|
models.Action.live.is_(True),
|
|
)
|
|
.order_by(models.Action.id)
|
|
.first()
|
|
)
|
|
|
|
|
|
def _rendered(
|
|
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
|
|
) -> schemas.CheckpointOut:
|
|
"""Reads one Save Point out with the three facts the panel needs about it."""
|
|
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
|
|
out = schemas.CheckpointOut.model_validate(checkpoint)
|
|
# The same `depth + 1` the branch list counts with, so "turn 42" means the
|
|
# same thing in both places.
|
|
out.turn = checkpoint.depth + 1
|
|
out.resolved = node is not None
|
|
out.on_path = node is not None and lineage.path_of(db, adventure).uncapped().contains(node)
|
|
return out
|
|
|
|
|
|
def _get_or_404(
|
|
db: Session, adventure: models.Adventure, checkpoint_id: int
|
|
) -> models.Checkpoint:
|
|
"""Resolves a Save Point id, refusing one that belongs to another campaign.
|
|
|
|
The ownership check is the reason this is a function rather than a `db.get`
|
|
at each call site. A Save Point names a position in one campaign's history,
|
|
and a coordinate from another campaign would name a different story's turn —
|
|
or, worse, resolve against this one by arithmetic coincidence. So the id is
|
|
matched against this adventure, and a Save Point belonging to another is a
|
|
404 rather than a restore of the wrong story.
|
|
"""
|
|
checkpoint = db.get(models.Checkpoint, checkpoint_id)
|
|
if checkpoint is None or checkpoint.adventure_id != adventure.id:
|
|
raise HTTPException(404, "Save Point not found")
|
|
return checkpoint
|
|
|
|
|
|
def _clean_name(raw: str) -> str:
|
|
"""Returns the trimmed name, refusing one that is blank once trimmed."""
|
|
name = (raw or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "A Save Point needs a name.")
|
|
return name
|
|
|
|
|
|
@router.get("/{adventure_id}/checkpoints", response_model=list[schemas.CheckpointOut])
|
|
def list_checkpoints(
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns the campaign's Save Points, newest first.
|
|
|
|
Newest first rather than in story order, because story order is not
|
|
something this list can honestly claim. Depths are positions along a path,
|
|
and two Save Points on lines that parted company are not comparable by depth
|
|
at all — ordering by it would draw a sequence that no reading of the story
|
|
passes through. When they were made is a fact about all of them.
|
|
"""
|
|
rows = (
|
|
db.query(models.Checkpoint)
|
|
.filter(models.Checkpoint.adventure_id == adventure.id)
|
|
.order_by(models.Checkpoint.created_at.desc(), models.Checkpoint.id.desc())
|
|
.all()
|
|
)
|
|
return [_rendered(db, adventure, row) for row in rows]
|
|
|
|
|
|
@router.post(
|
|
"/{adventure_id}/checkpoints",
|
|
response_model=schemas.CheckpointOut,
|
|
status_code=201,
|
|
)
|
|
def create_checkpoint(
|
|
payload: schemas.CheckpointCreate,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Names the position the story is currently being read at.
|
|
|
|
The active head, not the retained tip. Creating a Save Point after two Undos
|
|
saves the undone position, because that is where the reader is and the
|
|
position they are looking at is the one they mean. The distinction only
|
|
exists at all because M3 stopped Undo from deleting.
|
|
|
|
The node at the head is resolved before the row is written, and its own
|
|
branch is what gets stored — which is not always the branch being read. A
|
|
head resting in a shared prefix sits on an ancestor's node, and the
|
|
ancestor is the branch that still names that position after the reader has
|
|
forked away from it.
|
|
"""
|
|
name = _clean_name(payload.name)
|
|
node = head.node_at(db, adventure, adventure.head_depth)
|
|
if node is None:
|
|
raise HTTPException(400, "There is no turn here to save yet.")
|
|
checkpoint = models.Checkpoint(
|
|
adventure_id=adventure.id,
|
|
name=name,
|
|
note=payload.note or "",
|
|
branch_id=node.branch_id,
|
|
depth=node.depth,
|
|
)
|
|
db.add(checkpoint)
|
|
db.commit()
|
|
db.refresh(checkpoint)
|
|
return _rendered(db, adventure, checkpoint)
|
|
|
|
|
|
@router.patch(
|
|
"/{adventure_id}/checkpoints/{checkpoint_id}",
|
|
response_model=schemas.CheckpointOut,
|
|
)
|
|
def rename_checkpoint(
|
|
checkpoint_id: int,
|
|
payload: schemas.CheckpointRename,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Changes a Save Point's label. Nothing else about it moves.
|
|
|
|
Not the coordinate, not the head, not a row of story. A Save Point that has
|
|
been renamed restores to exactly the position it did before, which is
|
|
`STORY-BRANCH-SEMANTICS.md` §23.
|
|
"""
|
|
checkpoint = _get_or_404(db, adventure, checkpoint_id)
|
|
if payload.name is not None:
|
|
checkpoint.name = _clean_name(payload.name)
|
|
if payload.note is not None:
|
|
checkpoint.note = payload.note
|
|
db.commit()
|
|
db.refresh(checkpoint)
|
|
return _rendered(db, adventure, checkpoint)
|
|
|
|
|
|
@router.delete("/{adventure_id}/checkpoints/{checkpoint_id}", status_code=204)
|
|
def delete_checkpoint(
|
|
checkpoint_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Removes the named pointer, and only the pointer.
|
|
|
|
The turn it named stays, its branch stays, the future past it stays, and the
|
|
head does not move. This endpoint deletes one row of the `checkpoints`
|
|
table. `STORY-BRANCH-SEMANTICS.md` §25.
|
|
"""
|
|
checkpoint = _get_or_404(db, adventure, checkpoint_id)
|
|
db.delete(checkpoint)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@router.post(
|
|
"/{adventure_id}/checkpoints/{checkpoint_id}/restore",
|
|
response_model=schemas.ActionPage,
|
|
)
|
|
def restore_checkpoint(
|
|
adventure_id: int,
|
|
checkpoint_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns the story to a Save Point, deleting nothing.
|
|
|
|
Four steps, and the last one is not this module's code: resolve the
|
|
coordinate, refuse it if it no longer names a live turn, hand it to
|
|
`head.move_to_node`, and answer with the window the head now caps. The
|
|
transcript, the world state, the assembled context and which memories can be
|
|
retrieved all move together, because all four already read through the one
|
|
path object the head caps — the same reason Undo needed no memory pruning.
|
|
|
|
The turns past the restored position are retained, exactly as they are after
|
|
an Undo, and ordinary Redo can still walk forward into them until the user
|
|
writes something different. Restore does not fork; the first write below the
|
|
head does.
|
|
|
|
A coordinate that no longer resolves is refused rather than approximated.
|
|
Moving the head to the nearest surviving turn would be the one outcome worse
|
|
than doing nothing: a Save Point that silently means somewhere else.
|
|
"""
|
|
checkpoint = _get_or_404(db, adventure, checkpoint_id)
|
|
turns.acquire_turn_lock(adventure_id)
|
|
try:
|
|
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
|
|
if node is None:
|
|
raise HTTPException(
|
|
409,
|
|
"That Save Point's position is no longer part of this story.",
|
|
)
|
|
head.move_to_node(db, adventure, node)
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
# A window, not the whole story, for the reason Undo gives: the client
|
|
# replaces its transcript with this, and the transcript is a window.
|
|
return current_window(db, adventure)
|
|
finally:
|
|
turns._active_turns.discard(adventure_id)
|