M4: add durable named Save Points
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c8e91f644
commit
e08d49c3eb
@@ -134,6 +134,16 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
|
||||
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
|
||||
"memories": [_exported_memory(m, local) for m in adventure.memories],
|
||||
# M4. A Save Point is a decision — someone chose this position and gave
|
||||
# it a name — so it goes in the file by the rule at the top of this
|
||||
# module. Nothing here is derived: the coordinate is the one stored, not
|
||||
# one recomputed from the rows, because the whole point of the pointer
|
||||
# is that no amount of reading the turns can tell you which one somebody
|
||||
# named. A bundle written before M4 has no key here and imports with no
|
||||
# Save Points, which is what such a campaign had.
|
||||
"checkpoints": [
|
||||
_exported_checkpoint(c, local) for c in _checkpoints_of(db, adventure)
|
||||
],
|
||||
"storyCards": [
|
||||
{"type": c.type, "name": c.name, "keys": c.keys,
|
||||
"entry": c.entry, "notes": c.notes}
|
||||
@@ -215,6 +225,35 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _checkpoints_of(db: Session, adventure: models.Adventure) -> list[models.Checkpoint]:
|
||||
"""Returns the campaign's Save Points in creation order.
|
||||
|
||||
Read with a query rather than through a relationship, for the reason
|
||||
`models.Branch` declares none: a relationship on `Adventure` would be loaded
|
||||
by anything that touches an adventure, and the export is the only thing in
|
||||
the application that wants every Save Point at once.
|
||||
"""
|
||||
return (
|
||||
db.query(models.Checkpoint)
|
||||
.filter(models.Checkpoint.adventure_id == adventure.id)
|
||||
.order_by(models.Checkpoint.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _exported_checkpoint(checkpoint: models.Checkpoint, local: dict[int, int]) -> dict:
|
||||
return {
|
||||
"name": checkpoint.name,
|
||||
"note": checkpoint.note,
|
||||
# The branch as a position in this file's list, like every other branch
|
||||
# reference in the bundle. The depth is a coordinate along it and needs
|
||||
# no translation.
|
||||
"branch": _local(checkpoint.branch_id, local),
|
||||
"depth": checkpoint.depth,
|
||||
"createdAt": checkpoint.created_at.isoformat() if checkpoint.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _imported_persona(persona) -> dict:
|
||||
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
|
||||
|
||||
@@ -280,6 +319,13 @@ def plan(bundle: dict, version: str) -> dict:
|
||||
"branches": branches,
|
||||
"nodes": nodes,
|
||||
"memories": _planned_memories(bundle, len(branches)),
|
||||
# M4. Empty for a version 1 bundle and for any version 2 bundle written
|
||||
# before Save Points existed, which is the same answer: no one had named
|
||||
# a position in those campaigns.
|
||||
"checkpoints": (
|
||||
_planned_checkpoints(bundle, len(branches), nodes)
|
||||
if version == FORMAT else []
|
||||
),
|
||||
"head": head,
|
||||
# None means the file does not say, which is every version 1 bundle and
|
||||
# every version 2 bundle written before M3. `_point_the_head` derives it
|
||||
@@ -525,6 +571,57 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _planned_checkpoints(
|
||||
bundle: dict, branches: int, nodes: list[dict]
|
||||
) -> list[dict]:
|
||||
"""Returns the file's Save Points, checked against the tree it also carries.
|
||||
|
||||
A Save Point whose coordinate names no turn in the file is dropped rather
|
||||
than imported, and dropped rather than raising. The two halves of that are
|
||||
each deliberate:
|
||||
|
||||
* Dropped, because an imported pointer to a position the imported story does
|
||||
not contain is a Save Point that can only ever refuse to restore. It would
|
||||
be a row that exists to disappoint.
|
||||
* Not a 400, unlike the head depth. The head is a position the campaign is
|
||||
read at, so a file that misplaces it opens the story in the wrong place
|
||||
and every read is affected. A Save Point is a bookmark, and a bad one
|
||||
spoils nothing else in the file — refusing to import a whole campaign
|
||||
because one bookmark is wrong would lose the story to save the bookmark.
|
||||
|
||||
A name that is blank once trimmed is dropped for the same reason the create
|
||||
endpoint refuses one: an unnamed Save Point is not identifiable in a list.
|
||||
"""
|
||||
# Every coordinate the file writes, not only the ones it marks live.
|
||||
# `_write_nodes` makes exactly one attempt at each coordinate live whatever
|
||||
# the file says, so a coordinate that exists is a coordinate that will
|
||||
# resolve — and reading the flags here would drop a Save Point over a
|
||||
# question the writer has already settled.
|
||||
written = {(n["branch"], n["depth"]) for n in nodes}
|
||||
raw = bundle.get("checkpoints")
|
||||
out: list[dict] = []
|
||||
for entry in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()[:schemas.CHECKPOINT_NAME_MAX]
|
||||
if not name:
|
||||
continue
|
||||
depth = entry.get("depth")
|
||||
if not _is_int(depth):
|
||||
continue
|
||||
branch = _as_index(entry.get("branch"), branches, default=None)
|
||||
if branch is None or (branch, depth) not in written:
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"note": str(entry.get("note") or ""),
|
||||
"branch": branch,
|
||||
"depth": depth,
|
||||
"createdAt": _as_time(entry.get("createdAt")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _planned_anchors(bundle: dict, branches: int) -> dict:
|
||||
anchors = {}
|
||||
for name in ("memory", "summary"):
|
||||
@@ -550,6 +647,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
_write_nodes(db, adventure, story["nodes"], ids)
|
||||
_write_memories(db, adventure, story["memories"], ids)
|
||||
_point_the_head(adventure, story, ids)
|
||||
_write_checkpoints(db, adventure, story["checkpoints"], ids)
|
||||
_write_anchors(adventure, story, ids)
|
||||
|
||||
|
||||
@@ -665,6 +763,32 @@ def _write_memories(
|
||||
db.add(memory)
|
||||
|
||||
|
||||
def _write_checkpoints(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
|
||||
) -> None:
|
||||
"""Writes the Save Points, and moves nothing.
|
||||
|
||||
Note what this function does not touch. The head is pointed by
|
||||
`_point_the_head` from the file's own `headBranch`/`headDepth`, and importing
|
||||
a Save Point must not disturb it — a campaign exported at turn 30 with a
|
||||
Save Point at turn 12 opens at turn 30. The bundle records where the story
|
||||
was being read and, separately, which positions someone named; restoring one
|
||||
of them is a thing the user does afterwards, not a thing an import does for
|
||||
them.
|
||||
"""
|
||||
for spec in specs:
|
||||
checkpoint = models.Checkpoint(
|
||||
adventure_id=adventure.id,
|
||||
name=spec["name"],
|
||||
note=spec["note"],
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
)
|
||||
if spec["createdAt"] is not None:
|
||||
checkpoint.created_at = spec["createdAt"]
|
||||
db.add(checkpoint)
|
||||
|
||||
|
||||
def _point_the_head(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user