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
@@ -67,10 +67,20 @@ that isn't the live one starts a new branch.
|
||||
displaced future stays on the line it was written for, and ordinary Redo stops offering it.
|
||||
Nothing a retry replaces is discarded either — the old attempt stays as another take of that
|
||||
turn, one keystroke and one click from becoming a branch of its own.
|
||||
- **Save Points.** Name a moment — "Before entering the abbey" — keep playing,
|
||||
restart the app, and come back to it. Restoring one moves the story back to
|
||||
that moment and deletes nothing: the turns you wrote after it stay, Redo still
|
||||
walks forward into them, and writing something different from the Save Point
|
||||
is what starts a new line while the old one is kept. A Save Point is a name for
|
||||
a position and holds no copy of the story, so restoring it is the same
|
||||
movement Undo makes (`backend/app/routers/adventures/checkpoints.py`,
|
||||
`backend/app/head.py`). They last until you delete them, and deleting one
|
||||
deletes no story.
|
||||
- **Import and export.** AI Dungeon-compatible scenario format; JSON for everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
|
||||
every branch, every take, the fork points, which branches the story has left behind, and the
|
||||
position it is being read at — all of them chosen rather than computed, which is the rule for
|
||||
what a bundle carries. A campaign exported after two Undos imports still undone, with its
|
||||
every branch, every take, the fork points, which branches the story has left behind, the Save
|
||||
Points and the position it is being read at — all of them chosen rather than computed, which is
|
||||
the rule for what a bundle carries. A campaign opens where its head says, never at a Save Point
|
||||
merely because it has one. A campaign exported after two Undos imports still undone, with its
|
||||
retained future intact, instead of silently reopening at its newest turn. Files that predate
|
||||
the head position, and files saved in the old single-line format, still import.
|
||||
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
|
||||
@@ -196,6 +206,7 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
|
||||
├─ tree.py forking, promotion, and where a node is placed
|
||||
├─ head.py the active head: where the story is read, and what moving it costs
|
||||
├─ checkpoints Save Points: durable names for positions, in routers/adventures/
|
||||
├─ attempts.py the takes of one turn, grouped by parent
|
||||
├─ context/ prompt assembly under a token budget + lineage/history windowing
|
||||
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
||||
@@ -210,7 +221,7 @@ development, Vite proxies `/api` to FastAPI.
|
||||
|
||||
## Tests
|
||||
|
||||
638 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
||||
680 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
||||
the model provider mocked. They run with no route to the Internet, which is a requirement
|
||||
rather than a convenience — an offline claim proved on a machine that has been online once
|
||||
proves nothing.
|
||||
|
||||
Reference in New Issue
Block a user