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
@@ -106,6 +106,25 @@ export const api = {
|
||||
}),
|
||||
deleteBranch: (advId, branchId) =>
|
||||
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
||||
// Save Points (M4). A Save Point is a durable name for a story position; the
|
||||
// server stores the coordinate and nothing else. Create takes no position —
|
||||
// it is always made at the campaign's active head, which is where the reader
|
||||
// is. Restore answers with the story as it now stands, like a branch switch,
|
||||
// so the caller replaces its window rather than reloading everything.
|
||||
listCheckpoints: (advId) => request(`/adventures/${advId}/checkpoints`),
|
||||
createCheckpoint: (advId, name, note = '') =>
|
||||
request(`/adventures/${advId}/checkpoints`, {
|
||||
method: 'POST', body: JSON.stringify({ name, note }),
|
||||
}),
|
||||
renameCheckpoint: (advId, checkpointId, name) =>
|
||||
request(`/adventures/${advId}/checkpoints/${checkpointId}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ name }),
|
||||
}),
|
||||
deleteCheckpoint: (advId, checkpointId) =>
|
||||
request(`/adventures/${advId}/checkpoints/${checkpointId}`, { method: 'DELETE' }),
|
||||
restoreCheckpoint: (advId, checkpointId) =>
|
||||
request(`/adventures/${advId}/checkpoints/${checkpointId}/restore`, { method: 'POST' }),
|
||||
|
||||
// Play a turn again, differently (SP9). An AI turn regenerates; a player's
|
||||
// own takes the text given. Streams, because it is a turn like any other.
|
||||
//
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BranchPanel } from './panels/BranchPanel'
|
||||
import { InsightsPanel } from './panels/InsightsPanel'
|
||||
import { MemoryPanel } from './panels/MemoryPanel'
|
||||
import { PlotPanel } from './panels/PlotPanel'
|
||||
import { SavePointPanel } from './panels/SavePointPanel'
|
||||
|
||||
const MODES = ['do', 'say', 'story']
|
||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||
@@ -68,7 +69,7 @@ export default function Play() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
|
||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
|
||||
// Bumped when something outside the turn loop changes the drawers' state
|
||||
// (currently "Update from scenario"), which no action count would reflect.
|
||||
const [stateKey, setStateKey] = useState(0)
|
||||
@@ -545,6 +546,8 @@ export default function Play() {
|
||||
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
||||
<button className={panel === 'branches' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
|
||||
<button className={panel === 'savepoints' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'savepoints' ? null : 'savepoints')}>Save Points</button>
|
||||
<button className={panel === 'insights' ? 'active' : ''}
|
||||
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
||||
Insights
|
||||
@@ -714,6 +717,13 @@ export default function Play() {
|
||||
from here: that future is retained, but it is no longer the
|
||||
continuation this story tells. */}
|
||||
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
|
||||
{/* Save Point sits with the history controls because that is what
|
||||
it is: a name for a position Undo and Redo move between. The
|
||||
button opens the panel, where the name is typed — the moment it
|
||||
saves is wherever the story is being read, so there is nothing
|
||||
to choose first. */}
|
||||
<button onClick={() => setPanel('savepoints')} disabled={busy}
|
||||
title="Name this moment so you can come back to it">⚑ Save Point</button>
|
||||
</div>
|
||||
<div className="input-bar">
|
||||
<div className="mode-select">
|
||||
@@ -752,7 +762,10 @@ export default function Play() {
|
||||
{panel && (
|
||||
<div className="side-panel">
|
||||
<div className="side-panel-header">
|
||||
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
|
||||
<h2>{{
|
||||
plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches',
|
||||
savepoints: 'Save Points', insights: 'Insights',
|
||||
}[panel]}</h2>
|
||||
<button onClick={() => setPanel(null)}>✕</button>
|
||||
</div>
|
||||
{panel === 'plot' ? (
|
||||
@@ -764,6 +777,17 @@ export default function Play() {
|
||||
// but deleting a branch deletes the memories that hung off it,
|
||||
// and that happens without a turn being played.
|
||||
refreshKey={`${actions.length}:${stateKey}`} />
|
||||
) : panel === 'savepoints' ? (
|
||||
// Restoring one moves the story exactly as Undo and Redo do, so it
|
||||
// adopts the returned window the same way a branch switch does —
|
||||
// the state panels are showing another position's numbers until
|
||||
// they re-read.
|
||||
<SavePointPanel
|
||||
advId={id}
|
||||
refreshKey={`${actions.length}:${stateKey}`}
|
||||
onRestored={adoptWindow}
|
||||
onError={(message) => setToast({ text: message, isError: true })}
|
||||
/>
|
||||
) : panel === 'branches' ? (
|
||||
<BranchPanel
|
||||
advId={id}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// Save Points: naming a place in the story, and going back to one.
|
||||
//
|
||||
// "Save Point" is the word throughout, and the words branch, head, node and
|
||||
// fork appear nowhere a player can read (`BROWSER-UX-SPEC.md` §23). The panel
|
||||
// is deliberately small — a form, a list, and two confirmations. It is not a
|
||||
// branch explorer, and the tree it sits over stays out of sight.
|
||||
//
|
||||
// Both confirmations exist to say what does *not* happen, because that is the
|
||||
// part a player cannot see and would otherwise assume the worst about: restore
|
||||
// keeps the later story, and deleting a Save Point deletes no story at all.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
|
||||
function SavePointPanel({ advId, refreshKey, onRestored, onError }) {
|
||||
const [points, setPoints] = useState(null)
|
||||
const [failed, setFailed] = useState(null)
|
||||
const [name, setName] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [renaming, setRenaming] = useState(null) // { id, text }
|
||||
const [confirming, setConfirming] = useState(null) // { id, kind }
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFailed(null)
|
||||
api.listCheckpoints(advId)
|
||||
.then((list) => { if (!cancelled) setPoints(list) })
|
||||
.catch((err) => { if (!cancelled) setFailed(err.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [advId, refreshKey, tick])
|
||||
|
||||
// Answers whether it worked, so a caller can keep its editor open on a
|
||||
// refusal — a rename the server turned down must not take the typed name
|
||||
// with it.
|
||||
async function run(id, work) {
|
||||
setBusyId(id)
|
||||
try {
|
||||
await work()
|
||||
setTick((t) => t + 1)
|
||||
return true
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
return false
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.createCheckpoint(advId, name.trim(), note.trim())
|
||||
setName('')
|
||||
setNote('')
|
||||
setTick((t) => t + 1)
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const restore = (p) => run(p.id, async () => {
|
||||
onRestored(await api.restoreCheckpoint(advId, p.id))
|
||||
setConfirming(null)
|
||||
})
|
||||
const rename = async (p) => {
|
||||
if (await run(p.id, () => api.renameCheckpoint(advId, p.id, renaming.text))) {
|
||||
setRenaming(null)
|
||||
}
|
||||
}
|
||||
const remove = async (p) => {
|
||||
if (await run(p.id, () => api.deleteCheckpoint(advId, p.id))) setConfirming(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="save-point-panel">
|
||||
<form className="save-point-new" onSubmit={create}>
|
||||
<label htmlFor="save-point-name">Save this moment</label>
|
||||
<input
|
||||
id="save-point-name"
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder="Before entering the abbey"
|
||||
value={name}
|
||||
disabled={saving}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="save-point-note"
|
||||
maxLength={500}
|
||||
placeholder="A note, if you want one (optional)"
|
||||
value={note}
|
||||
disabled={saving}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="primary" disabled={saving || !name.trim()}>
|
||||
Save Point
|
||||
</button>
|
||||
<p className="save-point-hint">
|
||||
Saves the moment you are reading now. If you have stepped back, that
|
||||
is the moment it saves.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{failed && <div className="panel-empty">Couldn’t read the Save Points — {failed}</div>}
|
||||
{!failed && !points && <div className="panel-empty">Reading your Save Points…</div>}
|
||||
{!failed && points?.length === 0 && (
|
||||
<p className="save-point-intro">
|
||||
No Save Points yet. Name a moment you might want to come back to, then
|
||||
keep playing — going back to it later leaves everything you wrote
|
||||
after it in place.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="save-point-list">
|
||||
{(points || []).map((p) => {
|
||||
const isRenaming = renaming?.id === p.id
|
||||
const busy = busyId === p.id
|
||||
const confirm = confirming?.id === p.id ? confirming.kind : null
|
||||
return (
|
||||
<div key={p.id} className={`save-point-row ${p.on_path ? '' : 'elsewhere'}`}>
|
||||
<div className="save-point-head">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
className="save-point-rename"
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
value={renaming.text}
|
||||
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') rename(p)
|
||||
if (e.key === 'Escape') setRenaming(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="save-point-name">{p.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="save-point-meta">
|
||||
Moment {p.turn}
|
||||
{/* Said plainly, without naming a branch: the story took a
|
||||
different turning after this point, and going back to it
|
||||
returns to the telling it was saved in. */}
|
||||
{!p.on_path && p.resolved && ' · on a path you left'}
|
||||
{!p.resolved && ' · this moment is no longer in the story'}
|
||||
</div>
|
||||
{p.note && <div className="save-point-note-text">{p.note}</div>}
|
||||
|
||||
{confirm === 'restore' ? (
|
||||
<div className="save-point-confirm">
|
||||
<span>
|
||||
The story will return to this Save Point. Everything you
|
||||
wrote after it is kept — it just stops being where you are.
|
||||
</span>
|
||||
<button type="button" className="primary" disabled={busy}
|
||||
onClick={() => restore(p)}>Restore</button>
|
||||
<button type="button" onClick={() => setConfirming(null)}>Cancel</button>
|
||||
</div>
|
||||
) : confirm === 'delete' ? (
|
||||
<div className="save-point-confirm">
|
||||
<span>
|
||||
Delete this Save Point? Deleting it does not delete any of
|
||||
the story — only the name you gave this moment.
|
||||
</span>
|
||||
<button type="button" className="danger" disabled={busy}
|
||||
onClick={() => remove(p)}>Delete</button>
|
||||
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="save-point-tools">
|
||||
<button type="button" disabled={busy || !p.resolved}
|
||||
title={p.resolved ? undefined
|
||||
: 'The moment this Save Point named is no longer in the story.'}
|
||||
onClick={() => setConfirming({ id: p.id, kind: 'restore' })}>
|
||||
Restore
|
||||
</button>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<button type="button" disabled={busy} onClick={() => rename(p)}>Save</button>
|
||||
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" disabled={busy}
|
||||
onClick={() => setRenaming({ id: p.id, text: p.name })}>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="danger" disabled={busy}
|
||||
onClick={() => setConfirming({ id: p.id, kind: 'delete' })}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SavePointPanel }
|
||||
@@ -255,6 +255,102 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
|
||||
Point is a name for a position, and it should not look like a different
|
||||
species of thing from the lines it names positions on. The confirmations
|
||||
borrow the branch panel's shape but not its danger colouring — restoring
|
||||
destroys nothing, and only deletion is red. */
|
||||
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
|
||||
.save-point-new label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.save-point-new input {
|
||||
padding: 5px 9px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.save-point-new input.save-point-note {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.save-point-hint, .save-point-intro {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.save-point-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
|
||||
than hidden: it still restores, and hiding it would be the automatic cleanup
|
||||
this milestone deliberately does not do. */
|
||||
.save-point-row.elsewhere { opacity: 0.72; }
|
||||
.save-point-head { display: flex; align-items: center; gap: 7px; }
|
||||
.save-point-name {
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.98rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.save-point-rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 7px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.save-point-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.save-point-note-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.save-point-tools, .save-point-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.save-point-tools button, .save-point-confirm button {
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.save-point-tools button:hover:not(:disabled),
|
||||
.save-point-confirm button:hover:not(:disabled) {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.save-point-tools button.danger:hover:not(:disabled),
|
||||
.save-point-confirm button.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.save-point-tools button:disabled,
|
||||
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
|
||||
.save-point-confirm span {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.action-edit { margin-bottom: 14px; }
|
||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
||||
several paragraphs, and the old fixed 110px turned that into a keyhole.
|
||||
|
||||
Reference in New Issue
Block a user